读写分离

读写分离

什么是读写分离?

什么是读写分离?

读写分离主要是为了将数据库的读操作和写操作分散到不同的数据库节点上。 写请求仍然进入主库,读请求分摊到一个或多个只读副本,因此它主要提升的是数据库读扩展能力。只有当主库原本被大量读请求拖慢时,读流量迁出后,写入链路才可能间接受益。

我简单画了一张图来帮助不太清楚读写分离的小伙伴理解。

读写分离示意图

一般情况下,我们都会选择一主多从,也就是一台主数据库负责写,其他的从数据库负责读。主库和从库之间会进行数据同步,以保证从库中数据的准确性。这样的架构实现起来比较简单,并且也符合系统的写少读多的特点。

如何实现读写分离?

如何实现读写分离?

不论是使用哪一种读写分离具体的实现方案,想要实现读写分离一般包含如下几步:

部署多台数据库,选择其中的一台作为主数据库,其他的一台或者多台作为从数据库。

保证主数据库和从数据库之间建立复制关系,这个过程也就是我们常说的主从复制。需要注意,MySQL 默认复制是异步的,从库通常会短暂落后于主库。

系统将写请求交给主数据库处理,读请求交给从数据库处理。

落实到项目本身的话,常用的方式有两种:

  1. 代理方式

代理方式实现读写分离

我们可以在应用和数据库之间加一个代理层。应用程序所有的数据请求都交给代理层处理,代理层负责分离读写请求,将它们路由到对应的数据库中。

提供类似功能的中间件有 MySQL Router(官方,MySQL Proxy 的替代方案)、ProxySQL、MaxScale、MyCat 等。Atlas 是基于 MySQL Proxy 的较早期方案,新项目选型时要重点关注维护状态、MySQL 新版本兼容性和社区活跃度。

关于 MySQL Router 多提一点:MySQL Router 从 8.2 开始支持透明读写分离,当前文档中也保留了这项能力。它会把读流量路由到只读实例,把写流量路由到可写实例,应用侧不需要自己判断 SQL 类型。具体介绍可以参考官方博客:MySQL 8.2 – transparent read/write splitting 和官方文档:Read/Write Splitting。

MySQL 8.2 – transparent read/write splitting

Read/Write Splitting

不过,透明读写分离不代表所有 SQL 都能无脑交给 Router。FOR UPDATE、LOCK IN SHARE MODE、GET_LOCK() 等语句必须走可写实例;某些看似只读但可能产生写效果的函数或语句,也可能在只读实例上失败。生产使用前要结合官方 statement 支持列表和业务 SQL 做验证。

FOR UPDATE
LOCK IN SHARE MODE
GET_LOCK()
  1. 组件方式

在这种方式中,我们可以通过引入第三方组件来实现读写请求的路由。

如果项目是 Java 技术栈,ShardingSphere-JDBC 是比较常见的选择,直接引入 jar 包即可使用,运维成本也比独立代理低一些。

你可以在 ShardingSphere 官方找到 ShardingSphere-JDBC 读写分离配置。

ShardingSphere-JDBC 读写分离配置

主从复制原理是什么?

主从复制原理是什么?

MySQL binlog(binary log,即二进制日志文件)主要记录了 MySQL 数据库中数据的所有变化(数据库执行的所有 DDL 和 DML 语句)。因此,我们根据主库的 MySQL binlog 日志就能够将主库的数据同步到从库中。

下面为了中文阅读仍然使用“主库/从库”。MySQL 8.0 起官方逐步用 Source/Replica 替代 master/slave:8.0.22 开始推荐 START REPLICA 等别名,8.0.23 开始推荐 CHANGE REPLICATION SOURCE TO 等新命令;MySQL 8.4 已移除 START SLAVE、CHANGE MASTER TO 等旧 SQL 语句。新脚本和监控项应尽量使用新命名,例如 SHOW REPLICA STATUS、replica_parallel_workers、rpl_semi_sync_source_wait_point。

START REPLICA
CHANGE REPLICATION SOURCE TO
START SLAVE
CHANGE MASTER TO
SHOW REPLICA STATUS
replica_parallel_workers
rpl_semi_sync_source_wait_point

更具体和详细的过程是这个样子的(图片来自于:《MySQL Master-Slave Replication on the Same Machine》):

《MySQL Master-Slave Replication on the Same Machine》

MySQL主从复制

主库将数据库中数据的变化写入到 binlog。

从库连接主库,请求 binlog 中的更新事件。

主库创建 binlog dump 线程,将 binlog 内容发送给从库。

从库的 I/O receiver 线程接收更新事件,并写入 relay log。

从库的 applier 线程读取 relay log,把其中的事件应用到本地。若使用 statement-based logging,可以理解成重放 SQL;若使用 row-based logging,则主要是应用行变更事件。

你一般看到 binlog 就要想到主从复制。当然,除了主从复制之外,binlog 还能帮助我们实现数据恢复。

拓展一下:

不知道大家有没有使用过阿里开源的一个叫做 canal 的工具。这个工具可以帮助我们实现 MySQL 和其他数据源比如 Elasticsearch 或者另外一台 MySQL 数据库之间的数据同步。很显然,这个工具的底层原理肯定也是依赖 binlog。canal 的原理就是模拟 MySQL 主从复制的过程,解析 binlog 将数据同步到其他的数据源。

另外,像咱们常用的分布式缓存组件 Redis 也是通过主从复制实现的读写分离。

简单总结一下:

MySQL 主从复制依赖 binlog。另外,常见的一些同步 MySQL 数据到其他数据源的工具(比如 canal)的底层一般也是依赖 binlog。

如何避免读到从库旧数据?

如何避免读到从库旧数据?

读写分离能提升数据库读并发,但也会引入一个问题:主库和从库的数据存在延迟。比如写完主库之后,主库数据同步到从库需要时间,这个时间差会导致主从数据短暂不一致,也就是 主从同步延迟 。

应用通常无法避免复制延迟本身,只能避免把强一致读请求路由到落后的从库。如果业务场景无法容忍读到旧数据,可以参考下面几种做法。

读写分离还要特别注意事务和请求链路边界。同一个业务事务里,如果先写后读,后续读通常应该继续走主库,不能让代理或中间件把读请求路由到从库。否则会出现“自己刚写的数据自己读不到”的问题。对于登录态、支付状态、订单创建后详情查询这类 read-your-writes 场景,可以按用户会话、请求链路或业务标记做短时间读主库。

强制将读请求路由到主库处理

强制将读请求路由到主库处理

对于极少数必须强一致的业务(如支付后立刻查询余额),可以通过 Hint 强制查主库。

try (HintManager hintManager = HintManager.getInstance()) {
    hintManager.setWriteRouteOnly();
    // 继续 JDBC 操作
}
try (HintManager hintManager = HintManager.getInstance()) {
    hintManager.setWriteRouteOnly();
    // 继续 JDBC 操作
}

ShardingSphere 5.x 使用 setWriteRouteOnly();4.x 老版本对应的是 setMasterRouteOnly(),示例代码需要按实际版本调整。

ShardingSphere 5.x 使用 setWriteRouteOnly();4.x 老版本对应的是 setMasterRouteOnly(),示例代码需要按实际版本调整。

setWriteRouteOnly()
setMasterRouteOnly()

注意:严禁大范围使用此方案!读写分离的初衷就是为了分担主库的读压力,若大量读请求因延迟而回退到主库,在促销、秒杀等高并发场景下容易压垮主库。更合理的取舍是:仅核心强一致链路读主库,非核心链路在业务层容忍最终一致性(如页面提示“数据同步中”)。

注意:严禁大范围使用此方案!读写分离的初衷就是为了分担主库的读压力,若大量读请求因延迟而回退到主库,在促销、秒杀等高并发场景下容易压垮主库。更合理的取舍是:仅核心强一致链路读主库,非核心链路在业务层容忍最终一致性(如页面提示“数据同步中”)。

对于这种方案,你可以将那些必须获取最新数据的读请求都交给主库处理。

延迟读取

延迟读取

还有一些朋友可能会想:既然主从同步存在延迟,那就在延迟之后读取。比如主从同步延迟 0.5s,就 1s 之后再读取数据。这个思路看起来简单,但不太可靠。

不过,如果你是这样设计业务流程就会好很多:对于一些对数据比较敏感的场景,你可以在完成写请求之后,避免立即进行请求操作。比如你支付成功之后,跳转到一个支付成功的页面,当你点击返回之后才返回自己的账户。

等待从库追上指定位置

等待从库追上指定位置

如果使用 GTID,也可以在写成功后拿到本次事务对应的 GTID,再在从库上调用 WAIT_FOR_EXECUTED_GTID_SET(gtid_set, timeout) 等待从库应用到该位点后再读。类似地,SOURCE_POS_WAIT() 可以等待副本读并应用到指定 binlog 位置。

WAIT_FOR_EXECUTED_GTID_SET(gtid_set, timeout)
SOURCE_POS_WAIT()

这种方式可以实现“读指定从库前先等它追上”,但会增加读延迟和实现复杂度,不适合所有高频请求。高并发核心链路更常见的做法仍然是:写后短时间内读主库,或者按 session/user 做 read-your-writes 路由。

总结

总结

关于如何避免读到从库旧数据,我们这里介绍了三种方案。实际上,延迟读取这种方案没办法完全避免读到旧数据,只能说可以减少出现延迟的概率而已,实际项目中一般不会作为核心方案使用。

总的来说,要想避免强一致读请求读到旧数据,一般还是要强制将那些必须获取最新数据的读请求都交给主库处理。如果你的项目的大部分业务场景对数据准确性要求不是那么高的话,可以让非核心链路容忍最终一致性。

从库也不是免费读能力。复杂报表、全表扫描、大分页和慢 SQL 打到从库,会影响复制 applier 执行,反过来加重主从延迟。读写分离之后仍然要治理慢 SQL、索引、连接池和报表流量。

什么情况下会出现主从延迟?如何尽量减少延迟?

什么情况下会出现主从延迟?如何尽量减少延迟?

我们在上面的内容中也提到了主从延迟以及避免主从延迟的方法,这里我们再来详细分析一下主从延迟出现的原因以及应该如何尽量减少主从延迟。

要搞懂什么情况下会出现主从延迟,我们需要先搞懂什么是主从延迟。

MySQL 主从同步延时是指从库的数据落后于主库的数据,这种情况可能由以下两个原因造成:

从库 I/O 线程接收 binlog 的速度跟不上主库写入 binlog 的速度,导致从库 relay log 的数据滞后于主库 binlog 的数据;

从库 applier 线程执行 relay log 的速度跟不上从库 I/O receiver 线程接收 binlog 的速度,导致从库的数据滞后于从库 relay log 的数据。

与主从同步有关的时间点主要有 3 个:

主库执行完一个事务,写入 binlog,将这个时刻记为 T1;

从库 I/O receiver 线程接收到 binlog 并写入 relay log 的时刻记为 T2;

从库 applier 线程读取 relay log 并应用到本地的时刻记为 T3。

注意:上述描述基于 MySQL 默认的异步复制模式。如果开启半同步复制,并设置 rpl_semi_sync_source_wait_point=AFTER_SYNC,主库会在写入并同步 binlog 后,等待至少一个从库确认收到事务事件,再提交到存储引擎并向客户端返回。这提高了故障切换时的数据安全性,但不等于从库已经完成应用。

注意:上述描述基于 MySQL 默认的异步复制模式。如果开启半同步复制,并设置 rpl_semi_sync_source_wait_point=AFTER_SYNC,主库会在写入并同步 binlog 后,等待至少一个从库确认收到事务事件,再提交到存储引擎并向客户端返回。这提高了故障切换时的数据安全性,但不等于从库已经完成应用。

rpl_semi_sync_source_wait_point=AFTER_SYNC

结合我们上面讲到的主从复制原理,可以得出:

T2 和 T1 的差值反映了从库 I/O receiver 线程的性能和网络传输的效率,这个差值越小说明从库 I/O receiver 线程的性能和网络传输效率越高。

T3 和 T2 的差值反映了从库 applier 线程执行的速度,这个差值越小,说明从库 applier 线程执行速度越快。

那什么情况下会出现主从延迟呢?这里列举几种常见的情况:

从库机器性能比主库差:从库接收 binlog、写入 relay log 以及应用 relay log 事件的速度会比较慢(也就是 T2-T1 和 T3-T2 的值会较大),进而导致延迟。解决方法是选择与主库一样规格或更高规格的机器作为从库,或者对从库进行性能优化,比如调整参数、增加缓存、使用 SSD 等。

从库处理的读请求过多:从库需要执行主库的所有写操作,同时还要响应读请求,如果读请求过多,会占用从库的 CPU、内存、网络等资源,影响从库的复制效率(也就是 T2-T1 和 T3-T2 的值会较大,和前一种情况类似)。解决方法是引入缓存(推荐)、使用一主多从的架构,将读请求分散到不同的从库,或者使用其他系统来提供查询的能力,比如将 binlog 接入到 Hadoop、Elasticsearch 等系统中。

大事务:运行时间比较长,长时间未提交的事务就可以称为大事务。由于大事务执行时间长,并且从库上的大事务会比主库上的大事务花费更多的时间和资源,因此更容易造成主从延迟。解决办法是避免大批量修改数据,尽量分批进行。类似的情况还有执行时间较长的慢 SQL ,实际项目遇到慢 SQL 应该进行优化。

从库太多:主库需要将 binlog 同步到所有的从库,如果从库数量太多,会增加同步的时间和开销(也就是 T2-T1 的值会比较大,但这里是因为主库同步压力大导致的)。解决方案是减少从库的数量,或者将从库分为不同的层级,让上层的从库再同步给下层的从库,减少主库的压力。

网络延迟:如果主从之间的网络传输速度慢,或者出现丢包、抖动等问题,那么就会影响 binlog 的传输效率,导致从库延迟。解决方法是优化网络环境,比如提升带宽、降低延迟、增加稳定性等。

单线程复制:MySQL 5.5 及之前,只支持单线程复制。为了优化复制性能,MySQL 5.6 引入了 多线程复制,但仅支持按库并行。MySQL 5.7 进一步完善,支持按组提交并行。MySQL 8.0.22 之后可以使用新命名:replica_parallel_workers > 0 启用多线程 applier,并结合 replica_parallel_type=LOGICAL_CLOCK 等策略提高并行应用能力。

replica_parallel_workers > 0
replica_parallel_type=LOGICAL_CLOCK

复制模式:MySQL 默认的复制是异步的,必然会存在延迟问题。全同步复制不存在延迟问题,但性能太差了。半同步复制是一种折中方案,相对于异步复制,半同步复制更主要解决的是数据安全性,而不是彻底消除读延迟。它可以让主库在返回提交成功前等待至少一个从库收到事务事件,但从库是否已经应用完成仍取决于 applier 进度。因此,半同步不能直接保证“写后立刻读从库一定读到最新值”。MySQL 5.5 开始,MySQL 以插件的形式支持 semi-sync 半同步复制,MySQL 5.7 引入了 增强半同步复制 。

……

分库分表

分库分表

读写分离主要应对的是数据库读并发,没有解决数据库存储问题。试想一下:如果 MySQL 一张表的数据量过大怎么办?

换言之,我们该如何解决 MySQL 的存储压力呢?

答案之一就是 分库分表。

在决定分库分表之前,建议先确认这几件事:

慢 SQL、索引、分页、缓存、读写分离是否已经优化过。

单表数据量、单库容量、连接数、写入 QPS 是否真的接近瓶颈。

核心查询是否能通过一个稳定的分片键覆盖。

业务是否能接受跨分片查询、分布式事务、数据迁移带来的复杂度。

分库分表不是数据库优化的第一步,更像是常规优化都扛不住之后的容量扩展方案。

什么是分库?

什么是分库?

分库 就是将数据库中的数据分散到不同的数据库上,可以垂直分库,也可以水平分库。

垂直分库 就是把单一数据库按照业务进行划分,不同的业务使用不同的数据库,进而将一个数据库的压力分担到多个数据库。

举个例子:说你将数据库中的用户表、订单表和商品表分别单独拆分为用户数据库、订单数据库和商品数据库。

垂直分库

水平分库 是把同一个表按一定规则拆分到不同的数据库中,每个库可以位于不同的服务器上,这样就实现了水平扩展,解决了单表的存储和性能瓶颈的问题。

举个例子:订单表数据量太大,你对订单表进行了水平切分(水平分表),然后将切分后的 2 张订单表分别放在两个不同的数据库。

水平分库

实际项目中,水平分库通常和水平分表一起出现:先把同一张逻辑表按行拆成多个实际表,再把这些实际表分布到不同数据库实例上。

什么是分表?

什么是分表?

分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。

垂直分表 是对数据表列的拆分,把一张列比较多的表拆分为多张表。

举个例子:我们可以将用户信息表中的一些列单独抽出来作为一个表。

水平分表 是对数据表行的拆分,把一张行比较多的表拆分为多张表,可以解决单一表数据量过大的问题。

举个例子:我们可以将用户信息表拆分成多个用户信息表,这样就可以避免单一表数据量过大对性能造成影响。

水平拆分只能解决单表数据量大的问题,为了提升性能,我们通常会选择将拆分后的多张表放在不同的数据库中。也就是说,水平分表通常和水平分库同时出现。

分表

什么情况下需要分库分表?

什么情况下需要分库分表?

遇到下面几种场景可以考虑分库分表:

单表的数据量达到千万级别以上(具体阈值取决于表结构复杂度、索引数量、硬件配置等),数据库读写开始变慢。

数据库中的数据占用的空间越来越大,备份时间越来越长。

应用的并发量太大(应该优先考虑其他性能优化方法,而非分库分表)。

不过,分库分表的成本太高,如非必要尽量不要采用。也不是单表到了千万级数据量就一定要分表,毕竟每张表包含的字段不同,它们在可接受性能下能够存放的数据量也不同,还是要具体情况具体分析。

如果性能瓶颈主要来自慢 SQL、索引设计或时间字段存储,通常应该先做常规 MySQL 优化,再考虑分库分表:

MySQL 执行计划分析:https://javaguide.cn/database/mysql/mysql-query-execution-plan.html

https://javaguide.cn/database/mysql/mysql-query-execution-plan.html

MySQL 索引详解:https://javaguide.cn/database/mysql/mysql-index.html

https://javaguide.cn/database/mysql/mysql-index.html

MySQL 索引失效场景总结:https://javaguide.cn/database/mysql/mysql-index-invalidation.html

https://javaguide.cn/database/mysql/mysql-index-invalidation.html

MySQL 时间类型数据存储建议:https://javaguide.cn/database/mysql/some-thoughts-on-database-storage-time.html

https://javaguide.cn/database/mysql/some-thoughts-on-database-storage-time.html

之前看过一篇文章分析 “InnoDB 中高度为 3 的 B+ 树最多可以存多少数据”,写的挺不错,感兴趣的可以看看。

InnoDB 中高度为 3 的 B+ 树最多可以存多少数据

常见的分片算法有哪些?

常见的分片算法有哪些?

分片算法主要解决了数据被水平分片之后,数据究竟该存放在哪个表的问题。

常见的分片算法有:

哈希分片:求指定分片键的哈希,然后根据哈希值确定数据应被放置在哪个表中。哈希分片比较适合随机读写的场景,不太适合经常需要范围查询的场景。哈希分片可以使每个表的数据分布相对均匀,但对动态伸缩(例如新增一个表或者库)不友好。

范围分片:按照特定的范围区间(比如时间区间、ID 区间)来分配数据,比如 将 id 为 1~299999 的记录分到第一个表, 300000~599999 的分到第二个表。范围分片适合需要经常进行范围查找且数据分布均匀的场景,不太适合随机读写的场景(数据未被分散,容易出现热点数据的问题)。

id
1~299999
300000~599999

一致性哈希分片:将哈希空间组织成一个环形结构,将分片键和节点(数据库或表)都映射到这个环上,然后根据顺时针的规则确定数据或请求应该分配到哪个节点上,缓解传统哈希对动态伸缩不友好的问题。一致性哈希更适合 KV 或缓存类场景;关系型分库分表里实际用得不多,范围查询、跨分片事务和二级索引问题仍然要单独设计。

在上述基础算法之上,还可以结合业务衍生出更复杂的路由策略:

映射表路由:维护一张独立的路由表来记录分片键与数据节点的映射关系,灵活但需要额外维护一套路由数据。路由表本身要考虑缓存、一致性、扩容和高可用,否则可能成为新的瓶颈或单点。

地域路由:以地理位置作为分片键,结合范围或映射表机制,将数据就近存放在特定机房(常用于 NewSQL 多活架构)。

分片键如何选择?

分片键如何选择?

分片键(Sharding Key)是数据分片的关键字段,直接影响数据分布和查询效率。一般来说,分片键应该具备以下特点:

具有共性,即能够覆盖绝大多数的查询场景,尽量减少单次查询所涉及的分片数量,降低数据库压力;

具有离散性,即能够将数据均匀地分散到各个分片上,避免数据倾斜和热点问题;

具有稳定性,即分片键的值不会发生变化,避免数据迁移和一致性问题;

具有扩展性,即能够支持分片的动态增加和减少,避免数据重新分片的开销。

实际项目中,分片键很难满足上面提到的所有特点,需要权衡一下。有些业务会把逻辑分片号写进业务 ID,比如订单 ID 中保留一段 buyer_route,这样只拿到订单 ID 时也能推导出路由。不过,不建议直接取用户 ID 后几位当路由键。如果用户 ID 来自 Snowflake、自增段或者其他有规律的生成器,低位不一定均匀,通常要先哈希再取模。

buyer_route

常见业务的分片键选择可以参考:

业务场景候选分片键注意事项订单系统用户 ID、商家 ID、订单 ID看主要查询路径,用户查订单和商家查订单可能冲突IM 消息会话 ID、用户 ID同一会话顺序和热点群聊要重点评估多租户系统租户 ID大租户容易形成热点,需要大租户单独拆分支付流水用户 ID、交易单号强一致查询和对账链路要提前设计

业务场景候选分片键注意事项

业务场景

候选分片键

注意事项

订单系统用户 ID、商家 ID、订单 ID看主要查询路径,用户查订单和商家查订单可能冲突

订单系统

用户 ID、商家 ID、订单 ID

看主要查询路径,用户查订单和商家查订单可能冲突

IM 消息会话 ID、用户 ID同一会话顺序和热点群聊要重点评估

IM 消息

会话 ID、用户 ID

同一会话顺序和热点群聊要重点评估

多租户系统租户 ID大租户容易形成热点,需要大租户单独拆分

多租户系统

租户 ID

大租户容易形成热点,需要大租户单独拆分

支付流水用户 ID、交易单号强一致查询和对账链路要提前设计

支付流水

用户 ID、交易单号

强一致查询和对账链路要提前设计

如果一个业务天然存在多条高频查询路径,可以考虑冗余一份查询维度数据,或者把部分查询交给搜索引擎、宽表、报表系统,而不是强行让一个分片键满足所有需求。

分片数量也不要只看当前数据量。物理分片太少,后续扩容会频繁;物理分片太多,连接数、路由、聚合查询和运维成本都会上升。常见做法是预先设计较多逻辑分片,例如 512 或 1024 个 bucket,再把多个 bucket 映射到少量物理库表。扩容时迁移 bucket 映射,而不是改业务取模规则。

这里我们以订单场景为例。

订单表是分库分表里很典型的例子,因为它天然有多条查询路径:

用户维度:用户查看自己的订单列表、订单详情、售后记录,常见条件是 buyer_id + create_time/status。

buyer_id + create_time/status

订单维度:通过 order_id 查询订单详情、处理支付回调、定位售后单。

order_id

商家维度:商家后台查看订单列表、按状态筛选、导出最近一段时间的订单。

大多数订单系统会优先照顾用户订单列表,而不是单纯按 order_id 分片。取舍点在查询形态:订单详情是点查,用户订单列表是高频范围查询。如果按 order_id 哈希取模,同一个用户的订单会分散到很多分片里,分页、排序、筛选都要跨分片执行。

order_id
order_id

按买家 ID 分片时,路由可以按下面的方式计算:

slot = hash(buyer_id) % (db_count * table_count)
db_index = slot / table_count
// 注意这里是除以每个库的表数,不是除以库数
table_index = slot % table_count
slot = hash(buyer_id) % (db_count * table_count)
db_index = slot / table_count
// 注意这里是除以每个库的表数,不是除以库数
table_index = slot % table_count

这里容易写错的是 db_index:slot 先除以每个库里的表数量,才能得到库编号;再对表数量取模,得到表编号。

db_index
slot

只按 buyer_id 分片还不够。通过 order_id 查询时,常见做法有两种:维护一张 order_id -> buyer_route 的路由表,或者把 buyer_route 写入订单 ID 的固定位置。前者多一次查询和一份存储,后者要求订单 ID 生成规则提前设计好位数。

buyer_id
order_id
order_id -> buyer_route
buyer_route

商家维度也要单独处理。B2C 场景下,商家和买家通常是多对多关系,不能靠把买家路由写进订单 ID 来解决商家查询。商家后台如果是低频查询,可以限制时间范围并交给 Elasticsearch、ClickHouse 或宽表;如果是高频核心链路,通常要建一张按 seller_id 分片的商家订单索引表,或者冗余一份商家订单读模型。

seller_id

订单主表和订单明细表要尽量使用同一套路由规则。比如 t_order 和 t_order_item 都按 buyer_route 分片,这样创建订单和查询订单详情可以落在同一个分片。ShardingSphere 里类似场景可以配置 binding table,但它有明确前提:同一组表使用相同分片规则,并且 SQL 关联条件包含分片键。仅仅配置 binding table,但 SQL 没有分片键条件,仍可能出现跨分片路由。

t_order
t_order_item
buyer_route

分库分表会带来什么问题呢?

分库分表会带来什么问题呢?

记住,你在公司做的任何技术决策,不光是要考虑这个技术能不能满足我们的要求,是否适合当前业务场景,还要重点考虑其带来的成本。

引入分库分表之后,会给系统带来什么挑战呢?

join 操作:需要区分单库 join 和跨分片 join。单库内有合适索引和执行计划时,join 是关系型数据库的基本能力,不应该一概否定。分库分表后的难点是跨分片 join:数据可能分布在多个库表中,中间件需要广播、路由、合并甚至做笛卡尔组合,性能和实现复杂度都会上升。对于需要跨分片 join 的地方,可以采用多次查询并在业务层组装数据,不过要考虑多次查询的一致性要求。

事务问题:同一个数据库中的表分布在了不同的数据库中,如果单个操作涉及到多个数据库,那么数据库自带的事务就无法满足我们的要求了。这个时候,我们就需要引入分布式事务了。关于分布式事务常见解决方案总结,网站上也有对应的总结:https://javaguide.cn/distributed-system/distributed-transaction.html 。

https://javaguide.cn/distributed-system/distributed-transaction.html

分布式 ID:分库之后, 数据遍布在不同服务器上的数据库,数据库的自增主键已经没办法满足生成的主键唯一了。我们如何为不同的数据节点生成全局唯一主键呢?这个时候,我们就需要为我们的系统引入分布式 ID 了。关于分布式 ID 的详细介绍&实现方案总结,可以看我写的这篇文章:分布式 ID 介绍&实现方案总结。

分布式 ID 介绍&实现方案总结

全局唯一约束问题:单库唯一索引只能保证单个分片内唯一。比如手机号、用户名、商家订单号如果没有作为分片键,数据库很难直接保证全局唯一。常见做法是建立全局唯一索引表、使用业务注册中心做预占,或者调整分片键和业务约束设计。

非分片键查询问题:如果查询条件里没有分片键,中间件无法判断应该访问哪个分片,通常只能把 SQL 广播到多个分片再合并结果。分片数量少时还能接受,分片数量上来以后,读扩散会拖慢核心链路。常见解决方式是补充路由表、冗余索引表,或者把后台检索交给搜索引擎、宽表、报表系统。

跨库聚合和分页查询问题:分库分表会导致常规聚合查询操作,如 group by,order by 等变得异常复杂。这是因为这些操作需要在多个分片上进行数据汇总和排序,而不是在单个数据库上进行。跨分片分页也很麻烦,比如查询第 1000 页,每个分片都可能需要返回前 N 页候选数据,再由中间件合并排序后截取目标页,分片数量越多,放大倍数越高。大结果集后台查询更适合走搜索引擎、宽表或离线报表系统。

动态扩缩容困难(Resharding):尤其是采用传统 Hash 取模算法时,一旦从 hash(key) % 32 扩到 hash(key) % 64,大量数据的映射关系都会变化。更稳的做法是先固定一批逻辑分片,比如 1024 个 bucket,再维护 bucket -> 物理库表 的映射;扩容时迁移部分 bucket,而不是让业务 ID 直接绑定物理表数量。也可以采用一致性哈希,或者使用支持自动 Rebalance 的分布式数据库(如 TiDB)。

hash(key) % 32
hash(key) % 64
bucket -> 物理库表

运维和变更成本:分片之后,DDL 变更、索引调整、数据备份、数据订正、故障定位和容量评估都要覆盖多个库表。上线前要准备批量变更工具、回滚方案和分片级监控,否则后续维护成本会很高。

……

另外,引入分库分表之后,一般需要 DBA 的参与,同时还需要更多的数据库服务器,这些都属于成本。

分库分表有没有什么比较推荐的方案?

分库分表有没有什么比较推荐的方案?

Apache ShardingSphere 是一款分布式数据库生态系统,可以在现有数据库之上提供分片、弹性伸缩、加密等能力。

ShardingSphere 项目是当当捐入 Apache 的,目前主要提供 ShardingSphere-JDBC 和 ShardingSphere-Proxy 两种接入方式。

ShardingSphere 适合“仍然使用传统关系型数据库,但希望通过中间层获得分片、读写分离、加密、治理等能力”的场景。它并不会改造 MySQL 内核;它是在 JDBC 或 Proxy 层提供透明路由、改写、归并和治理能力。除了读写分离和分库分表,它还提供分布式事务、数据库治理、影子库、数据加密和脱敏等功能。

相比完全自研路由层,ShardingSphere 的优势主要有三点:ShardingSphere-JDBC 更接近原生 JDBC 调用链,少一层网络代理;JDBC 和 Proxy 两种接入方式可以覆盖 Java 应用和多语言系统;分片、读写分离、加密、影子库和治理能力在同一套规则体系里配置,后续扩展成本会低一些。

ShardingSphere 提供的功能如下:

ShardingSphere 提供的功能

实际选型时,主要看团队更适合哪种接入方式:

ShardingSphere-JDBC:以 jar 包形式接入,适合 Java 应用,少一层网络转发,应用需要自己引入依赖并管理配置。

ShardingSphere-Proxy:以独立代理服务形式部署,应用通过 MySQL/PostgreSQL 协议访问,适合多语言系统或者希望把分片规则收敛到代理层的团队,但会增加一层代理运维成本。

不过,还是要多提一句:现在也有不少公司会选择 TiDB 这类分布式关系型数据库。 以 TiDB 为例,底层数据会按 key range 切成多个 Region,Region 超过阈值后会继续拆分,并由集群调度到不同 TiKV 节点上。这样可以少处理一部分手工分片带来的路由、扩容和迁移问题,但也要评估 SQL 兼容性、迁移成本、运维能力和现有生态适配情况。

自动 Region 拆分也不代表业务完全不需要关心热点。连续写入的自增主键、时间递增键、大租户热点、单 Region 初始写入等仍可能形成热点,生产环境仍然要结合业务写入模式评估热点问题。

分库分表后,数据怎么迁移呢?

分库分表后,数据怎么迁移呢?

分库分表之后,我们如何将老库(单库单表)的数据迁移到新库(分库分表后的数据库系统)呢?

比较简单同时也常用的方案是停机迁移,写个脚本把老库的数据写到新库中。比如你在凌晨 2 点,系统使用人数比较少的时候,挂一个公告说系统要维护升级预计 1 小时。然后,用脚本将老库的数据都同步到新库中。

如果你不想停机迁移数据,可以采用“存量回灌 + 增量同步 + 灰度切流”的思路。应用层双写通常很难做到真正原子,除非引入分布式事务或者把双写放进同一个具备事务能力的存储系统里。更现实的目标是保证可重试、可追踪、可校验。

不停机迁移通常分几步:

建新库表结构:分片规则、索引、唯一约束、默认值、字符集都要确认。

存量回灌:按主键范围或时间范围分批把老库数据写入新库。

增量同步:通过 Canal、Debezium 或自研 CDC 订阅 binlog,把存量回灌期间的新变更同步到新库。

双读校验:抽样或全量比对关键字段、金额、状态和数量。

灰度切读:少量读流量走新库,观察路由和结果。

切写:确认增量追平后,把写流量切到新库。

保留回滚窗口:老库继续保留一段时间,直到确认新库稳定。

防止旧数据覆盖新数据时,优先使用单调版本号、binlog 位点、GTID、业务事件版本或递增 sequence。update_time 可以辅助排查,但不建议作为唯一并发控制依据。写入新库时可以使用类似条件:

update_time
WHERE target.version IS NULL OR target.version < incoming.version
WHERE target.version IS NULL OR target.version < incoming.version

删除也要有 tombstone/version,否则旧的插入事件可能把已经删除的数据又写回来。

迁移上线前建议准备一份检查清单:

数据校验:按分片、按时间范围校验数量、金额、状态等关键字段。

灰度读流量:先让少量只读请求走新库,确认路由和查询结果正常。

回滚方案:保留老库写入链路和数据同步链路,出现问题能快速切回。

幂等写入:迁移脚本、双写任务、补偿任务都必须可重复执行。

监控告警:关注同步延迟、失败重试、分片热点、跨库查询耗时和数据库连接数。

总结

总结

读写分离主要是为了将数据库的读操作和写操作分散到不同节点上,核心收益是提升读扩展能力、降低主库读压力。

读写分离基于主从复制,MySQL 主从复制依赖 binlog。

分库 就是将数据库中的数据分散到不同的数据库上。分表 就是对单表的数据进行拆分,可以是垂直拆分,也可以是水平拆分。

引入分库分表之后,需要系统解决事务、分布式 ID、跨分片 join、非分片键查询、跨库聚合和数据迁移等问题。

订单表这类业务不要只看 order_id 点查,还要看用户订单列表、商家后台查询、支付回调和运营检索等入口。

order_id

如果必须手动分库分表,可以优先调研 ShardingSphere;如果团队能接受引入分布式关系型数据库,也可以评估 TiDB 这类方案,但迁移和运维成本要提前算清楚。

写在最后

写在最后

如果内容对你有帮助的话,欢迎顺手给 JavaGuide 点一个免费的 Star 支持一下:GitHub | Gitee。

GitHub

Gitee

JavaGuide 已持续维护近七年,累计 6100+ 次提交,来自 620+ 位贡献者共同完善。你的 Star、反馈和 PR,都是这个项目继续更新的动力。

如果你正在准备后端/AI 应用开发面试,也可以了解一下我的知识星球,里面包括后端和 AI 实战项目、简历优化、一对一提问和高频考点资料,已经持续维护六年。

知识星球