ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot多数据源与分库分表实战:路由、事务与分片落位

Spring Boot多数据源与分库分表实战:路由、事务与分片落位

简介:这套面向 Spring Boot 开发者的数据库实践示例,聚焦多数据源接入与分库分表落地,适合希望理解 dynamic-datasource 切换与 Sharding-JDBC 水平分表规则的中级开发者参考。项目以 Spring Boot 搭建,整合 MyBatis-Plus、Druid 与 Lombok,并用 JUnit 验证数据访问逻辑,覆盖同库拆表、跨源路由和连接池配置等场景,便于梳理依赖配置、Mapper 使用和测试流程。压缩包共 162 个文件、约 164KB,以 115 个 XML 映射与配置文件、14 个 Java 源码及编译类为主,另有 YML、properties、日志和 Maven 包装文件。已有 625 人学习,可作为多数据源与分表方案的入门对照、配置排查与实验基础。

1. 从一次订单库告警说起:Spring Boot 多数据源加分库分表这套工程能干什么

去年帮一个做交易的团队看线上问题,监控里最刺眼的不是接口超时,而是订单主库的 CPU 长期贴着 80%,一张t_order两千万行,报表同学一条带GROUP BY的导出 SQL 就能把主库拖到抖动。他们真正要的并不是“再买一台机器”,而是把读写混跑的业务库和报表库分开,再把订单表按用户维度拆到多个库表里,让每次查询只落在一个确定的分片上。

这份 Spring Boot 工程就是围绕这个场景整理的一套可运行骨架:多数据源动态路由、分片规则配置、跨库分页与全局 ID 都落到了具体类和配置文件,不是只给几段文档片段。适合正在做单库拆分、又不想一上来就引重型中间件的后端同学,也适合想让泛泛的技术选型变成真实代码结构的老手。后面几章按“边界怎么划 → 数据源怎么装 → 分片怎么配 → 坑在哪 → 怎么验证”的顺序拆。

2. 边界先划清:多数据源和分库分表不是一件事

2.1 多数据源解决“去哪儿”,分片解决“拆几份”

这两个词在日常沟通里经常被混着说,但落到代码上是两套完全不同的机制。多数据源是横向并存:订单库、报表库、归档库各自是独立实例,业务代码按场景挑一个连上去,逻辑表名和 SQL 都不用变,变的是DataSource。分片是纵向切分:同一个逻辑表t_order按规则散成t_order_0、t_order_1这种物理表,散落在多个库上,SQL 会被改写成带真实表名的形式,结果集还要归并。

判断先后顺序,我一般按这张表走:

线上现象优先多数据源优先分片说明
报表/导出 SQL 拖慢主库是拆出只读库,成本最低,可随时回滚
单表超两千万行且月增百万级是索引深度和 DDL 时间都会失控
两个业务库需要互查是靠字段冗余或离线同步解决,别指望跨库 JOIN
写入 QPS 打满单实例是先确认不是慢 SQL 占着连接
历史数据只读归档是冷热分离,热库只留近半年

顺序上我建议先拆数据源再拆表。原因很实际:拆数据源的改造点集中在配置和路由层,出问题把开关切回单库就行;拆表要动 SQL、动分片键、动历史数据迁移,一旦分片键选错,回滚成本是数周的迁移窗口。很多团队一上来就奔着分库分表去,结果发现真正的瓶颈是报表查询,白折腾一轮。

还有一个常见误判:把“读写分离”当成“多数据源”。读写分离本质是同一份数据的多个副本,路由依据是 SQL 类型(写走主、读走从),而多数据源路由依据是业务语义(这单逻辑该去订单库还是报表库)。两者在实现上可以复用同一套AbstractRoutingDataSource,但决策来源完全不同,混在一个切面里会很难排查。

2.2 三条落地路线怎么选

把“多数据源 + 分片”落到 Spring Boot 里,业界常见三条路线,代价差别很大:

路线典型实现侵入性跨库 JOIN运维成本适合谁
纯动态数据源自定义AbstractRoutingDataSource+ AOP 注解中,业务要标注解不支持,自己聚合低库数量少、分片规则简单
客户端分片分片中间件以 JDBC 驱动形式接入低,SQL 自动改写绑定表/广播表有限支持中大多数 OLTP 拆分场景
代理层分片独立部署的数据库代理最低,应用无感同上高,多一层要保活多语言栈、历史系统多

这份工程走的是“两条线并存”:业务库之间的切换用动态数据源,因为切换点少、语义清晰;订单这类大表用客户端分片,因为要的是 SQL 改写和结果归并的自动化。硬把两者塞进一个组件里,配置会变成一团,排查时你根本分不清某条 SQL 是被路由改的还是被分片改的。

选型时还有一个容易忽略的点:分片中间件是跟着应用走的,应用实例从 4 个扩到 20 个,数据库看到的是 20 份连接池,不是 4 份。数据库的max_connections必须在扩容前重新算一遍,这个坑在第 5 章会具体讲。

2.3 工程骨架与模块职责

拿到一份源码包,我第一件事不是跑起来,而是看目录结构判断它是不是“能改”的骨架。这套工程的模块划分大致是这样:

模块职责关键类/配置
common上下文、注解、异常、返回体DataSourceContext、@DS
datasource路由数据源装配、切面、健康检查RoutingDataSource、DsAspect
sharding-config分片规则、算法、全局 IDUserIdModAlgorithm、雪花配置
order-service订单写入与查询样板OrderService、OrderMapper
report-service跨源查询与离线聚合ReportService

判断骨架好坏有个朴素标准:每个模块能不能单独关掉。比如把datasource模块的切面注掉,工程应该还能以单数据源方式启动,只是所有@DS失效——这种“可退”的设计,才是线上敢灰度它的前提。如果一份代码里路由逻辑散落在各个 Service 里,那它更像个示例,不是骨架。

3. 数据源装配与动态路由:配置、切面、事务三件事

3.1 配置骨架:连接池参数比 URL 更值得抠

动态数据源的配置项看着多,真正需要反复调的就那么几个。下面这份 YAML 是工程里的形态,字段名与主流的动态数据源 starter 保持一致,换组件时键名需要对齐:

spring: datasource: dynamic: primary: ds_order # 未命中注解时的默认库,别设成报表库 strict: true # 找不到数据源直接抛异常,禁止静默回落 grace-destroy: true # 关闭时等在途请求跑完,避免切换瞬间报连接已关闭 datasource: ds_order: url: jdbc:mysql://10.0.0.11:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: app_rw password: ${ORDER_DB_PWD} # 走环境变量,别写死在仓库里 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 # 拿不到连接 3 秒就失败,别让线程无限等 max-lifetime: 1200000 # 略小于数据库 wait_timeout,避免用到被服务端杀掉的连接 ds_report: url: jdbc:mysql://10.0.0.12:3306/report_db?useSSL=false&serverTimezone=Asia/Shanghai username: app_ro password: ${REPORT_DB_PWD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 3000

几个参数值的说明:primary千万别图省事设成报表库,一旦某次切面没生效,写请求会静默落到只读库上,报错还算好事,最怕的是库恰好可写,脏数据就进去了。strict: true的作用是把“注解写错数据源名”这种低级错误暴露成立即失败,而不是悄悄回落到默认库——后者在灰度期极难发现。max-lifetime要小于数据库侧的wait_timeout,常见做法是留 2 分钟余量,否则连接池里会攒着一批已被服务端断开的死连接。

连接池总大小要乘上实例数算总和。4 个实例 × 20 看起来不多,但分片之后每张逻辑表要连 4 个库,每个库又是 20 条连接,加起来就是 320,这时候数据库max_connections如果不是按这个量级配的,上线当天就会看到Too many connections。

3.2 路由实现:ThreadLocal + AbstractRoutingDataSource + AOP

路由的完整链路是三步:注解标在方法上,切面把数据源名压进线程上下文,路由数据源在取连接时读上下文决定连哪个库。三部分代码分开看都很短,问题都出在细节上。

// 上下文用栈而不是单值:一个方法里可能嵌套调用另一个带 @DS 的方法 public final class DataSourceContext { private static final ThreadLocal<Deque<String>> HOLDER = ThreadLocal.withInitial(ArrayDeque::new); public static void push(String ds) { HOLDER.get().push(ds); } public static void pop() { Deque<String> stack = HOLDER.get(); if (!stack.isEmpty()) { stack.pop(); } } public static String peek() { return HOLDER.get().peek(); // 空栈返回 null,路由时回落 primary } public static void clear() { HOLDER.remove(); // 请求结束必须清,线程池会复用线程 } }

用栈而不是用一个String字段,是因为真实业务里经常出现“订单服务里调报表服务”这种嵌套。用单值的话,内层 pop 之后外层就丢了上下文,取连接时回落默认库,现象是数据偶发写错库,而且只在特定调用链上复现,非常难查。

// 路由数据源:只负责回答"当前该连哪个库" public class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContext.peek(); } }

AbstractRoutingDataSource在首次取连接时调用determineCurrentLookupKey,之后这次事务内就固定用这个连接。这一点决定了后面事务那一节的结论:切库必须发生在取连接之前。

// 切面:@Order 必须高于事务切面,否则事务先开、连接已绑定,切了也没用 @Aspect @Order(Ordered.HIGHEST_PRECEDENCE) @Component public class DsAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { DataSourceContext.push(ds.value()); try { return pjp.proceed(); } finally { DataSourceContext.pop(); // 必须放 finally,异常路径也要还原 } } }

@Order(Ordered.HIGHEST_PRECEDENCE)这一行是整个路由能不能生效的关键。Spring 的事务切面默认优先级较低,如果你的切面排在它后面,执行顺序就变成“先开事务拿连接,再切数据源”,此时连接已经从默认库取出来了,注解写得再对也没用。这类问题的排查特征很明显:日志里能看到数据源切换的记录,但 SQL 实际打在了主库上。

另外记得在 Web 过滤器或拦截器里调一次DataSourceContext.clear()。异步线程池场景下这条尤其重要,线程被复用时带着上一次请求的数据源名跑,等于给下个请求埋了个随机库。

3.3 事务与切换的先后顺序:注解写了为什么不生效

这是我在真实项目里见过最多的一种翻车,几乎每个做多数据源的团队都踩过一次。反例长这样:

// 反例:外层先开事务,内层再切库 @Transactional(rollbackFor = Exception.class) public void syncOrder(Order order) { orderMapper.insert(order); // 走 primary(ds_order) reportService.writeSummary(order); // 内部标了 @DS("ds_report"),但连接已绑定 }

reportService.writeSummary上哪怕明明白白写着@DS("ds_report"),实际还是写进了订单库。原因是外层@Transactional在方法入口就向数据源要了连接并绑定到事务上下文,内层切面改的只是ThreadLocal里的名字,连接已经定了。这种 bug 的危险之处在于它不报错,数据照样写成功,只是写错了地方,往往要等对账时才发现。

修正方式有三种,按推荐程度排:一是把带@DS的方法提到事务外层,让调用方不带事务;二是拆成两个独立的 Bean,各自管自己的事务边界;三是用TransactionTemplate手动控制,先压上下文再开事务。

// 正解一:切库方法自己开事务,调用方不要在事务里调它 @DS("ds_report") @Transactional(rollbackFor = Exception.class) public void writeSummary(Order order) { summaryMapper.insert(buildSummary(order)); }
// 正解二:确实要在一个业务流程里跨库写,用编程式事务显式排顺序 public void syncOrder(Order order) { orderMapper.insert(order); // 订单库,各自独立事务 DataSourceContext.push("ds_report"); try { transactionTemplate.executeWithoutResult(status -> summaryMapper.insert(buildSummary(order))); // 先压上下文,再开事务 } finally { DataSourceContext.pop(); } }

代价要说清楚:这两种写法都是“两个本地事务”,中间任何一步失败都不会自动回滚另一步。跨库强一致要靠柔性方案,常见做法是本地消息表 + 补偿任务,别指望一个@Transactional罩住两个库。我在评审时看到有人在方法上标了@DS又标@Transactional,还假设它们原子,基本可以直接打回。

3.4 主从延迟的兜底别省

读写分离配好之后,紧接着的问题就是“写完立刻读不到”。下单成功后跳详情页,从库还没追上,用户看到空数据。这类问题不是配置能解决的,只能在代码里做取舍:写后短时间内(比如 1 秒内)的读强制走主库,或者把刚写入的对象先放本地缓存/Redis,读的时候优先读缓存,用过期时间兜住延迟窗口。

我一般的做法是给路由上下文加一个forceMaster标记,在写方法结束后打上,随请求上下文传递,读路径上优先判断这个标记。代价是主库压力会上升一点,但比用户投诉“刚下的单不见了”划算得多。监控上要盯从库的Seconds_Behind_Master,超过阈值就告警,别等业务方来问。

4. 分片规则落地:分片键、算法、跨库分页和全局 ID

4.1 分片键选型:选“查询必带”的字段,不是“最均匀”的字段

分片键选错的代价是迁移全量数据。选之前先回答一个问题:这个业务 90% 的查询,WHERE 里一定带哪个字段?如果是 C 端订单,答案通常是用户 ID;如果是运营后台按订单号查,答案是订单号。两者往往不一致,那就得看哪边量大。

候选分片键优点代价适用
user_id同一用户数据聚在一处,C 端列表查询天然带后台按订单号查要广播到所有分片C 端订单、账户流水
order_id点查直达单表按用户查历史订单要全路由;ID 生成必须全局唯一详情页、回调幂等
create_time归档和冷热分离方便写入热点全压在最新分片,负载不均日志、账单、流水

常见的折中是“主分片键 + 二级索引表”:主表按user_id分片,另建一张按order_id分片的小表存order_id → user_id映射,点查先查映射再定位。多一次查询换掉全路由,通常划算。另外要注意分片键不能为 null,一旦插入时没带,路由算不出目标,有的实现会报错,有的会往默认节点写,后者更麻烦。

4.2 分片算法:先用内置,兜不住再自定义

简单取模用内置的 inline 表达式就够,写自定义算法是为了处理两个事:分片结果要可预测、要和历史数据的分布对齐。下面这个算法把 4 库 8 表(每库 2 张表)的落位规则固定下来:

// 自定义精确分片算法:slot = user_id % 8,库 = slot / 2,表 = slot % 2 public class UserIdModAlgorithm implements StandardShardingAlgorithm<Long> { private static final int TABLE_COUNT = 2; // 每库 2 张表 private int slot(long userId) { return (int) Math.floorMod(userId, 8L); // floorMod 兼容负数,别用 % } @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { int tableIndex = slot(shardingValue.getValue()) % TABLE_COUNT; String target = shardingValue.getLogicTableName() + "_" + tableIndex; if (!availableTargetNames.contains(target)) { throw new IllegalStateException("分片目标不存在: " + target); } return target; } @Override public Collection<String> doSharding(Collection<String> availableTargetNames, RangeShardingValue<Long> shardingValue) { return availableTargetNames; // 范围查询无法收敛,只能广播 } }

两个细节:Math.floorMod而不是%,因为用户 ID 如果出现负数,%会给出负下标,拼出的表名根本不存在;availableTargetNames的校验别省,配置里的actual-data-nodes写错时,这行能把问题提前暴露成启动期或首次调用期的明确异常,而不是一条神秘的空结果。

对应的规则配置大致如下,注意不同大版本的 starter 键路径有差异,分片规则放在rules节点下还是直接挂在shardingsphere下,要按你实际依赖的主版本对齐:

spring: shardingsphere: rules: sharding: tables: t_order: actual-data-nodes: ds_${0..3}.t_order_${0..1} database-strategy: # 库路由,与表路由共用同一套 slot 规则 standard: sharding-column: user_id sharding-algorithm-name: order-db-slot table-strategy: standard: sharding-column: user_id sharding-algorithm-name: order-tbl-slot key-generate-strategy: column: order_id key-generator-name: snowflake

库和表两条策略必须用同一套 slot 计算,否则会出现“算出来的库和表不匹配”的情况:读的时候按库策略找到 A 库、按表策略找到t_order_1,而这条数据当初写进了 B 库的t_order_0。我一般把 slot 计算抽成一个静态方法,两个算法类都调它,从源头保证一致。

4.3 跨库分页与聚合:归并的代价在内存里

分片之后LIMIT 10 OFFSET 10000这种写法会变成性能陷阱。中间件为了拿到正确的第 10001 到 10010 条,需要向每个分片请求LIMIT 10010,再在内存里归并排序取最后 10 条。4 个分片就是 4 万行数据过一遍内存,翻到几万页时直接 OOM 也不是没发生过。

工程里可用的三种处理方式:一是游标分页,用WHERE order_id > #{lastId} ORDER BY order_id LIMIT 10,前提是 ID 全局有序(雪花 ID 基本满足),代价是不能跳页;二是限制最大翻页深度,超过 N 页引导用户加筛选条件,产品上完全可接受;三是把列表数据同步到 ES 或宽表,分片库只承担点查和写入。

聚合的问题更隐蔽。COUNT(DISTINCT user_id)、AVG、多字段GROUP BY这类 SQL,中间件要归并各分片的中间结果再算最终值,AVG在部分实现里会被改写成SUM/COUNT两路归并,结果精度和你想的可能不一样。血泪经验是:报表类查询别直接打分片库,走离线同步出来的汇总表,延迟几分钟换来的是主库安稳。

4.4 全局 ID:别让 workerId 撞车

分片之后自增主键彻底失效,必须换成全局 ID。雪花算法是最常见的方案,但它有个前置条件:多实例部署时,每个实例的workerId必须唯一。我见过最典型的翻车是所有人用默认的0/0,压测没暴露(单实例),上线扩到 8 个 Pod 之后 ID 开始重复,主键冲突报错刷屏。

// workerId 从号段表申请:启动时占位,宕机后靠心跳超时回收 @Bean public KeyGenerator orderKeyGenerator(WorkerIdAllocator allocator) { long workerId = allocator.acquire(); // 0~1023,唯一 SnowflakeIdGenerator generator = new SnowflakeIdGenerator(workerId, 1L); return () -> String.valueOf(generator.nextId()); // 返回字符串,避免前端精度丢失 }

SnowflakeIdGenerator的两个参数分别是workerId和datacenterId,加起来占 10 位,上限 1024 个节点,超过就得换号段模式或者改位分配。返回值统一转String,因为 JavaScript 的Number精度到 2^53,19 位的长整型传到前端会末位变 0,这个坑跟分片没关系,但几乎每个用雪花 ID 的项目都会遇到一次。

5. 避坑与排查:五条上线后才懂的记录

5.1 插入时没带分片键,数据写到了默认节点

现象:新增订单后按用户 ID 查不到,但按订单号能查到,等于数据“看得见摸不着”。

原因:分片键为 null 时算不出路由,部分实现会回落到默认数据源或默认表,写入成功但不属于任何分片。

解决:入库前做非空校验,sharding-column对应的字段加数据库非空约束;写入链路入口统一用参数校验拦住,不要指望中间件兜底。

5.2 跨库 JOIN 直接报错,改成 IN 查询又把连接打满

现象:t_order和t_user关联查询报“找不到表”,拆成两次查询后,IN里塞了几千个 ID。

原因:分片表只能和同分片规则的绑定表 JOIN;IN列表过大时,中间件会把它展开成对多个分片的多次查询。

解决:优先让两张表走相同分片键并配成绑定表;跨维度查询改为字段冗余(订单表里存一份用户名快照)或走离线宽表;IN列表分批,单批控制在 200 以内。

5.3 连接数超限,数据库被打成 too many connections

现象:扩容到 16 个实例后,高峰期应用日志里大量Too many connections,重启能缓解十几分钟。

原因:连接池大小是“每实例 × 每数据源 × 每分片”,扩容把总连接数翻倍,数据库侧上限没跟着调。

解决:先把maximum-pool-size× 实例数 × 数据源数的总和算清楚,预留 30% 余量再调数据库max_connections;连接数不是越大越好,单库超过 200 之后往往先撞上 CPU 和锁竞争。

5.4 分页插件和分片中间件一起用,COUNT 结果翻倍

现象:列表接口返回的总条数是实际的 2 倍或 4 倍,数据本身正常。

原因:分页插件自己会改写一次 COUNT SQL,分片中间件又改写一次,两边叠加后计数逻辑错乱。

解决:二选一。用了分片中间件就把分页插件的自动 COUNT 关掉,改用中间件自己的归并计数,或者干脆返回“是否有下一页”而不返回总数。

5.5 异步线程里路由上下文丢了,SQL 打到了默认库

现象:主流程正常,但发到线程池里的异步任务写数据,全写进了默认库。

原因:ThreadLocal不跨线程传递,@Async或手动submit出来的任务拿到的是空上下文,peek()返回 null 后回落 primary。

解决:任务提交前抓一份上下文快照,在任务体里重新push并在finally里pop;或者封装一个ContextAwareTaskDecorator统一处理,比在每个submit前面手动压栈可靠。

6. 验证与进阶:怎么证明数据真的落在对的库表

配置跑通不等于数据是对的。分片最怕的是“看起来能用”,实际一部分数据落错了位置,只有特定查询路径才查不到。我做这类改造有一个固定习惯:上线前跑一遍落位校验,抽样 1000 个用户 ID,按算法算出预期位置,再去每个分片核对实际行数。

# 落位校验:按 user_id % 8 的规则,核对数据是否真的落在预期库表 import pymysql DB_COUNT, TABLE_COUNT = 4, 2 # 4 库,每库 2 张表 def expected_location(user_id: int): slot = user_id % 8 return slot // TABLE_COUNT, slot % TABLE_COUNT # (库下标, 表下标) def count_in_shard(db_idx: int, tbl_idx: int, user_id: int) -> int: conn = pymysql.connect(host=f"10.0.0.{11 + db_idx}", port=3306, user="app_ro", password="***", database="order_db") try: with conn.cursor() as cur: cur.execute(f"SELECT COUNT(*) FROM t_order_{tbl_idx} WHERE user_id = %s", (user_id,)) return cur.fetchone()[0] finally: conn.close() if __name__ == "__main__": bad = [] for uid in range(1, 1001): db, tbl = expected_location(uid) if count_in_shard(db, tbl, uid) == 0: bad.append((uid, db, tbl)) # 预期位置没有数据,说明落位错了 print(f"校验 1000 个用户,异常 {len(bad)} 条", bad[:10])

脚本逻辑很直白:按分片算法反推每个用户应该在哪个库的哪张表,然后去那个位置数一下。如果bad列表非空,说明写入路径的路由和查询路径的算法不一致,多半是 4.2 节提到的“库策略和表策略没用同一套 slot”。这段脚本在数据迁移前后各跑一次,比看任何监控面板都直接。

第二步是把路由结果打到日志里。分片中间件一般有 SQL 展示开关,打开后能看到改写后的真实 SQL 和命中的节点:

spring: shardingsphere: props: sql-show: true # 只建议在预发和灰度实例打开,生产全开会刷爆日志

打开后一条查询会打印成Actual SQL: ds_1 ::: SELECT ... FROM t_order_1 WHERE user_id = ?这种形式,“Actual SQL”后面那一段就是落位证据。排查“为什么这条数据查不到”时,先看这条日志,能省掉大量猜测。生产环境我一般只在灰度实例开,并且加采样比例,全量打开对磁盘和日志采集链路都是负担。

第三步是灰度和回滚。新增分片或调整算法时,双写一段时间是最稳的做法:老路径写原表,新路径写新分片,用一个比对任务定时抽样核对两边内容。比对期间任何一侧失败都只告警不回滚,观察 24 小时确认无差异再切读。这套流程听着笨,但比事后补数据便宜太多。

进阶方向有两个值得提前想清楚。一是绑定表配置,把经常一起 JOIN 的表设成同分片键的绑定表,中间件就不会把 JOIN 拆成笛卡尔积,这是提升查询性能最省力的一招。二是把统计分析类查询整体挪走,分片库只做 OLTP,报表走同步出来的宽表,两条链路各自演进,别在一套表上既要低延迟点查又要多维聚合。

说个我自己的教训。第一次做分库分表时,我把算法写在两个类里,一个算库一个算表,当时觉得逻辑一样就复制了一份。三个月后有人改了表的取模基数,库的那个类忘了同步,结果新增数据全部落错位,花了两个通宵写脚本搬数据。从那以后我每次做分片,都强制先把 slot 计算抽成单点、跑一遍落位校验脚本,再谈性能优化。希望帮到你。

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

RELATED READING

延伸阅读

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