简介:围绕大数据量场景下的分库分表与多数据源切换,这份SSMDemo示例工程面向具备Java Web和MyBatis基础的开发者及架构学习者,帮助理解Sharding-JDBC的接入方式、分片规则配置与SQL自动路由流程。压缩包共73个文件、约66KB,以java源码、xml配置、properties配置和class编译产物为主,辅以jsp页面、sql初始化脚本及少量工程配置文件,便于导入开发工具后对照调试。已有2070人学习下载。工程按配置、启动和业务代码分层组织,读者可据此梳理多数据源定义、分片策略选择、跨库关联查询、全局唯一ID及数据迁移等问题的处理思路,也能参考示例完成分库分表原型的搭建与验证。对于需要课程设计、技术预研或项目改造前储备方案的读者,这份轻量示例具备较高的参考价值。
1. 分库分表:真正容易出问题的不是切分算法,而是那条连接属于谁
分库分表最反直觉的一点是:切分策略定好了,数据迁移跑完了,灰度切流也顺顺利利,上线后第一批故障往往跟分片算法毫无关系——SQL 明明打到了正确的库,却跑在了错误的连接上。
单库应用只有一条数据源,连接是“默认就有”的东西,代码里根本不需要为它做选择。分库之后,这个默认值消失了:每一次数据库操作都要先回答“用哪个库”,而且答案还会随方法调用层层变化。多数据源切换本质上就是在解决这个“上下文传播”问题——把一次调用链上选定的库,从入口一路带到最底层的 Connection。
搞懂这条链路,你才知道为什么注解加了却不生效、事务一开切库就不生效、异步线程里切库又变成了不确定的状态。这些问题的根因都指向同一个地方:连接是从哪里取出来的,又是什么时候被缓存的。
2. 路由:一条 SQL 是怎么被裁决到某个库某张表的
在讨论“切换”之前,得先搞清楚“选谁”。路由是多数据源切换的上游:它决定了一条逻辑 SQL 命中一个集合还是多个集合,而切换只是在执行阶段把这个集合落实成真实连接。很多切库问题之所以难查,是因为大家默认路由结果是“一个”,实际上它天然是“一批”。
2.1 逻辑表与实际节点:路由结果为什么是一个集合
应用代码里写的t_order是逻辑表,物理上它是ds_0.t_order_0到ds_1.t_order_3这若干个真实节点的集合。分片键的值经过算法计算,得出一个或多个(数据源名, 物理表名)的二元组,行业里通常叫数据节点或者路由单元。
常见的实现思路是先把库和表“拉平”成一个大桶再用一次取模,而不是先算库再算表:
public final class ShardRouter { private final int dbCount; // 分库数量,例如 2 private final int tableCount; // 每库分表数量,例如 4 private final int bucketCount; // 全局桶数 = dbCount * tableCount public ShardRouter(int dbCount, int tableCount) { this.dbCount = dbCount; this.tableCount = tableCount; this.bucketCount = dbCount * tableCount; } // 返回的是 List:一条逻辑 SQL 可能命中多个物理节点 public List<DataNode> route(String logicTable, Long shardValue) { List<DataNode> nodes = new ArrayList<>(); if (shardValue == null) { // 分片键缺失:只能全路由,这是代价最高的一种情况 for (int i = 0; i < dbCount; i++) { for (int j = 0; j < tableCount; j++) { nodes.add(new DataNode("ds_" + i, logicTable + "_" + j)); } } return nodes; } // floorMod 而不是 %:分片键为负数时 % 会返回负值,导致数组越界 int bucket = Math.floorMod(shardValue, bucketCount); int dbIndex = bucket / tableCount; // 高位定库 int tableIndex = bucket % tableCount; // 低位定表 nodes.add(new DataNode("ds_" + dbIndex, logicTable + "_" + tableIndex)); return nodes; } }参数和取舍值得逐条说:dbCount与tableCount一旦确定就写进了数据分布的物理事实,改一次要搬数据,所以通常预留余量;bucketCount用乘法拉平,是因为“先取模定库、再取模定表”在后续扩容时会同时打乱库和表两层映射,而拉平后扩容只需处理一层;floorMod处理负数,这是血泪经验——早期用%的实现在用户 ID 为负或者哈希值是负数时会直接抛数组越界。
关键结论在返回值类型上:route()返回List而不是单个节点。等值查询恰好得到一个元素,IN查询得到多个,范围查询和缺少分片键的查询则可能是全集。所以“路由”这一步的产物永远是一个待执行集合,而不是一个确定目标。
2.2 从 SQL 解析到结果归并:路由链路的六个阶段
一条 SQL 走完路由,中间要经过六个阶段,每一步都可能成为故障点。理解这六个阶段的价值在于:当结果不对时,你能快速判断问题出在哪一段,而不是盲目怀疑分片算法。
| 阶段 | 做什么 | 典型故障表现 |
|---|---|---|
| SQL 解析 | 把 SQL 文本解析成语法树,识别表名、条件、排序、分页 | 复杂子查询解析失败,直接报语法不支持 |
| 分片条件提取 | 从 WHERE 中抽取分片键的=、IN、BETWEEN值 | 分片键被函数包裹后提取不到,退化为全路由 |
| 路由计算 | 用分片算法把条件值映射成数据节点集合 | 结果集重复或缺失,通常是算法与数据迁移不一致 |
| SQL 改写 | 逻辑表名换物理表名,条件按节点裁剪,分页重写 | 改写后 SQL 参数错位,返回数据错行 |
| 执行 | 在各节点上并发或串行执行 | 连接池被单条全路由 SQL 打满 |
| 结果归并 | 对多节点结果做排序、聚合、分页合并 | 内存溢出,或LIMIT后的条数不对 |
改写这一段最容易出问题。举一个实际改写效果:
-- 应用提交的逻辑 SQL SELECT * FROM t_order WHERE order_id IN (101, 102, 103) ORDER BY id DESC LIMIT 10; -- 发往 ds_0.t_order_1 的物理 SQL(假设 101、103 落在该节点) SELECT * FROM t_order_1 WHERE order_id IN (101, 103) -- 102 不属于本节点,被裁剪掉 ORDER BY id DESC LIMIT 10; -- 单节点无需改写;多节点时 offset 要按片放大裁剪这一步不只是“优化”:如果把102也发下去,本节点返回空结果没问题,但归并层要做去重,重复数据会在这时候冒出来。而分页那一行才是真正的麻烦——多节点场景下每个节点都必须取offset + limit条,归并后再截断,跳过多少行就要在内存里排出多少行,这就是深分页问题的根源,后面会展开。
2.3 绑定表与广播表:没配绑定表,JOIN 会被放大成笛卡尔积
分片之后还保留 JOIN 的应用,一定要关心绑定表。绑定表指的是分片规则完全一致的一组表,比如订单主表和订单明细表都用order_id分片、分片数量和算法也一致。
配置了绑定关系后,路由引擎知道t_order_1和t_order_item_1在同一个库里,JOIN 只发往一个节点。没配绑定关系时,引擎只能保守处理:主表命中的所有节点与明细表命中的所有节点做组合,形成 N×M 个执行单元,再在归并层按 JOIN 语义合并。
分片数为 2 库 × 4 表时,这个差距是 1 次执行和 64 次执行的差别。这个数字不是危言耸听:它意味着连接池瞬间被一条 SQL 占满,后面所有请求排队超时,日志上看起来像是“数据库挂了”,实际是被自己的路由放大拖垮的。
另一类是广播表,也就是字典表、配置表这种数据量小、几乎不变的全局表。它在每个库都存一份全量数据,写操作广播到所有节点,读操作只取任意一个节点。广播表的写入是跨库写,必须放在事务边界之外,或者用最终一致的手段补齐——这一点在第五章还会遇到。
3. 多数据源切换:为什么一加@Transactional,切库就失效
路由解决了“数据在哪”,切换解决“用哪条连接去拿”。这两件事在代码里往往被混成一个注解,但它们的作用时机完全不同:路由在 SQL 改写阶段生效,切换在获取连接阶段生效。一旦事务介入,获取连接这个动作会被提前,切换的时机就错过了。
3.1 路由数据源只是一个 key 提供者
多数据源最经典的实现方式,是把多个真实数据源注册进一个路由数据源,然后由一个上下文变量决定当前用哪个。抽象路由数据源的核心方法只有一个:
public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 每次 getConnection() 时被调用,返回值作为 key 去 targetDataSources 里查 return DataSourceContextHolder.get(); } }DataSourceContextHolder用ThreadLocal存当前线程选定的数据源名。这里有个容易被忽略的契约:路由数据源本身并不持有连接,它只是一个“key 到真实数据源”的查表器,并且这个查表动作只在getConnection()的那一瞬间发生。换句话说,一旦某条连接已经被取出来并缓存,再修改ThreadLocal也不会产生任何影响。
targetDataSources的 key 必须覆盖所有可能的值,且要配置defaultTargetDataSource作为默认值。默认值是否配置会决定故障形态:配了默认值,切库失败时表现为“静默打到主库”;没配默认值,则直接抛找不到数据源的异常。前者更难查,后者更好排查,我个人倾向后者。
3.2 AOP 顺序:切库切面必须排在事务切面之外
切库注解通常靠 AOP 实现:方法上加@UseDataSource("ds_report"),切面在方法执行前设置 key,执行后还原。问题在于,同一个方法上常常还挂着@Transactional,两个切面谁先谁后,决定了切库是否有效。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface UseDataSource { String value(); } @Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 必须比事务切面更靠外层 public class DataSourceAspect { @Around("@annotation(useDataSource)") public Object switchDs(ProceedingJoinPoint pjp, UseDataSource useDataSource) throws Throwable { String origin = DataSourceContextHolder.get(); // 记住外层上下文,用于还原 try { DataSourceContextHolder.set(useDataSource.value()); return pjp.proceed(); // 此时才进入事务切面,事务在新 key 下开启 } finally { // 还原而不是 clear:嵌套调用时 clear 会把外层上下文一起抹掉 if (origin == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(origin); } } } }这里有两个关键点。第一是@Order:事务切面的默认优先级是最低的,所以只要给切库切面一个更高优先级,它就会排在事务之外,先切 key、再开事务,事务拿到的连接才是目标库的连接。顺序反过来的话,事务先开、连接先取,切库注解就成了一段永远不生效的代码——这也是很多人觉得“注解不生效”的第一大原因,其实注解生效了,只是生效得太晚。
第二是finally里的还原逻辑。写法上clear()只有一行更省事,但一旦出现 A 方法切到 ds_0、内部调用 B 方法切到 ds_1、B 返回后 A 继续执行的场景,clear()会让 A 剩下的代码回到默认库,数据就写错地方了。用“保存-还原”的栈式写法,成本和clear()几乎一样,却能省掉一堆排查成本。
3.3 连接被事务固定之后,切 key 就没用了
即使 AOP 顺序对了,还有一个更隐蔽的坑:在同一个事务内部再次切库。这不是顺序问题,而是连接已经被事务绑定住了。
@Service public class OrderService { @Transactional // 事务在这里开启,连接已绑定到 ds_order public void createOrder(Order order) { orderMapper.insert(order); // 走 ds_order.t_order_0 // 期望切到报表库,实际无效:连接早就在事务开启时取好了 reportService.writeLog(order.getId()); } }原理在事务管理器的doBegin()里:事务开启时会通过DataSourceUtils.getConnection(dataSource)取一条连接,并把ConnectionHolder以数据源对象本身为 key 绑定到TransactionSynchronizationManager的线程资源里。后续同一个数据源上再调用getConnection(),会先命中这个已绑定的ConnectionHolder直接复用,根本不会再走determineCurrentLookupKey()。
所以“事务内切库”这件事在机制层面就是不成立的,不是配置问题,也不是框架缺陷。常见的处理方式是三种:把跨库操作移到事务边界之外,先提交再写;用REQUIRES_NEW挂起外层事务、重开一个新事务拿到新连接,但前提是切库切面在事务切面之外;或者干脆把跨库写入改成异步消息,接受最终一致。
提示:
REQUIRES_NEW会占用两条连接,外层事务未提交期间内层事务若访问同一行数据,会直接死锁。并发量大的写入路径上慎用。
4. 排查现场:切库失效的五种典型症状与定位路径
切库失效的表现高度相似——数据落到了不该落的库——但根因分散在代理、顺序、线程、连接复用四个层面。下面五条来自实际排查记录,按“现象 → 原因 → 解决”整理,遇到问题时可以按顺序对号入座。
第一条:注解加了,日志里 key 也变了,SQL 还是打到旧库。
现象是切库前后的 SQL 日志显示的库名没变,但上下文打印出来的数据源名确实切换了。原因通常是事务已经先于切库开启,连接在doBegin()时就被取走并绑定,后面改 key 不会触发重新取连接。解决方式是调整切面优先级,让切库切面排在事务切面之外,或者把切库动作提到事务方法调用之前,用外层方法包一层。
第二条:直接在类内部调用带注解的方法,注解完全不生效。
现象是外部调用正常,同一个类里this.xxx()调用时切库失效。原因是注解靠代理实现,自调用不经过代理对象,切面逻辑根本没执行。解决方式是把这个方法拆到独立的 Bean 里,或者注入自身代理、用AopContext.currentProxy()拿到代理再调用。拆 Bean 是最省心的做法,代理方式在开启exposeProxy后还会带来额外的栈开销。
第三条:嵌套调用返回后,外层后续操作落到了默认库。
现象是 A 切 ds_0、调用 B 切 ds_1、B 正常返回,A 剩下的写操作却写进了默认库。原因就是前面提到的finally里直接clear(),把整个线程的上下文清空了。解决方式是改成保存-还原的写法,同时建议在ThreadLocal的remove()调用上做静态检查,禁止业务代码直接清理上下文。
第四条:异步线程或者线程池里切库,结果随机。
现象是同一个任务偶尔落到正确的库,偶尔落到主库,重启后又换一种表现。原因是ThreadLocal不跨线程传递,提交任务时子线程拿到的是null,走了默认数据源;更糟的是线程池复用线程,上一个任务残留的 key 会被下一个任务读到,出现数据串库。解决方式是在任务提交处显式捕获上下文并在子线程中设置,或者使用支持上下文传递的ThreadLocal实现,同时保证任务结束时清理。
第五条:主从分离场景下,刚写入的数据立刻查不到。
现象是写入接口返回成功,紧接着的查询返回旧值甚至空。原因是路由把读请求发到了从库,而从库复制存在延迟窗口。解决方式是在同一个请求内标记“写后读”,在一段时间窗口内强制走主库;或者在事务内所有读都走主库。这里要提醒的是,强制走主的窗口时长不能拍脑袋定,得看实际的复制延迟分布,通常取 P99 再留一点余量。
注意:排查切库问题一定要先打两类日志——切库切面里的 key 变更日志,以及连接获取时的真实库标识。只有 key 日志,你只能证明“意图正确”;加上连接日志,才能证明“事实正确”。
5. 跨库之后:分布式事务与深分页为什么都在做兜底措施
分片键设计得好,绝大多数请求是单库单表,一切顺风顺水。麻烦永远来自那 5% 的跨库请求,它们在大促或者批量任务时集中爆发,把“架构很美”变成“到处打补丁”。
5.1 跨库事务:两阶段提交为什么在业务写入路径上几乎不可用
跨库写入的标准答案听起来很美:两阶段提交,协调者先让所有参与者 prepare,全部成功后统一 commit。但落到业务写入路径上,代价是同步阻塞——prepare 阶段资源已经锁定,直到 commit 才释放,期间任何一次网络抖动都会把锁持有时间拉长到秒级甚至分钟级。分片数越多,失败概率按乘法叠加,整体可用性反而下降。
常见的替代是三条路:本地消息表,把跨库动作拆成“本地事务 + 消息投递”,靠重试保证最终一致;TCC,业务侧写 try/confirm/cancel 三段,把锁的粒度交还给业务;Saga,把长事务拆成一串可补偿的短事务。它们的共同点是放弃强一致,换取可用性和吞吐。
这里有个设计上的取舍值得强调:与其把跨库事务做得很强,不如让跨库事务不发生。把强关联的数据用同一个分片键切,让订单和明细、用户和账户永远落在同一个库,跨库写自然消失。分片键选得对,事务问题就被消解在数据模型里了;选得不对,后面所有的分布式事务方案都只能算补救。
5.2 跨库分页:为什么LIMIT offset越翻越慢
单库分页时,LIMIT 100000, 20只是扫描代价高。跨库分页时,代价被分片数放大:每个分片都得返回offset + limit条,归并层在内存里排序后再丢弃前offset条。4 个分片、翻到第 10 万条,内存里要同时排 40 万行。
常见的优化是二次查询法:第一轮每个分片只取limit条并记录本片的最小值,拿到全局最小值后,第二轮在各分片按BETWEEN 全局最小值和本片最小值补查,最后归并。这样每轮的返回条数被压到limit量级,内存占用与页码无关。
还有一种更彻底的做法是禁止跳页,只允许“下一页”,把上一页最后一条的排序键作为游标传下去,本质是把offset换成WHERE sort_key > ?,让每个分片都能走索引定位。这个限制在产品侧需要接受度,但在数据量大的场景里,它是唯一能稳住 P99 的方案。
5.3 读写分离下的延迟窗口与强制走主
多数据源切换最常见的另一个用途是读写分离:写走主库,读走从库。路由规则通常按 SQL 类型自动判定,SELECT走从、其余走主。
问题在于“自动判定”不理解业务语义。下单成功后立刻跳转到订单详情页,这个SELECT是完全合法的读请求,但它要求读到刚写入的数据。自动路由会把它发到从库,于是用户看到“订单不存在”。处理方式是在写操作之后给当前请求打一个标记,让后续读请求强制走主,标记可以放在ThreadLocal里,随请求结束一起清理,也可以放在缓存里按主键维度标记一小段时间。
注意:强制走主的标记必须设置过期时间。用主键维度标记时,标记本身会成为热点 key,需要评估内存占用和清理策略。
6. 设计取舍:把分片键当成架构约束,而不是一个普通字段
回过头看,分库分表里所有的复杂问题——跨库事务、跨库分页、跨库 JOIN、切库失效——都不是“实现得不够好”,而是数据被打散之后必然出现的代价。真正决定系统好不好维护的,是分片键选得对不对,而不是中间件用得多花哨。
我一般用三个问题来验证一个分片键是否合格。第一,80% 以上的核心查询能不能只带这一个字段就完成路由?如果不能,说明业务的主访问路径和分片键不匹配,后面必然出现大量全路由查询。第二,强关联的表能不能共用同一个分片键?订单和明细、主体和扩展表,如果分片键不一致,JOIN 就只能靠广播或应用层拼装。第三,分片键的值会不会变?用户 ID、订单 ID 这类不变,而状态、地域、租户归属这类可能迁移的字段,一旦变更就意味着跨库搬家。
这三个问题的答案里,第二个最容易被忽视。很多人把订单主表分了片,明细表却按明细 ID 分片,理由是“明细查询也很多”。结果主表和明细表永远不在同一个库,每次查详情都要跨库拼装,事务更是无从谈起。真正合理的做法是让明细表跟随主表分片,明细 ID 查询通过冗余主表的订单 ID 走二次路由,把复杂度收敛到一个字段上。
多数据源切换也是同样的逻辑。它看起来是一个技术实现问题,实际上是一个约束问题:连接是有生命周期的,事务一旦开启,库就已经选定,任何试图在事务内部改变这个选择的努力都是徒劳。所以架构上必须保证“一个事务只碰一个库”,把跨库需求推到事务边界之外,用消息、补偿或者异步任务去处理。
有一次线上出现数据写错库的问题,排查了整整一天,最后发现是某位同学在@Transactional方法里做了嵌套切库,代码看起来完全正确,甚至单测也能通过——因为单测里没有真正开启事务代理。从那以后我每次评审涉及多数据源的代码,都强制走一遍三件事:确认切库切面在事务切面之外、确认嵌套调用没有clear()、确认异步任务里显式传递了上下文。这三条挡住的问题,比任何框架升级都多。
希望帮到你。
本文还有配套的精品资源,点击获取