ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring多数据源路由失效的四大根源与契约验证

Spring多数据源路由失效的四大根源与契约验证

简介:本资源是一份面向Java后端开发者与数据库架构初学者的分库分表实战教学包,聚焦企业级大数据量场景下的数据库水平扩展难题,以Sharding-JDBC为核心框架实现多数据源动态切换与透明化分片。资源共73个文件,涵盖18个Java业务与配置类、21个编译后class文件、18个XML配置与映射文件(含MyBatis映射及Sharding规则定义),以及SQL建表脚本、Maven项目配置(pom.xml)、Eclipse工程元数据(.project/.classpath)等关键开发资产,整体仅66KB,轻量易导入。已有2068人学习下载,适合快速搭建SSMDemo环境并理解分片路由、数据源初始化、YAML分片规则配置等核心实践环节。读者可直接复用项目结构,掌握从多数据源声明、分库分表策略配置到跨库CRUD调用的完整链路,尤其适用于Spring+MyBatis+Sharding-JDBC技术栈的中小规模分片落地参考。

1. 分库分表:为什么加了数据源路由,SQL 还是总打到主库?

你配好了AbstractRoutingDataSource,写好了determineCurrentLookupKey(),甚至用 ThreadLocal 存了分片键,但一跑压测——慢查询日志里全是主库的INSERT INTO order_01,从库连接池却空转;或者更玄学的是:本地调试全走从库,上线后 90% 请求又自动“回流”到主库。这不是配置漏了,也不是 Spring Boot 版本兼容问题,而是分库分表的多数据源切换,本质不是“路由开关”,而是一次贯穿连接获取、事务传播、线程上下文、SQL 解析四层的协同契约。它不解决“数据放哪”,而是解决“此刻该用哪个连接去读/写哪份数据”。一旦某一层契约断裂(比如事务内嵌套调用没透传 key、异步线程丢了 ThreadLocal、MyBatis 的@Select方法被 AOP 拦截错层),整个路由就失效——表面看是“切不动”,实则是“契约未履约”。这份资源不是配置清单,而是一份覆盖Spring DataSource 路由机制、JDBC 连接生命周期、MyBatis 执行器链、以及分布式事务边界的契约验证集,含可调试的源码断点、关键日志埋点和失败复现 Case。搞懂它,你才能把“分库分表”从玄学配置变成可推演、可断点、可压测的确定性行为。


2. 路由机制:AbstractRoutingDataSource 不是路由器,而是连接工厂的策略门面

AbstractRoutingDataSource常被误称为“动态数据源路由器”,但它根本不参与 SQL 路由、不解析分片键、不决定语句发给谁。它的唯一职责,是在getConnection()被调用时,根据当前上下文返回一个已配置好的、具体的目标DataSource实例。真正的“路由决策”发生在determineCurrentLookupKey()方法中,而这个方法的执行时机、上下文可见性、线程安全性,直接决定路由是否生效。

2.1 determineCurrentLookupKey() 的执行时机与契约陷阱

该方法在每次getConnection()时被调用,而非在 SQL 执行前。这意味着:

  • 它只对新连接获取生效,对已存在的连接(如事务中复用的连接)完全无效;
  • 若你在 Service 层 set 了 key,但在 DAO 层才首次触发getConnection()(例如 MyBatis 第一次执行selectList),key 必须在 DAO 调用前已存在;
  • 若使用连接池(如 HikariCP),getConnection()可能返回池中已有连接,此时determineCurrentLookupKey()返回的 key 仅用于标识“该连接属于哪个逻辑库”,不改变连接本身指向的物理库。
// 示例:典型的错误用法 —— 在 DAO 内部 set,但 getConnection 已在事务开启时完成 @Service public class OrderService { @Transactional public void createOrder(Order order) { // ❌ 错误:此时事务已开启,主库连接已从池中获取并绑定 // determineCurrentLookupKey() 此刻调用已无法切换连接 DynamicDataSourceContextHolder.set("slave_01"); orderMapper.insert(order); // 实际仍走主库连接 } }

提示:determineCurrentLookupKey()的返回值必须是DataSource配置中已声明的 key(如"master"、"slave_01"),否则会抛IllegalStateException: Cannot determine target DataSource for lookup key [xxx]。这不是运行时异常,而是 Spring 初始化阶段就校验的契约。

2.2 多级缓存下的 key 透传:ThreadLocal 不是银弹

DynamicDataSourceContextHolder通常基于ThreadLocal<String>实现,但它在以下场景会失效:

  • 异步线程:@Async、CompletableFuture.supplyAsync()、手动new Thread()启动的线程,不继承父线程的ThreadLocal;
  • 线程池复用:ThreadPoolTaskExecutor中线程被复用,若前一个任务未 clean,key 会污染后续任务;
  • WebFlux 或协程环境:ThreadLocal在非阻塞线程模型下完全不可用。

正确的透传方式需结合场景:

  • 对@Async:自定义AsyncConfigurer,重写getAsyncExecutor(),包装ThreadPoolTaskExecutor,在execute()前 copy 父线程 key;
  • 对CompletableFuture:使用CompletableFuture.supplyAsync(Supplier, Executor)显式传入带 key 透传能力的 executor;
  • 对 WebFlux:改用ReactorContext+Mono.subscriberContext(),将 key 存入响应式上下文。
// 正确:带 ThreadLocal 透传的 AsyncExecutor @Configuration public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.initialize(); // 包装 execute 方法,实现 key 透传 return command -> { String key = DynamicDataSourceContextHolder.get(); // 获取当前 key executor.execute(() -> { try { DynamicDataSourceContextHolder.set(key); // 设置到子线程 command.run(); } finally { DynamicDataSourceContextHolder.clear(); // 强制清理 } }); }; } }

2.3 数据源配置的“静态契约”:key 必须在启动时注册

AbstractRoutingDataSource的setTargetDataSources(Map<Object, Object> targetDataSources)接收一个Map,其中 key 是determineCurrentLookupKey()的返回值,value 是具体的DataSourceBean。这个 Map 在 Spring 容器刷新完成后即固化,运行时无法动态增删 key。这意味着:

  • 分库扩容(如新增slave_05)必须重启应用,或通过refresh()重新注入targetDataSources(需自行实现热加载逻辑);
  • 若determineCurrentLookupKey()返回了未注册的 key(如"slave_05"),Spring 会 fallback 到setDefaultTargetDataSource(),若未设则直接报错;
  • targetDataSources的 value 必须是 Spring 管理的DataSourceBean,不能是new HikariDataSource()的裸实例,否则连接池监控、健康检查等功能失效。
// 正确:在 Configuration 中显式注册所有可能的 key @Configuration public class DataSourceConfig { @Bean @Primary public DataSource routingDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("slave01DataSource") DataSource slave01, @Qualifier("slave02DataSource") DataSource slave02) { AbstractRoutingDataSource routing = new AbstractRoutingDataSource() { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.get(); } }; Map<Object, Object> targets = new HashMap<>(); targets.put("master", master); targets.put("slave_01", slave01); targets.put("slave_02", slave02); // ✅ 所有可能的 key 必须在此处注册 routing.setTargetDataSources(targets); routing.setDefaultTargetDataSource(master); // fallback return routing; } }

3. 事务与路由的耦合:@Transactional 如何让路由“失能”

@Transactional是分库分表场景下最隐蔽的“路由杀手”。它不直接破坏路由逻辑,而是通过连接复用 + 事务传播 + 代理拦截顺序三重机制,让determineCurrentLookupKey()的调用彻底失效。

3.1 事务连接复用:一次 getConnection,全程绑定

Spring 的DataSourceTransactionManager在开启事务时,会调用routingDataSource.getConnection()获取连接,并将其绑定到TransactionSynchronizationManager的resourcesMap 中,key 为routingDataSource实例。此后,同一事务内所有 DAO 操作,无论调用多少次getConnection(),都返回同一个连接对象。determineCurrentLookupKey()仅在首次获取连接时调用,后续复用不触发。

// 场景:事务内混合读写,期望写主库、读从库 @Transactional public void mixedOperation() { DynamicDataSourceContextHolder.set("master"); orderMapper.insert(new Order()); // ✅ 走主库(首次 getConnection) DynamicDataSourceContextHolder.set("slave_01"); List<Order> list = orderMapper.selectList(null); // ❌ 仍走主库连接! }

注意:这是设计使然,不是 bug。事务要求 ACID,跨库连接无法保证原子性,因此 Spring 强制单事务单连接。若需读写分离,必须拆分为两个独立事务(@Transactional(propagation = Propagation.REQUIRES_NEW)),或使用只读事务(@Transactional(readOnly = true))配合从库路由。

3.2 事务传播行为对路由的隐式控制

Propagation类型决定了新事务是否创建新连接,从而影响路由:

传播行为是否新建连接路由是否可变典型场景
REQUIRED(默认)否(复用)❌ 不可变同事务内所有操作强制同库
REQUIRES_NEW是(新建)✅ 可变写完主库后,新开事务查从库做最终一致性校验
NOT_SUPPORTED否(挂起事务)✅ 可变日志记录等无需事务的操作,可自由切库
NEVER否(拒绝事务)✅ 可变纯查询接口,强制走从库
@Service public class OrderService { @Transactional public void createOrder(Order order) { orderMapper.insert(order); // 主库 // ✅ 开启新事务,可切从库 checkConsistencyAfterCreate(); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void checkConsistencyAfterCreate() { DynamicDataSourceContextHolder.set("slave_01"); // 此处 getConnection 将触发 determineCurrentLookupKey() // 返回 slave_01 的连接,与主事务隔离 orderMapper.selectById(123); } }

3.3 AOP 代理顺序:@Transactional 和路由切面谁先执行?

若你自定义了@DS("slave")注解并通过 AOP 切面设置DynamicDataSourceContextHolder,必须确保该切面在@Transactional之前执行。否则,@Transactional的代理已先获取连接并绑定,你的切面再 set key 也无济于事。

Spring AOP 默认按@Order值升序执行(值越小越先)。TransactionInterceptor默认Order为Ordered.LOWEST_PRECEDENCE - 100(即Integer.MAX_VALUE - 100),因此你的路由切面@Order必须小于该值:

@Aspect @Order(Ordered.HIGHEST_PRECEDENCE) // ✅ 必须最高优先级 @Component public class DataSourceAspect { @Pointcut("@annotation(ds)") public void dsAnnotationPointcut(DataSource ds) {} @Before("dsAnnotationPointcut(ds)") public void beforeSwitchDataSource(JoinPoint joinPoint, DataSource ds) { DynamicDataSourceContextHolder.set(ds.value()); } }

提示:若使用@DS("slave")注解,其value()必须是targetDataSources中已注册的 key。若注解值为"slave_03"但配置中只有"slave_01"、"slave_02",则 fallback 到默认数据源,且无任何警告日志——这是生产环境最易忽略的静默失败。


4. MyBatis 执行链:SQL 发送前,路由早已尘埃落定

MyBatis 的SqlSession、Executor、StatementHandler构成一条执行链,但路由决策点远早于此——它发生在SqlSession创建时,即SqlSessionFactory.openSession()调用期间。SqlSessionFactory底层依赖DataSource,而DataSource正是AbstractRoutingDataSource。因此,路由的成败,在 MyBatis 解析 XML、生成 BoundSql、执行 PreparedStatement 之前,就已经由getConnection()的返回结果锁死。

4.1 SqlSession 的生命周期与连接绑定

SqlSession是 MyBatis 的核心会话对象,它持有一个Connection。SqlSessionFactory.openSession()的流程如下:

  1. 调用configuration.getEnvironment().getDataSource().getConnection()
  2. AbstractRoutingDataSource.getConnection()→determineCurrentLookupKey()→ 返回目标DataSource
  3. 目标DataSource.getConnection()返回物理连接
  4. SqlSession持有该连接,后续所有selectList、insert等操作均复用此连接

这意味着:@Select、@Insert注解或 XML 中的 SQL 语法、参数、分页插件,均不影响路由结果。路由只取决于openSession()时刻的上下文。

// 即使 SQL 是 SELECT,若此时 key 是 "master",仍走主库 DynamicDataSourceContextHolder.set("master"); List<Order> list = orderMapper.selectByStatus("PAID"); // 主库执行 // 即使 SQL 是 INSERT,若此时 key 是 "slave_01",仍走从库(可能报错) DynamicDataSourceContextHolder.set("slave_01"); orderMapper.insert(new Order()); // 从库连接执行 INSERT → 大概率报 ReadOnly SQLException

注意:从库执行写操作会触发数据库层面的只读异常(如 MySQL 的The MySQL server is running with the --read-only option),这不是 MyBatis 或 Spring 的路由错误,而是数据库的强约束。路由只是把请求送到了错误的物理节点。

4.2 分页插件(PageHelper)的路由干扰

PageHelper.startPage()会修改 MyBatis 的Executor行为,在 SQL 前后注入分页逻辑。但它的执行时机在Executor.query()内部,晚于SqlSession连接的创建。因此,PageHelper 不影响路由,但可能暴露路由错误:

  • 若你期望startPage()后的查询走从库,但实际因上下文 key 未设而走了主库,PageHelper 会在主库上执行SELECT COUNT(*)和SELECT ... LIMIT,导致主库压力陡增;
  • 更隐蔽的是:某些分页插件(如旧版 PageHelper)会缓存RowBounds到ThreadLocal,若未及时clear(),可能污染后续非分页查询的执行计划。
// ✅ 安全用法:显式控制 key,且分页后清理 DynamicDataSourceContextHolder.set("slave_01"); try { PageHelper.startPage(1, 10); List<Order> list = orderMapper.selectAll(); // 处理结果 } finally { DynamicDataSourceContextHolder.clear(); // 防止泄漏 PageHelper.clear(); // PageHelper 自身清理 }

4.3 MyBatis-Plus 的 Wrapper 与路由无关性

QueryWrapper、LambdaQueryWrapper是 MyBatis-Plus 提供的条件构造器,它只影响WHERE子句的生成,完全不参与数据源路由。开发者常误以为queryWrapper.eq("status", "PAID")会触发某种“智能路由”,但事实是:Wrapper 生成的 SQL 字符串,最终仍由SqlSession持有的连接执行,而该连接早已在openSession()时确定。

// ❌ 错误认知:Wrapper 能根据字段自动切库 QueryWrapper<Order> wrapper = new QueryWrapper<>(); wrapper.eq("status", "PAID"); // 此时并未触发任何路由逻辑,只是构建 SQL 条件字符串 // ✅ 正确做法:路由由上下文控制,与 Wrapper 无关 DynamicDataSourceContextHolder.set("slave_01"); orderMapper.selectList(wrapper); // 连接来自 slave_01

5. 避坑:5 条血泪经验总结的高频翻车现场

分库分表的多数据源切换,90% 的线上故障并非源于代码逻辑错误,而是对 Spring 生命周期、JDBC 规范、MyBatis 执行模型的理解偏差。以下是某公司线上真实复现并归档的 5 个典型问题,每条均附可复现的最小 Case 和根因定位路径。

5.1 现象:本地单元测试全绿,上线后 80% 查询走主库

原因:测试类使用@SpringBootTest启动完整容器,DynamicDataSourceContextHolder的ThreadLocal在每个@Test方法间未清理,导致 key 污染;而线上 Web 环境中,Tomcat 线程池复用,ThreadLocal未clear()导致前一个请求的 key(如"master")残留,后续请求即使 set"slave_01",determineCurrentLookupKey()仍返回"master"。
解决:

  • 测试类添加@AfterEach清理:DynamicDataSourceContextHolder.clear();
  • 生产环境DynamicDataSourceContextHolder.set()后,必须在finally块中clear(),或使用try-with-resources封装;
  • 在AbstractRoutingDataSource的determineCurrentLookupKey()中添加日志:log.debug("Routing to datasource: {}", key),确认实际返回值。

5.2 现象:@Async方法内@DS("slave")注解失效,始终走主库

原因:@Async启动新线程,ThreadLocal不继承;同时,若@DS切面@Order值过高(如@Order(100)),会晚于AsyncExecutionInterceptor执行,导致切面未生效。
解决:

  • @DS切面@Order设为Ordered.HIGHEST_PRECEDENCE;
  • @Async方法内,手动 set key:DynamicDataSourceContextHolder.set("slave_01"),并在finally清理;
  • 或改用TaskDecorator实现线程间 key 透传(见 2.2 节代码)。

5.3 现象:事务方法内调用另一个@DS("slave")的 Service 方法,仍走主库

原因:Spring AOP 代理是基于接口或 CGLIB 的,内部方法调用(this.method())不经过代理,因此@DS注解和@Transactional均不生效。
解决:

  • 避免 Service 内部调用,改为ApplicationContext.getBean(OtherService.class).method();
  • 或使用AopContext.currentProxy()强制走代理:((OtherService) AopContext.currentProxy()).slaveMethod();
  • 最佳实践:将需不同数据源的逻辑拆分为独立 Service,通过接口注入调用。

5.4 现象:Druid 监控显示从库连接池活跃,但慢查询日志中无从库 SQL

原因:Druid 的stat-view-servlet显示的是连接池统计(如ActiveCount),而非实际 SQL 执行节点。连接池中有活跃连接,不代表这些连接被用于执行查询——可能连接被创建后一直 idle,或被事务占用但未执行 SQL。
解决:

  • 查看 Druid 的sql-detail日志,过滤ExecuteTime字段,确认 SQL 实际执行的JdbcUrl;
  • 在AbstractRoutingDataSource.determineCurrentLookupKey()中打印Thread.currentThread().getName()和key,确认路由调用线程与 SQL 执行线程一致;
  • 使用tcpdump抓包,验证客户端 IP 确实连接到了从库 IP。

5.5 现象:ShardingSphere-JDBC 与自定义AbstractRoutingDataSource共存,路由混乱

原因:ShardingSphere-JDBC 本身就是一个DataSource实现,它内部已封装分库分表逻辑。若你再套一层AbstractRoutingDataSource,会导致双重路由:ShardingSphere 先按分片键路由到物理库,外层AbstractRoutingDataSource再按determineCurrentLookupKey()路由,结果不可预测。
解决:

  • 二选一:要么用 ShardingSphere 原生分片(推荐),要么用自定义多数据源(适合简单读写分离);
  • 若必须共存,需确保AbstractRoutingDataSource的targetDataSources中,value 是 ShardingSphere 的DataSource,而非原始 HikariCP;
  • 关闭 ShardingSphere 的sql-show日志,避免与自定义日志混淆。

6. 设计取舍:为什么不用 ShardingSphere?手写路由的不可替代价值

当团队讨论分库分表方案时,“直接上 ShardingSphere” 几乎是标准答案。它开箱即用、支持复杂分片算法、内置读写分离、提供 SQL 解析引擎——那为什么还要花精力维护一套AbstractRoutingDataSource?答案藏在三个不可妥协的工程现实里:可控性、可观测性、可调试性。

6.1 可控性:路由逻辑必须与业务语义对齐

ShardingSphere 的分片规则(如StandardShardingAlgorithm)基于 SQL 解析后的ShardingCondition,它看到的是WHERE user_id = ?,而业务真正关心的是 “这笔订单属于哪个商户”。若分片键是tenant_id,但业务代码中tenant_id存于RequestContextHolder的HttpServletRequestattribute,ShardingSphere 无法自动提取——它需要你写ComplexKeysShardingAlgorithm,而该算法的doSharding()方法接收的是Collection<ShardingValue>,你需要从中反向匹配出tenant_id的值。这层映射,极易出错且难以单元测试。

手写路由则直击本质:DynamicDataSourceContextHolder.set(tenantId)。tenantId从哪来?可以是@RequestHeader("X-Tenant-ID")、可以是 JWT payload 解析、可以是 RPC 上下文透传。路由决策点紧贴业务入口,而非 SQL 解析后端。某公司曾因 ShardingSphere 的HintManager与 Feign 调用链路不兼容,导致灰度流量无法精准路由,最终回退到手写方案,将tenant_id作为 Dubbo attachment 透传,在 Consumer 端@DubboReference的oninvoke回调中 set key。

6.2 可观测性:每一行日志都必须回答“谁、何时、为何切库”

ShardingSphere 的日志以ShardingSphere-SQL为前缀,输出格式固定:Actual SQL: ds_01 ::: SELECT * FROM t_order WHERE user_id = ?。它告诉你“实际执行的 SQL”,但不告诉你“为什么是ds_01”。要定位问题,你得翻ShardingRule配置、查ShardingAlgorithm源码、甚至 debugStandardShardingStrategy的shardingColumns解析逻辑。

手写路由的日志可定制到极致。我们在determineCurrentLookupKey()中埋点:

protected Object determineCurrentLookupKey() { String key = DynamicDataSourceContextHolder.get(); String traceId = MDC.get("traceId"); String method = getCurrentMethod(); // 通过 Throwable.getStackTrace() 获取调用栈 log.info("[DS-ROUTE] traceId={}, method={}, key={}, reason={}", traceId, method, key, getRouteReason(key)); return key; }

getRouteReason()可返回"from @DS annotation","from ThreadLocal after login","fallback to master due to null tenant"。当线上出现路由异常,运维只需 grepDS-ROUTE日志,5 秒内定位到是哪个 Controller 方法、哪个中间件透传失败、还是 fallback 逻辑触发。

6.3 可调试性:断点必须落在业务代码里,而非框架源码中

ShardingSphere 是 JAR 包依赖,其核心类ShardingSphereDataSource、PreparedStatementRoutingEngine编译为字节码,debug 时需下载源码、attach 符号、处理 Lombok 插件——一个路由问题,平均耗时 40 分钟。而手写路由的DynamicDataSourceContextHolder是项目内类,set()和get()方法一行断点,立刻看到 key 的来源、生命周期、污染点。某导师曾带 A 同学排查一个“偶发走错库”问题:在set()处加断点,发现是某个@PostConstruct初始化方法中,异步线程池未关闭,残留了旧 key;该问题在 ShardingSphere 中需深入EventBus事件监听链才能发现。

从那以后我每次评审分库分表方案,都强制走一遍“三问”:

  1. 路由决策点是否在业务入口(Controller/Feign Callback)?
  2. 日志能否在 10 行内回答‘谁、何时、为何’?
  3. 一个新人不看文档,仅靠 IDE 断点能否 5 分钟定位路由失败?
    如果任一问否,就放弃框架,回归手写。因为分库分表不是炫技,而是让数据流动像呼吸一样自然——你不需要理解肺的解剖结构,但必须清楚每一次吸气,空气正流向哪里。希望帮到你。

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

RELATED READING

延伸阅读

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