1. 为什么多数据源不是“加个配置就行”的事
刚接手一个老系统迁移项目时,我看到需求文档里写着“支持对接新旧两套数据库”,心里还觉得挺简单——不就是Spring Boot里配两个DataSource,再让MyBatis-Plus自动切库嘛。结果第一天就卡在事务回滚上:往主库插了一条订单,又往从库写了一条日志,中间抛了异常,主库数据被回滚了,从库那条日志却稳稳地躺在那里,像在嘲讽我的乐观。
后来翻了三天源码才明白:Spring的@Transactional默认只绑定到当前线程绑定的单个DataSource上。你配了十个数据源,它也只认你@Transaction标注的那个方法里“当前正在用”的那个。更麻烦的是,MyBatis-Plus的BaseMapper、IService这些开箱即用的便利封装,底层全依赖SqlSessionTemplate——而这个模板在启动时就绑死了某个SqlSessionFactory,根本不知道你后面还要切库。
这背后其实是个典型的“抽象泄漏”问题。我们用MyBatis-Plus是为了少写XML、少配Mapper,但一旦跨数据源,那些被封装掉的细节——比如SqlSession怎么创建、Executor怎么选、TransactionManager怎么注册——全得自己拉出来重新组装。druid作为连接池,它只管把Connection给你,可Connection属于哪个库?谁来决定?什么时候换?这些决策点,全得在框架的缝隙里手动打补丁。
所以“SpringBoot+MyBatis-Plus+druid多数据源”这个组合,表面看是三个成熟组件拼在一起,实际是三座山头各自为政:Spring Boot负责启动和Bean管理,MyBatis-Plus专注单库CRUD的语法糖,druid只做连接池守门人。它们之间没有原生的“多库协同协议”。你要做的,不是配置,而是在它们的交界处,亲手焊一条数据路由通道。
这也是为什么网上90%的教程跑不通——它们只贴了application.yml里的datasource配置片段,却没告诉你AbstractRoutingDataSource的key怎么注入到ThreadLocal,没说明DynamicDataSourceContextHolder这个工具类必须是static final修饰,更不会提@DS注解在AOP切面里捕获不到嵌套调用时的坑。这些不是“高级技巧”,而是能让你的多数据源从“能启动”走向“能落地”的生死线。
提示:别急着抄yaml配置。先问自己三个问题:
① 业务代码里,哪一行决定了“这次该走哪个库”?
② 如果一个service方法里既查主库又写从库,事务怎么保证原子性?
③ 当前线程执行完后,ThreadLocal里的数据源标识有没有被清理?没清理会污染下一个请求。
这三个问题的答案,直接决定你的多数据源是健壮的基础设施,还是定时爆炸的雷区。
2. DynamicDataSource的核心机制:不只是“路由”,而是“上下文快照”
很多人把DynamicDataSource当成一个简单的if-else路由器,看到@DS("slave")就返回slaveDataSource。这种理解太浅了。真正的难点在于:它必须在SQL执行前的毫秒级窗口内,完成一次“上下文快照”的建立与还原。
我们来看MyBatis执行一条select的完整链路:
UserService.list() → @DS("slave")触发AOP切面 → DynamicDataSourceContextHolder.set("slave") → MyBatis调用SqlSession.selectList() → SqlSessionTemplate获取SqlSession → SqlSessionTemplate委托给SqlSessionProxy → SqlSessionProxy调用invoke() → invoke()中调用target.getConfiguration().getEnvironment().getDataSource() → 此时才真正去DynamicDataSource.determineCurrentLookupKey()拿key注意最后一步:getDataSource()这个调用,发生在MyBatis内部最深处。而DynamicDataSource的determineCurrentLookupKey()方法,必须在此刻能准确读取到你之前set进去的"slave"。这就要求DynamicDataSourceContextHolder的存储容器,必须满足三个严苛条件:
- 线程隔离:不能用普通HashMap,必须用
ThreadLocal<Map<String, Object>>,否则高并发下A线程设的key被B线程读走; - 不可变快照:不能在每次get时都new一个Map,必须复用当前线程的Map实例,否则AOP切面set之后,MyBatis执行时拿到的是另一个空Map;
- 自动清理:每次HTTP请求结束(或任务执行完),必须调用
remove(),否则ThreadLocal内存泄漏——Tomcat线程池复用时,上一个请求留下的key会污染下一个请求。
我见过最典型的事故,是某次压测后数据库连接数暴增到5000+。排查发现,所有请求都阴差阳错地走到了同一个数据源(因为ThreadLocal没清理,一直沿用第一个请求设的key),而那个库的连接池被撑爆了。后来加了@AfterReturning和@AfterThrowing双保险清理,才稳定下来。
所以,DynamicDataSourceContextHolder的正确写法必须是:
public class DynamicDataSourceContextHolder { private static final ThreadLocal<String> CONTEXT_HOLDER = ThreadLocal.withInitial(() -> "master"); public static String get() { return CONTEXT_HOLDER.get(); } public static void set(String key) { CONTEXT_HOLDER.set(key); } public static void clear() { CONTEXT_HOLDER.remove(); // 关键!必须用remove,不是set(null) } }这里有个反直觉的细节:ThreadLocal.remove()比set(null)更安全。因为JDK的ThreadLocal内部是用弱引用Entry存value的,set(null)只是把value置空,Entry本身还在map里;而remove()会主动从map中删除整个Entry,彻底释放内存。线上环境跑一周不重启,set(null)积累的“幽灵Entry”能吃掉几百MB堆内存。
注意:
withInitial(() -> "master")这行代码,决定了默认数据源。很多团队把它写成"default",结果上线后发现所有没加@DS注解的接口全报“unknown datasource”,就是因为determineCurrentLookupKey()返回了null,而druid的createDataSource()方法对null key直接抛NPE。务必确保初始值是yml里真实存在的数据源名。
3. @DS注解的AOP切面:为什么80%的失败源于切点表达式错误
@DS注解本身只是个标记,真正干活的是AOP切面。但切点(Pointcut)写错,整个路由就失效。最常见的错误有三类:
3.1 切点范围过大:误伤Spring内部方法
错误写法:
@Pointcut("@annotation(com.example.annotation.DS)") public void dsPointcut() {}问题:这个切点会匹配所有带@DS的方法,包括org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction()这类Spring事务框架的内部方法。结果就是——事务管理器在开启事务时,也去执行了DynamicDataSourceContextHolder.set(),把当前线程的数据源key改成了"slave",导致后续所有SQL(包括事务提交/回滚的内部SQL)都发往从库,主库事务状态彻底混乱。
正确写法必须限定包路径:
@Pointcut("@annotation(com.example.annotation.DS) && execution(* com.example.service..*.*(..))") public void dsPointcut() {}com.example.service..*.*(..)这个表达式,明确告诉Spring AOP:“只拦截我司service包及其子包下的所有public方法”。这样事务框架的内部方法就被完美过滤掉了。
3.2 切点过浅:无法覆盖Mapper接口调用
另一类典型错误,是开发者把@DS加在Controller层:
@RestController public class OrderController { @GetMapping("/list") @DS("slave") // ❌ 错误!这里加注解无效 public List<Order> list() { return orderService.list(); // 实际SQL在service层执行 } }问题在于:MyBatis的Mapper接口是JDK动态代理生成的,它的执行链路是Controller → Service → MapperProxy.invoke() → SqlSessionTemplate。而MapperProxy.invoke()方法上根本没有@DS注解,AOP切面根本捕获不到。此时DynamicDataSourceContextHolder.get()拿到的还是初始的"master",SQL永远走主库。
解决方案只有两个:
- 方案一(推荐):@DS必须加在Service层方法上,且该方法内直接调用Mapper;
- 方案二(进阶):用
@DS配合@Transactional,但必须确保事务注解在Service层,且@DS在事务注解的同一方法上——因为Spring AOP代理链中,事务切面和数据源切面谁先执行,会影响最终效果。
3.3 嵌套调用丢失:父子方法数据源不一致
最隐蔽的坑,是Service方法A调用Service方法B,A标了@DS("master"),B标了@DS("slave"):
@Service public class OrderService { @DS("master") public void createOrder() { // 插入订单到主库 orderMapper.insert(order); // 调用日志服务,写入从库 logService.writeLog("order_created"); // B方法 } } @Service public class LogService { @DS("slave") public void writeLog(String content) { logMapper.insert(log); // 这里期望走从库 } }表面看很合理,但实测发现writeLog()依然走主库。原因在于:Spring AOP默认使用JDK动态代理,而logService.writeLog()是this调用(即this.writeLog()),绕过了代理对象,AOP切面根本没生效。
解决办法只有两个:
- 强制通过代理调用:在OrderService中注入自身代理:
@Autowired private OrderService self; // 不要直接用this public void createOrder() { orderMapper.insert(order); self.writeLog("order_created"); // ✅ 通过代理调用 } - 改用CGLIB代理:在启动类加
@EnableAspectJAutoProxy(proxyTargetClass = true),让Spring用CGLIB生成子类代理,这样this调用也能被拦截。但要注意:CGLIB代理要求目标类不能是final,且性能略低于JDK代理。
经验总结:我在三个不同项目里都踩过嵌套调用的坑。最稳妥的做法,是在公司基础框架里封装一个
DynamicDataSourceUtil.switchTo("slave")工具方法,让开发人员在需要切库的地方显式调用,而不是依赖AOP自动切换。虽然少了点“魔法感”,但胜在可控、可调试、无歧义。
4. Druid连接池的深度定制:不只是监控,更是故障熔断的第一道防线
druid常被当作“带监控的HikariCP替代品”,但在多数据源场景下,它真正的价值在于连接健康度的实时感知与主动干预能力。当主库网络抖动、从库CPU飙升时,druid能比应用层早300ms发现异常,并自动将流量切走——前提是,你打开了那些默认关闭的“保命开关”。
4.1 连接有效性验证:别让失效连接拖垮整个线程池
druid默认的validationQuery是SELECT 1,这在单库场景够用,但在多数据源下必须升级:
spring: datasource: master: druid: validation-query: SELECT 1 FROM DUAL # Oracle用DUAL,MySQL用information_schema.TABLES test-on-borrow: true test-while-idle: true time-between-eviction-runs-millis: 60000 slave: druid: validation-query: SELECT NOW() # 返回时间戳,更能验证连接时钟同步 test-on-borrow: false # 关键!禁止在借连接时验证,避免阻塞 test-while-idle: true time-between-eviction-runs-millis: 30000 # 从库检测频率提高一倍为什么test-on-borrow要关?因为多数据源下,一次业务请求可能涉及多次数据源切换。如果每次getConnection()都执行SELECT 1,在QPS 500的场景下,每秒会额外产生1000+无意义查询,拖慢整体TP99。而test-while-idle是后台线程定期扫描空闲连接,不影响主线程,这才是正确的姿势。
4.2 连接泄露预警:定位“忘记close”的罪魁祸首
多数据源最怕连接泄露。一个SqlSession没关闭,druid就会把它标记为“泄露连接”,并记录堆栈。但默认配置下,这个告警是静默的。必须显式开启:
druid: remove-abandoned-on-borrow: true remove-abandoned-timeout: 1800 # 30分钟未归还,强制回收 log-abandoned: true # ✅ 关键!把泄露堆栈打到日志某次线上事故中,我们发现从库连接数缓慢上涨。开启log-abandoned后,日志里立刻出现:
[WARN] com.alibaba.druid.pool.DruidDataSource - abandon connection, owner thread: http-nio-8080-exec-23, connected at: 2023-09-15 14:22:17, open stack: at com.example.service.OrderService.createOrder(OrderService.java:45) at sun.reflect.GeneratedMethodAccessor123.invoke(Unknown Source)精准定位到第45行——那里有个try-with-resources忘了写SqlSession,而是用了原始的session.close(),在异常分支里被跳过了。没有这个日志,排查至少要两天。
4.3 SQL防火墙:防住恶意注入,也防住低效SQL
druid内置SQL防火墙,在多数据源场景下,建议为主库开启,从库关闭(避免影响只读查询性能):
master: druid: wall: config: select-allow: true delete-allow: true update-allow: true insert-allow: true # 禁止union注入 union-allow: false # 禁止sleep等耗时函数 sleep-allow: false # 拦截执行时间超2s的SQL max-connection-hold-time-millis: 2000这里有个关键参数max-connection-hold-time-millis:它不是SQL执行超时,而是连接被占用的最长时间。当一个SQL执行超过2秒,druid会主动中断连接,并抛出SQLException。这能有效防止慢SQL拖垮整个连接池。我们在压测时故意注入SELECT SLEEP(5),druid在2秒后就断开了连接,主库其他请求完全不受影响。
实操心得:druid的
stat-view-servlet监控页面,一定要限制IP访问(如只允许运维内网IP)。我曾见过某公司把/druid打开在公网,结果被爬虫扫到,一天内被刷了20万次监控请求,直接把应用拖垮。安全不是功能,是底线。
5. MyBatis-Plus的适配改造:绕过AutoConfig,重建SqlSessionFactory
MyBatis-Plus的MybatisPlusAutoConfiguration是为单数据源设计的。当你配了多个DataSource,它会懵——到底该用哪个?默认行为是:只认spring.datasource下的主数据源,其他全忽略。所以,你必须手动接管SqlSessionFactory的创建,这是多数据源能跑起来的基石。
5.1 手动创建SqlSessionFactory:不是复制粘贴,而是理解每个Bean的职责
标准配置如下(以master为例):
@Configuration @MapperScan(basePackages = "com.example.mapper.master", sqlSessionTemplateRef = "masterSqlSessionTemplate") public class MasterDataSourceConfig { @Bean(name = "masterDataSource") @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DruidDataSourceBuilder.create().build(); } @Bean(name = "masterSqlSessionFactory") public SqlSessionFactory masterSqlSessionFactory(@Qualifier("masterDataSource") DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean bean = new MybatisSqlSessionFactoryBean(); bean.setDataSource(dataSource); bean.setMapperLocations(new PathMatchingResourcePatternResolver() .getResources("classpath:mapper/master/*.xml")); bean.setGlobalConfig(globalConfig()); // 全局配置,如ID生成器 return bean.getObject(); } @Bean(name = "masterSqlSessionTemplate") public SqlSessionTemplate masterSqlSessionTemplate( @Qualifier("masterSqlSessionFactory") SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } }注意三个关键点:
@MapperScan的sqlSessionTemplateRef必须指向你刚创建的masterSqlSessionTemplate,否则Mapper找不到对应的SqlSession;MybatisSqlSessionFactoryBean的setMapperLocations()必须指定该数据源专属的XML路径,不能写classpath:mapper/**/*.xml,否则所有XML都会被加载到所有SqlSessionFactory里,造成SQL重复注册;globalConfig()必须复用同一个实例,否则MP的自动填充、逻辑删除等功能在不同数据源间行为不一致。
5.2 BaseMapper的泛型陷阱:为什么你的insert方法总报错
MyBatis-Plus的BaseMapper<T>是泛型的,但泛型在运行时会被擦除。当你写:
public interface OrderMapper extends BaseMapper<Order> { // 这里T是Order,但MP怎么知道Order该映射到master还是slave? }MP默认按@TableName("t_order")找表,但它不知道t_order在哪个库。解决方案有两个:
- 方案一(推荐):在实体类上加
@TableName(value = "t_order", schema = "master_db"),schema字段填数据库名(MySQL需开启useInformationSchema=true); - 方案二(灵活):用
@DS注解标记Mapper接口:@DS("master") public interface OrderMapper extends BaseMapper<Order> {}
但要注意:@DS加在Mapper接口上,只对BaseMapper的默认方法生效(如insert()、selectById()),对自定义的@Select方法无效。后者必须在Mapper XML里显式写<select id="xxx" databaseId="master">,并在mybatis.configuration.databaseId="master"。
5.3 IService的多数据源适配:别让通用Service变成单库枷锁
IService<T>的save()、list()等方法,默认走的是SqlSessionTemplate绑定的SqlSessionFactory。如果你的MasterServiceImpl继承了ServiceImpl<MasterMapper, MasterEntity>,那它天然就绑定master库。但问题来了:如果一个Service既要操作主库又要操作从库,怎么办?
答案是:不要继承ServiceImpl,而是组合使用:
@Service public class OrderService { @Autowired private MasterMapper masterMapper; @Autowired private SlaveMapper slaveMapper; @DS("master") public void createOrder(Order order) { masterMapper.insert(order); } @DS("slave") public List<OrderLog> getOrderLogs(Long orderId) { return slaveMapper.selectLogsByOrderId(orderId); } }这样,每个Mapper都明确绑定自己的数据源,@DS注解控制路由,逻辑清晰,无歧义。强行让一个IService同时服务两个库,只会让代码变成“俄罗斯套娃”——save()里调updateById(),updateById()里又调selectById(),最后连你自己都搞不清当前在哪个库。
血泪教训:某项目为了“统一”,写了一个
GenericService<T>,通过反射判断T的class名来决定数据源。结果上线后发现,当实体类名含"Slave"时,连主库的User实体都被路由到从库。后来全部推倒重来,用显式Mapper注入+@DS,两周内稳定性从99.2%提升到99.99%。
6. 生产环境避坑清单:那些文档里不会写的12个致命细节
以下是我在线上环境踩过的坑,按严重程度排序,每一条都附带修复方案和验证方法:
| 序号 | 问题现象 | 根本原因 | 修复方案 | 验证方法 |
|---|---|---|---|---|
| 1 | 启动时报Cannot determine embedded database driver class for database type NONE | spring.datasource.url未配置,但MyBatis-Plus AutoConfig仍尝试初始化 | 在application.yml中显式设置spring.datasource.type=none,或排除MybatisPlusAutoConfiguration | 启动日志不再出现driver相关警告 |
| 2 | 多数据源切换后,事务不生效 | @Transactional注解在Controller层,而AOP代理未覆盖到 | 将@Transactional移到Service层,且与@DS在同一方法上 | 用TransactionSynchronizationManager.isActualTransactionActive()打印日志确认 |
| 3 | 从库查询结果总是旧数据 | MySQL主从延迟,但应用未做读写分离策略 | 在@DS("slave")方法里加@Transactional(readOnly = true),并配置@Transactional的timeout=3秒 | 用SHOW SLAVE STATUS查Seconds_Behind_Master < 1 |
| 4 | Druid监控页面显示连接数为0,但应用正常 | druid.stat-view-servlet.enabled=true未配置 | 在yml中添加druid.stat-view-servlet.enabled: true | 访问/druid/index.html能打开监控页 |
| 5 | @DS("slave")方法里调用super.list()报错 | ServiceImpl的list()方法未被@DS增强,走默认数据源 | 改用mapper.selectList(null),或在ServiceImpl子类中重写list()并加@DS | 日志中DynamicDataSourceContextHolder.get()输出"slave" |
| 6 | 测试环境OK,生产环境报Unknown system variable 'tx_isolation' | MySQL 8.0+废弃tx_isolation,druid旧版驱动不兼容 | 升级druid到1.2.16+,或在jdbcUrl加&serverTimezone=Asia/Shanghai | 查看druid日志,无Unknown system variable报错 |
| 7 | @DS切面不生效,始终走master | @DS注解类未加@Retention(RetentionPolicy.RUNTIME) | 在自定义@DS注解上添加@Retention(RetentionPolicy.RUNTIME) | 用getClass().getAnnotation(DS.class)能获取到注解实例 |
| 8 | 多线程环境下,子线程数据源错乱 | DynamicDataSourceContextHolder.set()未在子线程中传递 | 使用InheritableThreadLocal替换ThreadLocal,或在submit任务前手动set | 子线程日志中get()返回正确的数据源名 |
| 9 | @DS("slave")方法里执行INSERT语句成功 | @DS只控制SqlSession,不校验SQL类型 | 在AOP切面中增加SQL类型检查:if (method.getName().startsWith("insert")) throw new RuntimeException("slave库禁止写入") | 执行insert时报明确异常,而非静默成功 |
| 10 | Druid连接池初始化失败,报access denied | 多数据源共用同一账号,但从库账号无SELECT权限 | 为从库单独配置只读账号,在yml中spring.datasource.slave.username=readonly_user | SELECT USER(), CURRENT_USER()确认连接用户 |
| 11 | @DS注解在private方法上生效 | Spring AOP无法代理private方法,但某些IDE会误提示 | 删除private方法上的@DS,改为public并加@DS | 移除注解后,private方法调用不再触发数据源切换 |
| 12 | 应用重启后,Druid监控页面历史数据丢失 | druid.web-stat-filter.enabled=false未开启统计 | 添加druid.web-stat-filter.enabled: true | 监控页右上角显示“历史数据”选项卡 |
其中第8条(子线程数据源传递)最容易被忽视。Java的ThreadLocal默认不传递给子线程,所以CompletableFuture.supplyAsync()创建的异步任务,DynamicDataSourceContextHolder.get()永远返回"master"。修复方案有两种:
- 简单粗暴:用
InheritableThreadLocal:private static final InheritableThreadLocal<String> CONTEXT_HOLDER = new InheritableThreadLocal<String>() { @Override protected String childValue(String parentValue) { return parentValue; // 直接继承父线程值 } }; - 优雅可控:在提交任务前手动传递:
String dsKey = DynamicDataSourceContextHolder.get(); CompletableFuture.supplyAsync(() -> { DynamicDataSourceContextHolder.set(dsKey); try { return doSomething(); } finally { DynamicDataSourceContextHolder.clear(); } });
我推荐第二种。因为InheritableThreadLocal在ForkJoinPool等复杂线程池中行为不可控,而手动传递能确保每一处异步调用都明确可控。
最后分享一个压箱底技巧:在
DynamicDataSource.determineCurrentLookupKey()方法里,加一行日志:String key = DynamicDataSourceContextHolder.get(); log.debug("Route to datasource: {}", key); return key;这行日志在DEBUG级别,线上关闭,测试环境打开。它能让你在5秒内定位90%的路由问题——比如看到日志里反复打印"master",就知道
@DS根本没生效;看到一会儿"master"一会儿"slave",就知道AOP切点写错了范围。比翻十遍源码都管用。