ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多数据源与分库分表:路由时机、事务绑定与连接池避坑

多数据源与分库分表:路由时机、事务绑定与连接池避坑

简介:面向需要掌握 Spring Boot 多数据源与分库分表实践的 Java 开发者,这份工程示例围绕同库水平分表、多数据源动态切换与连接池管理展开,技术栈采用 MyBatis-Plus 的 dynamic-datasource、Sharding-JDBC 和 Druid,适合已具备 Spring Boot 基础、希望理解分片规则、数据源路由及事务边界的读者对照学习。压缩包共 162 个文件、约 164KB,主体为 115 个 XML 映射与配置、14 个 Java 源码及对应编译产物、6 个 YAML 与 3 个 properties 环境配置,另含 JUnit 测试、日志、Maven 包装脚本和依赖 jar,目录组织接近可直接运行的工程。已有 625 人学习。读者可从中获得多数据源接入、分库分表规则配置、分片键选择、实体与 Mapper 分层、单元测试验证及 SQL 日志排查等完整线索,便于快速复现实验。对希望把数据源切换、分片算法配置和连接池参数迁移到自身业务的中级开发者,也具备直接参考价值。

1. 多数据源与分库分表:为什么“加个注解就切换”往往一开始就是错的

很多人对多数据源的第一印象,是“加一个@DS注解就能切换”。但在 Spring Boot 项目里,一旦真的同时接入多个数据源并执行分库分表,最先出问题的往往不是 SQL 写错,而是事务、连接池和路由时机。表象是“有时候查到了别的库的数据”,底层却是数据源在事务开启那一刻就被绑定到线程上了。Spring Boot 里“多数据源 + 分库分表”这件事,值得搞懂的不是某一个注解怎么用,而是路由决策发生在请求生命周期的哪一步、绑定在哪个上下文里、什么时候必须释放。一旦把这条链路讲清楚,用注解切换数据源、解析分片键、处理分布式事务,都是这条链路上的具体实现。

2. 路由的时机:分片键是在哪一步被消费掉的

2.1 从 DataSource 到路由数据源的替换点

Spring 的JdbcTemplate、MyBatis 的SqlSessionFactory,最终都会向容器要一个DataSource,然后调getConnection()。分库分表框架要做的第一件事,就是把这个“真实数据源”替换成一个路由数据源——它本身也实现DataSource接口,但getConnection()返回的不是物理连接,而是一层包装。

// 路由数据源的骨架:不是真的连库,而是决定"连哪个库" public class RoutingDataSource extends AbstractDataSource { // 真正持有 N 个物理数据源,key 形如 ds0 / ds1 / ds0.t_order_0 private Map<String, DataSource> targetDataSources; @Override public Connection getConnection() throws SQLException { // 关键:路由依据来自 ThreadLocal,而不是方法参数 String dsKey = DataSourceContextHolder.get(); if (dsKey == null) { // 没有显式指定时,退化到默认库;这一步是很多"串库"事故的源头 dsKey = "ds0"; } DataSource target = targetDataSources.get(dsKey); if (target == null) { throw new IllegalStateException("no datasource bound: " + dsKey); } return target.getConnection(); } }

这段代码说明了一个本质:路由不是“每次查询时按 SQL 重新算”,而是“按当前线程上下文里已经算好的 key 去取”。DataSourceContextHolder通常是ThreadLocal实现,这意味着路由结果的有效范围是当前线程 + 当前绑定周期。参数说明上,targetDataSources的 key 设计成两级(ds0表示库、ds0.t_order_0表示库加表)是常见做法,因为库路由和表路由的时机并不相同:库通常在进入方法前就能定,表往往要解析 SQL 里的分片键才能定。

2.2 库路由与表路由为什么必须分开算

很多人把“分库”和“分表”当成一件事,实际上它们在实现上是两个阶段。库路由的输入通常是业务维度的标识(用户 ID、租户 ID),在方法入口由注解或拦截器给出;表路由的输入是 SQL 语句里的分片键值,必须等 SQL 生成之后、执行之前才能拿到。

// 表路由的典型插入点:SQL 已经拼好,但还没交给 JDBC public class ShardingInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { Object[] args = invocation.getArgs(); MappedStatement ms = (MappedStatement) args[0]; // args[1] 是参数对象,分片键通常藏在里面 Object param = args[1]; // 1. 从参数中抽取分片键值(可能是 userId、orderId) ShardingValue sv = ShardingKeyExtractor.extract(ms.getId(), param); // 2. 用分片算法算出真实表名:t_order -> t_order_3 String actualTable = ShardingAlgorithm.doShard(sv); DataSourceContextHolder.setTable(actualTable); try { // 3. 改写的 SQL 在后续 StatementHandler 中生效 return invocation.proceed(); } finally { // 4. 必须清理,否则线程复用会串表 DataSourceContextHolder.clearTable(); } } }

这段代码里有几个参数值得注意。ms.getId()是 MyBatis 的语句全限定名,常被用作“哪条 SQL 需要分片”的判断依据;没有配置分片规则的语句要原样放行,否则全局查询会报错。ShardingKeyExtractor是容易出问题的地方:分片键可能藏在Map、@Param注解参数、甚至是嵌套对象里,抽取失败就直接抛异常,绝不能“抽不到就当默认值”,因为静默降级会让数据写进错误的表。

finally里的清理同样关键。线程池复用的线程如果不清理ThreadLocal,下一次请求可能拿到上一次的表名,这类 bug 的现象是“偶发查到别人的数据”,排查时你会怀疑索引、怀疑缓存,最后发现是上下文没清干净,算是典型的一个坑。

2.3 事务开启的一瞬间,路由就被固定了

这是整个机制里最反直觉的部分。Spring 的DataSourceTransactionManager在doBegin()时会调用dataSource.getConnection()并把这个连接放进TransactionSynchronizationManager的ThreadLocal里。也就是说,事务一旦开启,后续所有getConnection()都会拿到同一个连接,路由决策不会再发生第二次。

// 伪代码:Spring 事务同步管理器的关键行为 protected void doBegin(Object transaction, TransactionDefinition definition) { DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction; if (txObject.getConnectionHolder() == null) { // 这里调用的是路由数据源,路由结果此刻被固化 Connection con = obtainDataSource().getConnection(); txObject.setConnectionHolder(new ConnectionHolder(con)); } // 后续无论怎么切 @DS,只要还在同一事务里,拿到的都是这根连接 TransactionSynchronizationManager.bindResource(obtainDataSource(), txObject.getConnectionHolder()); }

由此可以推出几条硬性约束:在事务内切换数据源是无效的,除非你显式地把事务挂起再重新开启;跨库写操作不可能靠一个本地事务兜住,必须引入两阶段提交或最终一致性方案;分表路由如果发生在事务开启之后,那事务绑定的连接对应的库必须是分表所在的那个库。常见的实现思路是“先确定库,再开事务,最后做表路由”,顺序乱了就会翻车。

提示:判断一个方法里数据源是否真的切换成功,不要看日志里打印的注解,要看连接的实际 URL。在getConnection()出口打印connection.getMetaData().getURL()是最省事的一个办法。

3. 分片算法与分布式主键:数据分布得均匀,主键又不能撞

3.1 取模、范围、哈希三种分片算法的取舍

分片算法决定了数据怎么落到具体库表,它的选择直接决定了后续扩容的难度。取模最简单,均匀性也最好,但扩容时需要数据迁移;范围分片对范围查询友好,但容易出现热点,比如按时间分片时,写入永远集中在最新一张表上;一致性哈希在扩容时迁移量小,但实现复杂度高,且节点少时倾斜明显。

算法均匀性范围查询扩容成本适用场景
取模高差(需扫全部分片)高(几乎全量迁移)分片数稳定、按主键等值查询
范围低(易热点)好低(只增新分片)时间序列、归档类数据
一致性哈希中差低节点频繁增减的弹性场景

有一类做法是“先按业务维度分库,再按主键取模分表”,它其实是在折中:库的数量少、变更成本高,用业务维度保证同租户数据在同一个库,便于做库内事务;表的数量多、都在一个库里,用取模保证均匀。这个组合在真实项目里出现的频率最高,因为它把“跨库”这个最难的问题压缩到了最低。

3.2 为什么自增主键在分表之后必然失效

单表时AUTO_INCREMENT由数据库保证全局唯一,分表之后每张表各管各的,t_order_0和t_order_1都会从 1 开始发号,主键冲突是必然的。这是分库分表里少数“只要做了就一定会遇到”的问题,没有例外。

// 雪花算法的核心:时间戳 + 机器位 + 序列号,拼成一个 long public class SnowflakeIdGenerator { private static final long EPOCH = 1700000000000L; // 自定义起始时间,决定可用年限 private static final long WORKER_BITS = 10L; // 机器位:最多 1024 个节点 private static final long SEQ_BITS = 12L; // 序列位:单节点每毫秒 4096 个 private long lastTimestamp = -1L; private long sequence = 0L; private final long workerId; public synchronized long nextId() { long now = System.currentTimeMillis(); if (now < lastTimestamp) { // 时钟回拨:直接抛错比悄悄等待更安全,否则可能发出重复 ID throw new IllegalStateException("clock moved backwards"); } if (now == lastTimestamp) { sequence = (sequence + 1) & ((1 << SEQ_BITS) - 1); if (sequence == 0) { // 同一毫秒序列号用尽,自旋到下一毫秒 while ((now = System.currentTimeMillis()) <= lastTimestamp) { } } } else { sequence = 0L; } lastTimestamp = now; return ((now - EPOCH) << (WORKER_BITS + SEQ_BITS)) | (workerId << SEQ_BITS) | sequence; } }

这段代码的参数背后都是成本计算。EPOCH决定 ID 可用到哪一年,41 位时间戳大约能用 69 年;WORKER_BITS为 10 意味着最多 1024 个节点同时发号,容器化环境下节点数一旦超过这个值就必须扩位,否则会撞号;SEQ_BITS为 12 意味着单节点每毫秒上限 4096 个,超过就要等到下一毫秒。时钟回拨那段是最有争议的地方:等待方案在容器里表现不稳定,直接抛错则会把问题暴露给调用方,常见做法是配合 NTP 校时加上“回拨幅度小就等待、幅度大就告警”。

注意:雪花 ID 是有序的,但如果把它用作分片键,跨毫秒的连续 ID 会集中在最新的分片上,导致写入热点。分片键最好选业务属性,而不是发号器产物。

3.3 分片键选错,后面全是补救

分片键的选择标准只有两条:查询必须能带上它,值必须足够分散。选了用户 ID 作为分片键,按订单号查就要扫全部分片;选了订单 ID,按用户查历史订单就要扫全部分片。很多系统最终会引入“基因法”:生成订单 ID 时把用户 ID 的低位编码进去,这样订单 ID 自身就隐含了分片信息,两种查询都能命中。

这个做法不是没有代价的。ID 里携带业务信息意味着 ID 的生成逻辑和分片规则耦合在一起,日后调整分片数会非常麻烦。我一般会把它理解为一种“用 ID 长度换查询便利”的权衡,只有在查询模式确实双重的时候才值得。

4. 避坑与排查:那些跑起来才发现的问题

4.1 事务内切库静默失效

现象:方法上标了切换注解,日志里也打印了目标库,但写进去的数据仍然出现在原库里。原因:事务已经开启,连接被绑定,注解只改了ThreadLocal里的 key,却没有影响已经拿到的连接。解决:把切换动作提到事务方法之外,由外层方法先切库,再调用带@Transactional的内层方法;或者用编程式事务配合事务挂起。排查手段是打印连接的真实 URL,而不是看注解值。

4.2 线程池复用导致上下文串号

现象:压测时偶发“查到不属于自己的数据”,且不可复现。原因:ThreadLocal里的分片信息没在请求结束时清理,下一个请求复用了同一个线程,读到了残留值。解决:在拦截器的finally里强制清理,或者在过滤器最外层统一remove()。这类问题的迷惑性很强,因为单线程调试永远不会出现,只有并发时才暴露。

4.3 跨库分页的 total 是错的

现象:分页查询第一页正常,翻到后面数据重复或缺失,总数也对不上。原因:LIMIT offset, size被下推到每个分片执行,各分片返回的是自己局部有序的结果,归并后整体顺序并不正确。解决:改为按分片键路由到单库查询,或者使用“二次查询法”:先取各分片的前 offset+size 条,算出全局最小边界,再回各分片补查。这条限制从机制上无法回避,只能绕。

4.4 全局表被误当成普通表分片

现象:字典表、配置表在各库里各有一份,更新后数据不一致。原因:这类表没有分片键,被默认规则随机路由到了某个库。解决:把它们注册为广播表,写操作同步到所有分片,读操作任取一个。代价是写放大,所以只适合低频变更的小表,不要拿它处理业务主表。

4.5 连接池数量按单库估算

现象:分片数从 4 扩到 16 后,数据库连接数暴涨,触发服务端上限。原因:每个物理数据源都持有独立的连接池,总数等于“分片数 × 每池连接数”。解决:按“总连接预算 ÷ 分片数”反算每池上限,同时区分读写池;分片越多,单池就要越小,否则扩容时被连接数卡住。

5. 设计取舍:一致性与复杂度,你只能选一个压住

多数据源加分库分表这套组合,本质上是在用应用层的复杂度换单库的容量上限。这个交换划不划算,取决于业务能不能接受“跨分片查询变慢、跨库事务变最终一致”这两条代价。如果不能接受,那就该先做读写分离、加缓存、做冷热分离,把分库分表往后排——这些手段的复杂度低一个量级,能撑住的量级却往往被低估。

真要上,我倾向的验证顺序是:先把路由层单独抽出来做单元测试,覆盖“分片键缺失、类型不匹配、分片数变更”三类边界;再用影子库跑一遍全链路,重点看事务边界内的路由行为是否和预期一致;最后才是压测,因为性能问题通常是最后暴露的,而正确性问题必须提前锁死。判断路由是否真的生效,最可靠的办法始终是看连接元数据里的 URL,而不是信任框架日志——日志打的是“准备切到哪”,不是“实际连到哪”,这中间的差值就是绝大多数问题的来源。

有一个取舍我踩过坑:早期为了省事,把库路由和表路由合并成一个注解,结果遇到“同库不同表要跨表查询”的场景时,注解语义就打架了。后来拆成两层配置,库路由固定在入口,表路由交给 SQL 解析,边界才清晰。复杂的东西拆开看都不难,难的是拆的时机——等你发现语义冲突再拆,改动面已经很大了。

从那以后我每次接入新的分片规则,都强制先跑一遍“分片键为 null”的用例,看它是抛异常还是静默走默认库。这个习惯帮我拦下过好几次数据写错库的事故。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进