单库写入超时后:Spring Boot多数据源与分库分表设计
简介面向 Java 后端开发者的多数据源与分库分表实战工程聚焦在同一个数据库内做水平分表、以及跨库配置多数据源两类典型场景适合已掌握 Spring Boot 基础、希望补齐数据层架构能力的中级开发者参考。项目以 Spring Boot 搭建多数据源部分借助 mybatis-plus 的 dynamic-datasource 实现动态切换分库分表交由 sharding-jdbc 处理连接池采用 alibaba 的 druid-spring-boot-starter并继承 MyBatis-Plus 简化 DAO 层、引入 Lombok 精简实体代码配套 JUnit 测试用例便于验证分片路由与读写结果。压缩包共 162 个文件、约 164KB其中 115 个 xml 主要为 Maven 与 MyBatis 映射配置14 个 java 与 14 个 class 对应测试类、服务实现和启动类另有 6 个 yml、3 个 properties 承载多环境与数据源参数。已有 625 人学习可作为数据层拆分方案的落地参照。1. 单库写入 3 秒超时之后多数据源与分库分表到底该谁上某业务系统的订单表跑到 8000 万行写入 P99 从 40ms 涨到 3 秒加索引、加从库都压不住这是很多团队第一次真正被逼着面对分库分表。但真动手前常见的误判是把「多数据源」和「分库分表」当成同一件事前者解决的是业务上本来就该隔离的多份数据怎么在一个 Spring Boot 应用里共存后者解决的是同一份业务数据被水平拆开之后怎么正确路由。两者会叠加——分片后的物理库往往还要落在多数据源容器里管理。这套方案面向要交一套完整数据访问层设计的同学也面向要把线上单库拆开的从业者讲清楚路由层怎么搭、分片键怎么选、事务和迁移怎么兜底最后落到验收怎么拿证据。2. 总体架构路由层、分片层与业务层各自的职责边界2.1 多数据源与分库分表解决的是两类问题先把边界划清楚否则后面所有设计都会拧巴。多数据源的本质是「选哪个库」判断依据来自业务语义——这条 SQL 该走订单库还是报表库该走主库还是归档库通常在方法入口就能确定属于静态或半静态路由。分库分表的本质量是「这份数据落在哪个物理表」判断依据来自数据本身——必须拿到分片键的值算哈希或取模属于数据驱动的路由。两者的判断时机不同所以实现手段也不同。多数据源放在最外层用注解加 AOP 在方法调用前把数据源定死分库分表放在数据源内部由分片中间件在 SQL 解析阶段改写表名。如果把它们揉在一起做一个「万能路由器」就会出现「注解指定了库 A但分片算法又算出库 B」的冲突排查起来非常痛苦。一个典型的分层是层次职责判断依据典型实现业务层声明走哪个业务域方法语义DS(order)路由层绑定 ThreadLocal 上下文、选物理 DataSource注解 / 调用链AbstractRoutingDataSource分片层解析 SQL、计算目标表、改写、归并分片键值ShardingSphere-JDBC存储层真实物理库表无MySQL 实例这张表建议直接抄进设计文档的架构章节答辩时评委问「你这个多数据源和分库分表怎么配合」对着这张表讲三句话就能说清。2.2 一条 SQL 的完整链路从注解到结果归并把一次查询的链路摊开才知道每一层该写什么、出错该看哪。以「按用户查订单列表」为例Service 方法标注DS(order)AOP 切面在方法执行前把order写入ThreadLocalMyBatis 获取连接时DynamicDataSource.determineCurrentLookupKey()从ThreadLocal读出租户标识返回对应的DataSource这个DataSource实际是 ShardingSphere 包装过的它解析 SQL发现逻辑表t_order、分片键user_id 1024算出目标库ds_1、目标表t_order_0SQL 被改写成select ... from ds_1.t_order_0 where user_id 1024执行结果返回给 MyBatis如果是多分片查询ShardingSphere 还要做排序归并和分页归并。链路里有两个容易忽略的点。第一第 2 步的ThreadLocal必须在线程归还前清理否则 Tomcat 线程复用会把上一次请求的库名带给下一个请求这类串库问题就是典型的玄学 bug日志里看不出任何异常。第二第 5 步的归并是内存操作跨分片limit 100000, 20会把前 100020 条全部拉到内存里排序分页越深越慢。提示设计文档里一定要单独画一张「SQL 生命周期」图标出 AOP、路由、改写、归并四个切点这比上千字的文字描述有用得多。2.3 选型的三条路线客户端分片、代理分片、手写路由分片这件事有三条路选错了后面全是返工。客户端分片以 ShardingSphere-JDBC 为代表以 jar 包形式嵌在 Spring Boot 应用里不需要额外部署进程SQL 改写和归并在本地内存完成链路短、延迟低。代价是分片逻辑和业务代码绑在同一个 JVM 里版本升级要跟着应用一起发版多语言栈用不了。代理分片以 ShardingSphere-Proxy 为代表独立进程应用把它当成一个普通 MySQL 连语言无关、运维统一。代价是多一跳网络代理层本身要做高可用出问题时的排查链路也长了一截。手写路由就是 AOP 加动态数据源加表名拼接看起来最轻量实际最难维护SQL 改写要自己写解析器归并要自己排序全局主键要自己发号一条order by ... limit就能让人怀疑人生。维度客户端分片代理分片手写路由部署复杂度低中低应用侵入性中依赖 jar低高跨语言支持否是否SQL 兼容性较好较好自己扛适合场景单一技术栈、追求低延迟多语言、统一治理分片维度极简单课程设计和毕设场景下我一般会推荐客户端分片不引入额外进程本地能跑通答辩时能演示真实的 SQL 改写日志。手写路由只在分片规则极其简单比如按租户 ID 一库一表、不做跨分片查询时才值得考虑否则就是给自己制造工作量。3. 动态数据源落地AbstractRoutingDataSource 加注解切换的完整链路3.1 数据源注册与 ThreadLocal 上下文先解决多数据源的容器问题。AbstractRoutingDataSource是 Spring 自带的路由抽象核心是两个方法determineCurrentLookupKey()决定当前用哪个数据源afterPropertiesSet()把targetDataSources里的多个数据源注册成一个 Map。上下文持有类用ThreadLocal并且用Deque而不是单个String原因是嵌套调用时需要一个栈——A 方法走order库内部调 B 方法要临时切到report库B 执行完必须回到order用单值会把外层状态冲掉。public final class DataSourceContextHolder { // 用栈保存支持方法嵌套时的临时切换与回退 private static final ThreadLocalDequeString CONTEXT ThreadLocal.withInitial(ArrayDeque::new); public static void push(String ds) { CONTEXT.get().push(ds); } public static void poll() { DequeString deque CONTEXT.get(); deque.poll(); if (deque.isEmpty()) { CONTEXT.remove(); // 防止线程池复用导致的内存泄漏 } } public static String peek() { DequeString deque CONTEXT.get(); return deque.isEmpty() ? null : deque.peek(); } }push/poll成对出现是这套设计的基本纪律缺一个就会串库。remove()那一行看起来多余实际是防内存泄漏的关键Tomcat 的工作线程存活期和 JVM 一样长不清理的ThreadLocal会随着线程一直挂着压测跑久了堆里全是ArrayDeque。路由实现类只做一件事从上下文取值取不到就返回默认数据源public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { String key DataSourceContextHolder.peek(); // 未显式指定时回落到默认库避免空指针 return key null ? master : key; } }数据源本身在配置类里注册每个物理库对应一个DataSource参数最大连接数、超时按各自库的读写压力单独调app: datasource: master: jdbc-url: jdbc:mysql://db-master:3306/app?useUnicodetruecharacterEncodingutf8 maximum-pool-size: 30 connection-timeout: 3000 order: jdbc-url: jdbc:mysql://db-order:3306/order_db maximum-pool-size: 20 connection-timeout: 3000 report: jdbc-url: jdbc:mysql://db-report:3306/report_db maximum-pool-size: 10 connection-timeout: 5000maximum-pool-size是最容易被忽略的参数。总连接数等于所有库的连接池上限之和三个库各 30 就是 90 条连接一台 4C8G 的 MySQL 默认max_connections才 151多个应用实例一挂就爆。通常的做法是单库上限控制在 10 到 20再按「实例数 × 单实例连接数 数据库上限 × 0.8」倒推。3.2 DS 注解与 AOP 切面的优先级注解本身很简单两个属性值数据源名和是否继承标注在类上时是否对方法生效。Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }真正需要设计的是切面。切面要做三件事解析注解方法优先于类、push 上下文、finally里 poll。Aspect Component Order(Ordered.HIGHEST_PRECEDENCE) // 必须早于事务切面执行 public class DataSourceAspect { Around(annotation(com.example.ds.DS) || within(com.example.ds.DS)) public Object switchDs(ProceedingJoinPoint pjp) throws Throwable { MethodSignature sig (MethodSignature) pjp.getSignature(); DS ds sig.getMethod().getAnnotation(DS.class); if (ds null) { // 方法上没有则回退到类上的注解 ds pjp.getTarget().getClass().getAnnotation(DS.class); } String key ds null ? master : ds.value(); DataSourceContextHolder.push(key); try { return pjp.proceed(); } finally { DataSourceContextHolder.poll(); // 必须放在 finally异常也要回退 } } }Order(Ordered.HIGHEST_PRECEDENCE)这一行是全篇最值钱的配置之一。Spring 的事务切面默认顺序是LOWEST_PRECEDENCE如果数据源切面排在事务后面事务管理器会先向DynamicDataSource要连接此时ThreadLocal还是空的于是拿到默认库的连接后面注解写得再对也没用——事务里的所有 SQL 全跑到默认库去了。这个坑没有报错、没有异常日志只有数据莫名其妙写错地方是血泪经验级别的翻车点。3.3 事务边界与连接池参数数据源切换和事务的关系必须讲透。Spring 的DataSourceTransactionManager在事务开启时就把一个Connection绑定到当前线程这个连接来自当时选中的那个DataSource。事务中途用DS切到另一个库SQL 仍然会走已经绑定的旧连接切换静默失效。所以设计原则是DS标注在事务的最外层事务内部不允许跨数据源。确实需要跨库写只有三条路——拆成两个独立事务加补偿本地消息表 定时重试、上 Seata 这类分布式事务框架、或者从业务上改成最终一致先写主库异步同步到副库。前两种是工程的代价第三种是设计的代价毕设和课程设计场景通常选第三种更容易讲清楚。另一个细节是只读事务。报表库的查询加上Transactional(readOnly true)可以让连接池走只读连接、部分驱动会下推只读标记减少锁竞争。但readOnly必须和DS打在同一层否则事务管理器仍然按默认库拿连接。连接超时connection-timeout建议设成 3000ms 而不是默认的 30 秒。数据库抖动时30 秒的超时会让 Tomcat 线程全部堆在拿连接上一个库的问题瞬间放大成全站不可用。4. 分库分表设计分片键、路由算法与全局主键的三次取舍4.1 分片键的选择决定 80% 的后期成本分片键定下来之后再改等于把数据全量重洗一遍所以这是整个方案里最该反复推敲的一步。判断标准只有一条最高频的查询条件是什么分片键就选什么。订单表如果 90% 的请求是「查我的订单」分片键就该是user_id如果是商家后台查「我店铺的订单」那merchant_id更合适。两边都高频怎么办这是常态靠基因法解决。基因法的思路是以user_id做主分片键生成order_id时把user_id的低几位拼进去这样既可以用order_id精确定位订单号里自带分片信息又能用user_id查列表。代价是订单号不是纯自增长度变长且业务上不能假设订单号连续。// 订单号 时间戳位 分片基因位 序列位 public long genOrderId(long userId, long seq) { long ts System.currentTimeMillis() - EPOCH; // 41 bit 时间 long gene userId 0x3FF; // 10 bit 分片基因对应 1024 个分片 return (ts 23) | (gene 13) | (seq 0x1FFF); // 13 bit 序列 }三个参数段分别控制时间位决定可用年限41 bit 约 69 年基因位决定最大分片数10 bit 支持 1024 个分片序列位决定单毫秒并发上限13 bit 支持 8192。改分片数时基因位要跟着改改完存量订单号就废了所以分片数最好一次规划到三年后的量级。不推荐用create_time做分片键除非业务是纯时间维度的日志或流水。时间分片的热点问题很明显所有写入都打到最新的那个分片上历史分片只读资源利用率极不均衡。4.2 分片算法、绑定表与广播表算法层面有四种常见选择取舍点在于「扩容时要不要搬数据」算法均匀性扩容成本适用场景取模%好高几乎全量重分布分片数长期不变范围range差易热点低只加新分片时间序列、归档类一致性哈希较好中只影响相邻节点节点数会变动基因位取模好中扩分片需重洗订单、交易类主表课程设计场景推荐取模加基因位配置简单、结果可预测、答辩时能手算验证。ShardingSphere 的inline表达式就够用不用上自定义ShardingAlgorithmspring: shardingsphere: datasource: names: ds_0,ds_1 ds_0: { type: com.zaxxer.hikari.HikariDataSource, jdbc-url: jdbc:mysql://db0:3306/order_db } ds_1: { type: com.zaxxer.hikari.HikariDataSource, jdbc-url: jdbc:mysql://db1:3306/order_db } rules: sharding: tables: t_order: actual-data-nodes: ds_${0..1}.t_order_${0..1} # 2 库 × 2 表 4 个物理表 database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db-inline table-strategy: standard: sharding-column: user_id sharding-algorithm-name: tbl-inline sharding-algorithms: db-inline: { type: INLINE, props: { algorithm-expression: ds_${user_id % 2} } } tbl-inline: { type: INLINE, props: { algorithm-expression: t_order_${user_id % 2} } }actual-data-nodes里的ds_${0..1}.t_order_${0..1}是 Groovy 风格的枚举表达式2 库 2 表展开成 4 个物理表写成ds_$-{0..1}也等价。algorithm-expression里的取模值必须和分片数一致改分片数时这两处要同步改漏一处就会出现「算出 ds_0 但表名写成 t_order_1」的错位。绑定表的配置是为了避免跨库 JOIN。t_order和t_order_item如果都用user_id做分片键配置成绑定表之后两者的分片结果必然一致JOIN 可以在单库内完成binding-tables: - t_order,t_order_item广播表broadcast-tables用于字典类小表比如t_area、t_config每个库都存一份全量数据写操作由中间件同步到所有分片。代价是写放大只适合低频写、高频读的小表。4.3 全局主键雪花与号段的取舍分表之后自增主键就废了两个分片都会从 1 开始发号。三种方案各有短板UUID 最简单但它是无序的InnoDB 的聚簇索引按主键顺序组织随机主键会导致 B 树频繁页分裂写入性能掉得厉害而且 32 位字符串做主键占空间。雪花算法是主流选择41 位时间戳 10 位机器位 12 位序列本地生成、趋势递增、无网络依赖。它的软肋是时钟回拨机器 NTP 校时往后跳生成的 ID 会重复。处理办法是检测到回拨就短暂等待回拨小于 5ms或直接抛异常绝不能默默继续发号。private long nextId() { long now System.currentTimeMillis(); if (now lastTimestamp) { long offset lastTimestamp - now; if (offset 5) { // 小幅回拨等到时间追上再发号 sleep(offset 1); now System.currentTimeMillis(); } else { throw new IllegalStateException(时钟回拨超过阈值: offset ms); } } if (now lastTimestamp) { sequence (sequence 1) 0xFFF; if (sequence 0) { now waitNextMillis(lastTimestamp); // 同一毫秒序号用尽自旋等下一毫秒 } } else { sequence 0L; } lastTimestamp now; return ((now - EPOCH) 22) | (workerId 12) | sequence; }号段模式是把 ID 生成交给数据库每次从sequence表里取一段比如 1000 个缓存在内存里发号用完再取。它的优点是 ID 连续、可读性好缺点是依赖数据库服务重启会丢一段可以用max_id覆盖的方式修正。生产环境常见的做法是双 buffer当前号段用到 20% 就异步预取下一个号段避免取号时的尖刺延迟。4.4 跨库查询的三种收敛策略跨分片查询是分库分表最贵的地方一定要在业务设计阶段收敛掉而不是指望中间件扛。第一种是字段冗余。商品表分片后订单列表要展示商品名与其跨库 JOIN不如在下单时把商品名快照写入订单表。冗余字段带来的更新问题在交易场景里恰好不是问题——订单本来就该存快照。第二种是绑定表。前面提到的t_order与t_order_item同分片键JOIN 就被限制在单库内这是成本最低的方案前提是两张表能共用分片键。第三种是内存归并。实在要跨分片让中间件把各分片的结果拉回来做归并排序。这条路能走通但只适合结果集小的场景。深分页是它的死穴limit 100000, 20在 4 个分片上要各取 100020 条内存和网络都扛不住。优化办法是改成「二次查询」——先用where id lastMaxId order by id limit 20拿到各分片的游标再合并取前 20 个 ID最后按 ID 回查明细。这也是很多大列表接口从偏移分页改成游标分页的原因。5. 排坑清单上线前最容易翻车的五个点5.1 不带分片键的查询触发全路由现象某个按订单号查详情的接口压测时 QPS 一上去数据库连接数就爆慢查询日志里出现select * from t_order_0 where order_no ?、t_order_1、t_order_2、t_order_3四条一模一样的 SQL。原因order_no不是分片键中间件无法计算目标分片只能把 SQL 广播到所有物理表再归并。1 次查询放大成 4 次加上归并开销实际成本是原来的 5 倍以上。表越多越惨256 个分片就是 256 次查询。解决短期靠基因法让order_no里带上分片基因查询时先解出分片键再查长期靠 SQL 审核在 CI 里接入分片中间件的执行计划日志发现Actual SQL条数大于 1 就报警。我一般会在测试环境把sql-show打开把路由到多个分片的 SQL 全部打印出来过一遍这一步能揪出大半问题。5.2 DS 与 Transactional 顺序颠倒切换静默失效现象Service 方法上明明写了DS(report)日志里也看到切面执行了但 SQL 还是打在主库上主库的慢查询里能看到这条本应走报表库的统计 SQL。原因Spring 的事务切面在方法调用前就向DynamicDataSource申请连接此时ThreadLocal还是空的拿到的是默认库连接整个事务期间这条连接都被复用。数据源切面执行了但已经晚了一步。解决数据源切面的Order必须小于事务切面的Order用Ordered.HIGHEST_PRECEDENCE最稳妥。排查时可以在切面里打日志输出ThreadLocal的当前值再在事务管理器里打日志输出实际拿到的连接 URL两个日志一对就清楚了。这个问题没有异常提示只能靠日志比对属于典型的黑匣子。5.3 跨库写用本地事务兜底数据对不上现象下单流程同时写订单库和库存库本地事务只能覆盖订单库库存扣减失败时订单已经落库出现超卖或脏数据对账时每天都能查出几十条差异。原因DataSourceTransactionManager的事务绑定在单个DataSource上第二个库的写操作是独立提交的第一个库回滚不了它。解决按业务容忍度选方案。资金类场景上分布式事务框架Seata 的 AT 或 TCC 模式代价是性能和多一个中间件交易类场景用本地消息表——订单库和消息表在同一个本地事务里写入再由定时任务扫表投递库存扣减失败重试加幂等。后者实现简单、可用性高是大多数团队的实际选择。毕设里把消息表的结构和重试幂等的设计讲清楚比硬上分布式事务框架更能体现思考深度。5.4 迁移期双写不一致切流后才发现现象从单库迁到分库双写跑了两周切读流量后发现部分用户查不到历史订单。原因双写只写了新数据存量数据迁移脚本按id分页跑跑到一半业务还在写边界数据被漏掉或者双写的异常被catch掉打了条 warn 日志实际没写进去。解决迁移分四步走——先做存量全量迁移按主键区间分批每批 5000 条记录断点可续跑再开双写并做双向对账新库有旧库没有、旧库有新库没有两个方向都查然后按用户维度灰度切读1% → 10% → 50% → 100%最后观察一周再停双写。对账 SQL 建议固化成脚本每天跑一次差异条数作为切流的硬性门禁。双写的异常绝对不能吞必须抛出来触发重试或落补偿表。5.5 深分页与 count 放大现象列表页翻到第 100 页时接口超时count 查询比列表查询还慢三倍。原因count(*)在分片场景下会被改写成对每个分片的 count 求和分片越多越慢深分页要拉取offset limit条记录做归并内存和网络双重压力。解决count 做缓存或者干脆去掉——大多数列表页用户根本不看总条数改成「是否有下一页」的游标分页成本立刻降一个量级。翻页用where id lastId order by id limit 20让每个分片只返回 20 条归并后再取 20 条。这套改法对前端有侵入但收益远大于成本值得推。6. 验收与答辩把「跑通了」变成「拿得出证据」功能跑通只是及格线真正要交付的是「可验证的正确性和性能」。我一般会准备一张验收清单每一条都有对应的执行方式和量化标准验收项执行方式通过标准路由正确性对 200 条不同user_id的订单做写入核对落库位置100% 命中user_id % 2的目标库表分片键缺失告警故意用order_no查询打开sql-show日志能明确看到广播到全部分片的 SQL事务一致性构造订单写成功、明细写失败的事务两张表均回滚无半条数据全局主键唯一4 线程并发各生成 10 万个 ID无重复且整体趋势递增数据一致性迁移后跑双向对账 SQL差异条数为 0性能基线单分片 vs 4 分片各压 5 分钟分片后 P99 不低于单库的 70%压测这一步容易被做假只压单个分片当然快但那不叫分片方案的性能。正确的做法是按真实流量比例分配user_id的分布让流量均匀落到所有物理表上再观察每个库的 CPU 和连接数是否均衡。如果某个库的连接数明显更高多半是分片算法有倾斜回去检查取模的字段是不是被隐式转换了类型。一致性校验的 SQL 可以直接复用对账脚本-- 逐用户比对分片库与源库的订单条数count 不一致即视为异常 SELECT o.user_id, o.cnt AS new_cnt, s.cnt AS old_cnt FROM (SELECT user_id, COUNT(*) cnt FROM ds_0.t_order_0 GROUP BY user_id UNION ALL SELECT user_id, COUNT(*) cnt FROM ds_1.t_order_1 GROUP BY user_id) o LEFT JOIN order_source.t_order s ON o.user_id s.user_id WHERE o.cnt s.cnt;UNION ALL把两个分片的结果先合并再和源库左连接比对条数。这条 SQL 只适合存量校验跑的时候要限时间窗口加user_id范围否则会锁住整张表。答辩环节评委最爱追问的是「为什么这么分」和「出问题了怎么办」而不是「你用了什么框架」。所以讲的时候把顺序倒过来先说清业务查询的特征哪几个维度高频、数据量增长曲线再说分片键选择如何匹配这些特征最后说扩容和故障时的应对。有一个细节我每次都会主动讲分片数为什么是 2 库 2 表而不是 16 库 64 表——因为当前量级下过度分片会让跨分片查询的成本远超收益留出增长空间比一次分到位更务实。从那以后我每次做数据访问层的设计都会强制走一遍「单条 SQL 的完整链路」推演从注解到上下文、到选库、到改写、到归并每一跳都问一句「这里如果为空会怎样」。这套推演做扎实了验收时就不需要靠运气。希望帮到你。本文还有配套的精品资源点击获取