简介:围绕分库分表与多数据源切换的实践资料,面向具备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?useSSL=false&serverTimezone=Asia/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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 其余 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) { Map<Object, 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, Map<Object, 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 ThreadLocal<Deque<String>> CONTEXT = ThreadLocal.withInitial(ArrayDeque::new); public static void push(String key) { CONTEXT.get().push(key); } public static void poll() { Deque<String> 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_order,physicalTable是上一步算出来的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 doesn't 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 拉出来对照,差一条就告警。这套校验救过我一次,当时扩容脚本跑到一半失败,有几张表没建,靠人工核对根本发现不了。从那以后我每次动分片规则,都强制先跑一遍路由断言测试再提合并,看着笨,但省下来的排查时间都是自己的。希望帮到你。
本文还有配套的精品资源,点击获取