首页/新闻资讯/正文详情

Spring Boot动态数据源分库分表:2库8表路由与SQL改写避坑

发布时间:2026/11/4 3:46:31 来源:云帆数科 栏目:资讯中心
Spring Boot动态数据源分库分表:2库8表路由与SQL改写避坑
简介面向中高级Java后端开发者的分库分表与多数据源切换实战示例聚焦单库单表在数据量激增后暴露的读写瓶颈围绕分片策略选择、数据路由与跨库跨表事务一致性展开并以轻量级JDBC框架Sharding-JDBC为主线给出可落地的实现路径。压缩包共73个文件、约66KB以18个java源码与21个class字节码为主体辅以18个xml配置、3个properties和1个sql脚本另有少量jsp页面及Eclipse、m2e工程元数据完整呈现一个可导入IDE直接运行的Maven工程结构。该资源已有2070人学习下载在同类入门与进阶材料中具备一定参考价值。读者可从中获得数据源配置、分片规则定义与事务策略设定的具体写法理解业务代码在不改动逻辑的前提下如何由框架自动完成SQL解析与路由并借助示例项目理清配置、启动与业务层之间的协作关系为后续处理关联查询、全局唯一ID与数据迁移等问题打下基础。1. 单库撑到 800 万行时我把订单表拆成了 2 库 8 表去年冬天某公司一个交易后台的订单表单库跑到 800 万行一个带status过滤的统计 SQL 从 200ms 涨到 12 秒。第一反应是加索引索引加上去了写入又慢了半拍再往上就是换更大规格的机器成本翻倍但天花板肉眼可见。那次之后我开始认真做分库分表最直接能用的方式就是先让数据源能切、表名能换。这套代码就是那时候常用的最小实现两个数据源ds0和ds1每个库四张t_order_xx一共 8 张真实表由分片键user_id决定落哪个库、哪张表。算法本身不复杂复杂的是切换过程中绕不开的几个问题事务绑定、线程复用、表名改写。这份资源适合正在把单库系统往分片架构上迁移的开发者。2. 架构拆解一条 insert 从进入应用到落库的选库选表链路分库分表听起来是个大工程真拆开看核心只有两件事第一知道这条 SQL 该去哪个库第二知道到了那个库之后表名要换成什么。前一件事靠数据源路由后一件事靠 SQL 改写。整套代码围绕这两条主线组织剩下的都是配套设施。2.1 逻辑表与真实表2 库 × 4 表的映射怎么定先明确三个概念后面所有代码都建立在这上面逻辑表业务代码里只认识t_orderSQL 里也只会写t_order。真实表物理存在的t_order_00到t_order_07分布在ds0、ds1两个库里。分片键这里是user_id决定一行数据落到哪个真实表。具体映射规则用的是两级取模先定位库再定位表user_id 范围示例库真实表0ds0t_order_001ds1t_order_052ds0t_order_013ds1t_order_064ds0t_order_025ds1t_order_07这张表看着有点怪user_id0去ds0、user_id1去ds1库和库之间是交替的。原因在公式里库下标取user_id % 2表下标取(user_id / 2) % 4两者组合起来就是上面这个结果。这样设计的好处是数据分布均匀2×4 一共 8 个落点每个落点分到约 1/8 的数据单个库只承担一半压力。另一个容易被忽略的点是真实表的编号我用的是全局连续编号t_order_00到t_order_07而不是每个库各自从00开始。全局编号的好处是排查问题时看到表名就知道它在哪个库t_order_06一定在ds1不需要再去翻规则。血泪经验是分片表名一旦带上库信息日志里 grep 一下就定位了能省掉很多来回确认。提示分片键的选择比算法本身重要得多。选订单号做分片键查「某个用户的所有订单」就要扫全表选用户号做分片键查「某一笔订单」如果不知道用户号同样要扫全表。这套代码假设主查询是按用户维度走的如果你的主要查询是后台按订单号查就需要额外维护一张订单号到用户号的索引表。2.2 三层结构注解层、上下文层、路由层各管什么整个实现按职责切成三层分层的关键在于「谁负责产生决策谁负责执行决策」第一层是注解层。业务代码上打一个DS(ds0)或者DS(ds1)声明「这段逻辑要去哪个库」。这层只做声明不做任何计算好处是业务代码里能一眼看出这条链路的目标库不用去翻配置文件。第二层是上下文层。用一个ThreadLocal保存当前线程正在使用的数据源标识。为什么必须是ThreadLocal因为数据源的选择是「请求级」的同一个线程在处理 A 请求时必须用ds0处理 B 请求时必须用ds1用成员变量会串。上下文层还负责在方法退出时清理这一步漏了就会出现线程池复用导致的数据串库。第三层是路由层。继承 Spring 的AbstractRoutingDataSource在每次获取连接时回调determineCurrentLookupKey()从上下文层取出标识返回给 Spring 让它去targetDataSources里选真正的连接池。这一层是框架级的业务代码完全无感。三层之外还有两个横切能力AOP 切面负责「进入方法时写上下文、退出方法时清上下文」MyBatis 拦截器负责「把 SQL 里的逻辑表名换成真实表名」。这两个能力都不参与路由决策只负责把决策落地。我一开始的版本把分片算法直接塞在切面里切面既算库又算表结果一个方法里查出 20 条订单、分属 8 张表切面算出来的表名是最后一条的前 19 条全查错了表。这个坑后面第 5 章会细说结论是库级切换用切面表级路由必须下沉到单条 SQL 的拦截器里粒度不一样。2.3 一次写请求的六步链路把上面三层串起来一条insert into t_order ...的完整生命周期是这样的请求打到OrderController调用orderService.create(userId, amount)。服务方法上如果有DS切面在方法执行前把库标识压进ThreadLocal如果没有走第 3 步的分片计算。业务层调用分片算法用user_id算出ShardResult{dsKeyds1, tablet_order_06}把结果压进另一个ThreadLocal分片上下文。如果DS没指定就由分片结果里的dsKey写进数据源上下文。MyBatis 执行 SQL拦截器捕获到StatementHandler.prepare从分片上下文里取出表名把t_order替换成t_order_06。AbstractRoutingDataSource在拿连接时回调返回ds1从ds1的连接池里取一个连接执行。这六步里第 3 步和第 5 步是最容易出问题的。第 3 步如果分片键为空整个链路会退化成「落默认库」第 5 步如果正则写得不够严谨t_order_item会被误伤成t_order_06_item。这两个问题在第 5 章都有对应的排查记录。3. 核心代码走读上下文、切面、拦截器三段拼出动态数据源架构讲完落到代码就三块上下文怎么存、数据源怎么选、SQL 怎么改。下面每一段都是可以直接抄进项目的形态参数和边界条件我在代码后面逐个说明。3.1 上下文容器为什么用 ThreadLocal 套一个栈单变量版本只能支持一层切换遇到「A 库方法内部调 B 库方法」的场景就废了内层方法退出后把上下文清空外层方法后续的 SQL 就落到了默认库。所以上下文容器我用的是ThreadLocalDequeString本质是个栈。public final class DataSourceContextHolder { /** 用栈而不是单值支持 A 库方法内部再调 B 库、退出后回到 A 的嵌套场景 */ private static final ThreadLocalDequeString HOLDER ThreadLocal.withInitial(ArrayDeque::new); private DataSourceContextHolder() { } public static void push(String dsKey) { HOLDER.get().push(dsKey); } public static String peek() { DequeString deque HOLDER.get(); return deque.isEmpty() ? null : deque.peek(); } /** 出栈后如果栈空了必须 remove否则线程池复用线程时会残留上一次的库标识 */ public static void poll() { DequeString deque HOLDER.get(); if (!deque.isEmpty()) { deque.poll(); } if (deque.isEmpty()) { HOLDER.remove(); } } }push和poll必须成对出现分别放在切面的proceed()前后。peek()返回null的设计是有意的返回null时AbstractRoutingDataSource会走defaultTargetDataSource这样单库跑单元测试、或者某些不走分片的全局查询就不需要显式指定数据源。HOLDER.remove()这一行我加了注释因为它是整个类里最容易被删掉的一行。删掉之后本地跑一百次都不会出问题一上压测就偶发数据串库查起来极其费劲。分片结果的上下文同理单独一个类ShardContextHolder存的是ShardResult对象包含dsKey和tableName。分两套上下文是因为它们的生命周期不同数据源上下文可能由注解直接指定分片上下文只由算法产生。混在一个类里清理时机就对不上了。3.2 动态数据源AbstractRoutingDataSource 的三个必改点Spring 提供了AbstractRoutingDataSource继承它只需要实现一个方法但有三处细节必须改对少了任何一处都会静默失效。public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultTarget, MapObject, Object targetDataSources) { super.setDefaultTargetDataSource(defaultTarget); super.setTargetDataSources(targetDataSources); // 这一行不能省它负责把 targetDataSources 解析进 resolvedDataSources // 不调用的话运行时 targetDataSources 是空的所有请求都会落到默认库 super.afterPropertiesSet(); } Override protected Object determineCurrentLookupKey() { // 每次 getConnection 时都会回调返回 null 表示走默认数据源 return DataSourceContextHolder.peek(); } }三个必改点分别是afterPropertiesSet()必须手动调。走 XML 或Bean配置时 Spring 会自动调但如果是自己 new 出来的比如放在Configuration里手工组装漏掉这行不会报错只是所有请求都跑默认库。我第一次遇到这个问题时对着日志看了两个小时最后发现是resolvedDataSources是空的。key 的类型必须和上下文里存的一致。上下文存String ds0targetDataSources的 key 也必须是String。混用String和枚举lookupKey查不到会静默 fallback 到默认库同样不报错。determineCurrentLookupKey()里不要做耗时操作。这个方法在每次获取连接时都会调放个数据库查询进去性能直接崩。targetDataSources的组装放在配置类里Configuration public class DataSourceConfig { Bean(ds0) ConfigurationProperties(prefix sharding.datasource.ds0) public DataSource ds0() { return DruidDataSourceBuilder.create().build(); } Bean(ds1) ConfigurationProperties(prefix sharding.datasource.ds1) public DataSource ds1() { return DruidDataSourceBuilder.create().build(); } Bean Primary public DataSource dynamicDataSource(Qualifier(ds0) DataSource ds0, Qualifier(ds1) DataSource ds1) { MapObject, Object targets new HashMap(4); targets.put(ds0, ds0); targets.put(ds1, ds1); return new DynamicDataSource(ds0, targets); } }Primary加在动态数据源上是为了让 MyBatis、事务管理器默认注入它而不是注入某个具体的ds0。这一点如果漏了Transactional会绑定到ds0上ds1里的事务压根不生效。3.3 切面与事务的顺序问题切面本身只有十几行但Order的值决定了它能不能在事务之前执行。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) // 必须高于事务切面否则切了也没用 public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { DataSourceContextHolder.push(ds.value()); try { return pjp.proceed(); } finally { DataSourceContextHolder.poll(); } } }Order(Ordered.HIGHEST_PRECEDENCE)是关键。事务切面在方法进入时会从数据源里取一个连接并绑定到当前线程之后整个事务周期都用这个连接。如果数据源切面排在事务切面后面执行连接已经绑好了再改上下文也没用——determineCurrentLookupKey()根本不会被再调一次。这个问题的隐蔽之处在于它不报错。你在ds0的事务里调了个DS(ds1)的方法SQL 照样执行成功只是数据全写进了ds0。等发现数据错位的时候往往是几天后对账才发现。注意切面只能解决「方法级别」的切换。如果一个事务方法内部要先查ds0、再写ds1那是跨库事务切面帮不了你。这种场景要么拆成两个事务、用补偿保证最终一致要么上分布式事务组件代价另算。DS注解本身的定义很简单Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { /** 数据源 key必须是 targetDataSources 里存在的值 */ String value(); }支持打在类上表示整个类的默认数据源打在方法上优先级更高。3.4 SQL 拦截把逻辑表名换成真实表名到这一步数据源已经切对了但 SQL 里还写着t_order直接执行会报表不存在。拦截器就干一件事在 SQL 发给数据库之前把逻辑表名换掉。Intercepts(Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class})) public class TableRewriteInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); MetaObject meta SystemMetaObject.forObject(handler); String sql (String) meta.getValue(delegate.boundSql.sql); ShardResult shard ShardContextHolder.peek(); if (shard ! null sql ! null) { // 用带单词边界的正则避免 t_order_item 被误伤成 t_order_06_item String rewritten sql.replaceAll((?i)\\bt_order\\b, shard.getTableName()); meta.setValue(delegate.boundSql.sql, rewritten); } return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 预留暂无配置项 } }几个参数和细节值得说明method prepare表示在StatementHandler准备语句时拦截。选这个时机是因为此时boundSql.sql已经组装完成、还没交给 JDBC改起来最安全。delegate.boundSql.sql是 MyBatis 内部的属性路径通过MetaObject反射读写。换 MyBatis 大版本时这个路径可能变升级时要重点回归。\\bt_order\\b里的\b是单词边界。不加边界t_order_item里的t_order也会被替换SQL 直接语法错误。这个坑我踩过一次报错信息是Table ds0.t_order_06_item doesnt exist一眼就能看出来。Plugin.wrap(target, this)不能漏否则拦截器不会生效。正则替换只适合表名简单、SQL 结构可控的场景。如果 SQL 里有子查询、union、动态拼接的表名正则就开始不可靠了常见做法是换成JSqlParser解析成 AST改完再序列化回字符串代价是每次执行多一次解析开销一般会加个本地缓存。还有个更隐蔽的问题count语句和list语句必须走同一套路由结果。分页查询时 MyBatis-Plus 会先发一条select count(*) from t_order where ...再发select * from t_order where ... limit ?。如果两条 SQL 之间分片上下文被清掉了count走的是真实表、list走的是逻辑表直接报表不存在。所以分片上下文的清理必须放在整个方法退出时而不是单条 SQL 执行完就清。3.5 分片算法两级取模与边界处理算法部分代码最短但要考虑的情况不少。public class OrderShardingAlgorithm { private static final int DB_COUNT 2; // 库数量 private static final int TABLE_COUNT_PER_DB 4; // 每库表数量 public ShardResult shard(long userId) { // 负数取模在 Java 里结果也是负数先转正否则会算出负下标 long key Math.abs(userId); // 第一级按库取模决定去哪个库 int dbIndex (int) (key % DB_COUNT); // 第二级在库内取模决定用哪张表 // 用 key / DB_COUNT 而不是直接用 key是为了让相邻用户分散到不同的表 int tableIndex (int) ((key / DB_COUNT) % TABLE_COUNT_PER_DB); // 全局连续编号db0 - 00~03db1 - 04~07看表名就知道在哪个库 int globalIndex dbIndex * TABLE_COUNT_PER_DB tableIndex; return new ShardResult( ds dbIndex, String.format(t_order_%02d, globalIndex) ); } }三个参数的含义很直白DB_COUNT是库的个数TABLE_COUNT_PER_DB是每个库里的表数userId是分片键。真正的门道在下面几点第一Math.abs不能省。Java 里-1 % 2的结果是-1而不是1直接拿去当数组下标会抛ArrayIndexOutOfBoundsException。如果分片键可能是负数比如某些用负数表示系统账户的约定这一行必须加。更极端的情况是Long.MIN_VALUEMath.abs之后还是负数严格来说要单独判一次。第二第二级为什么用key / DB_COUNT。如果两级都用key % Nuser_id0和user_id2会落到同一个库的同名表0%20、2%200%40、2%42其实不会但更常见的问题是分布不均。先除再模等于把用户按库分好组之后组内再做一次均匀打散8 个落点的分布会更平。第三扩容时的兼容性要提前想。现在是 2 库 4 表如果哪天要扩到 4 库 8 表dbIndex key % 4会把原来在ds0的一半数据搬到ds2。这种「翻倍扩容」是取模方案绕不过去的迁移成本。真要平滑扩容常见做法是提前规划成 2 的幂、用一致性哈希、或者按分片键区间路由。这一块在第 6 章展开。分片算法算完之后把结果写进上下文顺便把dsKey也写进数据源上下文如果方法上没有DSpublic void bindShard(long userId) { ShardResult result algorithm.shard(userId); ShardContextHolder.push(result); // 注解优先方法上显式指定了 DS 就不覆盖 if (DataSourceContextHolder.peek() null) { DataSourceContextHolder.push(result.getDsKey()); } }if (DataSourceContextHolder.peek() null)这个判断保留了手动指定数据源的能力比如某些强制走主库的读场景。4. 跑起来建库脚本、配置项和第一条跨库查询代码看完了接下来把它跑起来。这一章的内容都是可复现的从建库到验证一共四步本地有 MySQL 就能走通。4.1 目录结构与模块职责工程按职责拆成五个模块拆分的依据是「谁会依赖谁」common里放上下文和注解谁都能依赖mybatis依赖common但datasource不依赖mybatisapi是启动模块依赖全部。模块职责关键类sharding-common上下文容器、注解、分片算法DataSourceContextHolder、ShardContextHolder、OrderShardingAlgorithmsharding-datasource动态数据源、AOP 切面、事务配置DynamicDataSource、DataSourceAspect、DataSourceConfigsharding-mybatisSQL 拦截器、表名改写、分页归并TableRewriteInterceptorsharding-api对外接口、启动类、实体OrderController、OrderServicescripts建库建表初始化脚本init.sql这么拆的好处是如果你的项目已经在用 MyBatis-Plus只想接动态数据源直接引sharding-commonsharding-datasource就行拦截器那部分可以不引。反过来想只做表级分片、不做库级分片也可以只引mybatis模块。实体和 Mapper 都在api模块Mapper 的写法有个硬约束SQL 里只能写逻辑表名t_order。哪怕你明知道这条数据要去ds1的t_order_06也不要在 Mapper 里硬编码物理表名否则拦截器改不到路由就断了。4.2 建库建表脚本脚本分两部分先建库再建表。两个库的表结构完全一致只有表名编号不同。-- 两个库分开建字符集保持一致避免跨库迁移时出现乱码 CREATE DATABASE IF NOT EXISTS ds0 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE DATABASE IF NOT EXISTS ds1 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 以 ds0.t_order_00 为模板剩下 7 张表只改库名和编号 CREATE TABLE IF NOT EXISTS ds0.t_order_00 ( order_id BIGINT NOT NULL COMMENT 雪花ID不能用自增, user_id BIGINT NOT NULL COMMENT 分片键, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (order_id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- ds1 的表编号从 04 开始保持全局连续方便从表名反推库 CREATE TABLE IF NOT EXISTS ds1.t_order_04 ( order_id BIGINT NOT NULL, user_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (order_id), KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;脚本里有几个决定主键用BIGINT而不是AUTO_INCREMENT。两个库各自自增ds0生成 1、2、3ds1也生成 1、2、3主键直接冲突。分布式 ID 用雪花算法生成在业务层写入前赋值数据库只负责存。索引建的是(user_id, status)联合索引。分片之后跨库查询代价极高绝大多数查询都必须带user_id才能定位到单表。联合索引把最常用的「某用户某状态的订单」这个查询覆盖到避免回表。没有建外键。跨库外键本身就不支持同库内的外键在分片场景下也会阻碍数据迁移一律在应用层保证。完整的 8 张表就是把上面两段复制、改编号ds0有t_order_00到t_order_03ds1有t_order_04到t_order_07。写个循环或者用存储过程生成都行手写容易改漏。4.3 配置文件里的关键参数配置集中在application.yml两段数据源和分片规则。sharding: datasource: ds0: url: jdbc:mysql://127.0.0.1:3306/ds0?useSSLfalseserverTimezoneAsia/Shanghai username: demo password: demo driver-class-name: com.mysql.cj.jdbc.Driver # 连接池参数分片后连接数翻倍每个库的池子不要开太大 initial-size: 5 max-active: 20 ds1: url: jdbc:mysql://127.0.0.1:3306/ds1?useSSLfalseserverTimezoneAsia/Shanghai username: demo password: demo driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 max-active: 20 rule: logic-table: t_order # 业务代码里写的逻辑表名 db-count: 2 # 库数量必须和 datasource 下的配置数一致 table-count-per-db: 4 # 每库表数量max-active这个值值得单独说。单库时代一个池子开 50 很正常分片之后两个库就是两个池子总连接数翻倍。如果数据库的max_connections是 200两个池子各开 50再加上其他应用很容易打满。分片之后连接池的总量要做除法不是做乘法。db-count和实际配置的数据源数量必须对齐。代码里用db-count算下标如果配了 3 但只有ds0、ds1两个数据源算出来的ds2在targetDataSources里找不到会静默 fallback 到默认库数据全写错地方。4.4 启动与验证三行命令确认路由生效启动类就是标准的 Spring Boot 启动类MapperScan指向 Mapper 包。起来之后做三步验证。第一步确认数据源都注册上了curl -s http://127.0.0.1:8080/actuator/health | jq .status返回UP说明应用起来了但还不能证明动态数据源生效。第二步写一条数据看日志里的路由结果# userId5 按公式应落到 ds1.t_order_07 curl -X POST http://127.0.0.1:8080/order \ -H Content-Type: application/json \ -d {userId: 5, amount: 99.00}期望在日志里看到类似这样的输出route key5 - ds1.t_order_07 Preparing: insert into t_order_07 (order_id, user_id, amount, status) values (?, ?, ?, ?)这两行日志是判断路由是否生效的直接证据。第一行来自分片算法第二行来自 MyBatis 拦截器改写后的 SQL。只要第二行里的表名还是t_order说明拦截器没生效去检查plugin()有没有被正确注册。第三步跨库验证。写userId0落ds0.t_order_00和userId5落ds1.t_order_07两条分别连到两个库上查-- 连 ds0 执行应该只有 user_id0 的那条 SELECT order_id, user_id FROM ds0.t_order_00; -- 连 ds1 执行应该只有 user_id5 的那条 SELECT order_id, user_id FROM ds1.t_order_07;如果两条数据都出现在同一个库里说明数据源切换没生效回到 3.2 节检查afterPropertiesSet()和Primary。提示本地调试时建议把 MyBatis 的 SQL 日志级别调到DEBUGlogging.level.你的mapper包debug。分片相关问题里一半以上靠日志就能直接定位比打断点快得多。5. 避坑排查多数据源切换最常见的五个翻车现场前面几章讲的都是「应该怎样」这一章讲「实际会怎样」。下面五条都是我在真实项目里遇到过、并且花了不少时间才定位的问题每条按现象、原因、解决三段写。5.1 事务方法内部切数据源完全没反应现象在Transactional方法里调另一个DS(ds1)的方法日志显示切到了ds1但数据实际写进了ds0。原因Spring 的事务在方法进入时就通过DataSourceTransactionManager拿到了连接并绑定到TransactionSynchronizationManager上。整个事务周期内后续所有 SQL 都复用这个已绑定的连接AbstractRoutingDataSource.determineCurrentLookupKey()只会在第一次获取连接时被调用。所以事务内切上下文框架根本不会再去查。解决把数据源切换提到事务外层。也就是说DS和Transactional不要出现在同一个方法上切换方法做外层、事务方法做内层或者反过来按库拆分事务。如果业务上必须在一个流程里操作两个库那就不是切数据源能解决的得按跨库事务处理拆成两个本地事务、加一个补偿任务或者引入分布式事务组件。5.2 偶发查到别的库的数据重启就好了现象压测时偶发某条查询返回了不该出现的数据本地复现不了重启后正常过一阵又出现。原因ThreadLocal没有清理干净。Tomcat 的线程池会复用线程上一次请求在ThreadLocal里留下的ds1没被清除下一次请求分到同一个线程、又恰好没有重新设置上下文就直接用了残留的ds1。本地请求量小线程复用概率低所以很难复现。解决确认切面的finally里调了poll()并且poll()在栈空时调了HOLDER.remove()。另外要注意异常路径如果pjp.proceed()抛异常finally里的清理仍然会执行这一点try-finally保证了。但如果是异步线程、线程池提交的任务ThreadLocal根本不会跨线程传递需要手动把上下文透传过去。5.3 所有数据都落到了 ds0现象写入看起来都成功了但去ds1查什么都没有全在ds0。原因排查下来通常是三种情况之一。第一种targetDataSources的 key 类型和上下文返回的类型不一致比如 map 里放的是枚举、上下文返回String查不到就走默认库。第二种DynamicDataSource是手工 new 的漏了afterPropertiesSet()resolvedDataSources是空的。第三种分片键为空算法里对null做了兜底返回ds0。解决在determineCurrentLookupKey()里打一行 debug 日志把返回值和targetDataSources.keySet()一起打出来一眼就能看出是不是 key 对不上。分片键为空的情况要在入口做参数校验不要把null悄悄兜底成ds0——静默兜底比分片失败更可怕因为数据错了你还以为成功了。5.4 分页查询的总条数永远不对现象列表页每页 10 条显示正常但总数显示的是全库总量翻到第 3 页就是空的了。原因分页插件会先执行一条select count(*)再执行select * ... limit。如果分片上下文在两条 SQL 之间被清掉了count可能走的是逻辑表报表不存在或者默认库总数算成全库的和list的结果对不上。另一种情况是分片键没出现在where条件里count需要扫全部 8 张表再求和代码里没做归并直接返回了单表结果。解决分片上下文的生命周期必须覆盖整个方法不能是单条 SQL 执行完就清理。跨表count的正确做法是广播到 8 张表分别 count 再求和limit则是每个表取offset limit条、在内存里归并排序后截取。这两块代码量不小如果分页需求不复杂最简单的办法是强制分页查询必须带分片键让查询能定位到单表。5.5 批量插入报 SQL 语法错误现象insert into t_order (…) values (…), (…), (…)这种批量插入改成 8 张表之后报语法错误或者数据错位。原因一条批量 insert 的多个 values 里可能包含不同的user_id按分片键算出来是不同的表。拦截器只能把t_order替换成一个表名替换成t_order_03之后本该去t_order_06的那条数据也进了t_order_03分片就失效了。解决批量插入必须在应用层按分片结果分组每个分组单独发一条 insert。代码上是先遍历一遍算出每条的ShardResult用MapString, ListOrder按表名分组再逐组执行。public void batchInsert(ListOrder orders) { // 先按分片结果分组一组对应一张真实表 MapString, ListOrder grouped orders.stream() .collect(Collectors.groupingBy(o - algorithm.shard(o.getUserId()).getTableName())); // 每组单独走一次插入组内共用同一个分片上下文 grouped.forEach((table, list) - { ShardResult shard algorithm.shard(list.get(0).getUserId()); ShardContextHolder.push(shard); try { orderMapper.insertBatch(list); } finally { ShardContextHolder.poll(); } }); }这段代码的前提是同一组内的所有记录分片结果一致所以用组内第一条算出来的ShardResult就行。分组这一步的groupingBy键用的是表名因为同一个表名一定对应同一个库全局连续编号的好处不需要再按库分一次。6. 二次开发把写死的取模规则换成可热更新的路由配置上面那套代码能跑但分片规则写死在static final里改一次配置要重启一次应用。真到了需要扩容、需要灰度、需要按租户走不同规则的时候这个限制就很难受。我后来的做法是把规则抽出来做成可热更新的对象。核心思路是整体替换引用而不是逐字段修改。规则对象做成不可变对象配置中心推下来一个新规则直接换引用读的地方用volatile保证可见性。Component public class ShardRuleManager { // 不可变对象 volatile读多写少场景下比加读写锁划算 private volatile ShardRule rule ShardRule.defaults(); /** 配置中心推送回调整体替换避免读到改了一半的规则 */ public void refresh(ShardRule newRule) { if (newRule null || newRule.getDbCount() 0) { return; // 非法配置直接丢弃保留上一版不要用默认值覆盖 } this.rule newRule; } public ShardRule current() { return rule; } }refresh里那个非法配置的判断是必须的。配置中心偶尔会推下来一个空值或者格式错误的配置如果直接赋值整个路由就崩了所有请求落到默认库。保留上一版可用配置比用默认值兜底安全得多。改造点按优先级排下来大致是这些改造点涉及模块实现难度影响面分片规则外置到配置中心sharding-common低需要重启变为热更新算法从取模换成一致性哈希sharding-common中扩容时迁移量大幅下降支持按租户指定不同规则sharding-commonapi中多租户场景必要加双写开关做灰度迁移sharding-datasource高迁移期数据一致性一致性哈希那块我多说一句。取模方案扩容时db-count从 2 改成 4user_id % 4会让原来一半的数据换库迁移量大、迁移期还得停写。换成一致性哈希之后扩容只影响相邻的一段数据迁移量大概是1/N。代价是哈希环的维护代码量上去了而且数据分布会有轻微不均需要加虚拟节点来平滑。验证方式我一般分两步走。第一步是单元测试把分片算法单独拎出来给定一组user_id断言落点和预期表名一致重点覆盖边界值0、负数、Long.MAX_VALUE、以及每个库的临界点。第二步是灰度验证用双写开关把新规则和老规则同时跑一段时间比对两边的落点差异确认没有非预期的迁移后再切读。这套验证流程比任何一次性的压测都管用。最后说个教训。我最早做这套东西的时候觉得分片算法就几行取模能出什么问题直接把规则写死在类里、也没加单元测试。结果某次需求变更把TABLE_COUNT_PER_DB从 4 改成 8改了常量、忘了改建表脚本线上写入直接报表不存在回滚花了二十分钟。从那以后我每次动分片规则都强制走一遍「改常量 → 改脚本 → 跑单测 → 灰度双写比对」这四步一步都不跳。希望帮到你。本文还有配套的精品资源点击获取

相关推荐

物联网省赛代码包拆解:矩阵按键、LoRa与电位器ADC实战
物联网省赛代码包拆解:矩阵按键、LoRa与电位器ADC实战

简介:面向2024年蓝桥杯第十五届物联网省赛备赛者与嵌入式学习者,这份完整代码围绕矩阵按键扫描、LoRa无线通信和电位器取值展开,可作为赛题实现参考与联调模板。压缩包共371个文件,约11.27MB,包含111个.h头文件、55个.… · 2026/11/4 3:46:31

AI生成代码跑不通?一套从报错分类到修复验证的完整救援流程
AI生成代码跑不通?一套从报错分类到修复验证的完整救援流程

我自己也经常干这种事:跟AI说“写个批量重命名文件的脚本”,它几十秒内吐出一段Python代码,长得挺像回事,我复制进编辑器一跑,直接一片红。第一个反应是AI在胡扯,第二个反应是找它算账,第三个反… · 2026/11/4 3:45:31

卡尔曼滤波飞行轨迹预测:状态建模、Q/R调参与多步外推排查
卡尔曼滤波飞行轨迹预测:状态建模、Q/R调参与多步外推排查

简介:围绕卡尔曼滤波在飞行物体运动轨迹预测中的实现,这套 MATLAB 工程面向具备一定信号处理与状态估计基础的高年级本科生、研究生及算法自学者,可用于处理含噪传感器数据并给出位置、速度与加速度的最优估计。压缩包共 6 个文件、约 129KB&… · 2026/11/4 3:45:31

商品状态机设计:解决电商库存、价格、上下架一致性问题
商品状态机设计:解决电商库存、价格、上下架一致性问题

简介:本资源是一个基于ASP.NET开发的网络商品销售管理系统源码包,面向Web开发初学者与.NET技术实践者,聚焦商品维护、订单处理与库存管理等核心电商功能实现。压缩包共55个文件,含15个DLL动态库(封装业务逻辑&#xff… · 2026/11/4 7:58:39

轻量级电商后台:SPU/SKU分离+商品状态机+SKU矩阵生成
轻量级电商后台:SPU/SKU分离+商品状态机+SKU矩阵生成

简介:这是一套基于ASP.NET开发的网络商品销售管理系统源码,面向初学者与Web开发学习者,聚焦商品维护、订单处理与库存管理等核心电商功能实现。资源共55个文件,包含15个DLL动态库(封装业务逻辑)、13个C#源文… · 2026/11/4 7:58:39

金融风控与工业物联网中的可解释异常检测技术
金融风控与工业物联网中的可解释异常检测技术

1. 项目背景与核心挑战 在金融风控和工业物联网领域,我们经常遇到一个棘手问题:算法检测出了异常,但没人能说清楚为什么。三周前某证券公司的系统警报触发了37次,风控团队却花了整整两天才确认其中只有5次是真实威胁——这种低效排… · 2026/11/4 7:58:39

Windows进程管理神器Process Explorer深度解析与应用
Windows进程管理神器Process Explorer深度解析与应用

1. 进程管理器 Process Explorer 深度解析作为一名在Windows系统管理领域摸爬滚打多年的老鸟,Process Explorer绝对是我工具箱里使用频率最高的神器之一。这款由微软Sysinternals团队开发的免费工具,可以说是任务管理器的"Pro Max"版本——它不… · 2026/11/4 7:58:39

51单片机RFID门禁系统:MFRC522+EEPROM工业级实现
51单片机RFID门禁系统:MFRC522+EEPROM工业级实现

简介:本资源为基于51单片机的RFID系统完整开发包,面向嵌入式初学者、电子设计爱好者及物联网入门开发者,解决RFID模块与经典8051架构微控制器协同工作的软硬件实现难题。压缩包含RFID模块驱动代码(适配MFRC522等主流芯片&#xff… · 2026/11/4 7:58:39

全球网络延迟模拟框架设计与实践
全球网络延迟模拟框架设计与实践

1. 为什么需要全球网络延迟模拟框架 在全球化应用部署的今天,我们的服务可能同时运行在东京、法兰克福和圣保罗的服务器上。但物理距离带来的网络延迟问题,往往要到用户投诉时才会被发现。去年我们团队就遇到过这样的情况:一个在欧洲内部测试… · 2026/11/4 7:57:39

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

AI辅助技术栈评估:提升研发决策效率60%
AI辅助技术栈评估:提升研发决策效率60%

1. 项目背景与核心价值技术决策一直是研发团队面临的重大挑战。随着技术生态的快速演进,技术栈的选择、维护和迭代变得愈发复杂。我们团队在过去两年里,先后经历了三次重大技术栈迁移,每次都需要投入大量人力进行技术评估和风险分析。直到去年… · 2026/11/4 0:00:17

从生成Token到直接决策:Jev如何重塑AI Agent工程实践
从生成Token到直接决策:Jev如何重塑AI Agent工程实践

1. 从"生成文字"到"拍板决策":Jev 到底在解决什么问题大多数人第一次接触大模型,脑子里建立的模型是"输入问题、输出文字"。你问它今天天气怎么样,它给你一段描述;你让它写个周报,它给你… · 2026/11/4 0:00:17

32G统一内存跑大模型:内存账、TPS实测与端云分流
32G统一内存跑大模型:内存账、TPS实测与端云分流

想在一台 32G 统一内存的小主机上把大模型跑成"日常可用",这件事我前后折腾了小半年。标题里说的这台 Mac mini M6,是我们这次讨论的目标平台,具体到手规格以实物为准;但下面这套推算方法和实测思路,对任何统… · 2026/11/4 0:00:17

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码