news 2026/11/4 17:36:36

Spring Boot多数据源与分库分表实战:路由、事务、分片键与迁移验收

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot多数据源与分库分表实战:路由、事务、分片键与迁移验收

简介:围绕多数据源与数据库分库分表展开的实战工程,面向已掌握Spring Boot基础、希望理解同库水平分表与多数据源切换的开发者。项目以Spring Boot搭建,多数据源部分采用MyBatis-Plus的dynamic-datasource,分库分表引入Sharding-JDBC,连接池使用Druid Spring Boot Starter,并借助MyBatis-Plus简化数据访问、Lombok减少样板代码,配合JUnit测试验证数据操作。压缩包共162个文件,约164KB,主要由115个XML映射与配置、14个Java源码、14个class编译文件、6个YML配置及若干properties、日志文件组成,便于对照配置与代码理解分表规则、数据源路由和测试流程。已有625人学习,适合需要落地多数据源与分库分表方案的开发者参考,可从中梳理依赖配置、实体映射、服务实现与单元测试的组织方式。

1. 需求先立住:单库单表撑不住的时候,为什么多数据源和分库分表要一起上

订单表过三千万行、写入开始出现毛刺的时候,先动索引和缓存往往只是拖延。这套方案把两个问题放在一起解:多数据源负责把订单、用户、账务这些边界清晰的数据库隔离开,分库分表负责把订单库内部那张最大的单表拆成 4 库 32 表,两者在同一个 Spring Boot 服务里共存,共用一套连接池、事务和监控。目标读者是两类人:一类是要交毕设或课设,需要一套真能跑起来、也能讲清楚设计取舍的多数据源加分库分表工程;另一类是手上有个单库快要撑不住的系统,需要一份能直接照着改的改造方案。下面按需求、架构、实现、易错点、验收的顺序走一遍,代码和配置都可以照着抄。

2. 总体架构与技术选型:三条路线怎么选,模块边界画在哪

技术选型这件事最怕两种极端:一种是上来就堆中间件,最后自己都说不清一条 SQL 走了几跳;另一种是全部手写,写到跨表聚合就写不动了。我一般会先把候选路线摆出来,用「改造量、可调试性、SQL 覆盖面、交付周期」四个维度打分,再决定哪一层交给现成组件、哪一层自己控。

2.1 三条落地路线的对比与取舍

分库分表在工程上有三种典型做法,差别不在技术高低,而在责任边界落在谁身上。

路线落地位置优势代价适用场景
代理型独立进程,应用连代理应用零侵入,多语言共享多一跳网络,代理本身要运维和容量规划多语言混布、DBA 统一管控
客户端分片应用内的 JDBC 层,以 jar 形式引入无额外网络跳数,断点能直接打到路由代码绑定 JVM 技术栈,升级要动依赖Java 单体或微服务,本方案选它
纯手写路由AOP 拦截 + 表名拼接完全可控,代码量看起来最小一旦出现 JOIN、聚合、子查询就开始翻车分片键固定、SQL 形态极简单的内部系统

这套方案选客户端分片,理由很实在:交付物是一个 Spring Boot 工程,评阅或接手的人能在 IDE 里单步调试一条 SQL 从注解到物理表的全过程,比对着一个黑匣子代理讲原理有说服力得多。同时手写一层轻量动态数据源,负责业务库之间的隔离,两件事的分工必须一开始就说明白,否则后面会出现「到底该配在哪」的扯皮。

分工是这样定的:动态数据源解决「订单库、用户库、账务库三个独立数据库之间怎么切」;分片组件解决「订单库内部 32 张物理表怎么路由」。前者是横向的业务隔离,后者是纵向的容量拆分,两者不在同一层,所以不冲突。

2.2 分层结构与各模块职责

整体是五层,从上往下依次收窄职责,任何一层都不越界做下一层的事。

层次承载内容关键约束
接口层Controller、参数校验、统一返回不写业务分支,不碰数据源
业务层Service,事务边界在这一层划定一个方法只操作一个逻辑库
数据访问层Mapper / Repository禁止跨库 JOIN,禁止拼表名
路由层动态数据源 + 分片路由只做选择,不做业务判断
存储层3 个逻辑库、订单库 4 库 32 表表结构一致,索引一致

这里有个容易被忽略的设计点:路由数据源必须包在分片数据源外面。也就是说,动态数据源的targetDataSources里,订单库对应的是分片组件装配出来的那个DataSource实例,用户库和账务库对应的是普通的连接池。这样上层只看到一个数据源入口,切换发生在最外层,分片发生在内层,调用链是单向的。

包结构我一般按这个方式切:

com.example.data ├── config # 数据源注册、事务管理器、分片规则装配 ├── routing # 动态数据源、上下文持有者、@DS 注解与切面 ├── sharding # 分片键解析、基因位工具、ID 生成器 ├── service # 业务服务,事务边界所在 └── mapper # 数据访问

按职责分包而不是按技术分包,好处是后面加一个业务库时,改动集中在config和routing两个包里,业务代码一行不动。

2.3 依赖与配置基线

依赖清单要保持克制,能不加的就不加,尤其是和事务、连接池相关的重复实现。

<!-- 基础 JDBC 能力与连接池,HikariCP 由 starter 间接引入 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <!-- AOP:动态数据源切换靠它,别用拦截器替代 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- 客户端分片组件,坐标为单条 starter 依赖 --> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc</artifactId> </dependency>

分片规则写在配置文件里,不要散落在 Java 代码中,方便评阅时一眼看出分片策略:

spring: shardingsphere: datasource: names: ds0,ds1,ds2,ds3 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://db-a:3306/order_0?useSSL=false username: order_rw password: ${ORDER_DS0_PWD} maximum-pool-size: 20 # 单库连接上限,按 4 库合计不超数据库上限反推 minimum-idle: 5 # ds1 / ds2 / ds3 同构,仅 jdbc-url 与账号不同 rules: sharding: tables: t_order: actual-data-nodes: ds$->{0..3}.t_order_$->{0..7} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db_mod table-strategy: standard: sharding-column: user_id sharding-algorithm-name: table_mod sharding-algorithms: db_mod: type: MOD props: sharding-count: 4 table_mod: type: MOD props: sharding-count: 8

几个参数要能解释清楚:actual-data-nodes用行表达式描述 4×8=32 个物理节点,写成表达式而不是 32 行,改动分片数量时只改数字;sharding-column统一写user_id,意味着所有不带用户维度的查询都会退化成广播,这一类 SQL 我在代码评审时单独列清单管控;两个MOD算法的分片数分别对应库数和表数,密码走环境变量注入,不要把明文写进仓库。

注意:分片相关的配置键在不同小版本上有调整,如果启动报「找不到配置项」,先确认你手上依赖的实际键路径,不要把网上的片段直接粘贴。连接池上限也别照抄 20,四个库加起来 80 个连接可能已经超过数据库侧的最大连接数,这个数要按库数 × 单库上限 + 余量倒推。

3. 多数据源实现:动态路由、AOP 切换和事务边界

多数据源最难的不是「怎么切」,而是「切了不生效怎么查」。我见过太多工程里注解写得漂漂亮亮,一跑起来 SQL 全打在主库上,原因八成出在事务和线程上下文这两个地方。这一章把注册、切换、事务三段拆开写,每段都能单独验证。

3.1 数据源注册与路由数据源装配

注册阶段只做三件事:把三个数据源建出来、挂上路由、指定默认库。

@Configuration public class DynamicDataSourceConfig { // 用户库:普通连接池,连接参数从 app.datasource.user 前缀读取 @Bean("userDataSource") @ConfigurationProperties(prefix = "app.datasource.user") public HikariDataSource userDataSource() { return new HikariDataSource(); } // 账务库:同上,独立账号与权限,业务上只允许写流水 @Bean("ledgerDataSource") @ConfigurationProperties(prefix = "app.datasource.ledger") public HikariDataSource ledgerDataSource() { return new HikariDataSource(); } // 路由数据源:订单库传入的是分片组件装配出的 DataSource, // 所以外层做库切换、内层做表路由,顺序不能反 @Bean @Primary public DataSource dynamicDataSource(DataSource shardingDataSource, @Qualifier("userDataSource") DataSource userDs, @Qualifier("ledgerDataSource") DataSource ledgerDs) { Map<Object, Object> targets = new HashMap<>(4); targets.put(DataSourceKey.ORDER, shardingDataSource); targets.put(DataSourceKey.USER, userDs); targets.put(DataSourceKey.LEDGER, ledgerDs); RoutingDataSource routing = new RoutingDataSource(); routing.setTargetDataSources(targets); routing.setDefaultTargetDataSource(userDs); // 兜底库,必须是最不容易被写错的库 routing.afterPropertiesSet(); // 必须调用,否则 targetDataSources 不会生效 return routing; } }

RoutingDataSource继承AbstractRoutingDataSource,只需要重写一个方法:

public class RoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 返回 null 时框架会自动落到 defaultTargetDataSource return DataSourceContextHolder.get(); } }

参数说明:setDefaultTargetDataSource的兜底库选择是设计决策,不是随手填——选读多写少的用户库,是因为万一上下文丢失,落到只读为主的库上出错面最小,落到账务库上问题就大了。afterPropertiesSet()容易漏,漏了的表现是启动不报错、运行时全部走默认库,排查成本很高。

3.2 @DS 注解、切面顺序与上下文传递

注解本身没有逻辑,真正的关键是切面的执行顺序。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DS { DataSourceKey value() default DataSourceKey.USER; }
@Aspect @Component @Order(Ordered.HIGHEST_PRECEDENCE) // 必须早于事务切面,否则事务已经绑定好连接了 public class DataSourceAspect { @Around("@annotation(ds)") public Object around(ProceedingJoinPoint pjp, DS ds) throws Throwable { DataSourceKey previous = DataSourceContextHolder.get(); DataSourceContextHolder.set(ds.value()); try { return pjp.proceed(); } finally { // 恢复而不是直接 clear,支持嵌套调用时回到外层的数据源 if (previous == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } } }

@Order(Ordered.HIGHEST_PRECEDENCE)这一行是这套方案里最不能省的一行。Spring 的事务切面默认优先级是LOWEST_PRECEDENCE,数值越大越晚执行;如果数据源切面排在它后面,@Transactional开启事务时已经从默认库拿了连接,后面的切换就是空转。这条经验是踩过坑才记住的,不是文档上抄的。

上下文持有者用ThreadLocal,同时必须给异步场景留出口:

public final class DataSourceContextHolder { private static final ThreadLocal<DataSourceKey> CTX = new ThreadLocal<>(); public static void set(DataSourceKey key) { CTX.set(key); } public static DataSourceKey get() { return CTX.get(); } public static void clear() { CTX.remove(); } // 手工提交线程池任务时用这个包一层,把父线程的库选择带过去 public static Runnable wrap(Runnable task) { DataSourceKey key = CTX.get(); return () -> { DataSourceKey origin = CTX.get(); CTX.set(key); try { task.run(); } finally { if (origin == null) CTX.remove(); else CTX.set(origin); } }; } }

ThreadLocal不会随线程池任务自动传递,这是多数据源场景里第二高频的翻车点。判断标准很简单:只要代码里出现了@Async、CompletableFuture或手动submit,就必须显式包裹上下文,否则那些 SQL 会默默落到默认库上,日志里看不出任何异常。

3.3 事务为什么会让切换失效,以及三种处理方式

先明确一件事:在同一个@Transactional方法内部,切换到另一个库,是不生效的。原因不是框架有 bug,而是事务开启时通过DataSourceUtils拿到连接后绑定到了线程上,后续同一个线程再取连接时直接复用绑定对象,根本不走determineCurrentLookupKey。

处理方式按代价从低到高有三种:

第一种,把切换放在事务外层。Service 拆分,编排层不加事务,只负责按顺序调用不同库的事务方法:

@Service public class OrderFacade { // 编排层不开事务,只决定调用顺序 public void placeOrder(OrderCmd cmd) { orderService.createOrder(cmd); // @DS(ORDER) + @Transactional,订单库本地事务 ledgerService.record(cmd); // @DS(LEDGER) + @Transactional,账务库本地事务 } }

第二种,一个库一个事务管理器,显式指定:

@Configuration public class TransactionConfig { // 分片库内部走本地事务,按库名区分管理器 @Bean("orderTxManager") public PlatformTransactionManager orderTxManager(DataSource shardingDataSource) { return new DataSourceTransactionManager(shardingDataSource); } @Bean("ledgerTxManager") public PlatformTransactionManager ledgerTxManager(@Qualifier("ledgerDataSource") DataSource ds) { return new DataSourceTransactionManager(ds); } }

使用时写@Transactional(transactionManager = "ledgerTxManager"),不写管理器名就会用@Primary的那个,跨库写会出现「一半提交一半回滚」。

第三种,接受最终一致。订单和账务之间用本地消息表:业务写入和消息记录在同一个库的同一个本地事务里提交,再由一个投递任务把消息推到另一个库,失败重试加幂等键。这是我在真实改造里最常用的方案,因为它不需要引入额外的协调者,回滚预案也好写——把投递任务停掉,脏数据不会再扩散。

选择标准我一般这么定:同一个库内的多表写必须用本地事务;跨库写且允许秒级延迟的,用消息表;跨库写且必须强一致的,先问清楚业务方能不能把这两张表合到一个库里,很多时候答案是能。

4. 分库分表核心设计:分片键、路由规则、全局 ID 与跨片查询

多数据源解决完「库之间」的问题,接下来是最容易做成半成品的一层。分片设计里 80% 的麻烦来自分片键选错,而不是中间件不会用。

4.1 分片键怎么选:用户维度、订单维度与基因位

订单表有两个天然查询入口:user_id(我的订单列表)和order_id(订单详情、支付回调)。只用其中一个做分片键,另一个查询就会变成 32 路广播,这是分片方案里最经典的二选一困局。

破局办法是基因位:把user_id的低若干位嵌入订单号,让订单号自带路由信息。

这里有个很多人算错的地方——基因位取几位。规则是取「分库数与分表数的最小公倍数所需的最小 2 的幂」。本例 4 库 8 表,最小公倍数是 8,所以基因位取 3 位,共 8 种取值。

public final class ShardGene { private static final int GENE_BITS = 3; // 4 库 8 表 => lcm=8 => 3 位 private static final long GENE_MASK = (1L << GENE_BITS) - 1; // 0b111 /** 由分片键取出基因段 */ public static long of(long userId) { return userId & GENE_MASK; } /** 由订单号反解出基因段 */ public static long fromOrderId(long orderId) { return orderId & GENE_MASK; } /** 基因段换算成库下标:8 张表时,库 = 基因 % 4 */ public static int dbIndex(long gene) { return (int) (gene % 4); } /** 基因段换算成表下标 */ public static int tableIndex(long gene) { return (int) (gene % 8); } }

逻辑说明:user_id & 0b111得到 0 到 7 之间的值,这个值同时能推出库下标和表下标,因为 8 是 4 的整数倍,gene % 4与user_id % 4结果一致,gene % 8与user_id % 8也一致。于是按user_id查和按order_id查,都能精确定位到同一张物理表,不会退化成广播。

参数说明:GENE_BITS是设计期常量,一旦上线就不能改——改了以后历史订单号的基因段和新规则对不上,查询会串表。分片数量变更只能走扩表加迁移,不能改基因位宽。这个约束要写进设计文档,答辩时被问到「以后怎么扩容」时,这是个加分回答。

4.2 绑定表与广播表:哪些表跟着分,哪些只能复制

订单库里的表不是都能分的,得先分类。

表类型例子策略判断依据
分片表订单主表、订单明细按user_id分片,与主表建绑定关系有明确的分片键,且总量大
绑定表订单明细、订单优惠明细与主表同规则分片,避免跨库 JOIN总是随主表一起查询
广播表省份字典、渠道字典每个库一份全量小表数据量小、变更少、要参与 JOIN
单表操作日志、异步任务不分片,单独放无分片键,或按时间归档
spring: shardingsphere: rules: sharding: binding-tables: - t_order,t_order_item # 绑定后同 user_id 落在同一库同一表,JOIN 可下推 broadcast-tables: - t_dict_channel # 每个库一份,写入时全部库同步执行

绑定表的意义在于把 JOIN 下推到单个物理库内执行,跨库 JOIN 是分片方案里最该被封禁的操作。广播表则要控制数量,字典表超过十张就别广播了,改成应用层缓存更划算——广播表的每次写都要打到所有库,事务和一致性都会变复杂。

4.3 全局唯一 ID:63 位布局、workerId 分配与时钟回拨

分片之后自增主键不能用了,两库同时插入会撞号。方案是自建 63 位 ID,布局必须一次定死:

public class OrderIdGenerator { // 63 位布局:41 位毫秒时间 + 12 位 workerId + 3 位分片基因 + 7 位序列 private static final int GENE_BITS = 3; private static final int SEQ_BITS = 7; // 单机每毫秒 128 个 private static final long SEQ_MASK = (1L << SEQ_BITS) - 1; private static final long GENE_MASK = (1L << GENE_BITS) - 1; private static final long WORKER_SHIFT = GENE_BITS + SEQ_BITS; // 10 private static final long TIME_SHIFT = WORKER_SHIFT + 12; // 22 private final long workerId; // 0 ~ 4095,从配置中心按实例分配 private long lastTimestamp = -1L; private long sequence = 0L; public OrderIdGenerator(long workerId) { if (workerId < 0 || workerId > 4095) { throw new IllegalArgumentException("workerId 必须在 0~4095 之间"); } this.workerId = workerId; } public synchronized long next(long userId) { long now = System.currentTimeMillis(); if (now < lastTimestamp) { // 时钟回拨:小幅度等待,大幅度直接抛,让上层换实例重试 long offset = lastTimestamp - now; if (offset > 5) { throw new IllegalStateException("时钟回拨 " + offset + "ms,拒绝生成"); } now = waitUntil(lastTimestamp); } if (now == lastTimestamp) { sequence = (sequence + 1) & SEQ_MASK; if (sequence == 0) { now = waitUntil(lastTimestamp + 1); } } else { sequence = 0L; } lastTimestamp = now; long gene = userId & GENE_MASK; return ((now - EPOCH) << TIME_SHIFT) | (workerId << WORKER_SHIFT) | (gene << SEQ_BITS) | sequence; } private long waitUntil(long target) { long now = System.currentTimeMillis(); while (now < target) { now = System.currentTimeMillis(); } return now; } }

逻辑说明:时间戳占高位保证整体递增,workerId段区分实例,基因段保证「订单号自带分片信息」,序列段兜住同一毫秒内的并发。EPOCH是一个固定的起始时间常量,减掉它能多用几十年。

参数说明:workerId绝对不能靠随机数或 IP 哈希生成,容器化部署下重启换 IP 就会撞号;正确做法是从配置中心或注册中心按实例名申请一个固定号段,或者用数据库号段表分配。时钟回拨的处理策略要写成明确规则:小幅回拨等待,大幅回拨直接失败,让调用方重试到另一台实例,这比悄悄生成重复 ID 安全得多。

4.4 跨分片查询与深分页:哪些 SQL 必须管住

分片之后有三类 SQL 会变慢,必须有明确的管控清单:

第一类是没带分片键的查询。select * from t_order where status = 1会被改写成 32 路查询再归并,数据量一大就把连接池打满。处理方式是给运营后台单独走一套按时间分区的归档库,而不是让后台查询直连分片库。

第二类是跨库聚合。count(*)、sum(amount)这类会被拆成 32 份再合并,结果正确但耗时是单库的若干倍。做法是把统计口径改成异步预聚合,定时任务跑到汇总表里,前台只读汇总表。

第三类是深分页。limit 100000, 10在分片场景下会变成 32 个分片各取 100010 条再归并,内存和网络都扛不住。可行做法有两种:一是业务上限制最大可翻页数,超出引导用户加筛选条件;二是改成二次查询,第一轮只取各分片的主键区间,第二轮按主键回表。

-- 二次查询第一轮:每个分片只取自己那一段的 id 边界 SELECT id FROM t_order_3 WHERE user_id = ? AND status = 1 ORDER BY id DESC LIMIT 10; -- 第二轮:拿到全局边界后按 id 精确回表,避免大偏移量 SELECT id, user_id, amount, create_time FROM t_order_3 WHERE user_id = ? AND id <= ? AND id > ? ORDER BY id DESC LIMIT 10;

两次查询的代价远小于一次大偏移量归并。这套改写要写进 DAO 的封装里,别指望每个开发都记得手动写。

4.5 存量迁移与双写切换:全量、增量、校验三步走

方案能跑通不等于能上线,存量数据还在老库里。我一般按三步推进,每一步都有独立可验收的产出。

第一步全量迁移。按主键区间分批读老库、按新规则写入 32 张物理表,每批 5000 行,批间 sleep 几十毫秒给线上留余量。迁移程序要幂等,重复执行不产生重复行,这靠新表的唯一索引兜底。

第二步增量追平。全量期间老库还在写,用基于 binlog 的增量订阅把这段时间的变更补到新库,追平的标准是「增量位点延迟稳定在秒级以内」。

第三步双写灰度。应用层同时写老库和新库,读仍走老库,按用户白名单逐步放量。双写阶段有几个细节必须处理:写新库失败不能影响主流程,要落到补偿队列;新库写入要带版本号或更新时间做乐观校验,避免增量任务和双写互相覆盖;灰度比例按用户 ID 取模控制,别按请求随机,否则同一用户会读到两个库的数据。

校验脚本建议按「总量、金额汇总、抽样明细」三个维度做,总量对得上但金额对不上是很常见的现象,说明有部分行被重复写或漏写。校验通过之后再切读,切读之后老库保留只读至少一周,这是唯一的后悔药。

5. 上线前最容易翻车的五处:现象、原因与处理

这一章的五条都是我在类似工程里真实遇到过的,按出现频率排序。每条都按「看到什么、为什么、怎么修」写,方便对着自己的工程自查。

5.1 注解加了但 SQL 还打在主库,事务把切换吞了

现象:Service 方法上明明标了@DS(ORDER),日志里打印的库名却一直是默认库,而且只有加了@Transactional的方法才这样。

原因:事务切面的优先级高于数据源切面,事务开启时已经通过默认数据源取到连接并绑定到线程,后续determineCurrentLookupKey不再被调用。

解决:把数据源切面的@Order调到Ordered.HIGHEST_PRECEDENCE,并确认@DS加在事务方法的外层调用方,而不是加在事务方法内部调用的私有方法上。自查方法很简单:在determineCurrentLookupKey里打一行带堆栈的日志,看它有没有被调用。

5.2 异步线程里上下文丢失,批量任务全写进了默认库

现象:主流程写得都对,但异步补偿任务、定时批量任务写入的数据全在默认库里,查新库查不到。

原因:ThreadLocal不随线程池任务传递,@Async和手动submit都在新线程上执行,上下文是空的,于是走了兜底数据源。

解决:统一用DataSourceContextHolder.wrap()包装任务,或者在自定义线程池的beforeExecute/afterExecute里做上下文传递。更好的做法是禁止在异步任务里依赖隐式上下文,强制在方法参数上显式传库标识,代码会啰嗦一点,但排障成本直线下降。

5.3 分片键上套了函数或类型不一致,SQL 退化成全路由

现象:按订单号查详情的接口平时几十毫秒,某些订单突然变成几百毫秒,慢查询日志里出现 32 条结构相同的 SQL。

原因:查询条件写成where date_format(create_time,'%Y-%m-%d') = ?,或者user_id传进来是字符串而字段是bigint,隐式转换让分片算法拿不到可用值,只能广播。

解决:分片键条件必须是裸字段等值或范围,不允许套函数、不允许隐式类型转换。在 DAO 层加一层参数类型断言,user_id入参统一用Long,Controller 层就做转换,别等到 SQL 层才发现。

5.4 雪花 ID 撞号,主键冲突偶发出现

现象:压测或滚动发布期间偶发主键冲突,日志里两条记录的 ID 完全一样,但时间戳不同。

原因:workerId靠随机数或 IP 尾号生成,容器重启后换了 IP 又拿到同一个号;或者时钟回拨后序列被重置。

解决:workerId走配置中心或数据库号段表按实例分配,实例下线后号段回收要有延迟;时钟回拨超过阈值直接抛异常而不是等待。上线前用压测脚本连续生成一千万个 ID 做去重校验,这一步花不了多少时间,但能省掉上线后半夜被叫起来的麻烦。

5.5 双写阶段统计口径不一致,报表数字对不上

现象:切换完成后订单总量对得上,但金额汇总比老库少了一截,财务对账直接卡住。

原因:双写时新库写入失败被吞掉了,只记了一条 warn 日志没进补偿队列;或者增量同步任务按更新时间拉取,同一毫秒内的多条变更漏了一部分。

解决:双写失败必须落补偿表并告警,补偿任务按指数退避重试;增量同步不要只依赖时间戳,用自增主键或位点做游标。校验脚本要按「按天分组汇总 + 随机抽样明细」两层做,只对总量会漏掉正负抵消的错误。

6. 验收与答辩:用一份可自证的清单替代口头描述

验收这件事,我不太相信「跑通了」这三个字。真正能拿得出手的交付,是别人拿着你的清单能自己复现一遍。我会准备一张表,每项都写明验证方法、预期结果和失败时的排查入口,评阅的人挑任意一行都能当场验证。

验收项验证方法通过标准
数据分布均匀遍历 32 张表统计行数,算最大最小值比值比值小于 1.5,无明显倾斜表
路由精确对同一user_id执行插入与按order_id查询两次命中同一张物理表,执行计划无广播
库切换生效三个业务库各打一条带库名的探针 SQL返回的库名与注解声明一致
事务边界故意在第二个库写入非法数据只有该库回滚,第一个库数据完整
ID 唯一连续生成一千万个 ID 去重重复数为 0,且整体单调递增
双写一致跑校验脚本对比老库与新库总量、金额、抽样明细三项全对
回滚可用关掉双写与增量任务,读切回老库业务功能正常,无脏读

数据分布验证不用写复杂程序,一段 SQL 加一个循环就够:

# 32 张表逐一计数,输出到文件后排序看倾斜 for db in 0 1 2 3; do for tb in 0 1 2 3 4 5 6 7; do echo -n "order_${db}.t_order_${tb}: " mysql -h db-a -N -e "select count(*) from order_${db}.t_order_${tb}" done done | sort -t: -k2 -n

这段脚本的输出直接对应验收表第一行。排序后如果最小值和最大值差出几倍,说明分片键取值本身有偏(比如某个渠道的用户 ID 集中在某个区间),要么换分片算法为一致性哈希,要么在业务上做二次打散。

答辩环节最容易被追问的三个问题,我建议提前准备好答案。第一个是「为什么不用自增主键」,答案落在分片后多库自增会撞号和水平扩容两点上,别只说「分布式场景不能用」。第二个是「分片键以后怎么改」,标准回答是基因位不可变、扩容走一致性哈希或双倍扩容加迁移,能说出迁移步骤比背概念有用。第三个是「跨库事务怎么办」,把本地消息表 + 幂等消费的流程画出来讲,比提任何框架名都扎实。

还有一个技巧:把压测数据留在工程里。单库与分片后各跑一轮同样压力的写入,把 QPS、P99 耗时、连接池占用三组数字做对比,哪怕绝对值不好看,趋势对了就能说明方案有效。这比任何文字描述都有说服力,评阅的人也更愿意相信一份有数字的方案。

从那以后我每次交付这类改造方案,都会强制先走一遍「关掉双写、切回老库」这条回滚路径,确认它真的能在十分钟内跑通,再去做别的优化。方案的完整性不在于功能有多少,而在于出问题时有没有退路。希望这套结构能帮到你,照着改成自己项目的版本,比从零想快得多。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 17:32:08

Claude Code 报错排查:ECONNRESET 与 API ERROR

某天下午&#xff0c;我在终端里敲下claude&#xff0c;回车&#xff0c;光标闪了两下&#xff0c;正准备把一段两百行的重构需求贴进去&#xff0c;屏幕上先甩出来一行红字&#xff1a;Unable to connect to API (ECONNRESET)。再试一次&#xff0c;变成API ERROR&#xff0c;…

作者头像 李华
网站建设 2026/11/4 17:32:05

中国模型反向收费美国巨头:技术授权与按量分成实战

1. 从“免费换量”到“按量收费”的转折点 “这一次&#xff0c;中国模型开始收美国巨头的钱”——这句话第一次看到的时候&#xff0c;我正蹲在工位上啃三明治&#xff0c;差点没噎着。做了七八年AI应用层开发&#xff0c;我太清楚过去几年国内大模型厂商在海外的处境了&#…

作者头像 李华
网站建设 2026/11/4 17:28:43

低代码开发平台全解析:架构引擎、应用场景与选型边界

简介&#xff1a;这是一份由头豹研究院出品的行业研究报告PDF&#xff0c;聚焦2022年中国低代码/无代码平台&#xff0c;面向企业数字化负责人、产品经理、低代码厂商从业者及关注该赛道的投资研究者&#xff0c;帮助他们在平台选型、部署决策与商业判断上建立系统认知。报告围…

作者头像 李华
网站建设 2026/11/4 17:26:32

高并发下数据库与缓存一致性解决方案与实践

1. 为什么我们需要关注数据库与缓存一致性问题第一次遇到缓存不一致的场景是在一个电商大促的深夜。当时商品详情页显示库存还剩15件&#xff0c;但用户下单时系统却提示"库存不足"。检查日志发现数据库实际库存早已归零&#xff0c;但Redis缓存中的值迟迟没有更新。…

作者头像 李华
网站建设 2026/11/4 17:25:12

企业专利数据分析:技术演进与合作网络解析

1. 项目背景与数据价值 企业合作专利数据是观察产业技术演进的重要窗口。1985-2024年这个时间跨度覆盖了全球技术发展的关键阶段——从个人计算机普及到移动互联网崛起&#xff0c;再到如今的人工智能时代。这类数据通常包含专利申请号、申请人、合作企业、技术分类、引用关系等…

作者头像 李华
网站建设 2026/11/4 17:24:47

从聊天机器人到Tool Server:团队效率工具的统一入口

1. 从"陪聊机器"到"团队工具箱"&#xff1a;这次转变是怎么发生的 1.1 一开始它只是个会"复读"的玩具 去年年初&#xff0c;我在 QQ 群里挂了一个聊天机器人&#xff0c;最初的定位非常朴素&#xff1a;群友说"在吗"&#xff0c;它能…

作者头像 李华