多数据源与分库分表实战:动态路由、分片算法与跨库事务避坑

发布时间:2026/11/4 8:47:10
多数据源与分库分表实战:动态路由、分片算法与跨库事务避坑

简介:围绕多数据源与数据库分库分表搭建的Spring Boot实战样例,面向已掌握基础CRUD、希望理解动态数据源切换与水平分表落地的后端开发者。项目以mybatis-plus的dynamic-datasource管理多数据源,用sharding-jdbc实现同库内按规则水平分表,并接入Druid连接池、MyBatis-Plus与Lombok,配合JUnit测试验证数据访问逻辑。压缩包共162个文件、约164KB,包含Java源码与测试类、Spring配置及yml/properties、MyBatis XML映射、日志和Maven包装文件等,结构紧凑,便于直接导入IDE运行和改造。已有625人学习,适合用来对照配置、梳理分表规则、观察多数据源与连接池协作方式,并补充测试与排错思路。

1. 多数据源 + 分库分表:为什么拆了库反而更慢了

很多人第一次在 Spring Boot 里同时接上多数据源和分库分表,第一反应是“这下性能该起飞了”。结果上线一看,某些接口的响应时间反而翻了倍。这不是玄学,是分片之后 SQL 的执行路径变了——原本一条走索引就能搞定的事,现在可能要跨多个物理库、归并多份结果集、再做一次内存排序。多数据源和分库分表解决的是单库容量与写入瓶颈,不是万能加速器。搞懂它背后到底改写了什么执行路径,才能判断哪条 SQL 会踩坑、哪张表不该急着拆。下面从路由、分片算法、跨库查询三层往下挖,最后落到那些真正让人翻车的细节。

2. 动态数据源路由:AbstractRoutingDataSource 到底怎么选库

Spring Boot 生态里最经典的多数据源实现,绕不开AbstractRoutingDataSource。它本身不创建连接,只干一件事:在每次获取连接之前,问一个叫determineCurrentLookupKey()的方法“这次该去哪个库”。这个设计的巧妙之处在于,它把“选库”这个决策从业务代码里彻底剥离出来,变成了一个可插拔的钩子。开发者只需要关心路由键是什么、存在哪,剩下的连接管理、事务绑定全都交给框架。

理解这一层,是搞懂多数据源所有坑的起点。因为后面遇到的脏路由、全库扫描、事务串库,本质上都是“路由键在某个时刻不见了”或“路由键被错误复用”。

2.1 一个 Map 撑起的路由骨架

先看核心骨架。AbstractRoutingDataSource内部维护两个东西:一个targetDataSources映射表,一个defaultTargetDataSource兜底。每次调用getConnection(),它会先用determineCurrentLookupKey()拿到一个 key,去映射表里找对应的DataSource,找不到就用兜底。

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从 ThreadLocal 取出当前线程绑定的数据源标识 // 返回 null 时会走 defaultTargetDataSource return DataSourceContextHolder.get(); } }

上面这个类只有一行有效逻辑,但它决定了整个系统的选库行为。determineCurrentLookupKey()的返回值会作为targetDataSources这个 Map 的 key 去查表,所以返回值类型必须和 Map 的 key 类型一致。很多人在这里返回枚举、返回字符串混用,最后查不到就默默走了默认库,问题藏得很深。

配置类把这套映射关系装配起来:

@Configuration public class DataSourceConfig { @Bean public DataSource dynamicDataSource() { Map<Object, Object> targetMap = new HashMap<>(); // key 就是路由键,value 是真实数据源 targetMap.put("master", masterDataSource()); targetMap.put("order_0", orderDataSource0()); targetMap.put("order_1", orderDataSource1()); DynamicDataSource ds = new DynamicDataSource(); ds.setTargetDataSources(targetMap); // 兜底库:路由键为空或查不到时的最后一道防线 ds.setDefaultTargetDataSource(masterDataSource()); return ds; } }

参数说明:setTargetDataSources接收的 Map,key 必须和determineCurrentLookupKey()的返回类型对得上;setDefaultTargetDataSource是必设项,不设的话 key 为空时会直接抛异常。这里有个容易忽略的点——DynamicDataSource需要重写afterPropertiesSet()来解析 Map 里的 DataSource,否则延迟解析会出问题。

2.2 ThreadLocal 与 AOP 的隐式约定

路由键存哪?绝大多数实现选ThreadLocal。原因是 Web 请求天然是“一个请求一个线程”,把路由键绑在线程上,整条调用链都能读到,不用一层层传参。

public final class DataSourceContextHolder { private static final ThreadLocal<String> CTX = new ThreadLocal<>(); public static void set(String key) { CTX.set(key); } public static String get() { return CTX.get(); } // 必须用 remove,避免线程池复用时的内存泄漏和脏数据 public static void clear() { CTX.remove(); } }

配套的切面负责在方法进入时设 key、退出时清理:

@Aspect @Component public class DataSourceAspect { @Around("@annotation(ds)") public Object switchDs(ProceedingJoinPoint pjp, DS ds) throws Throwable { String key = ds.value(); try { DataSourceContextHolder.set(key); return pjp.proceed(); } finally { // 无论成功失败都要清,否则线程被复用时路由键会残留在下一个请求里 DataSourceContextHolder.clear(); } } }

这段代码的关键在finally里的clear()。Tomcat 的线程池是复用的,一个线程处理完请求 A 后可能马上去处理请求 B。如果 A 没有清理路由键,B 进来时get()会读到 A 留下的值,结果 B 的查询打到了 A 的库上——这就是典型的“脏路由”。表现是数据时对时错,很难复现。

2.3 路由键丢失的三种典型场景

即便切面写对了,路由键在某些场景下还是会丢,而且丢得很隐蔽。

第一种是异步线程。主线程设了路由键,提交到线程池后子线程是全新的 ThreadLocal,get()返回 null,直接走默认库。业务代码不会报错,只是查错了地方。

第二种是事务传播。Spring 的事务在方法进入时就已经绑定好连接,如果路由键是在事务内部才设置的,那这次事务用的还是外层连接对应的数据源,切面形同虚设。

第三种是嵌套调用里@DS注解失效。同类内部方法互相调用不走代理,注解不生效,路由键压根没被设置。

这三种情况的共同点是“不报错、只是查错库”,排查时只能靠日志里打印实际数据源的标识。稳妥的做法是在determineCurrentLookupKey()里加一行 debug 日志,把 key 和当前线程名一起打出来,出问题时对照请求链路一查就清楚。

3. 分片键与路由算法:一条 SQL 如何被改写成 N 条

多数据源解决的是“库”的隔离,分库分表解决的是“库内表”的隔离。两者叠加时,一次查询可能要先选库、再选表,两层路由叠在一起。而这一切的起点,是分片键。

3.1 分片键的选择决定了系统的天花板

分片键选错,后面所有优化都是在补窟窿。常见的选择标准有三条:基数足够大、分布足够均匀、查询频率足够高。订单场景选订单号或用户 ID,用户场景选用户 ID,日志场景选时间——这些是血泪经验总结出来的,因为分片键一旦选定,绝大多数查询都必须带上它,否则就会退化成全库扫描。

举个例子:订单表按user_id分片,那“按订单号查订单”这个高频需求就必须额外建一张映射表,从订单号查到user_id,再去对应分片取数据。如果一开始按订单号分片,“查某用户的所有订单”又得改成扫全部片。没有两全的分片键,只有“主查询路径优先”的取舍。

3.2 取模、范围、一致性哈希的取舍

分片算法常见三类,优缺点差异明显:

算法优点缺点适用场景
取模分布均匀、实现简单扩容时几乎全量迁移分片数稳定、数据量可预估
范围扩容方便、按区间定位快容易热点、数据倾斜时间序列、日志类数据
一致性哈希扩容时只迁移少量数据实现复杂、有虚拟节点调优成本分片数会动态增长

取模的实现最小:

public class ModShardingAlgorithm { private final int shardCount; public ModShardingAlgorithm(int shardCount) { this.shardCount = shardCount; } // 对分片键取模,得到物理表下标 // floorMod 保证负数也能得到非负结果 public int shard(long shardKey) { return (int) Math.floorMod(shardKey, shardCount); } }

参数说明:shardCount是物理表数量,一般取 2 的幂方便后续扩容;shardKey是分片键值,注意用long避免溢出。Math.floorMod而不是%,是因为%对负数返回负值,会导致下标越界。这个细节在用户 ID 为负数或哈希值为负时就会翻车。

取模的死穴在扩容。假设从 4 片扩到 8 片,原本id % 4 = 1的数据,在新规则下id % 8可能是 1 也可能是 5,大约一半数据要迁移。这就是为什么大厂分片数一上来就定死,比如直接分 1024 片,映射到少量物理库上,扩容时只挪库不挪片。

3.3 SQL 改写与归并的底层动作

分片中间件在收到一条 SQL 后,实际做了四步:解析、改写、路由、归并。

解析是把 SQL 拆成 AST,识别出分片键在哪、涉及哪些表;改写是把逻辑表名换成物理表名,比如t_order改成t_order_3;路由是算出目标分片集合;归并是把多个分片返回的结果集合并,做排序、分页、聚合。

其中最容易出问题的是归并阶段的分页。LIMIT 10 OFFSET 100在单库是“跳过 100 条取 10 条”,但在分片场景下,每个分片都要取LIMIT 110,然后在内存里合并排序后再取第 101 到 110 条。分片越多,内存压力越大,深分页的性能衰减是平方级的。所以分片系统里深分页基本要靠“游标”或“按分片键定位”来规避,OFFSET越大越慢,这一点和单库的直觉完全不同。

4. 跨库 JOIN 与分布式事务:分片之后必须直面的账

单库时代,JOIN 是数据库的基本能力。分片之后,JOIN 变成了一个需要专门设计的问题——跨库的数据不在一个连接里,数据库层面根本 join 不了。

4.1 广播表、绑定表与冗余字段的补偿

主流中间件给了三种解法。广播表是把数据量小、变更少的字典表在每个分片都存一份,查询时直接本地 join;绑定表是让分片规则一致的两张表(比如订单和订单明细都按order_id分片)落在同一个物理库,join 退化成单库 join;冗余字段是把高频 join 的字段直接冗余到主表,用空间换掉 join。

-- 绑定表示例:订单与明细按同一个分片键分片 -- 两张表的数据一定落在同一个物理库,join 不会跨库 CREATE TABLE t_order ( order_id BIGINT PRIMARY KEY, user_id BIGINT, amount DECIMAL(10,2) ); CREATE TABLE t_order_item ( item_id BIGINT PRIMARY KEY, order_id BIGINT, sku_id BIGINT );

上面两张表都按order_id分片,中间件在路由时能识别出 join 的两边分片键一致,直接下推到单库执行,这是性能最好的一种情况。反过来,如果订单按user_id分、明细按order_id分,join 就会退化成笛卡尔积式的跨库关联,中间件要么报错要么在内存里硬拼,数据量一大直接 OOM。

冗余字段的代价是数据一致性。下单时把用户名冗余进订单表,用户改名后历史订单里的名字就旧了。这类不一致通常靠“写时双写 + 异步补偿”来兜,或者干脆接受“订单快照”的语义——记录的是下单那一刻的状态,反而更符合业务。

4.2 全局 ID 的生成策略

分片之后,数据库自增主键彻底失效——每个分片都从 1 开始,主键必然冲突。常见方案有三类:UUID、号段模式、雪花算法。

UUID 简单但无序,作为主键会导致 B+ 树频繁分裂,写入性能差;号段模式从中心服务批量领一段 ID,用完再领,性能好但要维护中心服务;雪花算法在本地生成,趋势递增,是目前最常用的。

public class SnowflakeIdGenerator { private final long workerId; // 自定义纪元,避免 41 位时间戳过早耗尽 private final long epoch = 1700000000000L; private long sequence = 0L; private long lastTimestamp = -1L; public synchronized long nextId() { long now = System.currentTimeMillis(); if (now == lastTimestamp) { // 同一毫秒内递增序列号,12 位最多 4096 个 sequence = (sequence + 1) & 0xFFF; if (sequence == 0) { // 序列号用尽,自旋到下一毫秒 while (now <= lastTimestamp) { now = System.currentTimeMillis(); } } } else { sequence = 0L; } lastTimestamp = now; // 41 位时间戳 + 10 位机器号 + 12 位序列号 return ((now - epoch) << 22) | (workerId << 12) | sequence; } }

参数说明:workerId在分布式部署时必须全局唯一,否则不同机器同一毫秒会生成相同 ID;epoch决定可用年限,41 位时间戳约 69 年;sequence的 12 位决定单机每毫秒上限 4096 个。时钟回拨是雪花算法的经典坑,now < lastTimestamp时如果直接返回,可能生成重复 ID,稳妥做法是报警并短暂等待,或切换到备用 workerId。

4.3 柔性事务与最终一致性

跨库写入无法用单库的 ACID 事务包住。强一致方案(两阶段提交)性能差、协调者单点,在高并发场景基本不用。工程上更常见的是最终一致性:本地事务 + 消息表 / 事务消息 + 补偿重试。

典型流程是:A 库扣款和写消息表在同一个本地事务里提交,然后异步把消息投递到 MQ,B 库消费消息后加款,失败则重试,超过阈值进死信人工处理。整个过程任何一个环节挂掉,数据都不会丢,只是会短暂不一致。

这套方案的代价是“中间态”可见。用户可能看到扣了款但没到账,需要一个“处理中”的状态兜住。很多团队为了省事直接用分布式事务框架,结果发现性能还不如消息方案,最后又改回来——这种反复很常见。

5. 避坑:分库分表落地时最容易翻车的几件事

前面讲的是机制,这一章讲的是机制没被正确使用时的表现。这些问题有个共同点:上线前测试环境一切正常,上线后偶发、难复现、排查方向容易跑偏。

5.1 路由键丢失导致全库扫描

现象:某个查询平时几十毫秒,高峰期突然变成几秒,慢查询日志里出现“扫描全部数据源”的记录。

原因:查询条件没带分片键,中间件无法定位分片,只能把 SQL 广播到所有库执行再归并。数据量越大越慢,而且会打满所有库的连接池。

解决:强制校验查询条件必须包含分片键,在 DAO 层加拦截;对确实无法带分片键的查询(比如后台运营查单),走单独的映射表或离线数仓,不要直接打到分片库上。

5.2 分片键变更引发数据迁移黑洞

现象:业务要把分片键从user_id改成order_id,评估时觉得“改个字段而已”,实际迁移时发现历史数据要全量重分布。

原因:分片键变了,数据所在的分片就变了,几乎等于全表迁移。迁移期间双写、对账、切流每一步都可能出问题。

解决:分片键在设计阶段就要定死。真要改,走“新建表 + 双写 + 存量迁移 + 灰度切读 + 下线旧表”的完整流程,每一步都要有回滚方案,不能图快。

5.3 自增主键冲突与排序错乱

现象:插入时报主键重复,或者分页结果里出现重复/遗漏的记录。

原因:没换全局 ID,各分片自增撞车;或者分页按自增主键排序,但主键在不同分片间不连续,归并后顺序错乱。

解决:统一用雪花算法或号段模式生成主键;排序字段改用有业务含义的时间戳或业务序号,不要依赖数据库自增。

5.4 分布式事务超时后的状态不一致

现象:用户反馈扣款成功但订单没生成,或者反过来,查日志发现事务提交超时但数据已落库。

原因:跨库操作没有统一的事务边界,某个环节超时后,前一段已提交、后一段没执行,中间态没人处理。

解决:所有跨库写操作都必须有补偿机制——消息表 + 定时对账 + 人工兜底。超时不是异常,是必须设计的状态。

5.5 连接池配置与数据源爆炸

现象:应用启动越来越慢,运行中频繁出现“获取连接超时”。

原因:每个物理库一个连接池,分片数一多,连接总数翻倍增长,数据库端的连接数被打满。

解决:按“实际并发量 / 分片数”反推每池大小,而不是每个池都配默认值;对不常访问的冷分片用懒加载;监控各池的活跃连接数,把配置调到能用但不浪费的水平。

6. 设计取舍:从单库到分片的演进路径该怎么选

分库分表不是起点,是终点。我见过太多项目一上来就奔着分片去,结果数据量还没到百万级,复杂度先把自己拖垮了。合理的演进路径是:单库 → 读写分离 → 垂直分库 → 水平分片,每一步都有明确的触发条件。

单库阶段关注索引和慢查询,读写分离解决读多写少的场景,垂直分库按业务边界拆分降低耦合,只有当单表数据量真的到千万级以上、写入成为瓶颈时,水平分片才有意义。提前分片的代价是:所有查询都要考虑分片键、所有事务都要考虑跨库、所有 join 都要重新设计——这些成本在数据量小的时候纯属浪费。

真到了要分片的那一步,中间件选型同样是取舍。代理层方案(如 ShardingSphere-Proxy)对应用透明,DBA 友好,但多一跳网络开销;客户端方案(如 ShardingSphere-JDBC)性能好、无额外部署,但和 Spring Boot 版本耦合紧,升级要跟着走。团队里如果有专职 DBA,代理层更省心;如果是纯应用团队,客户端方案落地更快。

分片数量也是取舍。分太少扛不住增长,分太多连接池和运维成本爆炸。经验值是“按未来三年的数据量预估,单表控制在 500 万到 2000 万行之间”,然后取 2 的幂方便扩容。

我自己的习惯是:每次决定分片之前,先拿真实数据跑一遍容量测算,算出三年后的单表行数和 QPS,再看这些数字是否真的越过了单库的红线。有一次就是因为跳过了这一步,凭感觉分了 64 片,结果一年后数据量只有预估的三分之一,白白背了 64 个连接池的运维包袱,后来花了两周才合回去。从那以后,我每次做分片决策都强制走一遍容量测算和回滚演练,不再凭直觉拍板。

这份资源里把多数据源配置、分片算法、全局 ID 生成这几块拆成了可运行的最小示例,对照着改比从零搭要省不少时间,需要的话可以取来直接跑一遍,重点看路由键在事务和异步线程里的表现。希望帮到你。

本文还有配套的精品资源,点击获取