Springboot多数据源实战:动态路由、读写分离与事务避坑

发布时间:2026/10/24 1:24:25
Springboot多数据源实战:动态路由、读写分离与事务避坑

上个月帮一位朋友排查一个老系统的慢查询问题,翻到配置目录时发现三个 DataSource 的 Bean 挤在同一个文件里,每个 Service 里还挂着@Autowired @Qualifier("slaveDataSource")这样的注解,读写切换全靠开发在业务代码里手写选择。这个现场我太熟悉了——Springboot 整合多数据源配置这件事,几乎所有做到一定规模的业务系统都会撞上:主库扛写、从库扛读,或者订单库和会员库物理隔离,再或者新旧系统并行期要同时连两套库。听起来只是多配一个DataSource,真动手才发现坑比想象中密集:数据源切换不生效、事务里切库失效、连接池参数拍脑袋定、Mapper 扫描串库。这篇就把我从零搭一套多数据源的过程完整摊开,包括方案选型时纠结的点、关键代码为什么这么写、参数怎么算出来的,以及那些只有上线后才会暴露的问题。适合已经会写 Springboot 增删改查、准备进阶到多库场景的开发者,也适合正在维护历史多数据源代码、想重构但不敢动的同学。

1. 先搞清楚多数据源到底要解决什么问题

1.1 三类典型场景,方案完全不一样

很多人一上来就问"多数据源怎么配",其实这个问题没法直接回答,因为场景不同,架构选择差得很远。我把遇到过的情况归成三类。

第一类是读写分离。一个主库负责写,一到多个从库负责读,业务代码基本不感知,只是希望查询自动落到从库。这类场景的数据源数量固定,库表结构完全一致,切换逻辑简单,重点是解决主从延迟带来的读不到问题。

第二类是业务分库。比如订单、库存、会员分别在不同实例上,彼此没有跨库事务需求,只是同一个应用要连多个库。这类场景每个库的 Mapper 和实体都不一样,更适合按模块划分独立的SqlSessionFactory,而不是靠运行时动态路由。

第三类是新旧并行或异构数据同步。系统迁移期间要同时读写老库和新库,或者要读一套线上库、写一套统计库。这类场景往往伴随字段映射、双写补偿,切换逻辑最复杂。

我这次要落地的是一个典型的第一类加半个第二类:主库写订单,从库读订单查询,同时还有一套独立的配置库用于读取字典和开关。所以最终方案是"动态路由 + 独立 SqlSessionFactory"的混合形态,后面会详细说明为什么不能只用一种。

注意:先把场景想清楚再动手写代码。用动态路由去解决业务分库,会让 Mapper 扫描变得极其别扭;用多套 SqlSessionFactory 去解决读写分离,则要在每个 Service 里手动选 Bean,代码侵入性很强。选错方向,后面重构成本翻倍。

1.2 为什么坚决不把数据源选择写在业务代码里

老系统那个写法,本质是把"用哪个库"这个基础设施决策,下沉到了业务方法里。它带来的问题不是性能,而是可维护性崩塌。

第一,业务逻辑被污染。一个查询方法里塞了三行数据源切换代码,再加上 try-finally 还原,方法体一半是基础设施代码。新人接手时根本分不清哪行是业务、哪行是框架。

第二,切换逻辑分散导致遗漏。读操作忘了切从库,白白压主库;写操作误切到从库,直接报错或写进只读实例。这类 bug 在代码评审时很难被发现,因为它不报错,只是悄悄降级。

第三,无法统一治理。想加一个"强制走主库"的开关、想在慢查询时自动降级、想做链路追踪标记,每个地方都得改一遍。

所以我给自己定的原则很明确:数据源的决策必须是声明式的,写在方法签名上,由框架统一拦截。业务代码里只允许出现一个注解,比如@DS("slave"),剩下的全部交给切面。这个原则一旦立住,后面所有的技术选型都围绕它展开。

1.3 切换粒度定在方法级还是类级

这个细节看似小,实际影响很大。我最终支持的是"类级默认 + 方法级覆盖"。

原因是大部分 Service 是天然单一职责的:订单查询 Service 基本全是读,订单写入 Service 基本全是写。如果只支持方法级,每个方法都要标一遍注解,冗余且容易漏。支持类级之后,在类上标一次@DS("slave"),内部个别需要强一致读的方法再单独标@DS("master")覆盖即可。

实现上要注意注解的作用域查找顺序:方法上的注解优先于类上的注解。Spring 的@Around("@annotation(ds)")只能拦截方法级注解,要支持类级就得改成拦截所有方法再手动查找。我选择的做法是用@within和@annotation两个切点合并,或者干脆在切面里通过反射拿Method和targetClass,按"方法注解 → 类注解 → 全局默认"的顺序取值。这个顺序写错,就会出现类上标了从库、方法上标了主库却仍然走从库的情况,排查起来非常费劲。

2. 方案选型:动态路由和多套工厂怎么选

2.1 AbstractRoutingDataSource 到底做了什么

Spring 自带的AbstractRoutingDataSource是多数据源方案里最常被提起的一个类,但很多人只是照着抄代码,并不清楚它内部发生了什么。

它本身实现了DataSource接口,对外表现得就像一个普通数据源。当你调用getConnection()时,它并不直接建连接,而是先调用子类实现的determineCurrentLookupKey()拿到一个 key,然后去内部的targetDataSources这个 Map 里找对应的真实数据源,最后把连接的获取委托给那个真实数据源。

关键点在于这个查找发生在每次获取连接时,而不是在 Bean 初始化时。这意味着只要你能保证在getConnection()被调用的那一刻,上下文里的 key 是正确的,切换就能生效。反过来也意味着,如果连接已经被拿到并持有(比如事务开启后),再怎么改 key 都没用。这是后面所有"切换失效"问题的总根源,先记住这句话。

它还有两个容易被忽略的细节。一是afterPropertiesSet()必须在设置完targetDataSources和defaultTargetDataSource之后手动调用,否则内部那个 resolved 的 Map 是空的,运行时会报找不到数据源。二是lenientFallback默认为 true,当 key 找不到对应数据源时会退回到默认数据源,这个行为在生产环境是双刃剑:它能防止因为 key 拼写错误导致全站报错,但也会让配置错误被悄悄掩盖,读请求打到主库上你还以为是路由生效了。我一般在上线前把日志级别调成能看到路由 key 的输出,确认一段时间后再关掉。

2.2 三种主流做法的对比

方案实现方式优点缺点适用场景
动态路由数据源继承AbstractRoutingDataSource+ AOP 切换 key业务零侵入,一处配置全局生效,扩展新库成本低事务内无法切换,调试时链路不直观读写分离、同构库切换
多套 SqlSessionFactory每个库独立配置工厂、模板、Mapper 扫描路径隔离彻底,各库可用不同 ORM 配置,互不干扰配置量大,同一事务跨库困难业务分库、异构库
手动注入 + Qualifier每个 Service 显式指定数据源 Bean逻辑直白,无魔法侵入性强,容易遗漏,无法统一治理临时脚本、极少数固定场景

这张表我在选型时反复看过好几遍。最终的结论是:不要指望一个方案通吃。

读写分离用动态路由,因为它要求对业务透明;配置库这种独立的、只有几个 Mapper 的库,用它自己的SqlSessionFactory和SqlSessionTemplate更干净,因为它的实体和 Mapper 跟主业务完全不重合,硬塞进动态路由反而要处理命名冲突。

2.3 为什么放弃了"多数据源注解 + 每个方法单独指定"的写法

中间我试过一版激进方案:取消默认数据源,所有涉及数据库操作的方法都必须显式标@DS,不标就抛异常。想法是强迫开发者明确表态,避免误用默认库。

实测一周就放弃了。原因很现实:项目里几百个方法,标全的成本极高,而且一旦漏标就在运行时炸,等于把编译期能发现的问题推迟到了运行期。更麻烦的是第三方组件、框架内部调用、定时任务这些地方,你根本没法加注解。

后来改成"默认主库 + 显式声明从库",才回到正轨。默认走主库这个设定很关键,它保证了即使注解漏加,最坏结果只是读请求打到主库(性能损失),而不是写请求打到从库(数据错误)。这个失败方向的选择,比方案本身的优雅程度重要得多。

3. 环境搭建与连接池参数怎么定

3.1 依赖清单与版本约束

基础依赖不多,但版本要对齐,尤其是 Springboot 2.x 和 3.x 在数据源自动配置上有差异。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency>

连接池选 HikariCP 没什么争议,Springboot 2.x 之后它就是默认实现,启动快、字节码精简、监控指标全。ORM 层这次用的是 MyBatis,因为项目里已经有大量 XML 需要复用。

这里有个提前要提醒的点:一旦你自己定义了DataSourceBean,Springboot 的数据源自动配置就会整体退出。也就是说spring.datasource.url这类单数据源配置不再生效,全部要自己接管。很多人配置完发现连接池参数不生效,就是这个原因——Bean 是自己 new 的,@ConfigurationProperties没绑定上,参数自然被忽略。

3.2 配置文件的分层写法

我把配置按数据源分组,前缀统一,方便后续用@ConfigurationProperties批量绑定。

spring: datasource: master: jdbc-url: jdbc:mysql://10.0.0.11:3306/biz_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true username: app_rw password: ${DB_MASTER_PWD} driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://10.0.0.12:3306/biz_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: app_ro password: ${DB_SLAVE_PWD} driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 12 minimum-idle: 4 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1740000 pool-name: master-pool

这里踩过一个非常典型的坑,值得单独拎出来说:HikariCP 的属性名是jdbc-url,不是url。因为HikariDataSource的 setter 叫setJdbcUrl(),没有setUrl()。用DataSourceBuilder手动构建时如果你写url:,绑定阶段不会报错,运行时却抛jdbcUrl is required with driverClassName。我第一次遇到时排查了快半小时,翻遍配置文件都看不出问题,因为从 YAML 语义上讲url完全合理。

还有一个细节是max-lifetime的值。它必须比数据库服务端的连接超时时间短一截,否则会出现连接池认为连接可用、实际已被服务端掐断的情况,表现为偶发的Communications link failure,而且频率不高,最难排查。数据库侧常见的等待超时是 8 小时(28800000 毫秒),我一般设成 29 分钟(1740000 毫秒),留出充足余量。

3.3 连接数不是越大越好

这是我见过最常见的参数误用:一遇到慢就调大maximum-pool-size,从 10 调到 50 再调到 200,然后数据库连接数打满,整个实例雪崩。

连接池大小的正确算法跟 CPU 核数和磁盘 IO 能力相关,业界流传的经验公式大致是:核数乘以 2 再加有效磁盘数。一台 4 核的应用服务器,按这个算下来 10 个左右就够。为什么这么少?因为连接的本质是数据库服务端的并发处理能力,应用侧开再多连接,数据库也只能按它的核数并行处理,多出来的连接只是在排队,还额外占用内存和上下文切换开销。

对于需要具体估算的场景,可以用利特尔法则:并发连接数约等于 QPS 乘以平均响应时间。假设单库峰值 800 QPS,一次查询平均耗时 8 毫秒,那么瞬时并发约等于 800 乘以 0.008,得 6.4。考虑突发流量和慢查询拖尾,给到 10 到 12 是合理的,再多就是浪费。

minimum-idle我一般设成最大连接数的三分之一左右。设太小会导致流量波峰时频繁创建新连接,建连本身要经历网络握手和认证,耗时可能超过查询本身;设成和最大值一样又失去了弹性。connection-timeout设 3 秒,超过这个时间还拿不到连接,说明池子已满,快速失败比让请求堆着更有价值——堆着的结果是线程池也被拖满,最终整机不可用。

4. 核心实现:路由上下文、动态数据源与切面

4.1 上下文持有者必须用 ThreadLocal

路由 key 需要在一个调用链内共享,并且不能跨线程串味,ThreadLocal是标准解法。

public final class DataSourceContextHolder { private static final ThreadLocal<String> HOLDER = new ThreadLocal<>(); private DataSourceContextHolder() { } public static void set(String key) { HOLDER.set(key); } public static String get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

用remove()而不是set(null),是因为线程池复用线程时,set(null)只是把值置空,Entry 还在,不会造成内存泄漏但语义上不干净;remove()会把整个 Entry 清掉,是更彻底的清理方式。

这里有个必须提前防住的坑:异步任务和线程池会丢失上下文。主线程设了slave,用@Async或CompletableFuture提交任务,子线程拿到的ThreadLocal是空的,于是回落到默认数据源,读请求跑到主库上,而且完全无感。如果业务里确实有异步查库的场景,需要用TaskDecorator在任务提交时拷贝上下文、执行完清理。我在项目里加了一个默认的装饰器,把这个动作变成框架的默认行为,避免每个人自己记得。

4.2 动态数据源实现与注册

继承AbstractRoutingDataSource,只需要实现一个方法。

public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.get(); } }

真正麻烦的是注册阶段。两套物理数据源要各自构建,然后塞进路由数据源,路由数据源本身再用@Primary标记,让没指定名字的注入都拿到它。

@Configuration public class DataSourceConfig { @Bean(name = "masterDataSource") @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean(name = "slaveDataSource") @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } @Bean(name = "dynamicDataSource") @Primary public DataSource dynamicDataSource(@Qualifier("masterDataSource") DataSource master, @Qualifier("slaveDataSource") DataSource slave) { Map<Object, Object> targets = new HashMap<>(4); targets.put("master", master); targets.put("slave", slave); DynamicDataSource routing = new DynamicDataSource(); routing.setTargetDataSources(targets); routing.setDefaultTargetDataSource(master); routing.afterPropertiesSet(); return routing; } }

这段代码里有三个位置容易出问题,我逐个说。

第一,@Primary必须加在路由数据源上。有些组件(比如某些监控端点、健康检查、批量任务框架)会按类型注入DataSource,如果容器里有两个以上且都没有@Primary,启动直接报NoUniqueBeanDefinitionException。

第二,afterPropertiesSet()这一行不能省。它内部负责把targetDataSources转换成 resolved 结构,省了就会得到IllegalArgumentException: DataSource router not initialized。而且它必须放在setTargetDataSources之后,顺序错了也白搭。

第三,@ConfigurationProperties绑定的前缀要和 YAML 完全对应。我见过把前缀写成spring.datasource.master.hikari然后抱怨连接池参数不生效的,因为那一层下面根本没有jdbc-url,构建出来的数据源是空的,要到第一次查询才炸。判断方法很简单:启动时打一个断点看构建出来的HikariDataSource的jdbcUrl字段是不是空,为空就是绑定出问题了。

另外,@ConfigurationProperties绑定的对象如果是DataSourceBuilder构建出来的,它绑定的是最终实例的属性,会走 setter。所以 YAML 里的键名必须严格匹配 setter 名——这也解释了为什么是jdbc-url而不是url,因为 setter 是setJdbcUrl。这个对应关系搞明白之后,什么minimum-idle、max-lifetime该写连字符还是驼峰,就都不用猜了。

4.3 注解定义与切面实现

注解本身很简单,重点是切面的顺序控制和上下文还原。

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DS { String value() default "master"; }

切面我用的是"取到就切、切完还原"的方式,而不是简单的 set + clear。因为存在嵌套调用的情况:外层方法标了slave,内部调用的方法又标了master,如果内层执行完直接 clear,外层后续的查询就会回落默认库,逻辑就乱了。

@Aspect @Component @Order(-100) public class DataSourceAspect { @Around("@annotation(com.example.ds.DS) || @within(com.example.ds.DS)") public Object around(ProceedingJoinPoint point) throws Throwable { String key = resolveKey(point); String previous = DataSourceContextHolder.get(); DataSourceContextHolder.set(key); try { return point.proceed(); } finally { if (previous == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.set(previous); } } } private String resolveKey(ProceedingJoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); DS methodDs = method.getAnnotation(DS.class); if (methodDs != null) { return methodDs.value(); } DS classDs = point.getTarget().getClass().getAnnotation(DS.class); return classDs != null ? classDs.value() : "master"; } }

@Order(-100)这个值不是随便写的,下一节讲事务的时候会详细解释为什么它必须比事务切面更靠外。这里的"靠外"指的是执行顺序上更早进入、更晚退出,也就是 order 值更小。

还有一个小细节是@within和@annotation的区别。@annotation只匹配方法上有注解的情况,@within匹配目标类上有注解的情况,两个用||连起来才能覆盖"类级默认 + 方法级覆盖"两种写法。我用@within而不是@target,是因为@target匹配的是运行时代理对象的类,如果被代理的类上没注解但代理类上有,语义会不一致,容易出怪问题。

5. ORM 层整合:SqlSessionFactory 与事务管理器

5.1 为什么事务管理器只能有一个

这是多数据源配置里最反直觉的一点,必须讲透。

Spring 的声明式事务工作流程是:切面在方法开始前从DataSource拿一个连接,把autoCommit设为 false,然后绑定到当前线程;方法执行期间所有的数据库操作都复用这一个连接;方法结束时提交或回滚。

注意关键词:拿一个连接。如果整个事务只有一个连接,那么事务开启的那一刻,走哪个库就已经定死了。后面无论你怎么修改ThreadLocal里的 key,都拿不到新连接,因为你已经在事务里了。

所以动态路由的切换必须发生在事务开启之前。这就是切面@Order要设成比事务切面小的原因。Spring 的DataSourceTransactionManager默认 order 是Ordered.LOWEST_PRECEDENCE(整型最大值),只要你的切面 order 比它小,就能保证先切数据源、再开事务,顺序就对了。

反过来说,如果你在事务方法内部调用另一个标了@DS的方法,想借此切换到另一个库,那是绝对做不到的——这种情况只能拆事务,把外部方法的事务去掉,或者用编程式事务精细控制边界。

@Configuration public class MyBatisConfig { @Bean(name = "transactionManager") @Primary public DataSourceTransactionManager transactionManager( @Qualifier("dynamicDataSource") DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }

事务管理器注入的是路由数据源而不是主库数据源。这一点极其关键:如果注入的是masterDataSource,事务管理器会在开启事务时直接锁死主库连接,即便切面把 key 改成了从库,查询依然走主库。我曾经为了排查这个问题,在没有任何业务变更的情况下盯着日志看了两个小时,最后发现是配置里少写了一个@Qualifier。

5.2 独立 SqlSessionFactory 的隔离配置

配置库那套独立的库,走多套工厂的方式更清晰。

@Configuration @MapperScan(basePackages = "com.example.config.mapper", sqlSessionFactoryRef = "configSqlSessionFactory") public class ConfigMyBatisConfig { @Bean(name = "configSqlSessionFactory") public SqlSessionFactory configSqlSessionFactory( @Qualifier("configDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations( new PathMatchingResourcePatternResolver() .getResources("classpath*:mapper/config/**/*.xml")); org.apache.ibatis.session.Configuration cfg = new org.apache.ibatis.session.Configuration(); cfg.setMapUnderscoreToCamelCase(true); cfg.setCacheEnabled(false); bean.setConfiguration(cfg); return bean.getObject(); } @Bean(name = "configSqlSessionTemplate") public SqlSessionTemplate configSqlSessionTemplate( @Qualifier("configSqlSessionFactory") SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } }

几个要点值得强调。

@MapperScan上的sqlSessionFactoryRef必须显式指定,否则多套工厂共存时,MyBatis 会注入到它默认找到的那个,导致配置库的 Mapper 跑到了主库上执行——报错信息通常是表不存在,因为主库里没有那张表。

mapperLocations的路径要严格隔离。我按mapper/master、mapper/slave、mapper/config分目录存放 XML,配合classpath*:mapper/master/**/*.xml这样的模式精确匹配。如果把所有 XML 都放在mapper/下面然后统一扫描,两套工厂就会互相加载对方的 XML,出现方法重复定义的启动错误。

cacheEnabled我关掉了。MyBatis 的二级缓存是按 namespace 隔离的,多数据源场景下如果同一个 namespace 被两套工厂加载,缓存会互相污染。这个缓存本身在多实例部署下就不太可靠,关掉更省心。

5.3 一个绕不开的现实问题:跨库查询

必须承认,动态路由解决不了跨库JOIN。主库的表和配置库的表没办法在一条 SQL 里关联,这是物理隔离的天然限制。

我的处理办法是"小表冗余 + 应用层拼装"。字典这类变化频率低、数据量小的表,直接在主库建一份冗余副本,用定时任务或消息驱动同步,查询时单库完成。对于确实需要跨库关联的业务查询,走应用层两次查询再按 key 拼装,控制单次查询的返回量,避免在大结果集上做内存 join。

这里有个经验值:应用层拼装的两次查询,结果集控制在几百条以内是可以接受的,超过一千条就要重新审视需求了。因为一旦涉及分页,跨库拼装的语义会变得非常微妙——分页在哪一侧做,都会导致总数不准。所以我在设计阶段就把"必须跨库分页"的需求挡回去了,改成先按条件在单库过滤再补全,或者干脆把需要联查的数据同步到同一侧。

6. 常见问题与排查技巧实录

6.1 切换完全不生效的几种典型情况

这是我被问得最多的问题,表现形式都是"加了注解但还是走了主库"。我整理了一张速查表,按出现频率排序。

现象根因排查动作处理方式
注解加了没反应切面未被扫描或类未被代理检查启动类扫描路径,看目标类是否在容器中补@Component或调整包扫描
只有部分方法生效类内自调用绕过代理在方法内打日志看是否进入切面拆到另一个 Bean,或用 AopContext 暴露代理
事务方法内切换无效连接已被事务持有关闭 SQL 日志看是否只有一条连接把切换提到事务外层
偶发走错库异步线程丢失上下文在子线程打印上下文值加TaskDecorator传递
全局都走了从库默认数据源配反了检查setDefaultTargetDataSource改成主库
启动报找不到数据源漏调afterPropertiesSet看异常栈关键字补上该调用

重点说说"类内自调用"这个坑,因为它最容易让人怀疑人生。Spring 的 AOP 是基于代理的,只有从代理对象外部进来的调用才会被拦截。你在 Service 的 A 方法里直接调this.b(),走的是原始对象的方法,注解完全失效。

判断方法很简单:在切面里打断点,如果自调用的方法没进来,那就是这个问题。解决办法要么是把 b 方法挪到另一个 Bean 里,要么注入自身代理,要么用AopContext.currentProxy()(需要开启exposeProxy)。我个人的偏好是第一种,从设计上就不该出现这种依赖,拆开之后职责也更清晰。

6.2 主从延迟导致的读不到数据

这个不属于配置问题,但一定是多数据源上线后最先遇到的业务问题。

场景很固定:用户提交表单,写主库成功,页面立刻跳转列表页查询,此时从库还没同步到,查出来是旧数据,用户以为没保存成功,重复提交。

我在项目里用了三层防护。第一层是写后强制读主:刚执行过写操作的用户会话,在接下来的一小段时间内(我设的是 1 秒)所有读请求都走主库,用一个带过期时间的标记实现。第二层是关键业务显式标主库:像支付状态查询、订单详情这类对一致性敏感的方法,直接标@DS("master"),不做任何投机。第三层是监控从库延迟:定时采样主从的位点差值,超过阈值时告警,必要时自动摘除从库路由。

第一层的实现要小心,标记不能放在ThreadLocal里,因为跨请求了。我把它放在会话上下文或者用一个短 TTL 的缓存,key 是用户标识。这个方案有侵入性,但比"所有查询都走主库"务实得多。

6.3 连接数暴涨与连接泄漏

上线后某天监控报警,应用侧总连接数从 40 多涨到 300 多,数据库侧接近上限。排查过程记录一下,因为排查思路比结论更有价值。

第一步看连接池指标。HikariCP 提供了active、idle、total、waiting几个指标,如果active长期接近上限而idle为 0,说明是真的不够用;如果total在涨但active不高,那就是连接泄漏或者没有正确归还。

第二步抓线程堆栈。用jstack导出一份,搜索处于等待连接状态的线程,看它们卡在哪个业务方法上。当时的堆栈直接指向了一个批量导入任务。

第三步定位代码。那个批量任务在一个事务里循环处理上万条数据,每条都触发一次查询,整个事务持有同一个连接长达几十秒。问题不是泄漏,而是长事务占着连接不放,并发几个任务就把池子占满了。

处理方式是把大事务拆小,按批次提交,每批几百条,事务边界收窄到批次级别。改完之后单任务占用的连接时间从几十秒降到几百毫秒,池子里的连接周转开了,总数自然回落。

顺便说个排查连接泄漏的实用技巧:把leak-detection-threshold设成比正常业务耗时略大的值(比如 20 秒),一旦有连接持有超时,日志里会打印完整的申请堆栈。这个参数在生产环境常开,开销极小,但在关键时候能救命。

6.4 分页插件在多数据源下的行为

分页插件的工作原理是拦截 SQL 后自动拼接limit和一条count查询,它本身不关心数据源。但有个细节要注意:如果同一个Interceptor实例被注册到多个SqlSessionFactory,配置上的差异会互相影响。

我遇到过的情况是,主库配置了分页插件,配置库没配,结果配置库的分页查询把全量数据都捞回内存了,数据量不大所以一直没发现,直到某张字典表涨到几万行才开始慢。解决方式是在每个SqlSessionFactory里独立 new 一个插件实例,不要复用同一个 Bean,也不要指望某个全局配置能覆盖所有工厂。

7. 生产环境的落地经验

7.1 让路由可见:日志与监控

动态路由最大的问题是"看不见"。代码里没有明显的分支,出问题时你只能靠猜。所以我在两个地方加了可观测性。

一是 SQL 执行日志。把 MyBatis 的日志级别调到 DEBUG,打印出的每条 SQL 前面会带数据源信息。我只在预发环境常开,生产环境用动态调整日志级别的端点按需打开,避免日志量过大。

二是连接池指标上报。用 Micrometer 把 HikariCP 的各项指标绑定到监控系统,重点看hikaricp.connections.active、hikaricp.connections.pending、hikaricp.connections.acquire三个。pending大于 0 说明有线程在排队等连接,这是最直接的容量不足信号,比看 CPU 和响应时间都灵敏。

另外我加了一个很土但有效的办法:在路由数据源的determineCurrentLookupKey里加了一个计数埋点,按 key 分别累计调用次数。跑一段时间后看分布,如果从库的调用量占比很低,说明注解漏标了;如果从库占比异常高,可能有本该强一致的查询走了从库。这个统计成本极低,但能直观反映路由是否按预期工作。

7.2 上线前必须走一遍的自检清单

我把每次接入多数据源都要过的检查项固定下来,避免遗漏。

  • 确认@Primary只加在路由数据源和主事务管理器上,其他 Bean 不加
  • 确认determineCurrentLookupKey里没有异常吞掉,否则会静默回落默认库
  • 确认所有@Transactional方法的@DS注解在方法外层或标在主库上
  • 确认异步线程池配置了上下文传递的装饰器
  • 确认从库账号只有读权限,从权限层面兜底写错库的风险
  • 确认连接池的max-lifetime小于数据库侧的超时时间
  • 确认监控面板上能看到两个池子的独立指标
  • 用压测工具跑一轮读写混合流量,观察连接数是否稳定

最后一条我特别强调。很多配置问题在低流量下完全看不出来,只有并发上来、连接开始排队时才会暴露。压测时重点看的不是 TPS,而是连接池的pending数和响应时间的 P99,这两个指标异常基本就说明池子参数需要调整了。

7.3 几个我踩过的小坑

第一个是关于@DS注解和@Transactional的相对位置。为了保证切面顺序正确,@Order值必须足够小。我设的-100,比事务切面小得多,这样即使以后引入了其他切面(比如缓存、限流),也不会被挤到事务里面去。这个值留足余量是有意义的,别设成-1这种紧贴边界的数。

第二个是关于从库账号的权限。这件事听着像运维范畴,但它其实是多数据源方案里最靠谱的一道保险。从库账号只给SELECT,即便代码里注解写错了、路由配反了,写操作也会因为权限不足直接失败,而不是把脏数据写进只读实例。失败是显性的,能立刻被发现;脏数据是隐性的,可能几天后才在数据核对时暴露。

第三个是关于配置文件的密码。上面示例里我用了${DB_MASTER_PWD}这种占位符,实际部署时通过环境变量注入。多数据源意味着多套凭据,明文写在配置文件里、随代码一起进版本库,是很常见的疏忽。我在评审时看到明文密码一律打回。

第四个是关于新增数据源的成本。这套方案设计好之后,加一个库的改动量就是:YAML 加一段配置、工厂类加一个 Bean、枚举加一个值、三个地方各加一行。我在项目里实际加过一个临时统计库,从动手到跑通大概二十分钟。这个扩展成本才是我最终认可这套方案的核心原因——不是因为它多优雅,而是因为它把变更收敛到了固定的几个位置,不会随着库的数量增加而失控。

如果后续要继续扩展,我觉得有两个方向值得关注。一是把数据源的元信息(库名、用途、负责人、是否只读)做成可配置的注册表,配合健康检查自动摘除不可用的库,这样主从切换就不用改代码。二是把路由决策从纯注解扩展成"注解 + 规则"的混合模式,比如按租户号做分片路由,ThreadLocal里存的不再是库的别名而是租户标识,由路由逻辑算目标库。这两种扩展都不需要动核心结构,只是在determineCurrentLookupKey里做文章,这也是当时坚持把这个方法保持得足够薄的原因。