多数据源与分库分表实战:动态路由、分片算法与跨库事务避坑
简介围绕多数据源与数据库分库分表搭建的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() { MapObject, 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接收的 Mapkey 必须和determineCurrentLookupKey()的返回类型对得上setDefaultTargetDataSource是必设项不设的话 key 为空时会直接抛异常。这里有个容易忽略的点——DynamicDataSource需要重写afterPropertiesSet()来解析 Map 里的 DataSource否则延迟解析会出问题。2.2 ThreadLocal 与 AOP 的隐式约定路由键存哪绝大多数实现选ThreadLocal。原因是 Web 请求天然是“一个请求一个线程”把路由键绑在线程上整条调用链都能读到不用一层层传参。public final class DataSourceContextHolder { private static final ThreadLocalString 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 路由键丢失的三种典型场景即便切面写对了路由键在某些场景下还是会丢而且丢得很隐蔽。第一种是异步线程。主线程设了路由键提交到线程池后子线程是全新的 ThreadLocalget()返回 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在分布式部署时必须全局唯一否则不同机器同一毫秒会生成相同 IDepoch决定可用年限41 位时间戳约 69 年sequence的 12 位决定单机每毫秒上限 4096 个。时钟回拨是雪花算法的经典坑now lastTimestamp时如果直接返回可能生成重复 ID稳妥做法是报警并短暂等待或切换到备用 workerId。4.3 柔性事务与最终一致性跨库写入无法用单库的 ACID 事务包住。强一致方案两阶段提交性能差、协调者单点在高并发场景基本不用。工程上更常见的是最终一致性本地事务 消息表 / 事务消息 补偿重试。典型流程是A 库扣款和写消息表在同一个本地事务里提交然后异步把消息投递到 MQB 库消费消息后加款失败则重试超过阈值进死信人工处理。整个过程任何一个环节挂掉数据都不会丢只是会短暂不一致。这套方案的代价是“中间态”可见。用户可能看到扣了款但没到账需要一个“处理中”的状态兜住。很多团队为了省事直接用分布式事务框架结果发现性能还不如消息方案最后又改回来——这种反复很常见。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 生成这几块拆成了可运行的最小示例对照着改比从零搭要省不少时间需要的话可以取来直接跑一遍重点看路由键在事务和异步线程里的表现。希望帮到你。本文还有配套的精品资源点击获取