简介:这份资源面向需要掌握多数据源与分库分表技术的 Spring Boot 开发者,聚焦同一数据库内水平分表与多库路由的落地实现。项目基于 Spring Boot 搭建,多数据源部分采用 mybatis-plus 的 dynamic-datasource,分库分表由 sharding-jdbc 完成,连接池管理使用 alibaba 的 druid-spring-boot-starter,并继承 MyBatis-Plus 简化数据库操作、引入 Lombok 减少样板代码,配合 JUnit 编写测试用例验证分片与路由效果。压缩包共 162 个文件,以 115 个 xml 映射与配置、14 个 java 源码、14 个 class 编译文件为主,另含 6 个 yml、3 个 properties 等配置及日志、构建脚本,整体约 164KB,结构紧凑便于按模块查阅。已有 624 人学习下载,适合想理解 dynamic-datasource 与 sharding-jdbc 协作方式、参考分表规则配置与测试写法的读者对照实践。
1. 多数据源加分库分表:一套 Spring Boot 工程里同时跑通两条链路
去年接手一个模拟项目X,需求很朴素:订单库要按用户 ID 拆成 8 个库、每个库再分 16 张表,同时还要读一个完全独立的老库存库做对账。团队第一反应是“上中间件”,结果运维不批新组件,最后只能回到 Spring Boot 原生方案里硬啃。多数据源和分库分表这两件事,单拎出来都不难,难的是它们在同一套工程里共存:数据源路由、事务边界、分片键解析、跨库查询,任何一环没对齐,跑起来就是玄学报错。这篇笔记拆的就是这套组合拳——Spring Boot 下用动态数据源做多库切换,用分片规则做单库内分表,两条链路各管各的,边界划清楚。适合正在做订单、账单、日志这类水平拆分场景的后端同学,也适合被“一个项目里既要连 A 库又要连 B 库”折磨过的从业者。下面从选型理由讲到可抄的配置,再到我踩过的几个血泪坑。
2. 多数据源与分片规则:先定边界再写代码
2.1 为什么不用现成中间件,而选应用层路由
多数据源的本质是“一个 Spring 容器里注册多个 DataSource,按业务标识切换”。分库分表的本质是“SQL 进数据库前,把逻辑表名和库名改写成物理表名和库名”。这两件事在应用层做,最大的好处是可控:路由逻辑写在代码里,出问题能打断点;坏处是侵入性强,每个需要切换的地方都得显式声明。
常见做法有三条路:一是引 ShardingSphere-JDBC 这类客户端分片组件,它同时支持多数据源和分片,但配置重、版本耦合紧;二是纯手写 AOP 切面 + ThreadLocal 上下文,轻但容易漏;三是 Spring 自带的AbstractRoutingDataSource做多数据源,分片部分自己写拦截器。模拟项目X最后选的是第三条,原因是老库存库那边有历史遗留的存储过程,中间件解析不了,必须走原生连接;而订单库的分片规则又相对固定,按user_id % 8定库、order_id % 16定表,手写拦截器足够。
选型时我一般看三个指标:分片键是否稳定、是否有跨库 JOIN、事务是否要求强一致。分片键稳定(比如用户 ID 不变)才能保证同一用户的数据落在同一库;有跨库 JOIN 就得在应用层做内存拼装;强一致事务跨库基本无解,只能靠最终一致或本地消息表。这三点想清楚,方案就定了大半。
2.2 动态数据源的注册与路由键设计
多数据源的落地核心是AbstractRoutingDataSource。它内部维护一个targetDataSources映射,determineCurrentLookupKey()返回哪个 key,就用哪个数据源。路由键放 ThreadLocal,请求进来时由拦截器或注解写入,请求结束清理。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { // 从 ThreadLocal 取当前线程绑定的数据源标识 return DataSourceContextHolder.get(); } } public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void set(String key) { CONTEXT.set(key); } public static String get() { return CONTEXT.get(); } // 必须清理,否则线程池复用会串库 public static void clear() { CONTEXT.remove(); } }determineCurrentLookupKey返回 null 时会走defaultTargetDataSource,所以主库要设成默认,避免漏设路由键时直接 NPE。DataSourceContextHolder的clear()是重点:Tomcat 线程池复用线程,上一个请求设了从库标识没清,下一个请求就莫名其妙读到从库,这种问题排查起来极其费劲。
注册阶段用@Configuration把主库、订单分片库、老库存库都建成DataSourceBean,再塞进DynamicDataSource的targetDataSources。注意每个物理库的DataSource要独立配连接池参数,订单分片库连接数给足,老库存库只读、连接数压小,别一套参数套所有库。
2.3 分片拦截器:SQL 改写的最小实现
分表不改库的场景,拦截器只需要改表名。用 MyBatis 的Interceptor拦StatementHandler的prepare方法,拿到原始 SQL 后按分片键替换逻辑表名。
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})) public class ShardTableInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler = (StatementHandler) invocation.getTarget(); MetaObject meta = SystemMetaObject.forObject(handler); String sql = (String) meta.getValue("delegate.boundSql.sql"); // 从参数里取分片键,实际项目按参数映射表解析 Long userId = ShardContext.getUserId(); if (userId != null && sql.contains("t_order")) { String suffix = String.valueOf(userId % 16); sql = sql.replace("t_order", "t_order_" + suffix); meta.setValue("delegate.boundSql.sql", sql); } return invocation.proceed(); } }这里只做了表名替换,库切换交给DynamicDataSource,两者解耦。ShardContext.getUserId()从请求上下文或方法参数里取,取不到就抛异常而不是静默走默认表——静默失败是分片系统里最危险的行为,数据写错表比报错难查十倍。SQL 改写要小心replace误伤,比如字段名里含t_order字样,生产环境建议用正则加词边界匹配。
3. 事务与跨库查询:边界划在哪,坑就在哪
3.1 本地事务只对单库有效,跨库要认命
Spring 的@Transactional绑定的是单个DataSource的连接。用了动态数据源后,事务开启时拿的是当时路由键对应的连接,事务中途切库,新库的操作不在原事务里,回滚也回滚不到。这是多数据源方案最硬的边界,没有银弹。
模拟项目X的做法是:订单写入只走订单分片库,事务范围严格限制在单库内;对账读老库存库走只读、不开事务;跨库的一致性靠“订单落库后发本地消息,异步补偿对账”来兜。代码上给事务方法加显式数据源绑定,避免事务中途被切走:
@Transactional(rollbackFor = Exception.class) public void createOrder(Order order) { // 事务开始前锁定数据源,整个方法内不切换 DataSourceContextHolder.set("orderShard"); try { orderMapper.insert(order); orderItemMapper.insertBatch(order.getItems()); } finally { DataSourceContextHolder.clear(); } }set放在方法体第一行而不是靠注解切面,是因为切面执行顺序不可控,事务拦截器可能先于数据源切面执行,导致连接拿错。这种顺序问题在日志里表现为“明明配了从库却读到主库”,翻车过两次才记住。
3.2 跨库查询用内存拼装,别硬写 JOIN
分库之后JOIN直接失效,跨库的关联查询只能在应用层做。常见做法是分批查主表、收集关联 ID、再批量查从表、内存里Map拼装。数据量小时无所谓,数据量大时要控制IN的批次大小,一般 500 到 1000 一批,避免 SQL 过长被数据库拒绝。
public List<OrderVO> queryWithStock(List<Long> orderIds) { List<Order> orders = orderMapper.selectByIds(orderIds); // 订单分片库 Set<Long> skuIds = orders.stream().map(Order::getSkuId).collect(Collectors.toSet()); // 老库存库批量查,分批避免 IN 过长 Map<Long, Stock> stockMap = new HashMap<>(); Lists.partition(new ArrayList<>(skuIds), 500).forEach(batch -> { DataSourceContextHolder.set("legacyStock"); try { stockMapper.selectBySkuIds(batch).forEach(s -> stockMap.put(s.getSkuId(), s)); } finally { DataSourceContextHolder.clear(); } }); return orders.stream().map(o -> assemble(o, stockMap.get(o.getSkuId()))) .collect(Collectors.toList()); }Lists.partition来自 Guava,没有依赖就手写循环。关键是每次切库后finally里清理上下文,否则后续查询全跑到老库存库上。内存拼装要注意空值处理,关联不到的数据返回占位对象而不是 null,前端少一堆判空逻辑。
3.3 分页与排序:分片后的 COUNT 和 ORDER BY 陷阱
单表分页简单,分片后LIMIT offset, size的语义变了:每个分片各自LIMIT再合并,结果可能少数据。正确做法是查全量再内存分页,或者用“二次查询法”:先各分片取offset + size条,归并排序后再截取。模拟项目X订单量不大,直接全量查加内存分页,超过阈值再上二次查询。
排序同理,ORDER BY create_time在各分片内有序,合并后要重新排。别指望数据库帮你做全局排序,分片系统里没有全局索引这回事。分页参数建议加最大页限制,防止有人翻到第一万页把内存打爆。
4. 避坑排查:五个让我加班到凌晨的现场
4.1 现象:定时任务批量写,数据全进了 0 号表
原因:定时任务线程没有经过 Web 拦截器,ShardContext里分片键为空,拦截器走了默认分支。解决:定时任务入口显式设置分片键,或者对批量任务按分片键分组后逐组处理,每组内设置上下文。批量场景下“按分片键分组”比“逐条设置”效率高得多。
4.2 现象:事务回滚了,但从库的日志还在写
原因:事务方法内切了数据源,从库操作不在原事务连接上,主库回滚时从库已提交。解决:跨库写操作移出事务方法,改成事务提交后的事件监听(@TransactionalEventListener)里执行,失败靠补偿重试。这个坑的本质是把“跨库”当成了“同库”,事务边界必须按物理库划。
4.3 现象:连接池耗尽,报Connection is not available
原因:动态数据源切换后,旧连接没归还,或者每个物理库共用了一个连接池配置,分片库连接数被老库存库的慢查询占满。解决:每个物理DataSource独立配 HikariCP,分片库maximumPoolSize按 QPS 估算,老库存库设只读并限制最大连接;同时检查切库代码是否在finally里清理上下文,上下文不清理会导致连接被错误复用。
4.4 现象:分片键取错,同一用户订单散落在多个表
原因:分片键从请求参数取,但部分接口用orderId反查userId时取了错字段,导致% 16结果不一致。解决:分片键解析统一收口到一个工具类,所有入口必须走同一套解析逻辑;上线前用脚本抽样校验“同一 userId 的订单是否落在同一表”,不一致直接拦截发布。
4.5 现象:SQL 改写后语法错误,t_order_3变成t_order_33
原因:replace对已带后缀的表名二次替换,或者正则没加词边界。解决:改写前先判断表名是否已含分片后缀,用正则\bt_order\b精确匹配;更稳妥的做法是维护逻辑表到物理表的映射,改写时整词替换而不是子串替换。
5. 上线前的验证清单与一个压测技巧
分片系统最怕“看起来跑通了”。我的习惯是上线前跑三组验证:第一组是数据一致性抽样,随机取 100 个用户,核对每个用户的订单是否全部落在预期库表,用脚本比对逻辑表与物理表的记录数总和;第二组是路由覆盖测试,把所有分片键取值(0 到 15)各跑一遍写入和查询,确认没有哪个分片被漏掉;第三组是故障注入,手动停掉一个分片库,观察请求是否快速失败而不是线程堆积。
压测时有个技巧:不要只压单用户,要按分片键均匀打散。单用户压测所有请求打到同一张表,测不出分片后的真实瓶颈;均匀打散才能暴露连接池分配不均、热点分片的问题。模拟项目X第一次压测就是单用户,结果上线后热点用户把单表打满,血泪经验。
| 验证项 | 方法 | 通过标准 |
|---|---|---|
| 数据一致性 | 抽样比对逻辑表与物理表记录数 | 总和相等,无孤儿记录 |
| 路由覆盖 | 分片键 0~15 全量写入查询 | 每个分片均有数据且可查回 |
| 故障注入 | 停单个分片库 | 快速失败,无线程堆积 |
| 压测分布 | 多用户均匀打散 | 各分片 QPS 偏差小于 20% |
从那以后我每次改分片规则,都强制先跑一遍路由覆盖测试再提交,宁可多花半小时,也不想再半夜被“数据写错表”叫醒。希望帮到你。
本文还有配套的精品资源,点击获取