ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

分库分表后多数据源切换实战:动态路由与ThreadLocal避坑指南

分库分表后多数据源切换实战:动态路由与ThreadLocal避坑指南

简介:这份资源面向需要应对大数据量场景的Java后端开发者与架构学习者,聚焦分库分表与多数据源切换这一数据库水平扩展主题,帮助理解数据路由、事务管理等核心难点,并借助Sharding-JDBC完成落地实践。压缩包共73个文件,约66KB,以18个java源码、21个class编译文件、18个xml配置与映射文件为主,辅以properties配置、jsp页面、sql脚本及Eclipse工程配置,构成一个结构完整的SSMDemo示例工程。目前已有2069人学习下载,具备一定的参考基础。读者可从中获得可运行的分片配置样例、数据源与分片规则的声明方式,以及跨库跨表查询、全局唯一ID等问题的处理思路,适合对照源码理解Sharding-JDBC的接入流程与工程组织方式。

1. 分库分表之后,多数据源切换才是真正让人睡不着的那一环

单库单表跑到几百万行时,加索引、加缓存还能撑;到了千万级、写入并发上来,分库分表就成了绕不开的选择。但很多方案在“怎么分”上写得很热闹,真正上线后翻车的却是另一件事:一个业务请求要跨两个库、三个表,事务怎么保证、数据源怎么切、切完怎么保证不串库。这套方案要解决的就是分库分表落地后的多数据源切换问题,适合正在做分布式数据库课程设计、毕设,或者要交一份可实施方案的读者。下面按需求、设计、实现、避坑、验收的顺序拆开讲,能照着改。

2. 需求与选型:先想清楚为什么要切数据源

2.1 分库分表后数据源切换的真实诉求

分库分表不是目的,目的是把单点压力拆开。拆开之后,一个订单查询可能落在 order_db_0,用户信息落在 user_db_1,库存落在 stock_db_0。应用层如果还按单数据源写,要么连错库,要么在代码里到处 new 连接,维护成本极高。通常的做法是引入一个数据源路由层,让业务代码只关心“查哪个逻辑表”,由路由层根据分片键算出物理库和物理表。

这里要区分两个概念:分片路由解决“数据在哪”,数据源切换解决“连接怎么拿”。前者是规则,后者是执行。很多课程设计只写了分片规则,没写切换机制,答辩时被问一句“跨库查询怎么处理”就卡住。合格的方案必须把这两层分开描述,并说明它们之间的调用关系。

2.2 技术选型:路由层放应用侧还是中间件侧

常见两条路:一是应用侧轻量路由,自己封装 DataSource 和分片算法,适合数据量可控、团队想完全掌控逻辑的场景;二是引入数据库中间件,把路由和切换下沉,应用像用单库一样用。前者代码透明、调试方便,后者运维成本低但排查链路长。

我一般建议毕设和课程设计走应用侧路由,因为要展示的是你对分片算法和切换机制的理解,而不是中间件的部署能力。具体到实现,用 Spring 的 AbstractRoutingDataSource 做动态数据源,配合 ThreadLocal 保存当前线程的路由键,再写一个分片算法类根据订单号或用户 ID 取模。这个组合代码量不大,但把“规则计算—数据源选择—连接获取”整条链路都暴露出来了,答辩时讲得清。

提示:选型时把“是否支持跨库事务”单独列一行。应用侧路由默认不带分布式事务,需要额外引入补偿或消息最终一致方案,这一点要在设计文档里写明,别等验收时才补。

2.3 分片键与数据源数量的确定方法

分片键选错,后面全是补丁。订单类业务通常用订单号或用户 ID 做分片键,保证同一用户的订单落在同一库,减少跨库。数据源数量不要拍脑袋,按“当前数据量 × 三年增长 ÷ 单库舒适容量”估算,再取 2 的幂方便取模。比如单库舒适容量按 500 万行算,当前 2000 万行、年增 30%,三年约 4400 万行,取 8 个库比较稳。

分片算法建议先用取模,简单可验证;数据倾斜明显时再换一致性哈希。方案文档里要把“为什么是 8 不是 16”写出来,这是评委常问的点。表数量同理,库内再按时间或哈希分表,库表和路由键的对应关系用一张配置表维护,别硬编码在代码里。

3. 总体设计:路由层、数据源层与事务边界的职责划分

3.1 三层结构:分片规则层、数据源路由层、连接执行层

整体分三层。分片规则层负责根据分片键算出目标库和目標表,输出一个路由结果对象,包含库索引、表索引、是否需要跨库聚合。数据源路由层继承 AbstractRoutingDataSource,从 ThreadLocal 里取库索引,返回对应的 DataSource。连接执行层就是 MyBatis 或 JdbcTemplate,拿到连接后拼物理表名执行 SQL。

三层之间只通过路由结果对象传递信息,不互相直接调用内部方法。这样做的价值是:换分片算法只动第一层,换连接池只动第二层,业务 SQL 基本不动。方案文档里画一张分层职责表,比画一堆箭头图更清楚。

层级职责关键类变更频率
分片规则层计算库表索引、判断跨库ShardingRule低
数据源路由层按索引选 DataSourceDynamicDataSource中
连接执行层执行 SQL、结果映射Mapper / JdbcTemplate高

3.2 动态数据源的注册与切换时序

启动时把所有物理数据源注册进一个 Map,key 用库索引,value 是 HikariDataSource。请求进来后,拦截器或 AOP 从参数里提取分片键,调用分片规则算出索引,写入 ThreadLocal,再放行到 DAO 层。DAO 执行时 AbstractRoutingDataSource 的 determineCurrentLookupKey 返回该索引,连接池据此取连接。请求结束在 finally 里清空 ThreadLocal,防止线程复用导致串库。

这个时序里最容易忽略的是清空时机。如果只在正常返回时清,异常分支就会把脏索引留给下一个请求,表现为“偶发查错库”,排查起来非常折磨。通常的做法是用 try-finally 包住整个执行段,或者用拦截器的 afterCompletion 统一清理。

3.3 跨库查询与聚合的边界约定

跨库查询不要试图在路由层做透明聚合,那是中间件的活。应用侧路由的合理边界是:单分片键查询直接路由;多分片键查询由业务层显式拆成多次单库查询,再在内存里合并;跨库 join 一律禁止,改成冗余字段或异步同步。方案里要把这条边界写成约束,而不是当成缺陷藏起来。

如果课程设计必须演示聚合,可以做一个受限场景:按时间范围查多个库,每个库返回部分结果,业务层用 CompletableFuture 并行取再合并。代码里要标注“仅用于演示,生产环境需评估连接数和超时”。这样既展示了能力,又没有把方案吹成万能。

4. 核心实现:动态数据源与分片路由的代码落地

4.1 动态数据源路由类的实现

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从当前线程取库索引,未设置时走默认库 Integer dbIndex = ShardingContext.getDbIndex(); return dbIndex == null ? "default" : "db_" + dbIndex; } }

这段代码的关键在 determineCurrentLookupKey 的返回值必须和注册时的 key 完全一致。注册时用targetDataSources.put("db_0", ds0),这里就返回"db_0",别一边用整数一边用字符串。ShardingContext 内部用 ThreadLocal 存索引,提供 set、get、clear 三个方法,clear 必须在请求结束时调用。参数上,默认库用于不分片的配置表或字典表,避免所有查询都强制带分片键。

4.2 分片规则计算与 ThreadLocal 上下文管理

public class ShardingRule { private static final int DB_COUNT = 8; private static final int TABLE_COUNT = 4; public static RouteResult route(long orderId) { // 库索引取模,表索引用高位避免与库索引完全同步 int dbIndex = (int) (orderId % DB_COUNT); int tableIndex = (int) ((orderId / DB_COUNT) % TABLE_COUNT); return new RouteResult(dbIndex, tableIndex); } }

库和表都用取模,但表索引用orderId / DB_COUNT再取模,目的是让同一库内的数据在表之间也均匀分布。如果库和表都用orderId % N,会出现库索引和表索引强相关,某些表永远空着。RouteResult 里再带一个 physicalTableName 方法,拼成order_${tableIndex},DAO 层直接取用。参数 DB_COUNT 和 TABLE_COUNT 建议放配置文件,别写死,答辩时能解释“扩容时改配置加迁移脚本”。

4.3 拦截器提取分片键与清理时机

@Component public class ShardingInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) { String orderId = req.getParameter("orderId"); if (orderId != null) { RouteResult r = ShardingRule.route(Long.parseLong(orderId)); ShardingContext.setDbIndex(r.getDbIndex()); } return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { // 无论成功失败都清理,防止线程池复用串库 ShardingContext.clear(); } }

preHandle 里只处理带分片键的请求,没有分片键的走默认库。afterCompletion 是清理的唯一入口,不要在其他地方重复 clear,否则嵌套调用时会提前清掉上下文。参数上,orderId 从 query 或 header 取都行,关键是和前端约定一致。如果分片键在请求体里,拦截器读不到,通常的做法是改成 AOP 切 Service 方法,从入参对象里取,这一点在方案里要写明取舍。

4.4 跨库分页查询的合并策略

跨库分页是分库分表里最容易被追问的点。假设每页 10 条,查第 3 页,不能每个库各取 10 条再拼,那样顺序是错的。正确做法是每个库取前(pageNo × pageSize)条,内存归并排序后再截取第 21 到 30 条。代码上用优先队列做多路归并,按排序字段比较。

// 各库取 offset + limit 条,归并后截取目标页 int fetchSize = pageNo * pageSize; List<Order> merged = new ArrayList<>(); for (DataSource ds : shards) { merged.addAll(queryFrom(ds, fetchSize)); } merged.sort(Comparator.comparing(Order::getCreateTime).reversed()); return merged.subList((pageNo - 1) * pageSize, Math.min(fetchSize, merged.size()));

这段代码只适合演示和小数据量,深分页时 fetchSize 会很大,生产环境要改成游标或二次查询。方案文档里把“深分页限制”写成已知约束,比假装没有更可信。参数 pageSize 建议设上限,比如 100,超过走导出通道。

5. 避坑与排查:多数据源切换最容易翻车的五个点

5.1 现象:偶发查错库,日志里库名对不上

原因几乎都是 ThreadLocal 没清或清晚了。线程池复用线程,上一个请求的库索引残留,下一个请求没设置就直接用了。解决方法是把 clear 放进 finally 或拦截器 afterCompletion,并在测试里专门写一个“连续发两个不同分片键请求”的用例,断言第二次的库索引正确。排查时在 determineCurrentLookupKey 里打一行 debug 日志,带上线程名和索引,串库时一眼能看出来。

5.2 现象:事务里切换数据源不生效

Spring 的@Transactional在方法进入时就绑定了连接,方法内部再改 ThreadLocal 不会换连接。原因是事务和数据源的绑定时机早于路由计算。解决办法是把分片键提取放到事务外层,或者用编程式事务在路由之后再开启。方案里要明确“事务边界必须在数据源确定之后”,这条写进设计约束,能省掉大量返工。

5.3 现象:跨库查询返回结果数量不对

多半是分页合并时每个库取的条数不够,或者排序字段在各库不一致。原因是对“每库取 pageSize 条”想当然。解决是按pageNo × pageSize取再归并,并确认排序字段在库里有索引。排查时把每个库的原始结果打印出来,对比总数和顺序,通常很快能定位是取数不足还是排序错乱。

5.4 现象:新增分库后旧数据路由不到

扩容时改了 DB_COUNT,旧数据的取模结果变了,按新规则查不到。原因是取模算法对模数敏感。解决办法是扩容时用成倍扩容加数据迁移,或者一开始就用一致性哈希。课程设计里如果不想做迁移,就在文档里写明“当前不支持在线扩容”,并给出迁移步骤概要,别留一个改配置就好的假象。

5.5 现象:连接池被打满,接口超时

跨库并行查询时每个库各拿一个连接,并发一高连接数翻倍。原因是没限制并行度和单请求连接数。解决办法是给跨库查询设信号量限流,单请求最多并行 4 个库,超时时间统一设短。排查时看连接池的 active 数和等待队列,结合慢查询日志定位是哪个库拖慢。参数上,Hikari 的 maximumPoolSize 按“库数 × 单库并发上限”估算,别直接抄默认值。

6. 验收与答辩:怎么证明这套切换机制真的可靠

验收不要只看“能查出来”,要按场景逐条过。我一般准备一张验收表,每行一个场景、预期结果、实际结果、证据(日志或截图)。下面这张表可以直接改成你方案里的验收章节。

场景操作预期判定依据
单分片键查询用 orderId 查订单命中对应库表日志库索引与取模一致
连续不同分片键同一线程先后查两个订单两次库索引不同且正确无串库日志
异常分支查询中抛异常后再查下次请求库索引正确ThreadLocal 已清
跨库分页查第 3 页总数和顺序正确与单库全量对比
事务内查询事务方法内查分片表连接与路由一致事务日志库名正确

答辩时最容易被问的三个问题提前准备好答案:分片键为什么选这个、跨库事务怎么处理、扩容怎么办。回答时先给结论再给依据,别绕。演示环节建议现场跑“连续两个不同分片键请求”的用例,这是最能证明切换机制有效的动作,比讲十分钟架构图管用。

还有一个技巧:把 ThreadLocal 的 set 和 clear 做成带调用栈日志的开关,答辩现场打开,投屏上能直接看到“哪个线程、哪个请求、设了哪个库、什么时候清的”。评委看到清理时机明确,基本不会再追着问串库问题。这个开关平时关掉,不影响性能。

血泪经验是早期我只在正常返回时清上下文,压测时偶发查错库,查了两天才定位到异常分支没清。从那以后我每次做多数据源方案,都强制先写“连续不同分片键 + 异常后再查”这两个用例,跑通再往下写业务。希望这套结构能帮你把方案交得踏实一点。

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

RELATED READING

延伸阅读

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