简介:《分库分表,多数据源的切换》配套示例工程,面向已具备 Java 与 MyBatis 基础、需要应对单库数据膨胀与多数据源路由问题的后端开发者。工程以 SSMDemo 为主线,覆盖分片配置文件的编写、启动类中数据源上下文的初始化,以及业务层调用 Sharding-JDBC API 完成 SQL 解析与路由的完整链路,可对照理解精确分片、范围分片等策略及跨库事务的处理思路。压缩包共 73 个文件,含 18 个 java 源码、18 个 xml 配置与映射、21 个编译后的 class、3 个 properties 与 2 个 jsp 页面,另有 mybatistest.sql 建表脚本,整体约 66KB,体量轻便,导入 IDE 即可运行调试。目前已有 2070 人学习下载,适合用作分库分表入门的可运行样板与排错参照。
1. 单库两千万行之后:分库分表和多数据源切换到底谁管什么
单库单表跑到两千万行、写入开始排队、DDL 要停服半小时的时候,分库分表这四个字就会被重新拿出来讨论。但真正让人翻车的往往不是分片算法,而是那句「我要在同一个事务里跨两个库」——分片解决的是容量问题,多数据源切换解决的是「这一次请求该连哪个库」,两件事经常被混为一谈,最后代码里一半是分片规则,一半是@DS注解,谁也说不清一条 SQL 走了哪条路。
这份资源包围绕后者展开:一套能跑起来的 Spring Boot + MyBatis 工程,把注解路由、上下文栈、切面顺序、分片规则、强制路由和分布式主键串成一条完整链路。适合正在给业务库做拆分、又不想一上来就引中间件的后端同学,也适合已经被「明明切了数据源、事务却失效」折腾过一轮的人。
2. 拆开资源包:路由骨架、分片规则与配置清单
资源包不是一个大而全的脚手架,它刻意拆得比较碎,每一块都能单独拎出来塞进现有工程。第一次打开会觉得类有点多,但只要抓住一条主线就不乱:所有「连哪个库」的判断,最终都收敛到一个方法返回值上;所有「落到哪张表」的判断,最终都收敛到一份规则配置里。看清这两条主线,剩下的都是围绕它们做的防御性代码。
2.1 五个模块的职责边界
目录大致按职责切分,不看包名看职责会更清楚。下面这张表是我自己维护时用的版本,改动频率那一列比职责更重要——它决定了你升级时要重点回归哪里。
| 模块 | 职责 | 关键类 | 改动频率 |
|---|---|---|---|
| 路由核心 | 维护线程内的数据源上下文栈、解析路由键 | DataSourceContextHolder、DynamicRoutingDataSource | 低,加新切换维度时才动 |
| 注解与切面 | 解析@DS、支持 SpEL、控制织入顺序 | DS、DataSourceAspect、SpelResolver | 中,业务要新维度就加 |
| 分片配置 | 实际节点、分库分表算法、绑定表 | ShardingRuleConfiguration、InlineShardingAlgorithm | 高,扩容必改 |
| 主键生成 | 号段与雪花的统一入口 | SegmentService、SnowflakeWorker | 低,但机器数变了要动 |
| 运维工具 | 路由日志开关、分片数据核对脚本 | RouteLogPrinter、ShardCheckRunner | 低,出问题时才想起它 |
值得单独说的是「运维工具」这一块。很多人拆库之后只在代码层做验证,结果上线两周才发现某张表的数据总量对不上,回头查已经无从下手。资源包里那个核对脚本很土,就是按分片键全量扫一遍、按库汇总行数,但它是我每次上线前必跑的一道工序。
2.2 AbstractRoutingDataSource 在哪一步决定连哪个库
整个方案的地基是 Spring 自带的AbstractRoutingDataSource。它内部维护两个 Map:targetDataSources存所有真实数据源,resolvedDataSources存初始化后的实例,另有一个defaultTargetDataSource兜底。关键点在于它是懒决定的——只有在你真正调getConnection()的那一刻,才会去问子类「这一次该用哪个 key」。
// 继承 AbstractRoutingDataSource,只覆写「取 key」这一件事 public class DynamicRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 返回 null 时,父类会退回到 defaultTargetDataSource // 所以生产上默认库一定是那个最稳、最不可能出问题的库 return DataSourceContextHolder.peek(); } }这段代码本身只有一行有效逻辑,真正的信息量在它的调用时机上。getConnection()是懒决定的,这意味着:如果你的切面在getConnection()之后才把上下文压栈,那这次路由完全不生效,SQL 会安安静静地走默认库,日志里一点异常都没有。这也是「代码没报错但数据写错库」最常见的成因,比任何分片算法写错都隐蔽。
另一个容易忽略的参数是lenientFallback,默认 true,意思是 key 在resolvedDataSources里找不到时会回落到默认库。做多租户场景时我一般把它设成 false,配合启动时校验,宁可启动失败也不要运行时静默串库。
2.3 分片路由 vs 数据源路由:两个方案共存的正确姿势
资源包里两套东西都在:一套是自研的动态数据源切换,一套是 ShardingSphere-JDBC 的分片规则。新手最容易犯的错是让两者平级竞争——同一个方法上既标了@DS("order_1"),又想让它走分片规则,结果就是路由键和分片键打架,SQL 落到一个不存在的物理库上。
我一般按「职责分层」来处理:分片负责同一个逻辑库内部的容量拆分,动态数据源负责业务维度的隔离(租户、读写、灰度、冷热)。共存时的做法是把 ShardingSphere 生成的那个DataSource当成动态数据源里的一个 target,路由键只到「逻辑库」这一层,进了逻辑库之后再由分片规则决定落到哪张物理表。
| 场景 | 用哪个 | 路由键 / 分片键 |
|---|---|---|
| 同一业务按用户 ID 拆 16 张表 | 分片 | user_id |
| 多租户各自独立库 | 动态数据源 | tenantId |
| 读写分离 | 动态数据源 | master/slave |
| 冷热数据分库 | 动态数据源 + 分片 | tenantId+create_time |
分层的代价是配置变多,但换来的是排查路径清晰:路由日志能告诉你进了哪个逻辑库,分片日志能告诉你落到哪张表,两段日志分开看,不用再靠猜。
3. 数据源切换落地:一个注解、一个栈和四个连接池参数
3.1 @DS 注解与上下文栈
注解本身极简,信息全在 value 上,支持字面量和 SpEL 两种写法。真正需要设计的是上下文容器——很多人用单个ThreadLocal<String>,本地调试一切正常,一上线程池就开始偶发串库,因为嵌套调用时内层把外层覆盖了、出栈时又恢复不回去。
// 上下文用栈而不是单值,支持 A 库 -> B 库 -> 回到 A 库 的嵌套调用 public final class DataSourceContextHolder { private static final ThreadLocal<Deque<String>> HOLDER = ThreadLocal.withInitial(ArrayDeque::new); public static void push(String dsKey) { HOLDER.get().push(dsKey); } public static String peek() { Deque<String> stack = HOLDER.get(); return stack.isEmpty() ? null : stack.peek(); // null 走默认库 } public static void poll() { Deque<String> stack = HOLDER.get(); if (!stack.isEmpty()) { stack.poll(); } if (stack.isEmpty()) { HOLDER.remove(); // 关键:栈空了就清掉,别留给下一个复用这个线程的请求 } } public static void clear() { HOLDER.remove(); // 给 Web 拦截器的 afterCompletion 兜底用 } }参数上只有一个决策点:push的时候允不允许覆盖。我的做法是不覆盖、只压栈,需要「临时切一下再切回来」的地方显式配对push/poll。代价是嵌套层级深的时候栈会长,但比起调试串库问题花的时间,这点内存开销可以忽略。
HOLDER.remove()那一行是血泪经验。线程池里的线程是复用的,只要有一条异常路径没走到poll,这个线程后面处理的每一个请求都会带着上一个请求的数据源——表现出来就是「压测时偶发、本地永远复现不了」的玄学问题。
3.2 切面顺序:@Order 写错等于没写
切面的优先级是这套方案里最容易写错、也最难自查的一处。@Transactional本身也是一个切面,它会在方法进入时向DataSourceTransactionManager申请连接并绑定到线程上。如果数据源切面排在事务切面里面(也就是执行得更晚),事务已经拿着默认库的连接了,你再压栈也没用。
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 必须早于事务切面,否则事务先绑定了连接 public class DataSourceAspect { @Around("@annotation(ds) || @within(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { // SpEL 解析,支持 @DS("#tenantId % 4") 这种按参数动态路由 String key = SpelResolver.resolve(ds.value(), pjp); if (key == null || key.isEmpty()) { return pjp.proceed(); // 解析不出来就别乱切,走默认库 } DataSourceContextHolder.push(key); try { return pjp.proceed(); } finally { DataSourceContextHolder.poll(); // 必须 finally,异常也要出栈 } } }@within那个分支是为了支持类级注解,写在 Service 类上比写在每个方法上省事,但要注意它和方法级注解的优先级——两者同时存在时,方法级的应该赢,实现上是在SpelResolver里先查方法再查类。
HIGHEST_PRECEDENCE不是随便写的。Spring 里Ordered.HIGHEST_PRECEDENCE是最小值,切面按值升序执行,所以它排在最外层。但这带来一个副作用:数据源切面在最外层,意味着事务切面在它里面,事务的作用域被限制在单个数据源内——这正是我们想要的语义,跨库就别指望一个本地事务能兜住。
3.3 连接池参数:把 maximum-pool-size 算出来
多数据源场景下连接池是乘法关系,不是加法。一个实例连 20 个库、每个池 20 个连接,就是 400 个连接,对着数据库的max_connections一算就知道撑不撑得住。我一般按这个公式估:单库峰值 QPS × 平均持有时长(毫秒)÷ 1000,再留 20% 余量。
| 参数 | 建议取值 | 依据 |
|---|---|---|
maximum-pool-size | 10~20,按库算 | 总连接数 = 池大小 × 库数,先算总量再定单池 |
minimum-idle | 接近 maximum | 突发流量时避免反复建连的抖动 |
connection-timeout | 3000ms | 要短于上游 RPC 超时,否则线程全堵在等连接上 |
max-lifetime | 小于数据库wait_timeout | 常见库是 8 小时,池设 30 分钟足够 |
leak-detection-threshold | 预发 5000,生产 0 | 用来抓忘记关连接的代码,生产开会拖性能 |
spring: datasource: dynamic: primary: order_0 # 兜底库,路由键解析失败时用它 strict: true # 找不到 key 直接抛异常,不静默回落 datasource: order_0: url: jdbc:mysql://db-order-a:3306/order_0?useSSL=false&serverTimezone=Asia/Shanghai username: app_rw password: ${ORDER_0_PWD} # 密码走环境变量,别进仓库 hikari: maximum-pool-size: 20 minimum-idle: 15 connection-timeout: 3000 max-lifetime: 1800000 order_1: url: jdbc:mysql://db-order-b:3306/order_1?useSSL=false&serverTimezone=Asia/Shanghai username: app_rw password: ${ORDER_1_PWD} hikari: maximum-pool-size: 20strict: true这个开关值得单说。默认行为是找不到路由键就走主库,看着安全,实际上是灾难——某个租户的 key 拼错了,请求全打到主库,主库压力翻倍,而你完全无感。打开 strict 之后这类问题会在第一次请求就抛出来,早暴露早修。
还有一处常被忽略:每个数据源都有独立的池,冷数据源在低峰期会缩到minimum-idle,一旦有请求打过来要重建连接,P99 直接飙。所以对「存在但不能慢」的库,minimum-idle我一般设得和maximum-pool-size很接近,宁可多占几个空闲连接。
4. 分片规则落地:分片键、Hint 强制路由与跨库分页的三条路
4.1 分片键要同时满足三个条件
分片键选错,后面所有优化都是徒劳。我的判断标准就三条:基数够高、绝大多数 SQL 都带它、写完之后不变。三条缺一条,分片规则就会在半年内被推翻重来。
| 候选键 | 基数 | 查询覆盖度 | 可变性 | 结论 |
|---|---|---|---|---|
| 用户 ID | 高 | 高(C 端查询基本都带) | 不变 | 推荐 |
| 订单 ID | 高 | 中(很多查询按用户维度) | 不变 | 适合做表级分片键 |
| 创建时间 | 高 | 中(后台报表爱用) | 不变 | 适合冷热分离,不适合均匀打散 |
| 状态 | 极低 | 高 | 频繁变 | 禁用,分片必然倾斜 |
资源包里的示例用的是「用户 ID 分库、订单 ID 分表」的组合,这也是实践中最常见的做法:库维度跟着用户走,保证同一用户的订单落在同一个库,跨库 JOIN 的可能性降到最低;表维度用订单 ID,让单库内的写入继续打散。
4.2 分片规则的配置与 Hint 强制路由
规则配置本身不复杂,麻烦的是「哪些表要绑定在一起」。绑定表(binding table)配错,一条带 JOIN 的 SQL 会退化成笛卡尔积式的广播查询,性能直接塌方。
spring: shardingsphere: datasource: names: ds0,ds1 ds0: { type: com.zaxxer.hikari.HikariDataSource, jdbc-url: jdbc:mysql://db-a:3306/order } ds1: { type: com.zaxxer.hikari.HikariDataSource, jdbc-url: jdbc:mysql://db-b:3306/order } rules: sharding: binding-tables: t_order,t_order_item # 必须同分片键,否则 JOIN 会广播 tables: t_order: actual-data-nodes: ds${0..1}.t_order_${0..7} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db_inline table-strategy: standard: sharding-column: order_id sharding-algorithm-name: tbl_inline sharding-algorithms: db_inline: type: INLINE props: algorithm-expression: ds${user_id % 2} tbl_inline: type: INLINE props: algorithm-expression: t_order_${order_id % 8}actual-data-nodes里的${0..1}是行表达式,会被展开成 2 个库 × 8 张表 = 16 个物理节点。改分片数时这里和算法表达式必须同步改,漏一个就会出现「路由到了不存在的表」的报错。
有些 SQL 天生带不上分片键,比如后台按订单号精确查询、或者运营按时间段导出。这类场景靠正常路由是走不通的,得用 Hint 强制指定。
// 已知数据落在 ds1 的 t_order_3,直接指定物理节点,跳过算法计算 try (HintManager hint = HintManager.getInstance()) { hint.addDatabaseShardingValue("t_order", 1); hint.addTableShardingValue("t_order", 3); return orderMapper.selectByOrderNo(orderNo); }HintManager是线程绑定的,必须用 try-with-resources 或者在 finally 里close(),否则这个线程后续的所有 SQL 都会被打到同一个分片,是另一个版本的串库问题。
4.3 跨分片分页:内存归并、游标分页和二次查询
LIMIT offset, size在分片场景下是性能杀手。ShardingSphere 的处理方式是把LIMIT 1000000, 20改写成LIMIT 0, 1000020分发到 16 个分片,每个分片都回表上百万行,再在内存里归并排序取前 20 条。翻到第几页,代价就乘几倍。
-- 反例:深翻页,每个分片都要扫 100 万行 SELECT * FROM t_order ORDER BY create_time DESC LIMIT 1000000, 20; -- 常见做法:游标分页,把 offset 换成上一页最后一条的位置 SELECT * FROM t_order WHERE create_time < #{lastCreateTime} AND user_id = #{userId} -- 带上分片键,直接定位到单个分片 ORDER BY create_time DESC LIMIT 20;游标分页的前提是排序字段唯一且可比较,create_time重复度高的时候要加order_id做次级排序,否则会漏数据。它的缺点是不能跳页,只适合「下一页」这种交互,后台管理那种要跳到第 500 页的场景还得另想办法。
第三条路是二次查询法:先在各分片上取offset/分片数 + size条,拿到这批数据的最小值,再回每个分片查一次大于该值的记录,最后归并。实现比游标分页复杂,但能支持跳页,资源包里给了个简版实现,用在运营后台足够了。
4.4 分布式主键:号段模式扛住写热点
分表之后自增主键不能用了,雪花算法是最常见的选择,但它的 workerId 分配在多实例、多机房环境下是个运维负担,时钟回拨更是黑匣子级别的故障。资源包里默认给的是号段模式,数据库只承担 1/step 的 QPS。
// 号段模式:一次取一批 ID 缓存在本地,数据库压力降到 1/1000 @Transactional(propagation = Propagation.REQUIRES_NEW) public Segment nextSegment(String bizTag) { // 先自增再读回,避免并发下的读写间隙 jdbc.update("UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = ?", bizTag); Map<String, Object> row = jdbc.queryForMap( "SELECT max_id, step FROM id_segment WHERE biz_tag = ?", bizTag); long max = ((Number) row.get("max_id")).longValue(); long step = ((Number) row.get("step")).longValue(); return new Segment(max - step, max); // 本地发号区间 [max-step, max) }关键参数是step。设太小,数据库 QPS 上去了;设太大,服务重启会浪费一大段 ID,而且 ID 的连续性变差。我一般让step能支撑单实例 10 分钟的发号量,重启损失可以接受。
雪花也不是不能用,但要处理好时钟回拨:回拨小于一定阈值就自旋等待,超过阈值直接抛异常让上层重试,千万别静默生成——重复 ID 是最难排查的一类问题,等发现的时候数据已经脏了。
5. 避坑现场:五个「代码没报错但数据错了」的排查记录
这一章是整份资源包里我最想让人先看的部分。下面五条都是实际踩过的,共同点是:不报错、不告警、日志看起来一切正常,只有数据对不上。
5.1 事务相关的两个坑
现象一:方法上加了@DS("order_1"),但 SQL 还是打到 order_0。原因:外层方法先开了@Transactional,事务切面拿到连接并绑定线程,数据源切面排在它里面,压栈时机已经晚了。 解决:把数据源切面的@Order调到HIGHEST_PRECEDENCE,并保证@DS标在事务方法的外层调用链上。判断方法很简单——看路由日志里determineCurrentLookupKey的返回值是不是在getConnection之前打印的。
现象二:跨库操作「看起来成功」,但另一个库没数据。原因:本地事务只绑定了一个连接,切到另一个数据源时新连接不在事务里,前一个库回滚了、后一个库已经提交。 解决:一个本地事务只允许一个数据源,这条要当纪律。跨库的一致性靠本地消息表加补偿,或者干脆做 TCC。资源包里给了一个本地消息表的实现,写业务表和写消息表在同一个库同一个事务里,投递交给异步任务。
5.2 路由与连接池的三个坑
现象三:压测时偶发串库,A 租户读到 B 租户的数据。原因:ThreadLocal没在异常路径上清理,线程池复用时残留了上一个请求的路由键。 解决:push/poll严格配对写在 finally 里,栈空即remove();再在 Web 拦截器的afterCompletion里兜底调一次clear()。两层防护,成本很低。
现象四:切到某个数据源后报Connection is not available, request timed out。原因:每个数据源独立池,热点库的池被打满,冷库的池闲着。总量没超,单池超了。 解决:按库的实际 QPS 单独配池大小,别全局一个值;同时打开池的 metrics,把active / idle / pending三个指标接进监控,热点库一眼就能看出来。
现象五:扩容后老数据查不到,新数据正常。原因:取模分片数从 8 改成 16,同一个 ID 的模值变了,老数据还在旧节点上。 解决:扩容只能成倍扩,且要配双写迁移——新写入按新规则写、同时按旧规则写一份,后台跑比对任务把老数据搬过去,搬完再切读。一步到位改分片数的,基本都会回滚。
5.3 出问题时的排查顺序
顺序比工具重要。我现在固定四步:先看路由日志确认进了哪个逻辑库,再在代码里执行SELECT DATABASE()确认连接真实指向,然后检查事务边界(这方法是不是在某个@Transactional里面),最后才看连接池指标。
# 排查期临时打开,确认 SQL 的路由结果,问题定位完记得关掉 logging: level: org.apache.shardingsphere.sharding: DEBUG com.example.dynamic.datasource: DEBUGDEBUG 级别的路由日志量很大,生产环境常开会拖慢响应,我一般只在预发或者单实例灰度时打开。
6. 验证与压测:怎么证明切换真的生效了
写完不等于生效,这类改造最怕「以为生效了」。我的验证分三层,从便宜到贵依次做。
第一层是单测,直接断言路由键。构造一个带@DS("#tenantId % 4")的方法,传不同 tenantId 调用,断言DataSourceContextHolder.peek()的返回值。这一层跑得快,能挡住大部分 SpEL 写错的问题。
第二层是集成测试里查真实连接,这是最不容易骗人的一招:
// 在切到目标数据源的方法内部执行,返回的就是真实连上的库名 String catalog = jdbcTemplate.queryForObject("SELECT DATABASE()", String.class); assertThat(catalog).isEqualTo("order_1");第三层是压测时的分布核对。分片最怕倾斜,光看总 QPS 没用,要看每个库每个表的量是不是均匀。
| 指标 | 期望 | 采集方式 |
|---|---|---|
| 各分片 QPS 偏差 | 小于 15% | 库级监控 / 代理层统计 |
| 各分片行数偏差 | 小于 10% | 全量核对脚本 |
| 路由键解析失败次数 | 0 | strict 模式下的异常计数 |
| 单池 pending 数 | 长期为 0 | 连接池 metrics |
| 跨库事务告警 | 0 | 事务管理器埋点 |
有一次压测总 QPS 很漂亮,拆开看发现 16 个分片里 3 个承担了 70% 的流量,原因是测试数据里某几个大租户的 ID 恰好模到同余。这种偏差在真实数据里迟早会出现,所以核对脚本我改成了上线前必跑、上线后每周跑一次。
从那以后我每次做数据源切换相关的改动,都强制走一遍「单测断言路由键 → 集成测试查DATABASE()→ 压测看分片分布」这三步,哪怕只改了一行注解。多花二十分钟,比事后翻 binlog 找一条写错库的记录划算太多。希望帮到你。
本文还有配套的精品资源,点击获取