简介:分库分表与多数据源切换的实战示例资料包,面向有一定基础的Java网络开发工程师,旨在解决数据量剧增时单一数据库的性能瓶颈与扩展难题。压缩包内共收纳73个文件,以源代码、编译后的class文件、XML配置和属性配置文件为主,辅以JSP页面、SQL初始化脚本及工程配置文件,完整呈现分库分表方案从数据源定义、分片规则声明到业务调用的全过程。资源包大小约66KB,轻量易获取。目前已有2068人学习/下载,常用于生产方案预研和技术验证。通过学习该示例,读者能够掌握基于哈希、范围等策略的分片路由原理,理解多数据源在应用层的自动切换机制,并熟悉跨库操作时分布式事务的简化处理方式。项目中的配置模块、启动类与业务代码对应清晰,便于逐段对照,尤其适合理解如何在不改动既有业务逻辑的前提下透明地完成分库分表改造。同时示例还涉及全局唯一标识生成、避免跨库关联查询等细节,对系统架构优化具有直接参考价值。
1. 分库分表之后,多数据源切换成了新的复杂度来源
订单表过了千万级,单库单表扛不住写入压力,某电商系统把订单数据拆到了 16 张表、3 个库里。表拆好了,新的麻烦出现了:应用里原来只用一套DataSource,现在要按用户维度路由到不同库,读写分离还得把查询打到只读库。分库分表本身不算难,真正让人头疼的是多数据源如何在一次请求里动态切换、事务边界怎么守、连接池参数怎么调。这套多数据源切换方案适合正处在分库分表落地阶段、被路由和事务问题卡住的团队,本文从原理、配置、实现到避坑完整走一遍。
2. 多数据源切换的核心:从 AbstractRoutingDataSource 到 ThreadLocal 的连接归属
2.1 为什么不能靠注入多个 DataSource 解决问题
不少开发者第一次做多数据源时,直接在一个 Service 里注入两个DataSource,用 if/else 判断走哪个库。这种写法在 demo 阶段没问题,但在真实业务里根本守不住事务边界。假设一个支付回调接口要先写订单库,再更新用户余额库,@Transactional注解管的是当前线程绑定的一个连接,两个数据源各拿各的连接,事务自然就分家了。更麻烦的是,MyBatis 的SqlSession是在 mapper 方法执行前从当前数据源拿连接的,一旦某个查询走了错误的库,整个请求的数据就错位了。
分库分表场景下真正需要的,是一个“当前线程该用哪个数据源”的全局开关,这个开关要能被 Service 层任意位置随时拨动,还要能被 MyBatis 执行 SQL 前感知到。Spring 的AbstractRoutingDataSource就是为此设计的,它内部维护一个targetDataSources映射表,每次getConnection()时调用determineCurrentLookupKey()决定返回哪个真实数据源。这个路由键的取值,通常就是放在ThreadLocal里的一个字符串。
2.2 ThreadLocal 传递路由键的工作原理
ThreadLocal在这里扮演的是“连接出生证”的角色。Java 的ThreadLocal每个线程独立存储一份变量副本,意味着一次请求从 Controller 进入 Service、再下钻到 Mapper,只要线程没有切换,路由键就能一直读到。实现时一般封装一个DataSourceContextHolder,提供setDataSource()、getDataSource()、clear()三个静态方法,内部持有一个ThreadLocal<String>。路由键值可以是"ds0"、"ds1"这样的库别名,也可以是"sharding0"、"sharding1"这种带分片语义的键。
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSource(String dataSourceKey) { CONTEXT.set(dataSourceKey); } public static String getDataSource() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }上面这段代码里,setDataSource在业务代码进入某个需要特定库的方法前调用,clear必须在请求结束或方法返回时执行。如果漏了clear,线程池复用线程时会把上一个请求的路由键带到下一个请求里,导致串库。ThreadLocal.remove()相比set(null)更彻底,能防止内存泄漏,因为在 Tomcat 这类线程池容器里线程长期存活,ThreadLocal的 key 弱引用如果没人清除会一直绑定旧值。
2.3 连接池选型:Druid 的监控优势与 HikariCP 的性能边界
多数据源场景下连接池选型不是玄学,要按两个维度选。第一是监控能力,分库之后每个库的连接数、活跃连接、SQL 执行耗时都得独立观测,不然出了故障都不知道是哪个库先堵的。第二是配置一致性,三个连接池如果最大连接数、超时时间各不相同,压测时会出现某个库连接池被打满而其他库闲置的情况。
我一般会选 Druid 而不是 HikariCP,原因是 Druid 自带的监控面板能把每个数据源的独立状态列出来,排查慢 SQL 时可以直接看哪条 SQL 打到了哪个库。HikariCP 性能确实更好,号称单连接吞吐比 Druid 高 20% 左右,但它默认没有 web 监控页,要看每个库的独立连接状态得自己接 Micrometer。分库分表这种多数据源场景,可观测性比那 20% 的性能更重要。
spring: datasource: druid: ds0: url: jdbc:mysql://192.168.1.101:3306/order_db_0?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: order_rw password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true filters: stat,wall,slf4j ds1: url: jdbc:mysql://192.168.1.102:3306/order_db_1?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: order_rw password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver initial-size: 5 min-idle: 5 max-active: 20 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true filters: stat,wall,slf4j上面这段 yml 配置里有个关键点:filters: stat,wall,slf4j。wall是 Druid 的 SQL 防火墙过滤器,它在分库分表场景下可能会拦截一些批量 INSERT 或分页子查询,如果日志里出现sql omit字样,就是 wall 在拦截。test-while-idle设置为 true 表示空闲连接在归还时会被validation-query探测,这个参数在多数据源下尤其重要,因为某个库重启后连接池里的旧连接不会自动失效,没有探活机制的话,路由到该库的请求会直接报连接异常。
3. 配置阶段就要想清楚:三个数据源的初始化脚本与兼容性核对
3.1 数据源初始化脚本的字符集与事务隔离级别统一
新建分库时最常见的问题是字符集不统一,一个库是utf8mb4,另一个库是utf8,用户一旦在订单备注里写入 emoji 字符,插入那个旧字符集的库就报Incorrect string value。分库分表的初始化脚本必须把字符集、排序规则、事务隔离级别都写死,不能依赖 MySQL 默认值。下面这段 DDL 展示了单库的建库语句,三个库执行同一份脚本,只改库名。
CREATE DATABASE IF NOT EXISTS order_db_0 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE order_db_0; SET GLOBAL transaction_isolation = 'READ-COMMITTED'; CREATE TABLE `t_order_0` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `user_id` bigint NOT NULL, `amount` decimal(12,2) NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;这里把隔离级别设置为READ-COMMITTED是因为分库分表后跨库查询本来就不支持分布式事务,REPEATABLE-READ在多个库之间会产生幻读不一致的假象。CREATE DATABASE语句里的IF NOT EXISTS不是多余的——初始化脚本往往会被自动化平台执行多次,没有这个保护就会中断。表名里的t_order_0是分表前缀,实际分表数量按业务量估算,每个库至少 4 张表起步,避免单表数据量再次破千万。
3.2 分片引擎与手动数据源的兼容性:一套红线
很多团队在分库分表前已经引入了 ShardingSphere,后来又因为多数据源切换需求手动加了一层路由,结果两边互踩。ShardingSphere 的ShardingDataSource本身就是一个AbstractRoutingDataSource,它接管所有真实库表的路由;如果业务代码里又用DataSourceContextHolder强行切换数据源,等于在引擎的determineCurrentLookupKey()之上再套一层,两层路由的键如果对不上,SQL 会被发到错误的库。
我一般会画一条红线:全局数据源路由和分片引擎不能同时接管物理库的路由,二者只能留一个。如果你的分片规则已经写死在 ShardingSphere 配置里,就不需要再手动切换数据源,除非是读写分离场景。读写分离时用 ShardingSphere 的read-write-split就够了,也不要手动切。手动切换数据源只在一种场景下合理:你有多个独立业务库,比如订单库和用户库,二者不做分片关联,需要按业务模块切换。
3.3 连接池参数在多数据源下必须各库独立调优
单数据源时代,max-active=20意味着整个应用最多 20 个连接。改成三个数据源之后,max-active变成每库 20 个,总计 60 个。如果某个库是纯查询库而另一个库是写入库,两个连接池参数就不该一样。查询库读多写少,max-active可以放大到 50,max-wait缩短到 3000 毫秒,快速失败不排队;写入库需要保证事务执行期间连接不被回收,min-idle可以设大一点。
这里有一个隐藏的坑:Druid 默认的remove-abandoned-timeout是 300 秒,如果某条慢 SQL 执行超过这个时间,连接池会强制回收连接,事务就会被中断。分库分表后跨库查询变多,慢 SQL 概率上升,remove-abandoned建议关闭或调大到 600 秒以上。连接池参数要按库的角色独立配置,不能图省事复制一份。
4. 手动切换落地:注解 + AOP 实现路由键的自动埋点与清理
4.1 自定义 @DataSource 注解与上下文持有器
多数据源切换落到工程里,最省事的做法是定义注解@DataSource,标注在 Service 方法或 Mapper 方法上,通过 AOP 切面在方法执行前设置路由键,方法返回后清理解除。这样做的好处是业务代码不侵入,调用方只要知道“这个方法走哪个库”即可,不需要关心切面内部如何工作。注解定义如下:
@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface DataSource { String value() default "ds0"; }@Target同时允许标注在类和接口上,这样如果整个 Service 都走同一个库,可以在类级别标注一次,方法级别的注解会覆盖类级别的。@Retention(RetentionPolicy.RUNTIME)是必须的,因为 AOP 切面在运行时需要通过反射读取注解值。value()默认ds0表示主库,这样不需要额外配置的现有方法仍然走默认库,改动面最小。
4.2 切面实现:try/finally 清理的规范姿势
切面类的核心逻辑并不复杂,就是把注解值写入DataSourceContextHolder,方法执行完再清理。但清理这一步必须放在finally里,否则方法抛异常时路由键不会被清除,线程回收到池子里后残留的键会污染下一个请求。下面这段代码展示了正确的清理顺序:
@Aspect @Component public class DataSourceAspect { @Around("@annotation(com.example.datasource.DataSource) || @within(com.example.datasource.DataSource)") public Object switchDataSource(ProceedingJoinPoint joinPoint) throws Throwable { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); DataSource dataSource = method.getAnnotation(DataSource.class); if (dataSource == null) { dataSource = joinPoint.getTarget().getClass().getAnnotation(DataSource.class); } String routeKey = dataSource.value(); DataSourceContextHolder.setDataSource(routeKey); try { return joinPoint.proceed(); } finally { DataSourceContextHolder.clear(); } } }这段代码里@within是 Spring AOP 的关键,它能让类级别标注的@DataSource也被切面捕获。如果只写@annotation,类级别注解是不生效的,这会导致你明明在类上标注了@DataSource("ds1"),实际运行时所有方法都走默认库。joinPoint.proceed()执行的是目标方法,也就是真正访问 Mapper 的代码;finally里的clear()确保即使 Mapper 抛了 SQL 异常,线程上下文也是干净的。
4.3 继承 AbstractRoutingDataSource 实现动态路由
有了路由键之后,还差最后一步:让AbstractRoutingDataSource真正按ThreadLocal值定位数据源。实现类很轻,只有determineCurrentLookupKey()一个方法必须覆盖。这个方法返回的字符串会作为 key 去targetDataSources里找真实的DataSource实例。
public class DynamicDataSource extends AbstractRoutingDataSource { public DynamicDataSource(DataSource defaultDataSource, Map<Object, Object> targetDataSources) { super.setDefaultTargetDataSource(defaultDataSource); super.setTargetDataSources(targetDataSources); super.afterPropertiesSet(); } @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSource(); } }afterPropertiesSet()这行容易漏,它来自InitializingBean接口,作用是初始化时把targetDataSources里的DataSource实例解析成连接池代理。如果漏调用,getConnection()时会拿不到真实连接,报IllegalStateException: Cannot determine target DataSource for lookup key。构造器里把解析逻辑写清楚,比让 Spring 容器自动调用更安全。
4.4 一个 @Transactional 引发的连接错位黑匣子
最坑的还不是切面本身,而是@Transactional与@DataSource的共存问题。Spring 的事务管理机制里,事务管理器在创建事务时会从数据源拿连接,并把连接绑定到当前线程;随后的所有 SQL 都通过这个线程绑定的连接执行。如果你的方法上同时标注了@Transactional和@DataSource("ds1"),事务过滤器会先启动,从默认数据源ds0拿连接并绑定线程,之后 AOP 切面才把路由键改成ds1,但执行 SQL 的仍旧是ds0的那个连接,导致你明明切了库,数据却写进了错的库。
解决方式是把@DataSource和@Transactional分到两个方法上,外层方法标注@DataSource("ds1"),内层方法标注@Transactional。这样 AOP 切面先设置路由键,事务启动时拿到的就是ds1的连接。我踩过这个坑之后,代码 review 时看到写在同一方法上的两个注解,会直接打回去改结构。
5. 多数据源切换避坑:五个高频问题从现象到根因
5.1 数据源切换了,查询却仍打到旧库
现象:接口里显式调用DataSourceContextHolder.setDataSource("ds1"),日志里打印出的 SQL 却还是执行在ds0上。
原因:这类问题多半是两级缓存在作怪。第一级是 Spring 的DataSourceTransactionManager在当前线程已经缓存了连接,第二级是 MyBatis 的SqlSessionTemplate会缓存SqlSession,同一个SqlSession生命周期内复用的是首次获取的数据源连接。切面里虽然改了ThreadLocal,但如果SqlSession是从上一级缓存里拿到的,新路由键根本不会触发重新取连接。
解决:把路由键的切换放到事务边界之前,也就是在 Service 外层切库,内层再开事务。排查时看mybatis.config.cache-enabled是否开启,分库场景建议显式关闭;如果用了 MyBatis-Plus,还要检查sqlSessionFactory是否是单例且未对多数据源做隔离。
5.2 线程池复用了上一次请求的路由键
现象:A 请求走了ds1,结束后线程归还给 Tomcat 线程池;B 请求是纯默认库查询,却被路由到ds1,读到了错误的数据。
原因:ThreadLocal的生命周期和线程绑定,Tomcat 线程池复用了处理 A 请求的线程,ThreadLocal里的路由键没有被清除,B 请求读到了残留值。
解决:严格按 4.2 节的写法,在finally里调DataSourceContextHolder.clear()。还要在拦截器层加兜底,实现一个HandlerInterceptor,在afterCompletion里调用clear(),防止 AOP 没有覆盖到的异步分支留下残留键。这条兜底我之后每次上线都强制加上。
5.3 Druid wall 防火墙拦截了分库后的批量分页 SQL
现象:多数据源切换后,某条分页查询在单库单表下正常,切到分库后报sql omit或sql injection violation,请求直接 500。
原因:Druid 的wall过滤器默认是开启状态的,它的 SQL 白名单机制对某些语法组合不兼容:比如分库后的LIMIT子查询、多表JOIN带UNION、批量INSERT ... ON DUPLICATE KEY UPDATE,都可能被误判为注入风险。
解决:在filters: stat,slf4j中去掉wall,或者单独配置wall的strict-mode: false,只保留stat做慢 SQL 统计。如果团队有安全合规要求不能完全关掉 wall,就把config-merge开启并放宽multi-statement-allow。这个参数取舍要写进团队开发规范,避免上线前临时改配置。
5.4 连接池参数配置不一致导致一个库被打满、其他库闲置
现象:压测时发现某个写库连接池抛wait millis 60000, active 20, maxActive 20,而另一个查询库连接池活跃连接数只有 2。
原因:三个数据源的max-active、max-wait配置复制粘贴完全一致,但业务流量分布是 80% 写订单库、20% 写用户库。写库连接池不够用,浅查询库又闲置。
解决:按角色独立调整参数表。写库max-active放大到 50、max-wait缩短到 3000 毫秒;只读库min-idle保持在 5、max-active20 即可。同时打开 Druid 监控面板,观察三个库各自的活跃连接曲线,哪条曲线先触顶就优先给对应库扩容。
5.5 事务跨多库后的回滚出现了镜像缺失
现象:分布式事务框架下,主库事务提交成功,从库事务回滚失败,控制台打印underlying transaction is not active。
原因:分库分表后,多库操作属于跨资源分布式事务。如果用了 Seata 类的 AT 模式,回滚时需要依赖 undo_log 表,但初始化脚本只建了业务表,没有建undo_log表,导致回滚阶段找不到镜像数据。
解决:在参与事务的每个物理库中都执行一次CREATE TABLE IF NOT EXISTS undo_log,表结构从 Seata 官方脚本复制,注意branch_id和xid字段的索引必须存在。排查时先看undo_log表里是否有残留记录,有残留说明回滚被中断过,需要手动清理脏数据后再重试。
6. 自动路由的落地验证:从手动切库到自定义分片规则
6.1 基于用户 ID 的哈希分片路由规则
手动数据源切换解决的是“多个独立库按业务模块切换”的问题,但分库分表更常见的诉求是按某个分片键自动路由。用户 ID 取模是最典型的分片规则,规则简单、分布均匀,缺点是取模数一旦定下来就不好扩容。实现上不需要引入重量级中间件,可以直接在determineCurrentLookupKey()里按user_id哈希计算库索引,这是从手动切换升级到自动路由的关键一步。
public class UserShardingDataSource extends AbstractRoutingDataSource { private static final int DB_COUNT = 3; @Override protected Object determineCurrentLookupKey() { Long userId = UserContext.getUserId(); if (userId == null) { return "ds0"; } int dbIndex = (int) (userId % DB_COUNT); return "ds" + dbIndex; } }这段代码里UserContext.getUserId()是从当前登录态里取用户 ID,实际项目中可以从RequestHolder的ThreadLocal中获取。userId % 3的取模结果决定了路由键ds0、ds1还是ds2。这里有两个注意点:第一,取模数必须和物理库数量一致,后续加库要重建数据或做双写迁移;第二,取模路由只适合单分片键,如果有订单号和用户 ID 两个维度的查询需求,就要做映射表或基因法(把用户 ID 编码进订单号)。
6.2 路由正确性的验证方法:用慢 SQL 日志和连接名双重确认
自动路由比手动切换更容易出现“看起来正确、实际打错库”的问题,因为代码里没有显式的setDataSource调用,路由键在框架内部计算,出错的概率被藏起来了。验证时不能只看数据是否查得到——运气好可能查出来的数据本身就是错的,只是你还没发现。
我一般会在压测环境同时做三件事:第一,开启 MyBatis 的 SQL 日志输出到控制台,观察每个SELECT语句前缀有没有数据库名;第二,在getConnection()的代理层打印DruidDataSource.getName(),确认连接实例是否来自预期的数据源;第三,准备一份跨库核对脚本,在每张分表里插入带有库标识的特殊测试数据,比如订单号前缀ds0-、ds1-,验证某类用户 ID 的查询结果里只能出现对应前缀。
6.3 灰度切换的检查清单:从注解扫描到连接池健康检查
自动路由上线前,我会强制走一遍五步检查清单,缺任何一步都可能在上线后翻车。第一步:扫描全工程,确认没有残留的硬编码数据源注入,所有DataSource类型必须是DynamicDataSource或其子类。第二步:核对targetDataSources里的 key 和determineCurrentLookupKey()返回值完全一致,大小写都不能有偏差,否则查出的是默认库数据。第三步:每个物理库执行一遍SELECT 1,确认连接池的validation-query能通。第四步:用压测工具模拟 10000 个用户 ID 均匀分布的分片请求,从监控面板看三个库的活跃连接曲线是否大致均衡。第五步:在只读库上单独跑一遍EXPLAIN计划,确认分页 SQL 没走全表扫描。
这套检查清单曾经救过一次上线事故预演:当时我在模拟场景里用一个 8 位数的用户 ID 做取模测试,表面看起来路由正常,但日志里发现 SQL 打到了ds0库的t_order_0表,而用户 ID 尾号是 1,应该打到ds1库。最终定位到原因是UserContext.getUserId()在异步线程里拿不到值,回退成了null默认走ds0,性能隐患变成了数据错位隐患。从那以后,我每次上自动路由版本都会强制在异步入口处显式传递用户 ID,并加上为空即抛出异常的守卫逻辑。
希望你也能用这套方法和清单,把多数据源切换做成一件干净、可控、可复盘的事。
本文还有配套的精品资源,点击获取