简介:这份资源面向需要掌握多数据源与分库分表技术的 Spring Boot 开发者,聚焦同一数据库内水平分表与跨库分库两类场景,解决单表数据膨胀、多数据源切换与读写路由等实际问题,适合具备一定 Spring Boot 与 MyBatis 基础、希望进阶分布式数据访问的中级开发者。项目采用 Spring Boot 搭建,多数据源基于 mybatis-plus 的 dynamic-datasource 实现,分库分表由 sharding-jdbc 承担,连接池选用 alibaba 的 druid-spring-boot-starter,并继承 MyBatis-Plus 简化数据库操作、引入 Lombok 精简实体代码,配合 JUnit 完成测试验证。压缩包共 162 个文件,以 115 个 xml 映射与配置、14 个 java 源码、14 个 class 编译文件为主,另有 6 个 yml、3 个 properties 等配置文件,整体约 164KB,结构紧凑便于按模块查阅。目前已有 624 人学习,可帮助读者理解多数据源动态切换、分库分表规则配置与测试用例组织方式,快速搭建可运行的示例工程并对照源码排查配置问题。
1. 多数据源与分库分表:为什么“加个注解就切换”往往是最危险的设计
很多人第一次接触多数据源,是从一个@DS("slave")注解开始的——方法上标一下,连接就切过去了,看起来优雅得像魔法。但真正在生产里跑过一段时间的人会发现,这套“注解即切换”的模型在事务、异步线程、嵌套调用、连接池预热这几个场景下会接连翻车。原因不复杂:数据源切换的本质是在 ThreadLocal 里放一个路由键,再由AbstractRoutingDataSource在getConnection()时读出来决定去哪个库,而 ThreadLocal 的可见性边界恰好和事务传播、线程池复用、AOP 代理顺序全都对不上。Spring Boot 生态里做多数据源和分库分表的方案很多,但底层机制就那么几层:路由键的存放、连接的选择、事务的绑定、SQL 的改写与归并。把这几层拆开看,你才会明白为什么有些配置“能跑但不可靠”,也才知道分库分表真正难的不是拆,而是拆完之后那条查询怎么拼回来。
2. 路由机制:AbstractRoutingDataSource到底在什么时候决定用哪个库
2.1 路由键存在哪:ThreadLocal 的可见性边界
Spring 的AbstractRoutingDataSource继承自AbstractDataSource,核心只有一个抽象方法determineCurrentLookupKey()。它在getConnection()被调用时执行,返回值作为 key 去targetDataSources这个 Map 里找真正的DataSource。常见的实现思路是把路由键放进ThreadLocal,因为一次请求内绝大多数数据库操作都发生在同一个线程上。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从 ThreadLocal 取出当前线程绑定的数据源标识 return DataSourceContextHolder.get(); } } public final class DataSourceContextHolder { // 用 ThreadLocal 保存路由键,线程隔离 private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } // 用完必须清理,否则线程池复用会串库 public static void clear() { CONTEXT.remove(); } }这段代码的关键不在set,而在clear。ThreadLocal的生命周期跟着线程走,而 Web 容器和业务线程池都会复用线程。如果一次请求结束时没有remove(),下一个请求复用同一线程时读到的就是上一个请求残留的路由键——这就是最典型的“串库”事故。我一般会把这个清理动作放在拦截器的afterCompletion里,并且用try-finally包住,保证异常路径也能清掉。
determineCurrentLookupKey()的调用时机也值得注意:它不是在方法进入时调一次,而是每次获取连接都会调。这意味着同一个事务里如果中途改了 ThreadLocal 的值,后续拿到的连接可能来自另一个库——而事务已经绑定在第一个连接上了,结果就是部分操作游离在事务之外。这是理解后面所有坑的前提。
2.2 AOP 切面与事务的先后顺序:谁包住谁
注解式切换通常靠一个@Around切面,在方法执行前set、执行后clear。问题在于 Spring 的事务也是 AOP,两个切面的顺序决定了行为。如果数据源切面在事务切面外层,那么事务开启时路由键已经就位,DataSourceTransactionManager拿到的连接来自正确的库;如果顺序反了,事务先开启、连接先绑定,切面再改路由键就完全无效——方法里所有 SQL 还是走原来的库。
常见的实现思路是给数据源切面显式指定@Order(Ordered.HIGHEST_PRECEDENCE),让它比@Transactional更早进入。但更稳妥的做法是不要在同一个方法上既开事务又切数据源,而是把数据源切换放在事务方法的外层调用者上。因为一旦事务已经绑定连接,中途换库在 JDBC 层面就是不成立的——一个Connection对应一个物理库,跨库事务需要分布式事务框架,不是切个注解能解决的。
2.3 嵌套调用时路由键的覆盖问题
假设方法 A 标了@DS("master"),内部调用方法 B 标了@DS("slave")。如果切面是简单的set后clear,B 执行完clear会把整个 ThreadLocal 清空,回到 A 继续执行时路由键已经没了,determineCurrentLookupKey()返回 null,落到默认数据源——可能既不是 master 也不是 slave。正确的做法是保存旧值、执行、恢复旧值,类似栈的语义:
@Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String previous = DataSourceContextHolder.get(); // 记住外层路由键 try { DataSourceContextHolder.set(ds.value()); return pjp.proceed(); } finally { // 恢复而不是清空,保证嵌套调用能回到外层数据源 if (previous == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } }这个细节在单层调用里完全看不出来,一旦出现 Service 互相调用就暴露。血泪经验是:凡是操作 ThreadLocal 的切面,都要按“保存—修改—恢复”写,而不是“设置—清空”。
3. 分库分表的 SQL 改写:一条SELECT是怎么被拆成 N 条再拼回来的
3.1 解析、路由、改写、归并:四步流水线
分库分表中间件(无论客户端模式还是代理模式)处理一条 SQL 的流程基本固定:SQL 解析 → 分片路由 → SQL 改写 → 结果归并。以SELECT * FROM t_order WHERE user_id = 100 AND order_id IN (1,2,3)为例,如果分片键是user_id,路由阶段算出100落在ds_1.t_order_0,改写阶段把表名替换成实际物理表,归并阶段把结果集合并返回。
难点在于分片键不在条件里。SELECT * FROM t_order WHERE order_id = 5这种查询,路由阶段无法定位到具体分片,只能全路由——向所有分片各发一条 SQL,再把结果归并。这就是为什么分库分表后“非分片键查询”会突然变慢:不是数据库慢了,是查询被放大成了 N 倍。常见的实现思路是引入基因法(把分片键的位运算结果编码进主键)或异构索引表,让非分片键也能反查出分片位置。
3.2 归并的三种形态:流式、内存、排序
结果归并不是简单地把 List 拼起来。按 SQL 语义分三类:
| 归并类型 | 触发场景 | 实现要点 |
|---|---|---|
| 遍历归并 | 无排序、无聚合的普通查询 | 逐条读取各分片结果,直接追加 |
| 排序归并 | 带ORDER BY | 各分片结果需有序,用优先队列做多路归并 |
| 聚合归并 | COUNT/SUM/AVG/MAX/MIN | AVG需拆成SUM/COUNT再算,不能直接平均 |
AVG是最容易翻车的:如果直接对各分片的平均值再求平均,结果在分片数据量不均时完全错误。正确做法是改写为SUM(x)/COUNT(x),归并阶段分别累加再相除。这类改写逻辑在中间件里是硬编码的规则,理解它你才知道为什么某些聚合查询在分片后结果对不上。
3.3 分页的深坑:LIMIT offset, size为什么要改写成LIMIT 0, offset+size
单库分页LIMIT 100000, 10只是扫描偏移,分片后每个分片都要返回前100010条,归并后再截取第100000到100010条。改写规则是:把LIMIT offset, size改成LIMIT 0, offset+size下发到各分片,归并排序后再内存截断。数据量大时这个内存开销非常可观,所以分片场景下的深分页要么禁用,要么改用“上次最大 ID”的游标分页。这是设计取舍,不是 bug。
4. 避坑与排查:多数据源分库分表最常见的五类翻车
4.1 现象:偶发读到别的租户/别的库的数据
原因几乎总是 ThreadLocal 没清理,线程池复用导致路由键残留。排查方法是打印determineCurrentLookupKey()的返回值并带上线程名,观察同一线程连续两次请求的 key 是否异常。解决:切面里用try-finally恢复旧值,拦截器afterCompletion兜底clear()。
4.2 现象:@Transactional方法里切换数据源无效
原因是事务切面先于数据源切面执行,连接已绑定。排查看两个切面的@Order值,或直接断点getConnection()的调用栈。解决:把数据源切换移到事务方法外层,或显式提高数据源切面优先级;跨库写操作改用分布式事务方案,别指望注解。
4.3 现象:异步线程里查到了默认库
@Async或手动new Thread不会继承父线程的 ThreadLocal。解决:用TaskDecorator在任务提交时把路由键复制到子线程,执行完清理;或者干脆在异步任务入口显式set。
4.4 现象:分片后COUNT结果偏大或AVG不准
COUNT偏大通常是全路由时重复统计了广播表;AVG不准是归并阶段直接平均。排查看中间件是否把聚合函数下推改写。解决:确认广播表配置正确,聚合查询改用SUM/COUNT手工计算。
4.5 现象:启动后第一个请求特别慢
连接池懒加载,多数据源意味着多个池首次建连叠加。解决:配置initialSize预热,或在健康检查里主动getConnection()一次。这个不算故障,但会让监控误报。
5. 设计取舍:分库分表不是性能银弹,而是一场关于“查询自由度”的交易
把多数据源和分库分表放在一起看,会发现它们解决的是两个不同层次的问题:多数据源是按业务或读写角色隔离连接,分库分表是按数据量水平切分存储。前者靠路由键和连接池管理,后者靠 SQL 解析改写和结果归并。两者叠加时,复杂度不是相加而是相乘——一个跨数据源的分片查询,既要选对库,又要拆对表,还要把结果拼对。
我越来越倾向于一个判断:分库分表真正卖给你的不是性能,而是“写入扩展性”,代价是牺牲查询自由度。一旦拆了,非分片键查询、跨分片 JOIN、深分页、分布式事务全部变成需要额外设计的成本项。所以选型时先问三个问题:数据量是否真的到了单库瓶颈(通常是千万级行或写入 TPS 打满)、分片键是否覆盖了 80% 以上的查询、团队是否愿意接受“某些查询就是查不了”的现实。如果答案是否定的,读写分离加索引优化往往比拆库更划算。
验证一套多数据源分库分表方案是否可靠,我一般会做三件事:压测时观察各分片连接池的活跃数是否均衡、用混沌手段杀掉一个分片看归并逻辑是否优雅降级、以及在事务方法里故意嵌套切换数据源看是否报错而不是静默串库。最后一条最能暴露设计缺陷——能报错的实现比能跑通的实现更值得信任。
从那以后我每次配多数据源,都强制先写一个“嵌套调用 + 异步线程 + 事务回滚”的冒烟用例跑一遍,跑不过就不往下写业务。希望帮到你。
本文还有配套的精品资源,点击获取