分库分表与多数据源切换实战:分片路由、动态数据源与SQL改写

发布时间:2026/11/4 7:22:56
分库分表与多数据源切换实战:分片路由、动态数据源与SQL改写 简介围绕分库分表与多数据源切换的实践资料面向具备Java Web基础、希望理解Sharding-JDBC分片路由与事务管理的开发者。内容以SSMDemo示例项目为线索覆盖数据源配置、分片规则定义、业务代码接入以及跨库跨表查询、全局唯一ID、数据迁移等常见问题的处理思路适合搭建可运行的入门实验环境并对照源码理解分库分表的落地方式。压缩包为rar格式共73个文件、约66KB主要由java源码、xml配置、class字节码、properties属性文件以及jsp页面、sql脚本等构成可据此查看工程结构、依赖配置与示例数据。已有2070人学习下载便于快速定位示例模块、复盘配置与路由流程并作为多数据源切换方案的参考。1. 从一次跨库查询超时说起分库分表真正难的是切换那一刻单表三千万行一个带三个条件的查询跑了 40 秒还超时。这是我第一次不得不做分库分表的场景。这份资源没有停在概念层面它给了一套能跑起来的分库分表加多数据源切换实现订单数据按用户 ID 拆到 2 个库、每库 4 张表业务代码只写逻辑表名t_order剩下的路由计算、数据源切换、SQL 改写、结果归并全部收在框架层。适合已经写过 CRUD、但没真正动手做过水平拆分的后端同学也适合想搞清楚动态数据源底层是怎么把连接换掉的人。跑通之后你会对「一条 SQL 是怎么落到某张物理表上」有完全不一样的体会。2. 架构拆解一条 SQL 从 Service 到物理表的完整链路这套实现最值得看的地方是它把「分片」和「多数据源」拆成了两层互相独立的能力。分片层负责回答「该去哪个库、哪张表」数据源层负责回答「怎么把连接换过去」。两层之间只靠一个字符串 key 通信耦合度很低后面想换路由算法或者想接真实的主从库改的都不是同一块代码。2.1 三层结构应用层、路由层、数据源层各管什么从调用方向看一次查询要穿过四个位置。应用层的 Service 和 Mapper 里写的永远是逻辑表名select * from t_order where user_id ?它不知道底下有几张表。路由层接过 SQL 和参数从参数里抠出分片键算出目标库下标和目标表后缀把逻辑表名替换成t_order_3再把数据源 key 塞进当前线程的上下文。数据源层是 Spring 的AbstractRoutingDataSource子类它在拿连接的那一刻读上下文决定去哪个连接池取 Connection。最后一层才是真正的 MySQL。项目目录大致是这样组织的sharding-demo ├── sharding-common # 上下文持有器、DS 注解、路由结果对象 ├── sharding-router # 分片规则表、取模计算、表名改写 ├── sharding-datasource # 动态数据源、连接池装配、key 解析 ├── sharding-starter # 自动装配业务方引入即用 └── sharding-app # 示例业务与测试用例拆成 starter 是有意为之。业务工程只需要在 pom 里引一个依赖、在 yaml 里写几条规则不用改任何一行业务代码就能接上。我在实际项目里见过把路由逻辑直接写进 Service 的写法一开始省事等到要加分片键或者换算法的时候全仓库搜表名能搜出两百多处硬编码那种维护体验相当难受。2.2 分片键与路由算法为什么默认用取模而不是范围分片键的选择基本决定了整套方案的成败。这份资源默认用user_id理由很朴素订单场景里九成以上的查询都带用户维度路由层能算出唯一下标SQL 就只打一张表。如果选了order_no做分片键那么按用户查订单就只能广播到全部 8 张表性能还不如不分。算法层面给了三种可选实现默认是取模算法扩容代价数据倾斜范围查询适用场景取模 MOD高扩容要迁数据低不支持需广播数据量可预估、查询必须带分片键范围 RANGE低追加新片即可可能冷热不均支持时间序列、日志类一致性哈希中只迁部分数据中需虚拟节点不支持节点数量频繁变动的场景取模的实现有两个细节值得留意一个是负数一个是库和表的下标要能对上public final class ShardCalculator { private ShardCalculator() {} /** * 把任意 hash 值映射到 [0, totalBuckets) 区间 * 用 floorMod 而不是 %因为 hashCode 可能是负数取余会得到负下标 */ public static int bucketOf(Object shardValue, int totalBuckets) { int h (shardValue null) ? 0 : shardValue.hashCode(); return Math.floorMod(h, totalBuckets); } /** 总桶位 - 库下标前 tableCount 个桶落在 0 号库 */ public static int dbIndexOf(int bucket, int tableCount) { return bucket / tableCount; } /** 总桶位 - 表后缀编号t_order_0 ... t_order_7 */ public static int tableIndexOf(int bucket) { return bucket; } }这里的关键是「先算总桶位再拆成库和表」而不是分别对库数和表数取两次模。两库四表共 8 个桶user_id 1001得到1001 % 8 1落在 0 号库的t_order_1。如果分别取模1001 % 2 1、1001 % 4 1看起来也对但一旦后续把每库表数从 4 改成 8两套下标就会错位历史数据全部对不上。统一走总桶位扩容时只需要处理桶位重排。2.3 配置模型数据源注册与读写分离怎么配数据源和分片规则都在 yaml 里声明key 的命名遵循{分组}_{库下标}_{master|slave}这个约定是路由层和数据源层之间唯一的契约sharding: datasource: order: master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/order_db_0?useSSLfalseserverTimezoneAsia/Shanghai username: demo password: demo maximum-pool-size: 20 minimum-idle: 5 rules: - logic-table: t_order # 业务代码里写的表名 shard-column: user_id # 从参数里抠哪个字段做分片键 db-count: 2 # 库数量 table-count: 4 # 每库表数量 algorithm: MOD group: order # 对应 datasource 下的分组名logic-table和group必须能对上路由算出来的 key 是order_0_master数据源容器里就得有这个名字的 bean。shard-column决定拦截器从 Mapper 参数里取哪个字段取不到就退回默认库不会抛异常。这一点在调试期很有用但也是后面容易出问题的地方第 5 章会细说。3. 跑起来两个库八张表把链路完整走通本地拉通不需要装任何中间件两个 MySQL 实例或者同一个实例的两个 schema就够。我一般会先用 Docker 起一个把两个 schema 都建在同一个实例上端口不同只是为了让读写分离的配置看起来更接近真实环境。3.1 建库建表与初始化数据两个库结构完全一致每库四张同构表。这里有两个必须遵守的约定物理表必须带分片键索引主键不能依赖数据库自增做全局唯一 ID。CREATE DATABASE IF NOT EXISTS order_db_0 DEFAULT CHARSET utf8mb4; CREATE DATABASE IF NOT EXISTS order_db_1 DEFAULT CHARSET utf8mb4; -- 以 0 号库为例t_order_0 ~ t_order_3 结构相同 CREATE TABLE order_db_0.t_order_0 ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id BIGINT UNSIGNED NOT NULL COMMENT 分片键所有查询尽量带上, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_created_at (created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 其余 7 张表照抄只改表名后缀id用了自增这只在单表内唯一。真实项目里订单号那类需要全局唯一的字段常见做法是雪花算法或者号段模式在应用层生成后写入别指望数据库帮你保证。idx_user_id是必须的否则每次按分片键查都要扫全表分片就白做了。3.2 数据源与分片规则的装配配置读进来之后由 starter 里的自动装配类负责把每个数据源实例化成独立的连接池再注册进动态数据源。这一段的关键是别把 8 个物理库塞进一个连接池那样切换就失去意义了。Configuration EnableConfigurationProperties(ShardingProperties.class) public class ShardingAutoConfiguration { Bean(name dynamicDataSource) Primary public DataSource dynamicDataSource(ShardingProperties props) { MapObject, Object targets new HashMap(); // 每个 {group}_{index}_{master|slave} 单独建一个连接池 props.getDatasource().forEach((group, conf) - { targets.put(group _master, buildPool(conf.getMaster())); if (conf.getSlave() ! null) { targets.put(group _slave, buildPool(conf.getSlave())); } }); // 兜底数据源路由不到时用一般是 0 号库主库 DataSource fallback (DataSource) targets.values().iterator().next(); return new DynamicDataSource(fallback, targets); } }Primary不能少否则 Spring 会因为容器里存在多个DataSource而在注入时报歧义。兜底数据源的作用是防止路由 key 为空时直接抛CannotDetermineDataSourceException把它指向 0 号库主库出问题至少还能读到一部分数据。3.3 用一段测试验证路由是否真的生效配完就跑业务代码验证是很容易自欺欺人的因为写错库也可能查得出数据。我习惯直接断言物理表里的行数SpringBootTest class ShardRouteTest { Autowired private OrderService orderService; Autowired private JdbcTemplate jdbcTemplate; Test void user_1001_should_land_in_db0_table1() { // 1001 % 8 1 - 0 号库t_order_1 orderService.createOrder(1001L, new BigDecimal(99.00)); Integer hit jdbcTemplate.queryForObject( select count(*) from order_db_0.t_order_1 where user_id 1001, Integer.class); assertThat(hit).isEqualTo(1); Integer miss jdbcTemplate.queryForObject( select count(*) from order_db_0.t_order_0 where user_id 1001, Integer.class); assertThat(miss).isZero(); } }这种「命中表加 1、相邻表必须为 0」的双向断言比只看接口返回 200 可靠得多。我第一次跑这套测试时就发现 0 号表里莫名多了一条数据追下去是参数名和shard-column对不上路由退回了默认库接口照样返回成功。4. 核心代码拆解动态数据源是怎么切过去的多数据源切换这件事很多人的第一反应是「在方法里手动 new 一个 DataSource 再拿连接」能跑但一旦碰上声明式事务就会立刻失效。这套实现走的是 Spring 官方预留的扩展点代码量很小但每一行都有它的位置。4.1 AbstractRoutingDataSource 与 ThreadLocal 上下文AbstractRoutingDataSource的思路是维护一个 targetDataSources 映射表每次getConnection()时回调determineCurrentLookupKey()拿到 key再从映射表里取真正的数据源。我们要做的就是让这个方法读到当前线程的上下文。public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultTarget, MapObject, Object targets) { super.setDefaultTargetDataSource(defaultTarget); super.setTargetDataSources(targets); // 必须显式调用否则 targetDataSources 不会被解析成 resolvedDataSources super.afterPropertiesSet(); } Override protected Object determineCurrentLookupKey() { // 返回 null 时框架回落到 defaultTargetDataSource return DataSourceContextHolder.peek(); } }上下文用栈而不是单个变量是为了支持嵌套切换Service 里调了另一个带DS的方法内层切完弹栈后还能回到外层的数据源。public final class DataSourceContextHolder { private static final ThreadLocalDequeString CONTEXT ThreadLocal.withInitial(ArrayDeque::new); public static void push(String key) { CONTEXT.get().push(key); } public static void poll() { DequeString deque CONTEXT.get(); if (!deque.isEmpty()) { deque.pop(); } // 栈空了就删掉 ThreadLocal避免线程池复用时残留 if (deque.isEmpty()) { CONTEXT.remove(); } } public static String peek() { return CONTEXT.get().peek(); } }CONTEXT.remove()这一行是血泪教训。线程池里的线程会被反复复用如果不清理上一个请求设置的数据源 key 会跟着线程活到下一个请求表现出来就是「偶发查到空数据」而且完全无法复现。4.2 注解加切面把切换动作收敛到一处业务代码里不该出现DataSourceContextHolder.push(...)这种调用太容易漏。用注解加 AOP 包一层切面负责 push 和 poll业务只声明意图。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { /** 逻辑分组名对应 yaml 里的 group */ String value(); /** 是否强制走主库写操作必须为 true */ boolean master() default true; }Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) // 必须比事务切面更早执行 public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { String key ds.value() (ds.master() ? _master : _slave); DataSourceContextHolder.push(key); try { return pjp.proceed(); } finally { // 必须放在 finally异常路径同样要弹栈 DataSourceContextHolder.poll(); } } }Order那一行决定成败。事务切面在进入方法时就已经向数据源要了连接并绑定到线程如果数据源切面排在它后面切换动作发生在连接已经拿到之后等于没切。设成最高优先级保证 push 先于事务开启连接才会从正确的库里取。4.3 分片路由从逻辑表名到物理表名的推导路由计算本身只有几行麻烦的是怎么把算出来的物理表名塞进即将执行的 SQL。这份资源用了 MyBatis 的StatementHandler拦截器在prepare阶段改 SQL 字符串。Intercepts(Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class})) public class ShardInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); ShardContext ctx ShardContextHolder.get(); // 由 Mapper 上层写入 if (ctx ! null) { String sql boundSql.getSql(); // 用 \b 做单词边界防止 t_order 误伤 t_order_item String rewritten sql.replaceAll(\\b ctx.getLogicTable() \\b, ctx.getPhysicalTable()); // BoundSql 的 sql 字段是 private 的只能走反射改 SystemMetaObject.forObject(boundSql).setValue(sql, rewritten); } return invocation.proceed(); } }参数说明logicTable是规则表里配置的t_orderphysicalTable是上一步算出来的t_order_3。SystemMetaObject来自 MyBatis 自带的反射工具比直接用Field.setAccessible干净一些。这套改写是字符串级别的只能处理单表查询一碰到 JOIN 就要另想办法具体问题留在避坑章节展开。4.4 事务边界为什么本地事务救不了跨库这是整套方案里最容易被误解的地方。很多人以为在方法里切一下数据源Transactional就能覆盖两个库实际上不行。Transactional(rollbackFor Exception.class) public void transfer(Long fromUser, Long toUser, BigDecimal amount) { // 两个用户可能落在不同库下面两次操作分属两个连接 orderService.deduct(fromUser, amount); // 路由到 order_0_master orderService.add(toUser, amount); // 路由到 order_1_master }原因在于DataSourceTransactionManager在事务开启时就通过getConnection()拿到了 Connection 并绑到线程上后续整个事务复用这一个连接。AbstractRoutingDataSource的路由只发生在那一次getConnection()里事务中途 push 新的 key 不会让事务换连接两次操作实际上打在同一个库上第二个库的写入根本没生效或者直接报错。跨库场景的常见做法是拆成多个独立的本地事务用消息表或补偿任务保证最终一致对一致性要求高的才考虑分布式事务框架但那属于另一个量级的复杂度。这套 demo 里没有内置跨库事务是刻意的设计取舍。5. 避坑与排查这五处不自己踩一遍线上还会再遇到文档里写得再清楚该踩的坑一个都不会少。下面这几条是我在本地和预发环境反复撞过的按现象、原因、解决三段记下来出问题时可以直接对照。5.1 数据源与线程相关的坑坑一异步方法里查到了默认库。现象是Async标注的方法里查订单返回的数据永远来自 0 号库。原因是 ThreadLocal 不跨线程切面在主线程 push 的 key 在新线程里读不到determineCurrentLookupKey()返回 null 就直接回落到兜底数据源。解决办法是别指望自动继承显式把 key 作为参数传到异步方法里再 push如果用线程池就在TaskDecorator里做上下文复制同时在任务结束时清理。InheritableThreadLocal看着能解决但线程池的线程是复用的子线程只在创建时继承一次第二次提交任务拿到的就是脏值。坑二偶发查到空数据重启就好。现象是同一个接口十次里有一次返回空列表日志上看不出任何异常。原因是某条异常路径上手动 push 了 key 但没走 pop栈里的值一直留着恰好这条请求复用到了同一个 Tomcat 工作线程路由就跑偏了。解决是把 push/pop 全部收进 AOP 的 try-finally业务代码里禁止直接操作上下文再在请求出口加一个兜底clear()宁可多清一次也不要留残留。坑三明明配了从库读请求还是全打到主库。现象是从库的连接数一直是 0。原因是DS(master false)只标注在了 Service 接口上而切点写的是annotation(ds)只能匹配方法上的注解接口上的拿不到。解决是改用within或者同时匹配注解和类级别写完之后用连接池的活跃连接数验证一遍别只看代码。5.2 分片与 SQL 改写相关的坑坑四不带分片键的查询慢得离谱。现象是按订单号查详情响应从 20ms 涨到 800ms。原因是order_no不是分片键路由层算不出下标只能把 SQL 广播到全部 8 张表再归并。常见解法有两个一是建一张order_no - user_id的映射表先查映射再路由二是基因法把user_id的低几位编进订单号从订单号里反解出分片键。前者多一次查询但实现简单后者没有额外开销但订单号生成规则要提前定好中途改会很麻烦。坑五表名替换把关联表也改了。现象是带 JOIN 的查询报t_order_item_3 doesnt exist。原因是字符串替换没做边界控制t_order是t_order_item的前缀正则一匹配就全中。改成\\b边界能挡住这一例但挡不住t_order a join t_order b这种自关联两张表都需要替换成不同的后缀。真正可靠的做法是上 SQL 解析器把 AST 里的表节点逐个改写字符串替换只适合 demo 级别。顺带一提深分页limit 100000, 20会被下推到每个分片各取十万条再在内存里归并排序越翻越慢改成用上一页最大 ID 做游标能绕开这个问题。6. 二次开发换算法、换数据源、加一张分片表真要把这套东西用到自己的项目里改动量比想象中小因为分片和数据源是分开的。换路由算法只需要实现一个接口并注册成 Bean规则表里把algorithm改成对应的名字即可Component(RANGE) public class RangeShardAlgorithm implements ShardAlgorithm { Override public int bucketOf(Object shardValue, ShardRule rule) { long v Long.parseLong(String.valueOf(shardValue)); // 按每 500 万一个区间切区间号就是桶位 return (int) (v / 5_000_000L); } }换真实数据源改 yaml 就行把127.0.0.1:3306换成实际地址从库配置补上master和slave的 key 命名不要动。加一张新的分片表需要在规则表里追加一条logic-table同时把物理表建齐两库四表就是 8 张少建一张会在路由到该桶位时报错而且只有那八分之一的数据会出问题测试覆盖不全很容易漏。改造点对应的风险和验证方式我整理成一张表加新表时照着走一遍改造点改哪里风险验证方式换分片算法实现 ShardAlgorithm 并注册历史数据桶位错乱用一批已知分片键断言库表下标扩容库表yaml 的 db-count / table-count存量数据需要迁移重分布比对逻辑表总行数与各物理表之和接主从yaml 补 slave 节点主从延迟导致读到旧数据写后立即读的场景强制走主库我最看重的是第二行那个行数比对写个定时任务把逻辑表和各物理表的 count 拉出来对照差一条就告警。这套校验救过我一次当时扩容脚本跑到一半失败有几张表没建靠人工核对根本发现不了。从那以后我每次动分片规则都强制先跑一遍路由断言测试再提合并看着笨但省下来的排查时间都是自己的。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询