Spring Boot 多数据源与分库分表实战:可直接运行的代码包拆解

发布时间:2026/10/10 3:13:14
Spring Boot 多数据源与分库分表实战:可直接运行的代码包拆解 简介一份面向Spring Boot开发者的数据库架构实践资源聚焦多数据源接入与分库分表场景适用于需要处理高并发数据拆分、希望了解Sharding-JDBC和dynamic-datasource配合使用的初中级工程师。项目基于Spring Boot搭建整合MyBatis-Plus简化数据操作引入Druid连接池管理数据库连接并用Lombok减少样板代码通过JUnit测试演示水平分表与多数据源路由的实际用法。压缩包共162个文件包含Java源码、XML映射与配置、YAML/Properties配置文件以及少量Class与日志文件整体仅164KB结构紧凑便于快速阅读。已有624人学习下载适合作为本地实验或生产改造前的参考模板。读者可获得完整的工程目录、多数据源切换配置、分库分表规则定义、测试用例与常见配置说明能直接对照学习或移植到自身项目中。1. 多数据源与分库分表一套能直接跑的 Spring Boot 代码包拆解做后端开发的迟早会撞上这么一堵墙业务量上来单库扛不住了想拆库拆表又怕把线上搞炸。市面上讲分库分表的文章不少但大多是只讲概念的科普真正能把「多数据源切换 分库分表 读写分离」串成一套完整 Demo 的很少。这套基于 Spring Boot 的实战代码包解决的就是从零搭一套可运行的分库分表骨架这件事适合那些已经会用 MyBatis-Plus、但对 ShardingSphere-JDBC 只停留在听说阶段的开发。这篇笔记我会把配置逐行拆开讲把你可能踩的坑提前标出来保证你照着搭能跑通而不是看了一堆原理还在原地打转。2. 多数据源配置先从 DS 注解和动态路由说起2.1 为什么 Spring Boot 默认的数据源方案撑不住多库场景Spring Boot 默认的做法是单个DataSource 事务管理器。你在application.yml里写一组spring.datasource.url应用启动时框架就帮你注册好一个数据源。这套机制在单库时代完全够用但一旦涉及「订单库和用户库物理隔离」或者「主库和从库分离」你面对的就是多个DataSource实例而 Spring 的自动配置并不会为你创建第二组。常见的做法是引入第三方动态数据源组件核心思路是用AbstractRoutingDataSource做路由。它内部维护一个targetDataSources映射表每次获取连接时通过determineCurrentLookupKey()返回的 key 决定去哪台库取连接。而DS注解做的事情本质就是往ThreadLocal里塞一个路由 key再在切面里调用determineCurrentLookupKey()。理解了这个原理你就明白为什么DS只对方法调用生效——它靠的是 AOP 代理类内部this自调用会绕开代理注解直接失效。这是后文会讲到的第一个坑点先记住这个结论。2.2 代码包里的多数据源配置按这个结构搭就能跑打开代码包先看application.yml的数据源部分。它的命名规则直接对应了后续DS注解里的库名不要改动前缀关系。spring: datasource: dynamic: primary: ds-order # 默认数据源兜底用的 strict: false # 未匹配到数据源时是否抛异常 datasource: ds-order: url: jdbc:mysql://localhost:3306/ds_order username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver ds-user: url: jdbc:mysql://localhost:3306/ds_user username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver这里我一般会建议把strict设为true。原因很简单开发期把配置写错了越早暴露越好生产环境上宁可启动失败也不要运行到一半才炸。在 service 层用注解切换数据源的写法如下Service public class OrderServiceImpl implements OrderService { DS(ds-order) public void createOrder(OrderDTO dto) { // 强制走订单库 orderMapper.insert(dto); } DS(ds-user) public UserVO findUser(String userId) { // 强制走用户库 return userMapper.selectById(userId); } }逻辑说明DS(ds-order)会在方法进入前把路由 key 设为ds-order方法结束后自动清除保证线程池复用连接时不会串库。参数说明DS可以写在类级别类里所有方法默认走这个库写在方法级别会覆盖类级别配置。代码包里是方法级和类级混合使用的建议你统一采用方法级理由后面会在事务章节展开。3. 分库分表接入 ShardingSphere-JDBC从 YAML 到实战落库3.1 ShardingSphere-JDBC 的定位和选型理由分库分表框架里ShardingSphere-JDBC 属于「客户端分片」路线。它直接把数据源封装在应用内对业务代码无侵入不需要额外部署中间件节点这对中小规模和多数内部系统来说成本最低。相比 ShardingSphere-Proxy 这种独立服务方案JDBC 模式少一层网络转发性能损耗也更小缺点是你的应用要自己扛连接池和路由复杂度。在代码包中ShardingSphere-JDBC 被配置在最外层数据源实际结构是「应用 → ShardingSphere 逻辑数据源 → 真实多数据源」所以它和多数据源组件并不冲突。需要注意版本匹配代码包里用的是与 Spring Boot 主版本对应的 ShardingSphere 版本你换版本时要先查兼容矩阵否则启动时会遇到Table not found的诡异报错。3.2 核心分片配置绑定表、广播表、分片算法下面这段是代码包里的核心 YAML拆分的是订单表t_order按order_no的哈希值分到两个库、每库 4 张表。这个配置可以直接抄但要小心库名和表名的占位符大小写。spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource url: jdbc:mysql://localhost:3306/ds0 ds1: type: com.zaxxer.hikari.HikariDataSource url: jdbc:mysql://localhost:3306/ds1 sharding: binding-tables: t_order broadcast-tables: t_config default-data-source-name: ds0 tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..3} database-strategy: inline: sharding-column: user_id algorithm-expression: ds$-{user_id % 2} table-strategy: inline: sharding-column: order_no algorithm-expression: t_order_$-{order_no.hashCode() % 4}逻辑说明binding-tables声明了t_order在关联查询时的绑定关系路由一张表时另一张表会按相同分片键路由避免出现跨库 JOINbroadcast-tables声明的是所有库都要冗余一份的配置表每次增删改会同步到全部数据源。数据库分片键选user_id表分片键选order_no这是常见的「按用户隔离 按订单分散」的组合。参数说明ds$-{0..1}是 ShardingSphere 的内联表达式表示ds0和ds1order_no.hashCode() % 4用的是 Java 字符串哈希再取模注意负数取模结果可能是负数ShardingSphere 内部会处理但你自己的代码里如果也做同样的取模判断要记得取绝对值。3.3 插入一条数据到分片表演示分片键如何参与路由代码包里写了一个标准的插入方法关键点在于order_no的生成策略。千万不能用数据库自增 ID 直接当分片键下面代码展示正确做法Service public class OrderShardingService { Resource private OrderMapper orderMapper; public void addOrder(Order order) { // 使用雪花算法生成订单号保证全局唯一 order.setOrderNo(String.valueOf(SnowflakeIdWorker.nextId())); orderMapper.insert(order); } }逻辑说明分片键order_no必须在insert前赋好值否则 ShardingSphere 找不到分片键会抛出Sharding value must be provided异常。雪花算法在这里的价值不仅是唯一性还保证了订单号大致有序这能让索引性能比随机 UUID 好很多。参数说明SnowflakeIdWorker.nextId()需要有 workerId 来源代码包里是从配置中心读取的自己单机跑可以直接写死成一个常量但多实例部署时务必保证 workerId 不重复否则会生成重复订单号这是分布式 ID 最常见的坑。4. 读写分离与强制走主库主从延迟场景下的正确姿势4.1 主从配置和读写分离的边界条件分库分表之后往往还要叠加读写分离代码包里把ds0配置成主库ds0_replica配置成从库YAML 结构如下spring: shardingsphere: sharding: master-slave-rules: ds0: master-data-source-name: ds0 slave-data-source-names: ds0_replica这套配置生效后所有写操作自动落到主库查询默认会负载均衡到从库。但这里有个天然的边界条件主从复制有延迟刚插入的数据立即去查从库很可能查不到。代码包里提供了一个「强制走主库」的工具类解决的就是这个场景。4.2 使用 HintManager 强制路由主库public Order getOrderFromMaster(String orderNo) { try (HintManager hintManager HintManager.getInstance()) { // 强制走主库规避主从延迟 hintManager.setMasterRouteOnly(); return orderMapper.selectByOrderNo(orderNo); } }逻辑说明HintManager.getInstance()创建一次强制路由上下文setMasterRouteOnly()会让后续同一线程内的查询全部走主库。try-with-resources确保了HintManager的生命周期不会跨方法残留这是这段代码里最容易忽略的地方。我见过有人把HintManager随手 new 出来不关结果下一个请求也一直被强制路由到主库主库压力直接翻倍。参数说明强制走主库只对当前线程生效不要在循环里反复创建HintManager单次方法调用内创建一次即可。4.3 批量插入的坑跨分片事务的隐式约束代码包在压测批量插入时特意留了一个边界说明一次insertBatch如果涉及多个分片库ShardingSphere 会拆成多条 SQL 分别执行但这不是一个分布式事务。默认情况下一个批次的写入分到多个库任何一个库失败另一个库已经提交的数据不会回滚。public void batchInsertOrders(ListOrder orders) { // 按 user_id 分组保证每组内部落到同一个库 MapInteger, ListOrder groupByUser orders.stream() .collect(Collectors.groupingBy(Order::getUserId)); groupByUser.forEach((userId, list) - { orderMapper.batchInsert(list); }); }逻辑说明这段代码在应用层先把订单按user_id拆组每组内部的所有订单都落到同一个物理库规避了跨库写入的原子性问题。这是分库分表场景下的常用妥协方案要么引入 Seata 这类分布式事务组件要么通过业务设计让「同一批数据天然落在同一分片」。代码包选用的是后者因为对多数场景来说分布式事务的成本远远高于重新设计数据分组的成本。5. 避坑 / 常见问题与排查手册五个值得写进周报的案例5.1 自增主键冲突跨库后 ID 重复导致线上数据错乱现象订单表在拆分为 4 张表之后每张表的自增主键都从 1 开始不同分片下会出现相同主键的数据指向不同订单。原因MySQL 的自增 ID 是单表维度的分片后每张表独立计数全局唯一性完全丧失。用主键直接关联业务的系统在这个阶段会出现各种张冠李戴的脏数据。解决代码包全面切到雪花 ID主键不再依赖数据库自增。如果你的业务有存量数据需要做一次 ID 映射迁移这是整个改造里最耗时但躲不掉的一步。5.2 分片键缺失导致的「全库路由」性能雪崩现象某个查询接口刚上线时响应只要 5 毫秒某个版本加了按用户昵称搜索的功能后接口直接变成 2 秒超时。原因查询条件里没有user_id或order_noShardingSphere 无法定位分片退化为对所有分片表全表扫描。这就是俗称的全库路由数据量一大必然拖垮接口。解决先看 SQL 执行日志确认是否出现Actual SQL: ds0 ::: select ...且datasource数量等于分片总数。业务上无法避免不带分片键的查询时建议引入 ES 或单独汇总库而不是在分片表上硬查。5.3 强制走主库的 HintManager 没有被释放现象压测期间主库的 CPU 突然打满但从库几乎无流量。原因代码里手动new HintManager()后没有调用close()路由上下文残留在线程池的线程上后续所有查询都被强制指向主库。解决我用try-with-resources替换掉所有手动创建方式然后在线程池场景里增加一个测试用例专门验证一个请求结束后下一个请求能否正常跑到从库。这个坑在本地单线程环境几乎测不出来一定要上压测才能暴露。5.4 事务方法内切换数据源不生效现象一个方法用DS切换了数据源但事务内的多条 SQL 仍然全部落在默认库。原因事务开关打开时连接在事务开始时就被绑定到线程上数据源路由已经失效。先开启事务再切库路由根本来不及生效。解决调整写法把需要切换数据源的子操作拆到不同 service 类里让每个方法各自管理数据源和事务。跨库事务一定要改造成「本地事务 最终一致性」试图用单库事务约束多库数据本身就是伪命题。5.5 ShardingSphere 版本升级后绑定表突然失效现象升级依赖后关联查询变慢日志里出现了跨库 JOIN 而不是原本的绑定表优化。原因新版本里binding-tables的配置项从sharding挪到了rules层级旧配置直接被忽略且没有报错。解决升级后第一时间检查配置项是否被废弃运行一个已知会触发绑定表优化的 SQL对比执行计划里的路由表数量。分库分表框架的版本升级不能只看兼容性清单必须做路由结果的回归验证。6. 验证手段与发布前的检查清单让每一轮改动都可回退、可观察这最后一段我想分享的不是什么高级技巧而是一套压过多次线上故障之后固化的验证习惯。代码包里附带了一个RouteAssertUtil核心逻辑是把每一条 SQL 实际命中的库和表打印出来配合测试用例断言路由结果符合预期。我每次改分片表达式或者新增分表都会先跑一遍全量测试确认所有 SQL 的路由结果和设计文档一致再考虑合并分支。# 观察分片命中的实际数据源和表 tail -f logs/sharding.log | grep Actual SQL// 路由断言示例确认 order_no 路由到 t_order_2 String actualTable RouteAssertUtil.getActualTable(sql, orderNo); Assert.assertEquals(t_order_2, actualTable);逻辑说明RouteAssertUtil本质是拦截器内部抓取 ShardingSphere 的SQLStatement路由结果再把实际表名透出给测试层断言。参数说明断言时注意分片键的取值边界比如order_no.hashCode()为负数时的取模结果建议设计一条负数边界用例。发布前的检查清单我已经固化成了三条第一所有DS注解是否有跨库事务的隐性耦合第二所有查询是否都带上了分片键不带分片键的接口是否有降级方案第三是否对主从延迟敏感的接口都加了强制主库路由。从那以后我每次上分库分表的变更都强制走一遍这套流程流程虽短但至少让「改坏了能第一时间知道坏在哪」这件事变得不那么玄学。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询