ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot动态数据源切换:注解+AOP+ThreadLocal+AbstractRoutingDataSource实战

Spring Boot动态数据源切换:注解+AOP+ThreadLocal+AbstractRoutingDataSource实战 手写一套动态数据源切换机制在Spring Boot项目里几乎是迟早要面对的事。无论是读写分离、多租户隔离还是数据中台的统一接入核心场景都是同一个一次请求内根据业务规则把连接切到不同的DataSource上。网上方案不少但绝大多数写得绕要么在Service层硬编码切换逻辑要么遇到事务就失效真正能直接用的少。这篇文章我会从底层原理开始带你把注解 AOP ThreadLocal AbstractRoutingDataSource这套组合完整撸一遍把所有坑都标注在实操路径里。1. 动态数据源到底在解决什么问题1.1 从分库分表聊起哪些场景真的需要动态数据源先别急着写代码想清楚要不要上这套方案比实现本身更重要。我见过不少团队项目就一个库一个表为了以后扩展方便硬是引入动态数据源最后配置复杂了不说事务问题还埋了一堆雷。真正需要动态切换数据源的场景大致就这三类。第一类是读写分离。主库负责写入从库负责查询单表数据量上来之后把读流量拆分到多个从库是最常见的第一步。这种场景不需要引入ShardingSphere那样重的中间件靠Spring应用层切换就够了。第二类是多租户系统每个租户独立一个库登录进来之后根据租户ID路由到对应的数据库数据天然隔离恢复和备份都简单。第三类是报表中心的场景一张报表要从不同业务库拉数据一个方法里甚至要连续切换好几个数据源。这三种场景有一个共同特征切换的维度跟业务方法的职责强相关要么按操作类型切读/写要么按业务入参切租户ID、项目ID。这意味着最适合的抽象方式是方法级别声明式切换也就是加个注解的事。理解了这一点你才能发自内心地认同后面这套设计的合理性。1.2 常规实现方式的三个坑在给出优雅方案之前我先把常见写法踩过的坑摊开来说这些坑是判断方案好坏的最好参照。第一个坑是把连接拿死在Service层。有些方案直接在Service里写DataSource dataSource dataSourceMap.get(xxx)然后手动从连接池拿连接、拼SQL、释放连接。这样做的后果是事务完全靠手管一不留神就连接泄漏而且业务代码里全是数据源的路由逻辑可读性极差。第二个坑是切换了数据源但连接是复用的。Spring管理的Bean在方法执行期间如果已经被某个DataSource的Connection绑定比如开启了事务你再怎么切换DataSource对象都没用因为实际拿着的是上一个库的连接。这个问题最迷惑人表现是日志里数据源切了SQL却打到旧库。第三个坑是ThreadLocal没有清理导致脏切换。线程池里的线程是复用的一次请求结束了你不清掉ThreadLocal里的数据源标识下一次请求拿到的就是上一个请求残留的路由值数据直接串库。这个问题在测试环境几乎复现不出来一上生产就随机性报错。注意判断一套动态数据源方案优不优雅就看它是否同时解决了声明式路由、事务内切换被拦截和上下文安全释放这三件事。缺了任何一件上线后都是灾难。2. 底层原理Spring的数据源路由机制2.1 DataSource接口与连接池的关系要理解动态数据源先要搞清楚Spring里DataSource扮演的角色。DataSource在JDBC规范里是一个连接工厂但在Spring生态中它更像一个资源定位器MyBatis的SqlSessionFactory从它这里拿连接Spring的DataSourceTransactionManager也从它这里拿连接。只要你能让DataSource对象在运行时动态决定去哪个真实连接池拿连接整个数据访问层就都被你接管了。这里有个容易被忽略的点Spring容器里注册的DataSource往往不是一个裸连接池而是连接池对象HikariCP、Druid本身。连接池在初始化的时候会按配置创建一批物理连接如果你的Bean里配置了多个连接池它们都是独立存在的互相不干扰。动态数据源要做的事情就是在所有这些连接池之上再加一层路由器让DataSource接口的getConnection()方法根据当前上下文返回正确连接池的连接。2.2 AbstractRoutingDataSource如何工作Spring抽象包里早就预留好了解决方案就是org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource。这个类的设计思路非常简洁它本身实现了DataSource接口内部维护一个MapObject, DataSource作为目标数据源集合还有一个determineCurrentLookupKey()抽象方法等着你去实现。关键代码逻辑是这样的每次调用getConnection()时父类会先执行determineCurrentLookupKey()拿到一个路由Key然后用这个Key从Map里找到对应的目标DataSource再调用目标DataSource的getConnection()返回连接。也就是说路由发生在拿连接这个瞬间而不是在初始化阶段。public abstract class AbstractRoutingDataSource extends AbstractDataSource { // 关键点getConnection 先确定 key再决定从哪个真实数据源拿连接 Override public Connection getConnection() throws SQLException { return determineTargetDataSource().getConnection(); } protected DataSource determineTargetDataSource() { Object lookupKey determineCurrentLookupKey(); // 根据 key 从 targetDataSources 这个 map 里取真实数据源 DataSource dataSource this.targetDataSources.get(lookupKey); return dataSource; } // 子类需要实现这个方法返回当前应该使用哪个数据源的标识 protected abstract Object determineCurrentLookupKey(); }这个设计最妙的地方在于MyBatis、JdbcTemplate、事务管理器拿到的都是AbstractRoutingDataSource这同一个对象但每次真正执行时连接来自哪个底层库完全由运行时决定。业务代码无感知数据访问框架也无感知一切被透明地代理掉了。2.3 ThreadLocal数据源标识的隐形口袋determineCurrentLookupKey()需要知道当前该用哪个数据源这个信息从哪里来在大多数Web请求场景下路由依据都跟当前线程绑定的业务上下文有关比如当前登录用户所属的租户、当前操作的类型等。所以最合理的存储位置就是ThreadLocal。ThreadLocal就像是每个线程随身携带的一个隐形口袋线程A放进去一个值线程B完全看不到同一个线程后续的执行代码不管经过多少层Service调用都能随时掏出来。这恰好匹配一次请求内持续有效的需求。public class DynamicDataSourceContextHolder { // 用 ThreadLocal 保存当前线程的数据源标识 private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void setDataSource(String dataSourceType) { CONTEXT_HOLDER.set(dataSourceType); } public static String getDataSource() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }这里必须强调一个细节清除时要用remove()而不是set(null)。ThreadLocal的remove()会真正删掉线程局部存储中的条目而set(null)只是放了一个null值对于池化的线程来说部分实现里仍可能残留Entry引用极端情况下会有内存泄漏风险。这个细节我在后面排查章节还会展开。3. 手写一个优雅的动态数据源完整实现3.1 第一步定义数据源枚举与注解整个实现的核心思路是先明确契约业务方法通过注解声明自己要用哪个数据源切面负责在方法执行前设置ThreadLocal方法结束后清理。所以第一个要定义的是数据源标识的枚举以及一个可以打在方法上的DataSource注解。public enum DataSourceType { MASTER, // 主库 SLAVE // 从库 }Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) Documented public interface DataSource { DataSourceType value() default DataSourceType.MASTER; }注解的Target里我同时加了METHOD和TYPE两个维度这样既能精确到方法也能在类级别兜底。Retention必须是RUNTIME否则AOP在运行期拿不到注解信息。3.2 第二步实现一个带默认值的数据源上下文持有器前面那个最简单的ThreadLocal版本其实不够严谨因为它没有默认值兜底。试想一个场景一个新写的Service方法忘记加DataSource注解此时determineCurrentLookupKey()返回null路由逻辑该怎么走我的做法是初始化时放入默认数据源标识这样即便忘记加注解也默认走主库不会直接报错。public class DynamicDataSourceContextHolder { private static final ThreadLocalDataSourceType CONTEXT_HOLDER new ThreadLocal(); // 默认数据源初始化时设置一次 private static volatile DataSourceType defaultDataSource DataSourceType.MASTER; public static void setDataSource(DataSourceType type) { CONTEXT_HOLDER.set(type); } public static DataSourceType getDataSource() { return CONTEXT_HOLDER.get() null ? defaultDataSource : CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }注意这里defaultDataSource用volatile修饰因为Spring容器初始化的线程和请求执行线程可能不是同一个保证可见性。这个设计虽然简单但实际使用中能减少一类非常隐蔽的空指针级别的故障AbstractRoutingDataSource拿到一个null key直接抛IllegalStateException。3.3 第三步基于AOP的自动切换切面切面是整个方案的操作核心它负责三件事解析方法上的数据源注解、在目标方法执行前设置上下文、在方法执行后包括异常路径清理上下文。这里我推荐使用Spring AOP的Around注解因为它能保证无论目标方法正常返回还是抛出异常清理逻辑都在finally里执行。Aspect Component public class DataSourceAspect { // 切入点所有标注了 DataSource 注解的方法 Around(annotation(dataSource)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DataSource dataSource) throws Throwable { // 1. 先记录之前的上下文值方便后续还原嵌套场景需要 DataSourceType previous DynamicDataSourceContextHolder.getDataSource(); // 2. 设置当前线程要用的数据源 DynamicDataSourceContextHolder.setDataSource(dataSource.value()); try { return joinPoint.proceed(); } finally { // 3. 清理当前线程上下文避免脏数据串到下一次请求 DynamicDataSourceContextHolder.clear(); } } }这段代码有几个值得咬文嚼字的地方。第一Around(annotation(dataSource))这种写法让Spring把注解实例直接注入到通知方法参数里不需要反射去拿性能更好。第二我没有在结束时还原previous值而是直接clear()。为什么因为正常情况下一旦方法返回线程就该回到池子里等待下一个请求任何残留值都是隐患。直接清掉比还原更安全。第三joinPoint.proceed()是有可能抛异常的finally保证异常场景下上下文同样被清理这是内存和线程安全双保险。注意如果你使用了Transactional注解和动态数据源切换混用切面的执行顺序会变得非常关键。我建议将数据源切面设置为Order(Ordered.HIGHEST_PRECEDENCE)确保它在事务切面之前执行否则事务管理器先绑定了连接后续切换就失效了。这个点稍后第4章详细讲。3.4 第四步继承AbstractRoutingDataSource并注册到容器有了线程上下文现在要做的就是把它接入Spring的数据源路由机制。写一个RoutingDataSource类继承AbstractRoutingDataSource实现determineCurrentLookupKey()方法返回值就是ThreadLocal里存的枚举。public class RoutingDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.getDataSource(); } }然后在配置类里把所有数据源准备好注册到RoutingDataSource中。这里我以HikariCP为例把主库、从库两个独立连接池配置好放进一个Map里。targetDataSources是路由目标集合defaultTargetDataSource是兜底数据源这两个属性必须都设置。Configuration public class DataSourceConfig { // 主库连接池 Bean ConfigurationProperties(prefix spring.datasource.master) public DataSource masterDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } // 从库连接池 Bean ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().type(HikariDataSource.class).build(); } // 路由数据源作为 MyBatis 等框架实际使用的 DataSource Bean public DataSource routingDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(DataSourceType.MASTER, masterDataSource()); targetDataSources.put(DataSourceType.SLAVE, slaveDataSource()); RoutingDataSource routingDataSource new RoutingDataSource(); routingDataSource.setTargetDataSources(targetDataSources); routingDataSource.setDefaultTargetDataSource(masterDataSource()); return routingDataSource; } }这里最核心的配置动作有两个一是把所有真实连接池放进targetDataSourcesKey跟枚举值一一对应二是把默认数据源设为Master保证未标注的情况下也能正常走主库。配置完成后你需要把SqlSessionFactory和DataSourceTransactionManager的DataSource都指向routingDataSource这一步漏了的话整个路由机制根本不会被触发。3.5 完整演示一个方法如何自动切换配置全部收口之后业务侧的使用体验就是一行注解的事这也是我一直追求的效果——框架的复杂度应该死在配置层业务代码越简单越好。Service public class OrderService { // 查询操作自动走从库 DataSource(DataSourceType.SLAVE) public ListOrder listOrders(Long userId) { // 直接调用 mapper底层连接来自从库 return orderMapper.selectByUserId(userId); } // 写入操作默认走主库注解都可省 DataSource(DataSourceType.MASTER) public void createOrder(Order order) { orderMapper.insert(order); } }调用listOrders时AOP切面在方法进入前设置ThreadLocal为SLAVEMyBatis执行getConnection()时RoutingDataSource返回从库连接方法结束即使没来得及清理也会由finally兜底清除。整个切换对业务代码完全透明后续想加第三个库只需要加枚举、加配置、加targetDataSources里的条目业务侧零改动。4. 事务、连接池与多数据源的进阶处理4.1 事务的遮天之手为什么加了事务就切换失败这是一个极其高频的线上事故。很多人在Service方法上同时加了DataSource(SLAVE)和Transactional结果发现查询还是打到了主库或者更诡异的是报Connection is read-only之类的错误。根因在于Spring的事务管理机制DataSourceTransactionManager在事务开启时就会从DataSource获取一个连接绑定到当前线程整个事务期间所有SQL都复用这个连接。如果你的动态数据源切面在事务切面之后才执行或者事务先于路由拿到了连接后续切换数据源根本无效。解决方案是控制切面顺序让数据源切面先于事务切面执行并且保证事务真正使用路由后的DataSourceConfiguration public class AopConfig { // 数据源切面放在最高优先级确保先于事务切面执行 Bean public DataSourceAspect dataSourceAspect() { return new DataSourceAspect(); } // 通过 Order 控制切面顺序 Order(Ordered.HIGHEST_PRECEDENCE) Aspect public static class DataSourceOrderAspect {} // 事务管理器必须指到 routingDataSource 上 Bean public DataSourceTransactionManager transactionManager(DataSource routingDataSource) { return new DataSourceTransactionManager(routingDataSource); } }另一个更稳妥的做法是不要在需要动态切换的多数据源方法上直接用Spring事务而是把事务控制在更小的Service内部。比如在Controller层调用一个不带Transactional的门面方法门面方法内部调用两个带事务的子方法每个子方法的路由自己负责。这样既兼顾了事务又完全不和路由打架。4.2 连接池隔离为什么需要每个数据源独立连接池有些简化方案试图用一个连接池动态改URL这个思路在实践中是不可行的。连接池在初始化时会预创建一批物理连接这些连接的底层Socket、认证信息都已经固定了你改了URL也不可能改到已经建立好的连接上去。所以动态数据源的前提就是每个目标库都有自己独立的连接池实例。连接池参数也要单独规划。主库写入压力大连接数可以给高一些从库做报表查询可能长查询多超时时间要调大。我把常用的对比参数整理一下配置的时候直接对照着填连接池参数主库写入为主从库查询为主说明maximum-pool-size2030从库并发读多池子给大minimum-idle510保持核心连接数减少建连开销connection-timeout3000030000连接获取超时max-lifetime18000001800000防止数据库端回收连接read-onlyfalsetrue从库强制只读防误写从库设read-onlytrue是很多人忽略但又特别实用的一步它能从数据库驱动层直接拦截误操作SQL避免一次手滑把查询方法里的update语句打到从库导致主从数据不一致。4.3 主从模式的读写分离优雅实践既然已经搭好了路由基础设施不妨顺手把读写分离做完整。一个常见需求是默认所有查询走从库只有带DataSource(MASTER)的方法才走主库。这个策略可以通过修改切面的默认行为来实现。我习惯做一个ReadOnly注解配合DataSource的默认值策略。查询方法上不需要标注任何注解切面默认路由到从库写入方法标注DataSource(MASTER)强制走主库。这样代码更简洁也避免遗忘。Aspect Component public class ReadWriteAspect { Around(execution(* com.example.service..*.*(..))) public Object route(ProceedingJoinPoint joinPoint) throws Throwable { // 默认走主库写操作必须精确标注 DataSourceType target DataSourceType.MASTER; // 如果方法上是只读注解切到从库 MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); ReadOnly readOnly method.getAnnotation(ReadOnly.class); if (readOnly ! null) { target DataSourceType.SLAVE; } DynamicDataSourceContextHolder.setDataSource(target); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }不过主从分离有个天然痛点主库写入后从库同步存在延迟。如果业务上写完立刻读就需要强制走主库。我的经验是所有强一致性的写完即读方法都必须显式标注DataSource(MASTER)并且这个规则要写进团队开发规范里。技术能解决路由问题但路由到哪里是业务规则决定的这部分只能靠约定。5. 常见故障与排查技巧实录5.1 切面不生效数据源切换毫无反应这是最常见的启动期问题。现象是加了注解但线程上下文从未被设置所有SQL都走默认数据源。排查顺序我建议按这四条来第一确认切面类被Spring扫描到。Aspect注解的Bean如果不在主类扫描包路径下Spring AOP不会理它。第二确认Spring AOP的自动代理已经开启。Spring Boot默认会开启但如果你自定义了EnableAspectJAutoProxy或者覆盖了自动配置可能把代理机制弄坏了。第三确认注解打在public方法上。Spring AOP基于动态代理private方法或同类内部自调用this.xxx()都不会走切面。第四确认业务方法不是通过this内部调用的。同类方法内部this.method()调用不走代理这是Spring AOP最经典的一道坑解决办法是拆到不同Bean或者用AopContext.currentProxy()。# 排查小技巧启动时加上以下日志级别能看到代理对象的生成情况 logging.level.org.springframework.aopDEBUG logging.level.org.springframework.jdbc.datasourceDEBUG日志里如果看到Creating proxy相关输出说明代理生成正常如果完全没有就回到扫描和配置层面排查。5.2 数据源切换成功了连接拿到的还是旧库这个问题出现的场景通常是方法A先切到从库执行了一段查询再切回主库执行写入结果发现写入也打到了从库。原因非常明确——事务或连接缓存干扰了路由。具体来说如果方法A上带了Transactional事务管理器在第一次获取连接后就把它绑定到当前线程的事务资源里后续所有JDBC操作都从这个绑定的连接上拿。此时你ThreadLocal里已经把Key改成MASTER了但事务管理器根本不会再找DataSource要连接连接是事务开启时那个连接池的路由等于被绕过。我建议的排查思路是先看方法上有没有事务注解有就去掉或缩小范围再看是不是有第三方框架在MyBatis层面做了连接缓存比如Transactional(propagation Propagation.REQUIRED)嵌套调用。最根治的做法是把可能路由不同数据源的方法彻底拆开各自独立开事务而不是依赖外层事务。5.3 多线程与异步方法导致上下文丢失Async异步执行、CompletableFuture、线程池这些都是动态数据源的隐形杀手。原因是 ThreadLocal 的线程隔离特性你主线程设置的Key在异步线程里根本看不到异步线程要么拿到null走默认数据源要么串到别人设置的值。排查场景往往是这样的一个查询方法内部来了个OrderMapper.select()异步执行主方法上明明设置了SLAVE但异步任务拿到的是默认MASTER连接。这不能怪ThreadLocal它的设计就是线程隔离的。解决方案有两条路一是在进入异步方法前把数据源类型作为参数传进去在异步方法内部显式set二是封装一个ContextCopyingDecorator在提交任务时复制主线程的ThreadLocal到子线程。public class ContextCopyingDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { // 记录主线程的数据源类型 DataSourceType type DynamicDataSourceContextHolder.getDataSource(); return () - { try { // 子线程复制进来 DynamicDataSourceContextHolder.setDataSource(type); runnable.run(); } finally { DynamicDataSourceContextHolder.clear(); } }; } }线程池配置允许传入TaskDecorator这个方案能优雅地把数据源上下文跨线程传递。但我的建议是线上系统尽量别在跨库操作的链路上引入异步因为异步不仅丢失上下文还会打乱事务边界和异常处理收益往往不值得代价。6. 我的个人实践体会与选型建议6.1 这条技术路线的边界在哪里这套注解 AOP ThreadLocal AbstractRoutingDataSource的方案被我用在多个生产项目里稳定性经过验证但使用边界必须说清楚。它适合数据源数量有限几个到几十个、路由规则明确按租户或按读写、不需要跨库查询的场景。如果你的路由规则复杂到需要按SQL内容动态判断或者需要在一次查询中同时访问多个分片那就该考虑ShardingSphere这类重框架了。另外在项目早期就要想清楚一个策略问题动态数据源是锦上添花的架构能力不是雪中送炭的容灾手段。它解决的是路由问题解决不了数据一致性、同步延迟和跨库事务。不要在只有单库的时候为未来扩展引入它等到真实需要读写分离或分库时再上也不迟这是成本最低的路径。6.2 三个提升体验的细节优化最后分享三个我实际用下来体验提升很大的细节优化虽然每个都只是几行代码但它们往往决定这套方案在团队里能不能被顺畅接受。第一个优化是数据源标识的日志埋点。在RoutingDataSource的determineCurrentLookupKey()里打印一条TRACE级别日志线上排查问题时能直接看到每个SQL实际路由到了哪个库配合-Dlogging.level.com.exampleTRACE参数定位数据打到错库的问题能快一个数量级。注意用TRACE而不是INFO否则生产日志里全是刷屏。第二个优化是给切面增加嵌套逻辑的入栈计数。上面提到的方案在方法A切到SLAVE、A内部又调用同样带注解的方法切回MASTER时外面那个finally会把内部的上下文清理掉。这里需要把ThreadLocal改成Stack结构支持嵌套的入栈和出栈。这个改动一般只有复杂业务链路上才需要但在做多租户中间件时可以提前把结构设计成栈成本很低收益很大。第三个优化是通过Spring的Environment做数据源配置的加密解耦。数据源密码不要明文写在application.yml里使用jasypt-spring-boot或者配置中心加密。动态数据源方案因为涉及多个库密码暴露面比单库更大这个细节必须重视。我用的是配置中心加解密插件本地开发用dev配置覆盖生产走加密值既安全又灵活。动态数据源切换没有黑魔法本质就是上下文存储 路由委托 切面生命周期管理这三件套。把每一件都做干净方案自然就优雅起来。希望这篇基于实战的拆解能帮你少踩几个坑如果你在落地过程中遇到其他诡异问题欢迎带上报错日志来交流。
返回列表