1. 为什么要在项目里搞动态数据源切换
做过ToB系统或者SaaS平台的朋友大概率都遇到过这个场景:同一套业务代码,需要根据当前登录用户所属的租户、请求携带的标识、或者某个业务开关,把SQL打到不同的数据库实例上。早期图省事,很多人直接写死一个数据源,后来业务一膨胀,读写分离、分库分表、多租户隔离全来了,硬编码那套根本扛不住。
我接手过一个模拟项目X,后台管理端要同时服务三个不同业务线,每条业务线的数据物理隔离,各自有独立的库。最开始的做法是每个业务线单独部署一套服务,运维成本高得离谱,改一个公共bug要发三次版。后来团队决定合并成一套服务,靠动态数据源切换在运行时决定这次请求走哪个库。核心实现手段,就是ThreadLocal。
这篇文章我想把当时踩过的坑、最终落地的方案、以及一些容易被忽略的细节完整讲一遍。适合正在做多租户、读写分离、分库分表,或者单纯想搞清楚Spring动态数据源底层原理的同学。哪怕你之前没接触过ThreadLocal,我也会用生活化的方式把它讲明白,保证你能照着复现一套可用的方案。
先说清楚这个方案能解决什么问题:它让同一个Service方法,在不改任何业务代码的前提下,根据上下文自动路由到不同的数据库连接。请求进来时确定数据源,请求结束时清理掉,全程对业务透明。这就是标题里"基于ThreadLocal来实现动态数据源切换"的核心价值。
2. 整体设计思路与方案选型拆解
2.1 为什么偏偏是ThreadLocal
要理解这个方案,得先明白一个前提:Web请求在服务端的处理模型。绝大多数Java Web容器(比如Tomcat)处理请求时,一个请求从头到尾基本由一个线程负责(同步模型下)。这就意味着,只要我把"这次请求该用哪个数据源"这个信息,绑定到当前线程身上,那么这条链路上任何一层代码——Controller、Service、DAO——都能随手取到,不需要一层层往下传参数。
ThreadLocal干的就是这件事。你可以把它想象成每个线程随身携带的一个储物柜,柜子里的东西只有这个线程自己能看见、能取用,别的线程碰不到。请求进来时往柜子里塞一个数据源标识,请求结束时把柜子清空,中间无论调用多少层方法,都能从柜子里把标识拿出来用。
对比几种常见方案,ThreadLocal的优势就很明显了:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 方法参数透传 | 每层方法加数据源参数 | 直观、无隐式状态 | 侵入性极强,改造成本高 |
| ThreadLocal | 线程绑定上下文 | 业务无感知、改造成本低 | 需注意内存泄漏和异步传递 |
| AOP + 注解 | 注解标记方法 | 声明式、优雅 | 粒度粗,动态性差 |
| 拦截器 + 请求属性 | request作用域 | 天然隔离 | 只能拿到request的地方能用 |
选ThreadLocal,本质是拿"隐式上下文"换"业务代码零侵入"。这个交易在动态数据源场景里非常划算,因为数据源切换点往往在框架层(比如AOP切面、MyBatis拦截器),业务代码根本不该关心自己连的是哪个库。
2.2 整体架构分层
方案落地后,整个链路大致分四层,我按请求流向拆一下:
- 入口层:一个拦截器(Interceptor)或者过滤器(Filter),在请求进入时解析出租户标识/数据源key,写入ThreadLocal。
- 上下文层:一个持有ThreadLocal的上下文容器类,提供set/get/clear三个方法,是全局唯一的"储物柜管理员"。
- 路由层:一个继承自Spring
AbstractRoutingDataSource的动态数据源,它的determineCurrentLookupKey()方法从上下文层取key,决定用哪个真实数据源。 - 清理层:请求结束时(拦截器的afterCompletion),调用clear把ThreadLocal清掉,防止线程复用导致的数据串库。
这个分层的关键在于职责单一:上下文层只管存取,路由层只管决策,入口层只管解析和清理。任何一层出问题都好定位。
2.3 一个必须提前想清楚的问题:异步怎么办
同步模型下ThreadLocal完美工作,但只要你用了线程池、@Async、CompletableFuture,子线程是拿不到父线程ThreadLocal里的值的。这是这个方案最大的软肋,也是我当初踩得最狠的坑。
解决办法有两个方向:一是用InheritableThreadLocal,让子线程继承父线程的值——但它在线程池场景下会失效,因为线程池里的线程是复用的,创建时机和任务提交时机对不上。二是用阿里开源的TransmittableThreadLocal(TTL),配合它的线程池装饰器,能在任务提交时把上下文快照传递过去。
我的建议是:如果你的系统里有异步,直接上TTL,别在InheritableThreadLocal上浪费时间。这个决策我在后面章节还会展开讲。
3. 核心细节解析与实操要点
3.1 上下文容器类的设计细节
上下文容器是整个方案的心脏,写得好不好直接决定稳定性。我见过不少实现,直接暴露一个ThreadLocal<String>给外面用,这是大忌。正确做法是把它包起来,只暴露语义化的方法。
public class DataSourceContextHolder { private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void setDataSourceKey(String key) { CONTEXT.set(key); } public static String getDataSourceKey() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }几个要点必须强调:
- 用
remove()而不是set(null)。set(null)只是把值置空,Entry还在,线程池复用线程时这个Entry会一直挂着,虽然值没了但结构还在,长期看是内存隐患。remove()才是彻底清理。 - key的默认值处理。
getDataSourceKey()返回null时,路由层要能兜底到默认数据源,否则会直接抛异常。我一般让路由层判断null就走主库。 - 不要在这个类里做业务判断。有人喜欢在这里写"如果key是A就用B"这种逻辑,这是把路由规则和上下文存储耦合了,后面维护会疯。
3.2 动态数据源的路由实现
Spring提供了AbstractRoutingDataSource这个抽象类,它就是为动态数据源量身定做的。你只需要继承它,实现determineCurrentLookupKey(),返回的key会去targetDataSources这个Map里找对应的真实数据源。
public class DynamicDataSource extends AbstractRoutingDataSource { @Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }配置的时候,把多个真实数据源塞进一个Map,再指定一个默认数据源:
@Configuration public class DataSourceConfig { @Bean public DataSource dynamicDataSource( @Qualifier("masterDataSource") DataSource master, @Qualifier("slaveDataSource") DataSource slave) { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", master); targetDataSources.put("slave", slave); DynamicDataSource dynamic = new DynamicDataSource(); dynamic.setTargetDataSources(targetDataSources); dynamic.setDefaultTargetDataSource(master); return dynamic; } }这里有个极易踩的坑:AbstractRoutingDataSource在初始化时会调用afterPropertiesSet(),把targetDataSources里的数据源解析成resolvedDataSources。如果你在运行时动态往Map里加数据源,是加不进去的,因为解析已经完成了。要支持运行时动态增删数据源,得自己维护一个可变的Map,并重写determineTargetDataSource()。这个细节很多教程都不提,但生产环境一旦需要热加数据源就会卡住。
3.3 切面与拦截器的分工
数据源key的写入时机很关键。我一般分两个地方:
- 拦截器(Interceptor):负责从请求头、URL参数、用户会话里解析出租户标识,写入ThreadLocal,并在
afterCompletion里清理。这是"请求级"的切换。 - AOP切面:负责方法级的切换,比如某个Service方法上标了
@DataSource("slave"),切面在方法执行前set,执行后clear。这是"方法级"的切换。
两者可以共存,但要注意嵌套时的恢复问题。如果拦截器设了master,切面又设了slave,切面执行完clear后,ThreadLocal就空了,拦截器后续的代码再取就取不到master了。正确做法是切面在set之前先保存旧值,执行完恢复旧值,而不是直接clear。
@Around("@annotation(dataSource)") public Object around(ProceedingJoinPoint point, DataSource dataSource) throws Throwable { String origin = DataSourceContextHolder.getDataSourceKey(); try { DataSourceContextHolder.setDataSourceKey(dataSource.value()); return point.proceed(); } finally { if (origin == null) { DataSourceContextHolder.clear(); } else { DataSourceContextHolder.setDataSourceKey(origin); } } }这个"保存-恢复"模式是嵌套场景的标准解法,务必记住。
3.4 事务与数据源切换的顺序陷阱
这是动态数据源里最隐蔽的坑,没有之一。Spring的事务和动态数据源的执行顺序是这样的:事务开启时会从数据源拿一个连接并绑定到当前线程,之后整个事务期间都用这个连接。如果你在事务方法内部才切换数据源,切换是不生效的,因为连接早就绑定了。
换句话说,数据源切换必须发生在事务开启之前。常见的错误写法:
@Transactional public void doSomething() { DataSourceContextHolder.setDataSourceKey("slave"); // 太晚了 userMapper.query(); // 还是走的master连接 }正确做法是把切换放在事务方法的外层,或者用编程式事务手动控制。我一般建议:切面的优先级要高于事务切面,通过@Order控制,让数据源切换先于事务执行。这个顺序问题我在模拟项目X上线前压测时才发现,当时数据一直写错库,排查了大半天。
4. 完整实操流程与关键环节实现
4.1 环境准备与依赖清单
假设你用的是Spring Boot,核心依赖其实很少:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.14.3</version> </dependency>TTL这个依赖是可选的,只有异步场景才需要。数据库驱动按你实际用的选,连接池我推荐HikariCP,Spring Boot默认就是它,性能稳。
配置文件里把多个数据源配好:
spring: datasource: master: jdbc-url: jdbc:mysql://host-a:3306/biz_db username: app_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver slave: jdbc-url: jdbc:mysql://host-b:3306/biz_db username: app_user password: your_password driver-class-name: com.mysql.cj.jdbc.Driver注意这里用的是jdbc-url而不是url,因为多数据源场景下Spring Boot不会自动装配,得手动建DataSource Bean,而HikariCP的配置项名就是jdbc-url。这个细节坑过不少人,配成url会报绑定失败。
4.2 数据源Bean的创建
每个真实数据源单独建Bean,用@ConfigurationProperties绑定配置:
@Configuration public class DataSourceConfig { @Bean("masterDataSource") @ConfigurationProperties(prefix = "spring.datasource.master") public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } @Bean("slaveDataSource") @ConfigurationProperties(prefix = "spring.datasource.slave") public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } }这里有个参数选择的经验:连接池大小不要拍脑袋定。我一般按"最大并发请求数 / 单请求平均持有连接时长"来估算。比如峰值QPS是200,每个请求平均持有连接50ms,那理论上需要10个连接就够,但考虑到突发和慢查询,我会配到20-30,并设置合理的connection-timeout(一般3秒)和max-lifetime(比数据库的wait_timeout略小,比如数据库是8小时,我就配7小时)。
4.3 拦截器实现请求级切换
拦截器负责解析和清理,这是请求级切换的入口:
@Component public class DataSourceInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String tenant = request.getHeader("X-Tenant-Id"); String key = resolveKey(tenant); DataSourceContextHolder.setDataSourceKey(key); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { DataSourceContextHolder.clear(); } private String resolveKey(String tenant) { if ("tenant_b".equals(tenant)) { return "slave"; } return "master"; } }afterCompletion里的clear是必须的,哪怕请求抛异常也要执行,因为它在finally语义里。如果漏了这一步,线程池里的线程被下一个请求复用时,会带着上一个请求的数据源key,直接导致数据串库。这种bug极其难查,因为它是概率性的,取决于线程复用时机。
4.4 异步场景的TTL改造
如果系统里有@Async或者手动提交线程池任务,就得把ThreadLocal换成TransmittableThreadLocal:
public class DataSourceContextHolder { private static final TransmittableThreadLocal<String> CONTEXT = new TransmittableThreadLocal<>(); public static void setDataSourceKey(String key) { CONTEXT.set(key); } public static String getDataSourceKey() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } }光换类型还不够,线程池必须用TTL的装饰器包一层,否则传递不生效:
ExecutorService executor = TtlExecutors.getTtlExecutorService( new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100)));或者用@Async的话,配置一个TaskDecorator:
@Bean("taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setTaskDecorator(new TtlTaskDecorator()); executor.initialize(); return executor; }TTL的原理是在任务提交时对上下文做一次快照,任务执行时把快照回放到执行线程,执行完再恢复。这样即使线程池复用线程,也不会串上下文。这个改造我强烈建议在项目初期就做,后期补的话要排查所有异步入口,工作量翻倍。
4.5 验证切换是否生效
写完代码别急着上线,先验证。最简单的办法是在DAO层打日志,把当前连接的实际URL打出来:
@Around("execution(* com.example.mapper.*.*(..))") public Object logDataSource(ProceedingJoinPoint point) throws Throwable { DataSource ds = DataSourceContextHolder.getDataSourceKey() == null ? null : null; log.info("current datasource key: {}", DataSourceContextHolder.getDataSourceKey()); return point.proceed(); }更严谨的做法是拿到Connection后调getMetaData().getURL(),直接看物理连接指向哪个库。我一般会写一个测试接口,手动set不同的key,然后查一条带库标识的数据,确认路由正确。这一步花十分钟,能省掉上线后几小时的排查。
5. 常见问题与排查技巧实录
5.1 数据串库了怎么办
这是最严重的问题,表现为A租户查到了B租户的数据。排查思路按这个顺序走:
- 先确认ThreadLocal有没有清理。在
afterCompletion里加日志,看每次请求结束是否都执行了clear。如果没执行,八成是拦截器没注册成功,或者请求走了别的路径绕过了拦截器。 - 确认异步链路。如果用了线程池但没做TTL传递,子线程会拿到脏数据。查一下所有
@Async和手动提交任务的地方。 - 确认事务顺序。如果切换发生在事务内部,连接没换,但ThreadLocal的key变了,后续非事务查询可能走错库,造成"同一请求内数据源不一致"的诡异现象。
我整理了一个速查表:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 偶发串库 | ThreadLocal未清理 | 检查afterCompletion日志 |
| 异步任务串库 | 未用TTL传递 | 检查线程池装饰器 |
| 事务内切换无效 | 切换晚于事务开启 | 调整切面@Order |
| 切换后仍走默认库 | key拼写不一致 | 核对Map的key与set的值 |
| 启动报数据源找不到 | targetDataSources未解析 | 检查是否运行时动态添加 |
5.2 内存泄漏的隐患
ThreadLocal的内存泄漏是老生常谈,但很多人只知道"要remove",不知道具体泄漏在哪。ThreadLocalMap的Entry的key是弱引用(指向ThreadLocal对象),value是强引用。当ThreadLocal对象被回收后,key变成null,但value还挂在Entry上,如果线程一直活着(线程池场景),这个value就永远回收不掉。
所以remove不是可选项,是必选项。而且要在finally语义里执行,保证异常时也能清理。我见过有人把clear写在业务代码最后一行,结果中间抛异常就漏了,这种写法必须改。
5.3 动态增删数据源的坑
前面提过,AbstractRoutingDataSource初始化后targetDataSources就固定了。如果业务需要运行时加数据源(比如新租户接入),得自己维护一个ConcurrentHashMap,并重写determineTargetDataSource():
public class DynamicDataSource extends AbstractRoutingDataSource { private final Map<Object, DataSource> dataSources = new ConcurrentHashMap<>(); public void addDataSource(Object key, DataSource ds) { dataSources.put(key, ds); } @Override protected DataSource determineTargetDataSource() { Object key = determineCurrentLookupKey(); DataSource ds = dataSources.get(key); return ds != null ? ds : super.determineTargetDataSource(); } }注意super.determineTargetDataSource()会走默认数据源,作为兜底。这个改造让数据源可以热插拔,适合多租户SaaS场景。
5.4 几个实操心得
- key的命名要统一规范。我一般用
业务线_用途的格式,比如order_master、order_slave,避免用纯数字或随机字符串,后期排查时一眼能看懂。 - 默认数据源一定要设。
setDefaultTargetDataSource不设的话,key为null时会直接抛IllegalStateException,线上就是500。 - 压测时重点测切换。单数据源压测看不出问题,一定要构造多租户并发请求,观察是否有串库。我一般用两个租户交替请求,跑个几万次,看数据落库是否正确。
- 日志里带上数据源key。在MDC里塞一份,这样每条日志都能看到当前请求走的是哪个库,排查问题时效率翻倍。
6. 方案扩展与进阶方向
6.1 读写分离的自动路由
动态数据源最经典的应用就是读写分离。思路是在DAO层根据方法名前缀自动判断:select、query、get开头的走从库,insert、update、delete开头的走主库。用AOP拦截Mapper方法,根据方法名set不同的key。
但要注意主从延迟问题。刚写完的数据立刻查,从库可能还没同步,会查不到。解决办法是写操作后强制走主库一段时间,或者用"写后读"标记。这个策略要根据业务容忍度来定,强一致场景干脆别用读写分离。
6.2 分库分表的结合
动态数据源是分库分表的基础设施。分库分表中间件(比如ShardingSphere)底层也是靠类似机制路由的。如果你想自己实现轻量级分库,可以在拦截器里根据分片键算出库的key,再配合分表逻辑。不过自己实现分库分表复杂度很高,除非有特殊需求,否则建议直接用成熟中间件。
6.3 与多租户隔离的结合
多租户SaaS里,数据隔离有三个层次:独立库、共享库独立schema、共享库共享表加租户字段。动态数据源解决的是前两个层次。如果租户量大,独立库成本高,可以做成"大租户独立库、小租户共享库"的混合模式,路由层根据租户等级决定走哪个key。这个灵活性正是动态数据源的价值所在。
我在模拟项目X里最终落地的就是混合模式:三个大业务线各自独立库,长尾小租户共享一个库靠字段隔离。路由逻辑封装在拦截器里,业务代码完全无感。上线后运维成本降了一大半,改公共逻辑只需要发一次版。
最后分享一个我踩过的坑:别忘了给拦截器配置排除路径。静态资源、健康检查这些请求没必要走数据源解析,如果拦截器对它们也set key,反而可能因为拿不到租户标识而设成null,触发默认库。我一般会excludePathPatterns("/static/**", "/actuator/**", "/health"),把这些路径排除掉,减少无谓的上下文操作。这个细节虽小,但能避免不少莫名其妙的日志噪音。