简介:一份面向Java后端开发者的分库分表与多数据源切换实战示例,基于Sharding-JDBC开源框架,配合SSM项目演示完整配置与路由机制,适合正在学习数据库水平扩展或准备改造单库架构的初中级开发人员。压缩包共73个文件,主要包含18个Java源码、18个XML配置(覆盖MyBatis映射与分片规则)、21个class编译文件、3个properties属性文件、2个JSP页面及SQL初始化脚本等,整体仅66KB,便于快速下载导入工程。目前已有2068人学习。通过该示例可直观理解数据源配置、分片策略定义、SQL自动解析与路由等关键流程,同时能接触到跨库事务、全局唯一ID、关联查询等实践要点,为后续在实际项目中落地分库分表提供可运行、可拓展的参考模板。
1. 分库分表:为什么一条正常的 SQL 突然查不到数据了
某公司把一个订单表拆到 32 个库、每库 128 张表之后,最直接的变化不是查询变快,而是一条原本在单库里跑得好好的 SELECT 开始间歇性报错——要么提示表不存在,要么返回的结果集少了一截。第一反应是代码写错了,查了半天发现 SQL 没动、索引没动、数据也没丢,真正变化的只是这条 SQL 执行时拿到的是哪一台数据库的连接。分库分表要解决的是容量和连接数瓶颈,但它引入的最大问题恰好是“路由”:一条 SQL 必须被正确送到某个分片的某张表上,送错地方就不是性能问题,而是正确性问题。搞懂分库分表,首先要搞懂的其实是底层的数据源路由机制——一条连接怎么被选中、怎么被绑定、又怎么在事务中被锁死。这才是从“知道分库分表”到“敢在生产环境动手”的分水岭。
2. 动态数据源:路由发生在 getConnection(),而不是 SQL 解析时
很多人以为分库分表是在 MyBatis 发 SQL 时由某个拦截器劫持一下,再判断一下去哪张表。实际上 JDBC 层的路由发生在更早的地方:创建 Statement 之前的 getConnection()。连接选错了,后面做多少 SQL 改写都白搭。
2.1 AbstractRoutingDataSource:一次 getConnection 决定整条 SQL 的去向
Spring 生态里做动态数据源,几乎绕不开 AbstractRoutingDataSource 这个基类。它的名字已经把机制说清楚了:它不是一个真实的数据源,而是一个路由器——路由发生在“拿连接”的那一刻。子类只需要实现一个方法:
// 每次获取连接时,Spring 回调这个方法拿到当前线程的路由 key public class OrderDataSourceRouter extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 返回 "order0" 时走订单分片库0,返回 null 时走默认数据源 return DynamicDataSourceContext.peek(); } }AbstractRoutingDataSource 的 getConnection() 内部做了三件事:先调用 determineCurrentLookupKey() 拿到路由 key;再从内部维护的 Map 里取出对应的目标 DataSource;最后调用目标数据源的 getConnection() 真正建立物理连接。后续 MyBatis 或 JdbcTemplate 拿到的都是一个普通的 Connection,之后的 Statement、ResultSet 全都基于这条连接,不会再经过路由层。
这段逻辑推导出几个反直觉的结论。第一,切换数据源的动作必须发生在“事务开启之前”或“第一条 SQL 执行之前”,因为一旦连接建立并被绑定到当前线程,路由层的使命就结束了。第二,所有数据源必须提前配置在 targetDataSources 里,路由只负责选、不负责建——没配置的 key 会直接抛异常,而不是自动创建连接。第三,这个类是 JDBC 层面的路由器,它不解析 SQL 也不改写表名;分库分表中间件里的“表路由”是更上层的职责,两者经常配合使用,但不要混为一谈。
这层最容易踩的差异在“默认数据源”的兜底行为上。有的框架在 determineCurrentLookupKey() 返回 null 时会回退到默认数据源,有的直接报错。生产环境里我会显式设置一个兜底数据源,并且让路由 key 在启动时做一遍合法性检查,避免无上下文时悄悄连到一个不存在的 key 上。
2.2 ThreadLocal 上下文:路由 key 怎么在请求里传递
determineCurrentLookupKey() 只是从某个地方拿 key,真正的机制在于 key 是怎么跟着调用链走的。主流实现都会用 ThreadLocal 存一份“当前数据源标识”,进入一个服务方法时塞进去,方法结束时清掉。最小实现长这样:
public final class DynamicDataSourceContext { // 每个线程独立一份路由标识,互不干扰 private static final ThreadLocal<String> HOLDER = new ThreadLocal<>(); public static void push(String ds) { HOLDER.set(ds); } public static String peek() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }用 ThreadLocal 的根本原因是 java.sql.Connection 本身不与线程绑定、却也不适合被跨线程共享,一个线程拿到连接后,后续所有 SQL 都应该在这条连接上执行。ThreadLocal 天然地把“数据源选择”和“当前线程”绑定起来,避免一个请求里多个线程互相覆盖路由标识。它最容易被忽略的机制是清理问题:ThreadLocal 的值在当前线程存活期间一直是强引用,如果设置了 key 却忘了 remove,线程池里的线程复用时,下一个任务会读到上一个任务残留的 key。很多“间歇性连错库”的问题就是这么来的,而且往往只在高峰期出现,压测都很难复现。
嵌套切换是另一个高频问题。方法 A 切到 order0,方法 A 内部调用的方法 B 又切到 user1,B 返回后 A 再执行 SQL,A 期望回到 order0。用单一 set 实现的话,B 退出后 key 已经变成 user1,A 的后续 SQL 全部走错库。常见的处理思路是维护一个栈而不是一个值——push 时入栈、退出时弹栈,保证后切换的先恢复。上面的代码把接口设计成 push/peek 正是为栈式切换留的余地,实际实现里再加一个 Deque 就好。
2.3 AOP 与强制路由:切换时机比切换动作本身更关键
动态数据源要自动切换,一般通过 AOP 拦截带注解的方法,比如自定义一个 @DataSource("order0"),切面在方法执行前 push、在 finally 里 clear。这个切面的执行顺序是整个机制的命门。Spring 事务管理本身也是 AOP 切面,如果事务切面先于数据源切面执行,事务管理器会先调用 dataSource.getConnection() 把连接和事务绑定到当前线程;等数据源切面再执行 push 时连接已经确定,路由 key 换了也没用。表现出来就是注解写了、日志里 key 也是新的,SQL 却依然跑在旧库上。
强制路由的正确姿势是把数据源切面的 Order 提到最高优先级,让它在事务开启前拿到路由控制权:
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class DataSourceAspect { @Around("@annotation(ds)") public Object switchDataSource(ProceedingJoinPoint pjp, DataSource ds) throws Throwable { DynamicDataSourceContext.push(ds.value()); try { return pjp.proceed(); } finally { DynamicDataSourceContext.clear(); } } }这个切面解决了绝大多数“注解不生效”的问题。它同时反映了一个设计取舍:把数据源切换做成隐式 AOP,出错时排查链路会比较长,所以我一般会要求把路由 key 打进日志 MDC,让每一条 SQL 都能看到它最终落在哪个库上。可观测的路由上下文,比任何框架自带的开关都重要——分库之后你先要能回答“这条 SQL 刚才去了哪里”,才能继续谈性能调优。
3. 分片路由:分片键、取模哈希与一致性哈希的底层差异
动态数据源解决的是“去哪个库”,分片路由解决的是“去哪张表”。两件事经常被放进一个词里,但它们的关注点完全不同:前者是连接选择,后者是数据分布。
3.1 分片键:选错分片键等于没分库
分片键是路由的依据,它决定了数据被均匀地撒到哪些分片上。选分片键的第一原则是“高频查询条件优先”。订单表如果按 user_id 分片,用户查自己的订单列表时,中间件拿到 user_id 直接定位到某个分片;但客服按 order_id 查单时,不知道 user_id 就只能把请求广播到所有分片。反过来,如果按 order_id 分片,订单详情查询很快,用户订单列表又变成全表扫描。无论怎么选,总有一类查询吃亏。
取模实现只是最简单的示意:
// userId = 12345,共 128 张表,分片结果是 user_57 int shard = userId & (128 - 1); // 等价于 userId % 128,位运算更快 String table = "user_" + shard;这段代码看起来简单,但它隐含了一个前提:user_id 的散列特性必须足够好。如果 user_id 是连续自增的,取模分布没问题;如果 user_id 的末位固定(比如从某个老系统迁移来的业务号段),取模结果就会严重倾斜,某几张表被塞满,其他表几乎是空的。所以严谨的做法是先对分片键做一次散列再取模,比如hash(user_id) % 128,坏的输入分布经过散列之后会被打散。
3.2 取模与一致性哈希:扩容时的数据“搬家”差异
取模路由的实现最简单,数据分布也最均匀,但它有个绕不开的问题:扩容时几乎全量数据都需要迁移。从 32 个分片扩到 64 个,原来落在 user_57 的数据,因为57 % 32和57 % 64的结果不同,大约一半的数据要搬到新位置。一致性哈希就是为了降低这个迁移量而设计的。
// 一致性哈希环的定位逻辑(简化示意) TreeMap<Long, String> ring = new TreeMap<>(); for (String node : nodes) { for (int i = 0; i < 160; i++) { // 虚拟节点:解决物理节点少导致的数据倾斜 ring.put(hash(node + "#" + i), node); } } long h = hash(userId); Long key = ring.ceilingKey(h); // 找到环上第一个不小于 h 的节点 if (key == null) { key = ring.firstKey(); // 绕回环头,形成闭环 } return ring.get(key);一致性哈希把数据和服务节点都映射到一个环形空间上,数据落在逆时针方向的第一个节点上。扩容时,只有新节点附近一小段数据需要迁移,其余数据原封不动。代价是节点少时数据分布可能严重不均,所以才引入虚拟节点:每个物理节点在环上放 160 个影子节点,让数据分布趋于均匀。
这两种方案的取舍可以拉一张对比表来看:
| 维度 | 取模路由 | 一致性哈希 |
|---|---|---|
| 数据均匀性 | 均匀,前提是散列足够好 | 依赖虚拟节点数量,物理节点少时可能倾斜 |
| 扩容迁移量 | 接近全量 | 只影响环上相邻区间 |
| 实现复杂度 | 极低,一行运算 | 需要维护哈希环和虚拟节点 |
| 定位排查成本 | 低,算一下就知道数据在哪 | 高,要复算环上位置 |
| 适用场景 | 分片数稳定、长期不扩容 | 节点频繁扩缩、集群弹性变化 |
我一般这么理解:分片数确定且几年内不会翻倍的系统,取模是更好的选择;只有那些分片节点经常调整、扩容缩容常态化的大规模系统,一致性哈希的“少迁移”优势才值得支付额外的维护成本。
3.3 分片键缺失时的全路由:广播的代价
分片路由最怕的不是算错,而是没得算。查询条件里缺了分片键时,中间件无法定位具体分片,只能把 SQL 广播到所有分片,再在内存里聚合结果。对等值查询来说这还只是“多查了几张表”,对带 ORDER BY 和 LIMIT 的查询,问题会被放大。
比如SELECT * FROM user_orders WHERE status = 1 ORDER BY amount DESC LIMIT 10,单库执行时只需扫描一个分片的数据;广播执行时,每个分片都要各自取出前十名,再由聚合节点做一次全局排序。如果表有 128 个分片,实际处理的数据量是单片的 128 倍,一次这样的查询就能把聚合节点的内存和 CPU 打满。遇到这种情况,常见的设计是额外维护一张“路由映射表”:把 order_id 映射到 user_id,查询先走映射表拿到分片键,再精确定位。映射表本身可以做成另一套分片,或者直接用缓存,这是以空间换时间。
3.4 雪花主键:多库自增主键不冲突的代价
分库之后,自增主键第一周就会撞车:两个库各自从 1 开始计数,合并查询时 ID 立即冲突。全局主键最常用的方案是雪花算法,它的结构本身就是一段位运算:
// 雪花算法 ID 的可视化切分:1位符号位 + 41位毫秒时间戳 + 10位机器ID + 12位序列号 long id = (timestamp << 22) | (workerId << 12) | sequence;41 位时间戳可以用约 69 年,10 位机器 ID 支持 1024 个节点,12 位序列号让单机每毫秒最多生成 4096 个 ID。它保证了“时间上有序、全局上唯一”,而且生成过程不需要网络交互,比 UUID 短得多,适合做索引。但它也有一个大家容易忽略的机制细节——时钟回拨。操作系统 NTP 校时如果往回跳了几毫秒,同一毫秒内序列号会重复,ID 就可能冲突。常见防线是记录上次生成 ID 的时间戳,一旦发现当前时间小于上次时间,就拒绝生成或等待时钟追回来。生产环境里,我都会给雪花算法加上“时钟回拨保护”的监控,因为它平时不触发、触发就是主键冲突。
4. 事务边界:一个 @Transactional 在多个数据源前管住了谁
多数据源最看不懂的地方不在路由,而在事务。单库时一个事务控制多条 SQL 是理所当然的,分库之后,一个事务要跨两个库操作,事情开始变得复杂起来。
4.1 连接优先于 SQL:事务与连接的一一绑定
Spring 事务的底层实现基于一个很朴素的原则:一个事务对应一条物理连接,事务的提交和回滚最终都作用在这条连接上。DataSourceTransactionManager 开启事务时,会执行类似这样的逻辑:
// 开启事务时,先从数据源拿到连接,并绑定到当前线程 Connection conn = DataSourceUtils.getConnection(dataSource); TransactionSynchronizationManager.bindResource(dataSource, conn);这段代码是整个“事务内切换数据源不生效”问题的根源。事务开始时连接就被绑定到 TransactionSynchronizationManager 的 resources 里,后续事务范围内的 getConnection 都返回这条被绑定的连接。此时就算你把 ThreadLocal 里的路由 key 改掉,AbstractRoutingDataSource 的 determineCurrentLookupKey() 也不再被调用,因为它拿连接时发现线程上已经有事务同步资源,直接复用了。
所以结论很直接:事务一旦开启,数据源就锁死了,事务内任何形式的动态切换都是无效的。正确的做法是把数据源切换放到事务之外完成,或者反过来,不让方法的事务边界覆盖多个数据源。这也是为什么很多跨库写操作宁可拆成两个独立事务加补偿,也不硬塞进一个事务里。
4.2 分布式事务的代价:从 2PC 到最终一致性
跨库事务最经典的理论方案是两阶段提交(2PC)。第一阶段叫准备阶段,事务协调者让每个参与库都执行 SQL 并加锁,但不提交,各自回复“准备好了”;第二阶段协调者再根据所有人的回复统一发提交或回滚命令。这个流程看起来严谨,实际用起来却非常贵:所有参与库的资源锁要一直握到协调者拿到最终结果,而协调者自己又是一个新的单点。
常见的实现思路是把这个代价转移到业务侧。比如使用本地消息表:主流程在自己的库里写业务数据和消息记录,放进同一个本地事务提交;异步任务扫描消息记录,逐个投递到其他分库;消费成功后回执标记完成,另一个巡检任务定时处理超时未回执的消息。这套机制牺牲了“强一致”,换来的是“最终一致”,但在订单、支付这类业务中,配合对账和状态机,可接受度远高于 2PC。跨库事务的取舍本质是:锁越重,一致性越强,可用性越低;锁越轻,吞吐越高,但需要业务接受一段不一致窗口。
4.3 异步线程与资源边界:路由上下文丢失的另一个面孔
多数据源系统里还有一个容易被忽略的细节:线程切换会直接丢掉 ThreadLocal 里的路由上下文。Spring 的 @Async 异步执行、消息队列消费者线程、定时任务线程池,都不继承提交方的 ThreadLocal。子线程拿到的是一个干净的上下文,路由 key 为 null,SQL 全部落回默认数据源。
如果默认数据源恰好是主库,异步任务里读从库的需求就静默失效;如果默认数据源只有业务库,异步任务再按逻辑库二次路由就会定位错。正确的传递方式是把路由 key 作为参数显式传给异步任务,在子线程入口处重新 push、finally 清理。这也意味着每个异步入口都必须单独做一次路由设置,漏一个就错一个,所以我倾向于把“异步任务路由恢复”做成一个统一的基类或回调模板,而不是让每个方法自己处理。多数据源的资源边界也一样,每个数据源都有自己的连接池,一个分片的慢 SQL 拖垮的是它自己那个池子,而不是全局,但正因为如此,监控必须按数据源维度拆分,不能只看全局平均耗时。
5. 避坑指南:多源切换最容易翻车的五个场景
分库分表的坑大多不是“不会用”,而是“机制没对上”。下面五个场景是生产里重复率最高的,每条都按现象、原因、解决三个步骤拆开讲。
5.1 事务内切换数据源不生效
- 现象:同一个方法上标了 @DataSource("order0") 和 @Transactional,结果 SQL 还是打到了默认库,日志里路由 key 却显示 order0。
- 原因:Spring 事务切面优先级默认高于数据源切面,事务管理器在数据源切面执行前就把连接绑定到了当前线程;之后无论 key 怎么变,线程上的连接都不会换。
- 解决:事务边界与数据源切换边界不能重叠。要么把 @DataSource 拆到事务方法外层的调用者上,要么让数据源切面 Order 最高,确保路由先于事务生效。跨库事务场景下,优先拆事务而不是硬切。
5.2 AOP 切面顺序导致的“日志对,库不对”
- 现象:自己加了 @Around 切面换数据源,日志排查发现 key 确实切换成功了,但翻数据库日志,SQL 还是落在旧库。
- 原因:AOP 切面执行顺序按照 Order 排序,数据源切面晚于事务切面执行时,它只是改了 ThreadLocal,连接早被前一个切面从默认数据源拿走了。
- 解决:给数据源切面加
@Order(Ordered.HIGHEST_PRECEDENCE),并检查项目中所有事务切面的 Order 配置。这个坑的特征是“行为不一致”,高并发下尤其难复现,所以要在方法入口和出口各打一条带 key 的日志。
5.3 线程池复用导致的路由上下文污染
- 现象:每次请求切到某个分片库后,过一段时间,其他完全不相关的请求也报“数据源不存在”或连到同一个库。
- 原因:ThreadLocal 里的 value 在请求结束后没有被 remove,线程归还线程池时把路由 key 留下了;下一个请求复用该线程,读到上一个请求残留的 key。
- 解决:在 finally 里 remove,而不是只 set;封装一个统一的动态数据源工具类,所有切换都通过它进出;接入层再设置一个“入口强制 clear”的过滤器,把漏网之鱼兜住。
5.4 分页 offset 被分片数量放大
- 现象:单库时
LIMIT 100000, 20虽然慢但能忍受,分片后同样的分页逻辑变成几秒甚至超时。 - 原因:分片中间件对深分页的处理是每个分片都取 100020 条,再合并后丢弃前面的 100000 条;分片数越多,无效数据的放大倍数越大。
- 解决:放弃深分页,改成基于 ID(或时间)的游标分页,用 where id > ? 的方式顺着主键往后翻;或者限制可翻页数,超过阈值就不提供服务。对用户侧的分页需求,深分页查到一千页以后本来就没人看,不值得为它拖垮全部后端。
5.5 缺分片键导致慢 SQL 打进每个分片
- 现象:以 order_id 等值查询的接口重构后响应变慢,过一会儿整个分片集群的查询都开始排队。
- 原因:表按 user_id 分片,但查询条件只有 order_id,中间件走全路由把 SQL 广播到每个分片,聚合节点瞬间被打爆。
- 解决:建立 order_id 到 user_id 的映射表,先查映射再路由;或者从业务上强制调用方传 user_id。排查这类问题时,直接在监控里看“广播路由”次数,SHOW STATUS 里也能看到该分片的连接数突然拉满。
6. 设计取舍:先回答“要不要分”,再谈“怎么切”
聊到分库分表的设计取舍,我的习惯是先做减法。大多数系统连垂直拆分都没做好就急着水平拆表,结果是路由层、事务层、聚合层的复杂度一起涌进来,收益却没多少。垂直拆分把订单、用户、商品这些领域拆到不同库,每个领域的连接池和资源边界立刻清晰,对业务查询模式的影响也小;水平拆分引入的分片路由、分布式事务、聚合排序才是真正的麻烦制造者。所以第一问永远是:现在的容量瓶颈到底是连接数还是数据量?如果是连接数,先上连接池扩容和读写分离;如果是单表数据量,先考虑分区表和归档;这些手段都用完了,再谈分库分表。
其次是中间件和自研的取舍。引入成熟的分库分表中间件,拿到的是透明路由和 SQL 改写,付出的代价是执行链路多了一个黑匣子,出了问题需要扒它的解析日志;自研路由表和控制逻辑,可控性最强,但要自己维护元数据、迁移工具和灰度方案。我的折中方案是:路由规则必须自己掌握,可以授权中间件执行;分片元数据做成一张配置表,开发改路由规则不发版。
最后是取模和一致性哈希的选择。取模的均匀性和可排查性都更好,缺点是扩容要搬数据;一致性哈希省了搬数据的成本,却要用虚拟节点和数据迁移工具来弥补倾斜和复杂度。如果分片数长期不变,我永远选取模;如果业务规模每年翻倍、机房经常扩缩,再上一致性哈希。确切地说,这不是一个技术选型问题,而是一个业务增速预判问题。
从那以后,我每次设计分片方案都强制自己先画一张路由决策表:分片键是什么、高频查询条件覆盖哪些、扩容周期多长、能不能接受广播查询,四项全过才动手。这套“先承认代价、再选择机制”的习惯,帮我避免了好几次看似先进实则短命的架构方案,希望帮到你。
本文还有配套的精品资源,点击获取