分库分表多数据源切换实战:从路由机制到跨库事务踩坑

发布时间:2026/10/31 11:14:29
分库分表多数据源切换实战:从路由机制到跨库事务踩坑

简介:围绕分库分表与多数据源切换的实战示例包,面向已有一定Java Web基础、希望理解Sharding-JDBC透明分片机制的开发者与架构学习者,可用于搭建分库分表实验环境,对照配置与代码验证数据路由和事务管理思路。包内共73个文件,以18个Java源码、18个XML配置和21个class编译产物为主体,另有properties、jsp、sql脚本及Eclipse工程配置,完整保留SSMDemo的Maven项目结构与分片配置线索,压缩包约66KB,体积轻便,便于直接导入调试。已有2070人学习下载,说明其在分片入门场景中具有参考价值。读者可从中获取数据源定义、分片规则配置、业务层调用与SQL路由的对应关系,并借助示例理解跨库跨表查询、全局唯一标识等常见问题的处理方向,适合作为分库分表与多数据源切换的对照样例和排错参考。

1. 分库分表之后,真正让人失眠的是多数据源切换

订单表按用户 ID 拆成 4 库 32 表,第一版只做了分片键路由,压测跑得挺顺。真正难受的是上线第三个月:运营要查跨月对账明细,风控要扫全量名单,归档任务还要按季度把冷数据搬到历史库,一个请求里得在三个库之间来回跳。这时候分库分表本身反而不是最痛的,痛的是多数据源切换——谁来决定这次 SQL 走哪个库、切换在事务里为什么不生效、ThreadLocal 忘记清理导致半夜串库,这些才是血泪经验集中的地方。下面按「机制 → 手写实现 → 读写分离与跨库事务 → 踩坑清单 → 验证手段」的顺序,把这个方案拆开讲,适合正在做或准备做分库分表、并且被动态数据源折腾过的后端同学照着复现。

2. 数据源切换的底层机制:一次 SQL 到底怎么选到那个库

2.1 从 getConnection 往下看,切换点其实只有一个

不管上层包装成什么样,Java 侧拿连接的唯一出口是DataSource.getConnection()。Spring 的AbstractRoutingDataSource把这件事做了一层转发:它自己不是真正的连接池,而是持有一个Map<Object, DataSource> targetDataSources,在getConnection()时调用determineCurrentLookupKey()拿到一个 key,用这个 key 去 map 里查真正的数据源,查不到就退回defaultTargetDataSource。

这条链路展开是这样的:

  1. 事务管理器或 MyBatis 调用DataSourceUtils.getConnection(dataSource);
  2. 请求打到AbstractRoutingDataSource#getConnection;
  3. 内部触发determineTargetDataSource()→determineCurrentLookupKey();
  4. 用返回的 key 在resolvedDataSources里查表,命中即用;
  5. 拿到的Connection被包成ConnectionHolder,注册到TransactionSynchronizationManager。

所以后面所有「注解切库」「分片键路由」「读写分离强制走主」,本质上都只是往第 3 步那个返回值里塞东西。理解这一点之后,很多玄学问题会一下变得有解释:为什么在事务方法内部再切一次不生效,为什么切了库但 SQL 还打在老库上,答案都在第 5 步。

切换手段注入点生效时机能否覆盖已开启的事务
方法级注解 + AOPdetermineCurrentLookupKey的返回值方法进入前不能,必须早于事务切面
ORM 拦截器改 SQL 库名SQL 文本SQL 执行前能改表名,改不了连接
手动DataSourceContextHolder.push同上随代码位置不能,取决于调用顺序
分片中间件路由层SQL 解析后重写解析阶段能,但复杂度高一个量级

这张表值得存下来。很多团队一开始图省事,在 Service 方法里手动push一个库名,结果被外层@Transactional包住,切了等于没切,排查半天以为是注解没生效。

2.2 三种主流落地方式的选型对比

注解切面方案:自定义一个@DS("order-1")注解,AOP 环绕时往 ThreadLocal 写 key。优点是零依赖、可控、出问题能打断点;缺点是不认识分片键,只能做「方法级」的静态切换,一个方法只能锁定一个库。

ORM 拦截器方案:在 MyBatis 的Interceptor里拦截Executor#query/update,从参数里解析分片键,动态算出库名表名,再改写 SQL。优点是可以做到「一条 SQL 一个路由」,跨库 UNION、批量插入都能覆盖。缺点是 SQL 解析容易翻车,in条件跨分片、子查询、order by归并都要自己处理。

连接池包装方案:把多个物理连接池塞进一个逻辑DataSource,对外仍暴露一个实例,内部根据 key 分发。这个思路就是AbstractRoutingDataSource的加强版,好处是对上层完全透明,ORM 不需要感知;坏处是连接数会翻倍膨胀,4 库 × 20 连接 = 80 个物理连接,再叠一套从库就是 160,数据库侧max_connections很容易被打满。

分片中间件方案:把路由、改写、归并全交给中间件层。省事,但代价是把一部分控制权交出去了,跨库事务、强制走主、影子表这些定制需求往往要靠 Hint 或者改源码。团队规模不大、分片规则又比较固定的时候,我一般会选「注解切面 + 简单分片键计算」,够用,出问题也查得动。

2.3 事务一开,后面的切换就白写了

这是踩坑最多的一条。DataSourceTransactionManager#doBegin在事务启动时就已经调用了dataSource.getConnection(),拿到的连接被绑定到当前线程的ConnectionHolder上。之后业务代码里哪怕把 ThreadLocal 里的 key 改成了order-2,MyBatis 执行时走的是SpringManagedTransaction#getConnection,它会先去TransactionSynchronizationManager找有没有已绑定的连接——有就直接复用,根本不会再去问AbstractRoutingDataSource要新连接。

说得更直白一点:事务边界决定了数据源边界,事务里面切库只能算改了个变量。

还有一种更隐蔽的情况:两个数据源各自绑定了自己的ConnectionHolder,此时同一个线程里会出现两条独立连接、两个独立事务。第一条提交了,第二条回滚,前面那条不会跟着回滚。这不是「分布式事务没配对」,而是本地事务本来就是各管各的。真要跨库一致,得往上走本地消息表或者两阶段提交,这部分在第 4 章展开。

注意:把切数据源的切面@Order设成比事务切面更小的值(比如Ordered.HIGHEST_PRECEDENCE),只能保证「切库发生在开事务之前」,不能保证「事务中途切库生效」。后者在单事务管理器下无解。

3. 手写一套能用的动态路由:配置、上下文、切面三段代码

3.1 数据源注册与动态路由实例装配

先解决「有多少个库、连接参数从哪来」。多库场景下不建议写死多个@Bean,4 库 32 表还能忍,扩到 16 库就是体力活了。用Map结构绑定配置,加库只改 YAML。

shard: datasource: order-0: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://db-order-0:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: app_rw password: ${ORDER0_PWD} maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 validation-timeout: 1000 order-1: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://db-order-1:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: app_rw password: ${ORDER1_PWD} maximum-pool-size: 20 minimum-idle: 5
@Data @ConfigurationProperties(prefix = "shard") public class ShardDataSourceProperties { // key 即逻辑库名,与路由 key 保持一致,避免两套命名互相翻译 private Map<String, HikariDataSource> datasource = new LinkedHashMap<>(); } @Configuration @EnableConfigurationProperties(ShardDataSourceProperties.class) public class DynamicDataSourceConfig { @Bean("dynamicDataSource") public DataSource dynamicDataSource(ShardDataSourceProperties props) { Map<Object, Object> targets = new LinkedHashMap<>(props.getDatasource()); if (targets.isEmpty()) { throw new IllegalStateException("shard.datasource 未配置任何数据源"); } DynamicRoutingDataSource ds = new DynamicRoutingDataSource(); ds.setTargetDataSources(targets); // 默认库一定要设,否则 key 为 null 时直接抛 IllegalStateException ds.setDefaultTargetDataSource(targets.get("order-0")); ds.afterPropertiesSet(); return ds; } } public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.peek(); } }

这里的几个点值得单独说明。afterPropertiesSet()必须手动调用,因为AbstractRoutingDataSource是在这个方法里把targetDataSources转成resolvedDataSources的,不调用的话运行期查表永远为空。setDefaultTargetDataSource也别省,定时任务、健康检查、启动时的元数据查询都可能在没有任何上下文的情况下发 SQL,没有默认库会直接启动失败。

HikariDataSource用jdbc-url而不是url,这是 Spring Boot 绑定HikariConfig时的约定,写成url会绑定不上而且不报错,最后表现为「连接串是 null」,这类问题查起来很费时间。maximum-pool-size的估算方式是:单库承受的连接数 ÷ 应用实例数,再留 20% 余量给运维和监控连接。

3.2 ThreadLocal 上下文:用栈而不是单值

很多人第一版会写成ThreadLocal<String>,然后遇到嵌套调用就翻车:外层方法指定order-1,内层调用一个指定order-0的方法,内层执行完把 ThreadLocal 清空了,外层后续 SQL 直接掉到默认库上。

public final class DataSourceContextHolder { private static final ThreadLocal<Deque<String>> HOLDER = ThreadLocal.withInitial(ArrayDeque::new); private DataSourceContextHolder() {} public static void push(String key) { if (key != null && !key.isEmpty()) { HOLDER.get().push(key); } } public static void poll() { Deque<String> deque = HOLDER.get(); deque.poll(); if (deque.isEmpty()) { HOLDER.remove(); // 关键:不留空壳,防止线程池复用时的脏读 } } public static String peek() { Deque<String> deque = HOLDER.get(); return deque.isEmpty() ? null : deque.peek(); } public static void clear() { HOLDER.remove(); } }

用Deque做栈式存取,push进、poll出,天然支持嵌套。poll之后判断为空就remove(),这一步是防内存泄漏和脏数据的后悔药——Tomcat 的线程是复用的,如果不 remove,下一个请求进来peek()可能拿到上一个请求残留的库名,表现为「偶发串库」,而且极难复现。

clear()留给最外层的 Filter 或拦截器兜底,在finally里调用一次,作为双保险。

3.3 注解 + AOP:方法级切换与分片键占位

注解定义要能表达两种意图:写死库名,或者按参数算库名。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { String value() default ""; // 直接指定,如 "order-1" String shardKey() default ""; // SpEL 表达式,如 "#userId" boolean master() default false; // 强制走主 }
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 必须早于 @Transactional 的默认优先级 public class DsSwitchAspect implements Ordered { private final ExpressionParser parser = new SpelExpressionParser(); private final ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer(); @Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String key = resolveKey(pjp, ds); DataSourceContextHolder.push(key); try { return pjp.proceed(); } finally { DataSourceContextHolder.poll(); } } private String resolveKey(ProceedingJoinPoint pjp, DS ds) { if (!ds.shardKey().isEmpty()) { MethodSignature sig = (MethodSignature) pjp.getSignature(); EvaluationContext ctx = new MethodBasedEvaluationContext( null, sig.getMethod(), pjp.getArgs(), discoverer); Object v = parser.parseExpression(ds.shardKey()).getValue(ctx); if (v == null) { throw new IllegalArgumentException("@DS 分片键解析为空:" + ds.shardKey()); } return ShardRouter.dbName(String.valueOf(v)); } return ds.master() ? ds.value() + "-master" : ds.value(); } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } }

@Order(Ordered.HIGHEST_PRECEDENCE)这一行是整个切面能不能生效的分水岭。Spring 里@Transactional的默认 order 是LOWEST_PRECEDENCE,数值越小越先执行,所以切库切面必须在事务切面之外层。如果写反了,事务先开、连接先拿,后面 push 什么都没用,表现出来的现象就是「注解明明加了,SQL 还是打在主库」。

分片键用 SpEL 而不是硬编码参数下标,好处是方法签名改了不用跟着改切面,坏处是表达式写错只有运行时才报,建议在单元测试里对每个带@DS(shardKey=...)的方法都跑一遍。

3.4 分片键到库表下标的计算

库名和表名的计算必须收敛到一个类里,绝对不能散落在各处。散开之后最容易出现的问题是新加一个库,老代码里有一处取模常量没改,数据写进了不存在的分片。

public final class ShardRouter { private static final int DB_COUNT = 4; private static final int TABLE_COUNT = 8; private static final String PREFIX = "order-"; private ShardRouter() {} public static String dbName(long shardKey) { return PREFIX + Math.floorMod(hash(shardKey), DB_COUNT); } public static String tableName(long shardKey) { int db = Math.floorMod(hash(shardKey), DB_COUNT); int tb = Math.floorMod(hash(shardKey) / DB_COUNT, TABLE_COUNT); return "t_order_" + (db * TABLE_COUNT + tb); } // 位扰动,避免连续 ID 在取模后集中落在同一个库 private static int hash(long key) { long h = key ^ (key >>> 33); h *= 0xff51afd7ed558ccdL; h ^= h >>> 33; return (int) (h ^ (h >>> 32)); } }

两个细节。第一,先算库再算表的分母要除以DB_COUNT,直接用hash % TABLE_COUNT会让库和表的分布产生相关性,某些库会明显更热。第二,用floorMod而不是%,因为分片键如果出现负数(比如由雪花 ID 异或出来的中间值),%会返回负下标,直接拼出order--1这种库名,连接直接失败。

参数约定上,DB_COUNT和TABLE_COUNT一旦定下就不要动,扩容时要么做双写迁移,要么上一致性哈希。绝大多数业务在 4 库 32 表这个量级能撑很久,前期把规则定死反而更省心。

4. 分库分表场景下的读写分离与跨库事务

4.1 强制走主:三种写法和各自的失效条件

读写分离接进来之后,最典型的翻车是「刚写完立刻查,查不到」。原因是从库复制有延迟,而查询被路由到了从库。

第一种写法是注解上加master=true,走本章 3.3 的resolveKey,把 key 拼成order-0-master。这种写法最直观,但要求每个需要强一致的读方法都记得加注解,漏一个就是一个线上问题。

第二种写法是 Hint,在代码里显式DataSourceContextHolder.push("order-0-master"),用完poll()。粒度更细,能精确到某一行查询,代价是可读性差,业务代码里混入路由逻辑。

第三种是「同事务内自动走主」:在AbstractRoutingDataSource的determineCurrentLookupKey里判断TransactionSynchronizationManager.isActualTransactionActive(),事务活跃就强制返回主库 key。这一招最省事,能覆盖掉大部分写后读场景,但要注意它会让「只读事务」也走主库,如果只读事务开得多,主库压力会上去。

还有一类失效场景要单独提:异步线程和 MQ 消费线程里,ThreadLocal 是取不到值的,peek()返回 null,SQL 全部落到默认库。这不是读写分离的问题,是上下文传递的问题,第 5 章会专门讲。

4.2 跨库事务:不引入分布式事务框架时怎么兜

分库分表之后,一个业务操作横跨两个库几乎是必然的。订单落在order-0,库存扣减落在inventory-1,本地事务管不了对方。能做的主要有三条路。

能拆就拆。把跨库操作改成「同库内完成 + 异步补偿」。比如订单和订单明细用同一个分片键,一定落同一个库;库存扣减改成发消息,由消费方幂等处理。这条路工程量最小,但要求业务能接受最终一致,很多交易场景不接受。

本地消息表。在业务库里建一张t_local_message,业务写库和写消息表在同一个本地事务里完成,然后由定时任务扫描未发送消息投递出去,消费方幂等消费。它保证了「业务成功则消息一定发出」,反过来不保证。实现上要注意消息表要跟业务表同库,否则又变成跨库了。

两阶段提交。适合强一致要求高、且参与方都能支持 XA 的场景。代价是性能下降明显,且协调者本身是单点。中小团队一般不会首选。

参数和边界上我一般的做法是:核心资金链路用本地消息表 + 对账兜底,非核心链路能异步就异步,XA 只在极少数必须强一致的场景局部使用。跨库事务这块没有银弹,选一条路走通比反复横跳强。

4.3 分片键缺失:全路由、结果归并与深分页

只要查询条件里没有分片键,路由层就只有两个选择:报错,或者全路由。全路由意味着 4 个库并发查、结果在内存里归并,order by和limit都得自己实现。

-- 没有分片键的查询,实际会拆成 4 条下发 SELECT id, user_id, amount, created_at FROM t_order_0 WHERE status = 1 AND created_at >= '2024-01-01' ORDER BY created_at DESC LIMIT 20;

归并的坑集中在分页。limit 100000, 20这种深分页,如果每个分片都取 100020 条再归并,内存直接爆。常见做法是改成游标分页:记住上一页最后一条的created_at + id,下一页用where (created_at, id) > (?, ?)往下扫,每个分片只需要返回 20 条。

还有一类是全路由下的聚合查询,count(*)、sum(amount)需要在内存里二次聚合。数据量小的时候没问题,量大了会明显拖慢接口。我的做法是给这类报表查询单独建一份宽表或走离线数仓,不在在线库上做全路由聚合。

5. 多数据源切换的踩坑与排查清单

5.1 现象:事务方法里切了库,SQL 还是打到原库

原因:事务开启时已经通过DataSourceTransactionManager#doBegin拿到了Connection并绑定到TransactionSynchronizationManager,后续getConnection直接复用,不再询问路由 key。

解决:把切库动作提到事务外层,也就是让切面 order 小于事务切面;实在需要在事务中间换库,只能把那段逻辑拆成独立的事务方法,用REQUIRES_NEW传播级别重新开一个事务,代价是两条连接、两个事务,回滚不互通。

5.2 现象:偶发串库,A 用户的数据出现在 B 用户的请求里

原因:ThreadLocal用完没清理,线程池复用线程时带出了上一次的残留值。还有一种变体是poll()之后Deque空了但没remove(),ThreadLocal的空壳一直挂在 Thread 上。

解决:finally里必须调poll();poll()内部判空后remove();最外层加一个Filter,finally里clear()兜底。上线前专门写一个并发测试,100 线程跑 1 万次,看有没有跨请求的 key 泄漏。

5.3 现象:应用启动报连接数超限,或者运行一段时间数据库侧连接被打满

原因:每个物理库一个HikariDataSource,每个池默认 10 个连接,4 库 + 4 从库 = 80 连接/实例,5 个实例就是 400。再加上定时任务、归档任务各自持有独立数据源,数量还会翻。

解决:统一在配置里给每个池设maximum-pool-size,按「单库上限 ÷ 实例数 × 0.8」估算;minimum-idle设小一点,比如 2,避免空闲期占着连接;把归档、对账这类离线任务的数据源单独隔离,不要和在线流量抢连接。

5.4 现象:异步线程或 MQ 消费里,SQL 全部落到默认库

原因:ThreadLocal不跨线程传递,@Async、CompletableFuture、线程池提交的任务里peek()返回 null。

解决:提交任务前把 key 取出来,用包装器带进子线程,在子线程的finally里清理;或者用TaskDecorator统一给线程池加装饰,在run()前后做push/poll。如果用的是 MQ,把库名或分片键放进消息体,消费端第一件事就是根据消息头push。

5.5 现象:只读事务里读到旧数据,主从延迟只在高峰期出现

原因:读请求被路由到从库,主从复制有毫秒级延迟,平时看不出来,高峰期写入量大时延迟拉长到几百毫秒。

解决:写后立即读的场景强制走主,用 4.1 的三种方式之一;对延迟敏感的业务,在从库上暴露Seconds_Behind_Master指标,超过阈值自动降级为主库读;对账类任务直接走主库,不参与读写分离。

6. 上线前怎么确认切换真的生效:三个可复现的检查手段

光靠代码 review 判断切库有没有生效,基本等于自欺欺人。我一般会做三件事,成本都不高。

第一,把路由决策打进日志。在determineCurrentLookupKey里打一行 debug,带上 key 和当前线程名。压测时打开,用grep统计各库的命中次数,分布均匀说明路由没问题。这一步曾经帮我抓到过一个 bug:分片键的 hash 扰动写错了,4 个库的流量分布是 7:1:1:1,业务量小的时候完全看不出来。

第二,用影子单据走一遍真实链路。造一条带特定分片键的测试数据,从接口入口调进去,然后去目标库直接select验证落库位置,同时确认源库没有这条数据。跨库场景下再补一条:故意让第二个库的操作失败,看第一个库是否按预期回滚或补偿。

第三,盯连接池指标。每个逻辑库暴露active / idle / wait三个值,重点看wait。如果某个库的等待线程数持续上涨,说明路由倾斜或者池子太小,这两种情况在压测报告里都会被平均 QPS 掩盖掉。

# 快速核对某个分片键最终落在哪个库表,避免人工推演 curl -s "http://localhost:8080/debug/route?userId=881234567890" | jq . # {"db":"order-2","table":"t_order_19","key":"order-2","thread":"http-nio-8080-exec-7"}

这个 debug 接口只在内网和测试环境开,暴露路由结果是排查串库问题最快的手段——线上出问题时,先确认「算出来的库」和「实际打到的库」是不是同一个,能省掉一大半猜测。

最后说一条教训。早些年做第一个分库分表项目,我默认「注解加了就万事大吉」,结果一个嵌套事务的方法切库失效,写了三个月的脏数据才被对账发现。后来养成习惯:凡是涉及路由的改动,日志、影子单据、连接池指标三样必须过一遍,缺一样不上线。这套流程看着笨,但比事后修数据便宜太多。希望帮到你。

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