Spring Webflux下如何在继承HikariDataSource的类中访问ReactiveSecurityContextHolder
响应式上下文是顺着响应式流的订阅链路从下游反向向上传播的,没有自动跨线程、跨非响应式调用链路传递的能力。你在TenantAwareDataSource的getConnection方法里直接调用Mono.subscriberContext()读不到租户ID,根本原因是:这个方法是被Spring Data JPA的同步阻塞链路调用的,调用位置完全脱离了WebFilter到控制器之间的响应式管道,自然拿不到存在Reactor上下文里的值。
你之前担心ThreadLocal不可用的前提,是把ThreadLocal和Web请求的Netty IO线程绑定——如果换个思路,把ThreadLocal的绑定时机放在阻塞执行的弹性线程池线程上,就完全不存在线程不匹配、上下文冲突的问题。
方案1:响应式-阻塞段上下文桥接(改造成本最低)
核心思路是:不要让DataSource自己主动去拉响应式上下文,而是在响应式流切到阻塞线程执行JPA操作之前,就把租户ID显式传递到当前阻塞线程的上下文中,让DataSource直接从本地线程上下文读租户值。
具体实现步骤:
- 定义仅用于阻塞执行段的线程上下文存储对象:
public class BlockingTenantContextHolder { // 仅在boundedElastic调度线程执行单个任务的生命周期内有效 private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>(); public static void set(String tenantId) { TENANT_ID.set(tenantId); } public static String get() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } } - 封装统一的JPA执行调度逻辑:所有JPA调用必须切到
boundedElastic线程池执行,在切线程的瞬间,从Reactor响应式上下文取出租户ID,绑定到当前阻塞线程的上下文,执行完任务后立刻清理。可以用AOP切面统一处理所有@Repository注解的方法,不需要每个接口单独写:@Aspect @Component public class JpaExecutionAspect { @Around("@target(org.springframework.stereotype.Repository)") public Object aroundRepositoryCall(ProceedingJoinPoint joinPoint) { return Mono.deferContextual(context -> { String tenantId = context.getOrDefault("TENANT_ID", "default"); return Mono.fromCallable(() -> { BlockingTenantContextHolder.set(tenantId); try { return joinPoint.proceed(); } finally { BlockingTenantContextHolder.clear(); } }).subscribeOn(Schedulers.boundedElastic()); }); } } - 改造
TenantAwareDataSource的getTenantId方法,直接从BlockingTenantContextHolder读取值即可。因为getConnection是在JPA调用的同一条boundedElastic线程上执行的,上下文完全一致:public class TenantAwareDataSource extends HikariDataSource { @Override public Connection getConnection() throws SQLException { Connection rawConn = super.getConnection(); String tenantId = BlockingTenantContextHolder.get(); // 连接获取后立刻执行SQL设置数据库侧的租户变量 try (Statement stmt = rawConn.createStatement()) { stmt.execute(String.format("SET app.current_tenant = '%s'", tenantId)); } return rawConn; } }
注意:这个方案完全规避了你之前担心的ThreadLocal问题。因为ThreadLocal的绑定、使用、清理全流程都在单个阻塞任务的执行周期内完成,线程执行完任务就会立刻清值,哪怕线程被复用,也不会带上上一个请求的租户ID;同时因为连接获取和租户设置都在同一个阻塞线程上,完全不存在线程不匹配导致的读不到值的问题。
方案2:连接初始化逻辑从DataSource层剥离
不要依赖重写DataSource.getConnection来设置租户变量——Hikari的连接池是提前初始化连接的,连接本身和单个请求没有绑定关系,硬在DataSource层做动态逻辑容易出现连接复用串租户的问题。
调整实现逻辑:
- 保留Reactor上下文的租户ID读取流程,在切到boundedElastic线程执行JPA操作时,把租户ID绑定到阻塞线程上下文
- 自定义Hibernate的
ConnectionProvider拦截逻辑,在每次从连接池拿出连接、绑定到当前Hibernate会话时,再执行设置租户变量的SQL,操作完成后、连接归还连接池时,重置连接上的租户变量 - 同样通过切面统一把所有JPA操作切到boundedElastic线程执行,避免阻塞Netty的事件循环线程
- 绝对不要在Netty的IO事件循环线程上直接执行JPA查询、获取数据库连接这类阻塞操作,所有阻塞逻辑必须切到
boundedElastic调度器的线程上执行 - 阻塞任务执行完成后,必须主动清理ThreadLocal中存储的租户ID,否则线程池复用线程时,会出现请求间租户上下文串用,造成严重的数据越权问题
- 不要试图在DataSource的通用初始化逻辑里读取Reactor上下文——连接池的初始化、连接创建的流程完全独立于单个请求的响应式链路,就算硬写
Mono.subscriberContext(),也永远拿不到当前请求的租户值
内容的提问来源于stack exchange,提问作者Daniel Silva

