简介围绕多数据源与数据库分库分表展开的实战工程面向已掌握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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency !-- AOP动态数据源切换靠它别用拦截器替代 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency !-- 客户端分片组件坐标为单条 starter 依赖 -- dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-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?useSSLfalse 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×832 个物理节点写成表达式而不是 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) { MapObject, 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 ThreadLocalDataSourceKey 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 表 lcm8 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 全局唯一 ID63 位布局、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入参统一用LongController 层就做转换别等到 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 耗时、连接池占用三组数字做对比哪怕绝对值不好看趋势对了就能说明方案有效。这比任何文字描述都有说服力评阅的人也更愿意相信一份有数字的方案。从那以后我每次交付这类改造方案都会强制先走一遍「关掉双写、切回老库」这条回滚路径确认它真的能在十分钟内跑通再去做别的优化。方案的完整性不在于功能有多少而在于出问题时有没有退路。希望这套结构能帮到你照着改成自己项目的版本比从零想快得多。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
鸿蒙6 DevEco Studio安装配置与首个工程跑通指南 DevEco Studio 这个东西,第一次装的人十个里有八个会在同一个地方卡住——不是安装包下错,就是首次启动的配置向导里 SDK 下载到一半没了下文。我前后在不同机器上装过七八次,Windows 和 macOS 都走过一遍,也帮别人远程排查过不少… · 2026/11/4 17:35:36
动态状态与适配器加载:让大模型推理时临时“附魔”能力 1. 项目背景与核心问题:为什么推理时动态改 State 这么重要先聊一个老生常谈但又绕不开的问题:大模型预训练之后,能力基本就“焊死”在权重里了。想让它懂一个新领域、新任务,传统做法无非两条路——微调(把全部参数或… · 2026/11/4 17:35:36
区间合并算法在信息学竞赛中的应用与实现 1. 题目背景与问题描述 这道题目来自信息学奥赛经典教材《信息学奥赛一本通》的第1537页,属于基础算法练习题。题目描述的是在校门外有一条长度为L的马路上种了一排树,每两棵相邻的树之间间隔1米。现在需要在这些树上挂彩灯,但施工队会移走部… · 2026/11/4 17:35:36
8G显存本地AI数字人实战:从ASR到TTS的完整搭建与调优指南 8G显存在AI本地化圈子里处境挺尴尬的:你说它能跑模型吧,7B量化之后勉强能聊;你说它跑不好吧,又确实比集显强太多。大多数教程讲的都是怎么用Ollama跑一个聊天模型,但很少有人说可以更进一步——把它变成一只能听懂人话… · 2026/11/5 0:00:39
大模型系统性入门:从原理、部署到微调应用的完整路线 1. 为什么你收藏了300个教程,依然觉得大模型没入门我先讲一个前几天在交流群里看到的真实场景:有人问"大模型怎么入门",群里立马有人甩出几个网盘链接,里面躺着几百个PDF和视频,说是"全网最全的大模型学… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
Mac软件下载站分类指南:四类渠道与安全判断标准 Mac用户找软件这件事,说大不大,说小也真不小。我用了十来年Mac,从最早在论坛里翻帖子找注册码,到后来自己维护一份资源清单,踩过的坑能写满一页纸:下到过套壳安装包、遇到过捆绑浏览器主页的“绿色版”、也… · 2026/11/4 23:59:37
双极步进电机控制实战: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 步进电机驱动实战解析 /* 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工业级双极步进电机控制方案 /* 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
多智能体协作系统架构设计与实践优化 1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39
大模型系统性入门实战指南:从原理到部署微调的全链路路线 提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39
鸿蒙文件系统沙箱机制解析与开发实践 1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39
双极步进电机控制实战: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 步进电机驱动实战解析 /* 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工业级双极步进电机控制方案 /* 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