简介:针对大数据量场景下的数据库扩展需求,该示例项目聚焦分库分表与多数据源切换,面向有一定Java Web基础的开发者和架构师。资源以SSM框架为基础,结合Sharding-JDBC演示数据分片、路由及跨库操作,可帮助读者理解水平拆分原理,并快速迁移到业务系统中。压缩包共73个文件,以Java源码、class字节码、XML配置为主,另含properties环境配置、JSP页面及SQL初始化脚本,整体约66KB,结构紧凑、便于直接导入工程阅读。目前已有2068人学习下载,是入门分库分表实践的轻量参考。通过该Demo,可掌握Sharding-JDBC的配置方式、分片规则定义、多数据源切换流程,以及分布式事务等关键要点;配套的mybatistest.sql和配置文件,还能为动手改造现有项目提供可复用的模板与排错思路。
1. 分库分表之后,多数据源切换为什么成了第一个拦路虎
分库分表做到一半,很多人会被一个看似基础的问题卡住:应用连的数据库从一套变成了八套,代码里的DataSource从单例变成了一个集合,每次请求到底该连哪个库、哪张物理表?分库分表的难点从来不只是建表和迁移数据,真正让团队熬夜的是分库分表之后的多数据源切换——路由规则怎么定、事务怎么跨库、连接怎么管,稍不注意就在线上出现“查到了错的库”“写进了错的表”这类事故。这篇文章面向正在做分库分表改造、或者已经在用多数据源但心里没底的开发者和运维,把数据源切换从原理到落地一步步拆开,讲清楚怎么做、参数怎么调、坑在哪里,让你看完能直接在自己的项目里复现一套可靠的路由方案。
2. 多数据源切换的技术选型:从动态数据源到读写分离代理
2.1 动态数据源路由的核心思路与 Spring 的抽象
日常开发里,一个应用对应一个数据库,DataSource是唯一的。分库分表之后,应用面对的是多个库里多个表,最直接的做法不是把每个数据源都当成独立连接池到处注入,而是做一个“路由器”:对外仍然暴露一个统一的DataSource,内部根据请求的特征把它转发到具体的物理数据源。
Spring 的AbstractRoutingDataSource就是干这个的。它有一个determineCurrentLookupKey()方法,返回一个 key,框架用这个 key 从targetDataSources里取真正要用的数据源。这套抽象的好处是,业务代码拿到的还是DataSource,事务管理器、ORM 框架都不用改,只要在进入 DAO 之前把当前线程要用的数据源 key 设置好,路由就生效了。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSourceKey(); } }这段代码的关键在于DynamicDataSourceContextHolder,它一般是一个ThreadLocal,用来保存当前线程选择的数据源 key。determineCurrentLookupKey()每次获取连接时都会调用,所以只要ThreadLocal里的值正确,路由就是正确的。参数上要特别注意,ThreadLocal用完之后一定要清理,否则线程池复用会串库,这一条后面在避坑章节详细说。
2.2 分库分表中间件与数据源切换的边界
很多团队在选型时会纠结:到底是用中间件(比如某分库分表中间件)还是自己在代码里做多数据源切换?这里有一个很容易混淆的点:分库分表中间件处理的是“SQL 如何路由到分片”的问题,而多数据源切换处理的是“连接如何路由到数据库实例”的问题。两者有重叠,但边界不同。
如果项目里已经引入了成熟的分库分表中间件,数据源切换这件事很多可以被中间件接管。中间件自己管理多套真实数据源,应用只连虚拟数据源,SQL 里的逻辑表会被改写为物理表,请求也会被分发到正确的分片。这时候,应用层的多数据源切换就退化成“只在中间件配置里维护分片规则”。
但现实是,很多项目只是“半分库分表”:订单表分了 16 张,用户表还留在单库,某些报表查询要走独立的统计库,某些历史数据要查归档库。这种混合场景下,引入一套完整中间件可能过重,团队更愿意用轻量的动态数据源方案,自己控制路由。两种做法没有绝对的好坏,主要看分片规则的复杂度以及团队对中间件源码的掌控能力。
2.3 选型对比:自己写路由 vs 中间件方案
下面这张表是我在技术方案评审时经常用的对比维度,适合拿来判断自己该走哪条路:
| 维度 | 自己写动态数据源路由 | 使用分库分表中间件 |
|---|---|---|
| 分片规则复杂度 | 规则简单,条数少于 20 条时可控 | 复杂分片、多字段路由、全局表、广播表更适合 |
| 对业务代码侵入性 | 侵入小,路由时机靠 AOP 或手动埋点 | 侵入更小,SQL 写在业务代码里即可 |
| 事务支持 | 本地事务好做,跨库事务需要额外方案 | 中间件自带分布式事务方案,但需理解其代价 |
| SQL 改写能力 | 没有,物理表名和分片条件需要业务保证 | 自动改写逻辑表为物理表,还能做结果归并 |
| 学习成本 | 低,半天能跑通 | 高,需要理解分片键、分片算法、归并策略 |
| 排错难度 | 容易,路由逻辑都在自己代码里 | 难,很多黑匣子行为需要看日志 |
从我带过的几个项目来看,数据源在 3 到 5 套以内、分片规则简单的,自己写动态数据源非常舒服;一旦超过 10 套真实数据源并且有多个维度分片,我会倾向中间件方案,因为后面要解决的问题会从“路由”变成“SQL 改写和结果归并”,这个复杂度靠手写很难覆盖。中间件方案要付出的代价是接受它的默认行为,比如排序、聚合、分页这些操作在跨分片时的语义,需要反复验证。
3. 落地一套可用的多数据源切换方案:从配置到路由
3.1 基础配置:多套数据源的注册与管理
无论是用 Spring Boot 还是裸 Spring,第一步都是把多套数据源定义出来并交给容器管理。常见做法是自定义一个配置类,把不同库的连接参数写到配置文件里,然后在代码里初始化各数据源并注册到RoutingDataSource中。
spring: datasource: ds0: jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0?useSSL=false username: order_rw password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver ds1: jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1?useSSL=false username: order_rw password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver ds_report: jdbc-url: jdbc:mysql://192.168.1.12:3306/report_db?useSSL=false username: report_ro password: xxxx driver-class-name: com.mysql.cj.jdbc.Driver配置里每一项都对应一套物理库。注意jdbc-url要写全参数,useSSL=false和serverTimezone这类配置最好在连接串里显式声明,避免因为数据库时区问题导致时间字段错乱。username和password建议按读写权限分开,只读库用只读账号,从账号层面就先拦住误写入。
@Configuration public class DataSourceConfig { @Bean @Primary public DataSource routingDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("ds0", buildDataSource("spring.datasource.ds0")); targetDataSources.put("ds1", buildDataSource("spring.datasource.ds1")); targetDataSources.put("ds_report", buildDataSource("spring.datasource.ds_report")); DynamicDataSource routing = new DynamicDataSource(); routing.setTargetDataSources(targetDataSources); routing.setDefaultTargetDataSource(targetDataSources.get("ds0")); return routing; } }这段代码把三个数据源注册进了路由表,并设置了默认数据源。@Primary很重要,因为项目里可能还有别的DataSource实例,Spring 注入时会迷茫。默认数据源建议设置成最常被访问的那个库,这样可以避免路由 key 缺失时直接报错。targetDataSources的 key 要和后面路由规则里返回的 key 完全一致,字符串拼写错误是最低级的踩坑点。
3.2 路由规则落实:分片键与线程上下文
有了路由数据源,接下来要解决“每个请求该用哪个 key”。我常用的方式是 AOP 切 service 方法,根据方法参数里的分片键计算数据源 key,然后写入上下文集合并清除。这样业务代码不感知数据源,只需要保证方法签名里有分片键。
@Aspect @Component public class DataSourceRouterAspect { @Around("@annotation(dataSourceRouter)") public Object around(ProceedingJoinPoint pjp, DataSourceRouter dataSourceRouter) throws Throwable { String dsKey = dataSourceRouter.value(); try { DynamicDataSourceContextHolder.setDataSourceKey(dsKey); return pjp.proceed(); } finally { DynamicDataSourceContextHolder.clearDataSourceKey(); } } }这个切面的核心是@annotation(dataSourceRouter)绑定了自定义注解,注解里的value()可以直接指定数据源 key。更灵活的做法是根据方法第一个参数的分片键去计算:比如订单号orderId取模得到库编号,再拼成ds0或ds1。我一般会把“分片键 → 数据源 key”的计算逻辑单独抽一个接口,方便做单元测试。
参数上有几个经验值可以分享:切面清理必须放在finally里,否则抛异常时ThreadLocal不会被清掉;@Aspect的优先级如果和事务切面冲突,要确保路由切面的@Order值比事务切面小,也就是路由先执行,这样才能在事务管理器获取连接之前把 key 设置好。
3.3 事务与切换的配合:避免切到一半
多数据源切换最怕和事务纠缠。Spring 事务管理器默认在事务开始时从DataSource获取连接,之后整个事务内都复用这一条连接。也就是说,如果事务内第一个操作路由到了 ds0,那么这个事务后面所有操作即使设置了 ds1 的 key,用的仍然是 ds0 的连接。
@Service public class OrderService { @Transactional public void createOrder(Order order) { orderDao.insert(order); // 这里即使切到 ds_report 也无效,事务连接已经绑定在 ds0 reportDao.append(order); } }这种现象不是 bug,而是本地事务的天然语义。跨数据源的事务本来就不该用本地事务硬扛。常见做法是:把需要跨库写多个表的操作拆成多个独立事务,用最终一致性去兜底;或者引入分布式事务方案,但代价是性能和复杂度都上升。我一般会先问业务能不能接受“写订单库成功但写报表库失败后靠定时任务补偿”,能接受就千万别上分布式事务。
如果你确实需要在一个方法里顺序操作多个数据源,而且每一步事务都要独立提交,可以把小方法拆开,用REQUIRES_NEW传播级别让它各自开事务。但要注意,REQUIRES_NEW会挂起外层事务,连接会被反复获取释放,连接池的存活连接数会涨得很快,务必把maxActive和maxWait调好。
4. 分库分表场景下的切换细节:表名改写与结果归并
4.1 分表后 SQL 怎么定位到物理表
多数据源切换解决的是“连到哪个库”,但分表之后还有一个问题:逻辑表order实际存在order_0到order_15这 16 张物理表里,SQL 里的表名怎么替换?如果自己写路由,这一步绕不开。我的做法是在 DAO 层用一个分表工具类,根据分片键生成物理表名,然后拼进 SQL。
public class TableRouter { public static String orderTable(Long orderId) { int shard = Math.floorMod(orderId, 16); return "order_" + shard; } }这个工具类用Math.floorMod而不是%,因为%对负数取模结果是负数,会导致order_-1这种表名。分片总数 16 要放在配置里,不要写死在代码中。拼表名的时候要注意防止 SQL 注入,表名不能直接拼接外部输入,必须先通过这个工具类转换成白名单内的物理表名。
但这种方式有一个隐性成本:每个 DAO 方法都要手动调用分表工具类,漏改一个方法就会查全表或查错表。我见过一个事故,就是某查询方法忘记用分表工具类,直接查了order,结果连到 ds0 后数据库里只有order_0没有order表,直接报错。所以如果分表规则涉及到非常多的 DAO 方法,我还是建议引入中间件来代劳表名改写,毕竟手写的东西总会在某个角落漏掉。
4.2 跨库查询与结果归并的代价
分库之后,一条不带分片键的查询会被路由到所有分片库去执行,然后把结果归并。这个归并逻辑看起来简单,实际很麻烦。例如SELECT * FROM order WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10,如果按order_id分片,那么所有分片库都要查各自的 10 条,然后在应用层合并排序,再截断成 10 条。这个过程如果只取每个分片的 TOP 10 是正确的,但如果是LIMIT 10000, 10,每个分片都要取 10010 条再归并,内存和耗时都会爆炸。
-- 这种分页查询在分库分表后的代价极高 SELECT * FROM order WHERE user_id = 123 ORDER BY create_time DESC LIMIT 10000, 10;应对方式有三种:第一种是禁止深分页,产品端只允许翻前几百页,这在我的项目里最常用;第二种是用游标分页,也就是WHERE create_time < ?这种方案,每个分片取固定条数,性能稳定;第三种是走搜索引擎或汇总库,把跨分片聚合需求提前做好报表。从数据源切换的角度看,这种查询最终会触发多个数据源连接,连接池的并发压力会被放大,所以我对跨分片查询的连接超时参数connectionTimeout会调得比单分片查询更短。
4.3 读写分离下如何把读请求打到只读库
很多分库方案同时做了读写分离:每个分片主库对应一个只读从库。这时候多数据源切换的维度就多了一个“读还是写”。常见做法是先按分片键确定主库,再按操作类型确定主从。我一般把读写分离放在 DAO 层而不是 Service 层,因为 DAO 能明确知道当前 SQL 是不是写操作。
public class ReadWriteRoutingDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { String baseKey = DynamicDataSourceContextHolder.getDataSourceKey(); if (DynamicDataSourceContextHolder.isReadOnly()) { return baseKey + "_read"; } return baseKey; } }这里_read后缀对应配置里的只读数据源 key。关键在于isReadOnly()这个标记怎么设置。我一般通过拦截器或者 MyBatis 插件识别 SQL 开头关键字,SELECT开头就标记只读。但要注意,事务内部不能用只读路由,否则主从延迟会导致刚写入的数据查不到。常见的折中是:同一个事务内强制走主库,也就是事务开启时把isReadOnly置为 false,事务结束后再恢复。
主从延迟这个问题在读写分离里是玄学级别。监控从库的Seconds_Behind_Master只能看趋势,不能精确知道某条数据延迟多久。所以对延迟敏感的业务,我宁愿让所有请求走主库,只把报表、统计这类明显可容忍延迟的查询打到只读库。不要试图在代码里做“刚写完强制读主库”的判断,那个判断本身就很容易出 bug。
5. 多数据源切换避坑指南:翻车现场与排查思路
5.1 数据源切换丢失:线程池复用把路由信息串了
现象:线上出现偶发性数据错乱,A 请求查了 B 库的数据,日志里能看到前一个请求的数据源 key 被后一个请求复用。
原因:线程池中的线程在处理完请求后没有被清理ThreadLocal,下一个任务复用线程时读到了上一个任务设置的 key。如果当前任务自己没设置 key,就会沿用旧值;如果设置了新值但没清理,再下一个任务又会被污染。
解决:路由切面的清理逻辑必须放在finally里,并且要在DynamicDataSourceContextHolder的clear()方法里真正调用ThreadLocal.remove(),而不是简单赋 null。另外,所有通过线程池异步执行的子任务,如果内部需要访问数据库,子线程的ThreadLocal并不会继承主线程的值,这时要在子任务里显式设置数据源 key,或者干脆禁止异步任务修改数据源。
5.2 事务开启后路由失效:连接已经绑定
现象:在一个@Transactional方法里先写了 ds0,再调用另一个设置了 ds1 的 service 方法,结果数据写进了 ds0,且没有报错。
原因:Spring 事务管理器在事务开始时已经通过 RoutingDataSource 获取了连接,这个连接被绑定到当前线程的事务资源中。之后所有 DAO 操作都复用这条连接,路由 key 的改变不会触发重新获取连接。
解决:先把业务拆开,确认跨库操作是否真的需要同一个事务。如果不需要,就拆成多个独立事务方法。如果必须跨库,考虑分布式事务,但要做好性能和心理准备。排查这个坑时,最简单的验证方式是看数据库的general_log,确认执行插入的会话 ID 是否一直没变。
5.3 连接数被打满:慢 SQL 集中在个别分片
现象:偶发连接池报CannotGetJdbcConnectionException,所有请求都拿不到连接,但数据库负载看起来不高。
原因:分片键设计不均匀。比如按用户 ID 分片,但大量测试账号、或者某个渠道的用户 ID 都集中在一个区间,导致 ds0 的连接池被打满,其他分片还很空。另外跨分片查询会同时请求多个数据源,如果这种查询并发高,每个数据源的连接都被占住。
解决:先看监控里每个数据源的活跃连接数。如果分布不均,评估是否需要改分片算法,比如从简单的取模改成一致性哈希或者范围分片。短期的办法是调大数据源连接池上限,但要同时加长maxWait并且设置合理的连接回收参数,否则连接泄漏会加速问题爆发。
5.4 配置刷新后路由还走老库
现象:改了分库规则或者迁移了数据库地址,发布后部分请求仍然走到旧库,甚至出现连不上的报错。
原因:连接池里已经建立的长连接还在复用,数据源刷新后旧连接没有失效;或者AbstractRoutingDataSource的targetDataSources虽然是新的,但dynamicDataSource是单例,map 里的引用没有重新设置。
解决:修改数据源配置后,不要只改数据库,要检查连接池是否配置了testWhileIdle和validationQuery,确保空闲连接能被检测掉。我一般会发布后先观察连接池的active和idle数量变化,如果旧连接还挂着,需要在发布前显式调用close()关闭连接池,或者重启应用。这里不要指望玄学,配置刷新必须有一套完整流程。
5.5 多数据源配置下 MyBatis 缓存失效
现象:一个 mapper 方法第一次查询走 ds0,结果正确;第二次请求被路由到 ds1,返回的却是 ds0 的数据。
原因:MyBatis 的一级缓存作用域是 SqlSession,默认跟随事务;但如果配置了二级缓存,缓存只认 statement 的 namespace,不认数据源 key。同一个 namespace 下的查询结果会被缓存,路由到别的数据源时直接命中了旧的缓存。
解决:关闭二级缓存,或者把二级缓存的 key 设计成包含数据源 key。常见做法是配置cache-ref或者自定义Cache类,在 key 里拼接determineCurrentLookupKey()。如果不想动缓存类,直接在 mapper 的二级缓存配置上加readOnly="true"也能减少一些错乱,但根治方式还是禁用或改 key。
6. 验证与进阶:把切换延迟和正确性一起管起来
多数据源切换不是说代码能跑就完事,我的习惯是把它当做一个独立模块来验证。第一步是单元测试覆盖路由计算逻辑,把分片键的边界值、负数和 null 都测一遍;第二步是集成测试里用真实数据库,分别验证持分片键和不持分片键的查询行为;第三步是验证并发场景,用多个线程同时设置不同的数据源 key,确认ThreadLocal不会互相干扰。
进阶一点的做法是给切换加一个“路由审计日志”。每次切换发生时,在切面里记录请求 ID、目标数据源 key、分片键、线程 ID 和耗时。线上出问题时,拿请求 ID 一搜就能还原完整的路由链路,比翻业务日志高效得多。这个日志不需要全量保存,可以做成采样,1% 的流量就够了。
灰度切换是另一个容易被忽略的点。分库分表改造完成后,从单库切到多库最怕一把梭。我一般会先在数据源路由里加一个开关:开关打开时按新分片规则路由,关闭时全部走默认库。用配置中心下发开关,先放 5% 流量观察两个周期,再逐步放大。放量过程中要盯着三类指标:各数据源的连接数曲线、SQL 慢查询日志、以及跨库查询失败率。如果某个分片库的连接数涨速明显异常,先缩回流量,排查分片键分布再继续。
最后一个技巧是接 API 监控。多数据源切换的故障往往表现为某个数据源整体超时,而不是具体 SQL 报错。所以我会在路由数据源外面包一层装饰,统计每个 key 的平均耗时、错误数和当前活跃连接数。用这些数据做一个简单的仪表盘,当某个 key 的错误率连续 5 分钟超过阈值,自动触发告警。做到这一步,多数据源切换基本就不再让人焦虑了,因为它从“隐藏的地雷”变成了“可见的运行指标”。我自己经历过几次切换事故,事后复盘时发现,如果当时能早点把路由审计和指标监控做上,很多问题半小时内就能定位。希望这些经验能帮你少走一段弯路,把你的分库分表切换做得更稳。
本文还有配套的精品资源,点击获取