Files
rui-docs/backend/guides/Resilience4j-ThreadPoolBulkhead-租户上下文传播问题排查指南.md
vifo 19de7e24ec docs: 迁移 spring-ai 通用文档到 rui-docs
从 docs-local 迁移以下文档:

- backend/guides/: AI开发环境配置、Nacos配置、GitNexus指南、OpenCode工作流等

- backend/templates/: Superpowers设计模板、计划模板、审查清单

- backend/config-templates/: 应用配置模板、Nacos配置

- backend/design/: 数据库表结构规划

- backend/specs/: 项目文档治理、MQ统一推送设计

- backend/: 代码分析报告、Feign分析报告、文档治理报告

- frontend/design/: Admin-UI分模块打包设计
2026-06-04 09:34:03 +08:00

17 KiB
Raw Permalink Blame History

Resilience4j ThreadPoolBulkhead 租户上下文跨线程传播问题排查指南

适用场景Spring Cloud + OpenFeign + Resilience4jThreadPoolBulkhead+ TransmittableThreadLocalTTL


1. 问题现象

1.1 典型日志特征

# HTTP 线程正确设置了租户上下文
[nio-9301-exec-3] GlobalContextFilter     : 租户上下文已设置: tenantId=5

# Feign 调用线程(线程池线程)却读取到错误的租户 ID
[pool-5-thread-1] OAuthRequestInterceptor : Feign 透传租户 ID: tenantId=4

# 后续请求无论 X-Tenant-Id 是多少,线程池线程始终返回第一次的 tenantId=4
[nio-9301-exec-4] GlobalContextFilter     : 租户上下文已设置: tenantId=51
[pool-5-thread-1] OAuthRequestInterceptor : Feign 透传租户 ID: tenantId=4   ← 仍然是 4

1.2 核心特征

现象 说明
HTTP 线程上下文正确 TenantContextHolder.getTenantId() 在 Controller/Filter 中返回正确值
线程池线程上下文错误 Feign 拦截器或 Service 中读取到旧值或 null
旧值具有"粘性" 线程池线程复用后,始终残留第一次被创建时的上下文值
与请求头不一致 请求头 X-Tenant-Id 变化,但业务线程读取的值不变

2. 问题根因

2.1 架构背景

本项目使用以下技术栈:

  • 租户上下文TenantContextHolder 基于 TransmittableThreadLocalTTL)实现
  • 服务间调用OpenFeign + Spring Cloud LoadBalancer
  • 熔断隔离Spring Cloud Circuit Breaker + Resilience4j ThreadPoolBulkhead
  • 线程池隔离目的:限制并发数,防止故障扩散

2.2 线程切换链路(关键!)

当 Feign 调用触发 Circuit Breaker + ThreadPoolBulkhead 时,一次请求会经历 三层线程

┌─────────────────┐     ┌─────────────────────────┐     ┌─────────────────────────┐
│   HTTP 线程      │     │  ThreadPoolBulkhead 线程 │     │  CircuitBreakerFactory  │
│ [nio-9301-exec] │ ──▶ │  [bulkhead-xxx-thread]   │ ──▶ │   ExecutorService 线程   │
│                 │     │                         │     │    [pool-N-thread-M]    │
└─────────────────┘     └─────────────────────────┘     └─────────────────────────┘
       │                          │                              │
       │ ① TTL 自动透传            │ ② ContextPropagator 恢复      │ ③ ??? 上下文丢失
       │    (原生 ThreadLocal     │     Resilience4j 官方机制)   │
       │     不跨线程池)           │                              │
       │                          │                              │
   tenantId=5               tenantId=5 ✓                    tenantId=4 ✗

2.3 为什么 ContextPropagator 不够?

Resilience4j 提供了 ContextPropagator 接口,官方设计目的是在 ThreadPoolBulkhead 线程池 内透传上下文:

  • retrieve():在调用方线程捕获上下文值
  • copy():在线程池线程恢复上下文值
  • clear():在线程池线程清理上下文值

但 Spring Cloud Circuit Breaker 内部还有一层线程池!

查看 Resilience4JCircuitBreaker.run() 源码:

if (executorService != null) {
    // ① 先把任务提交到工厂自己的 ExecutorServicenewCachedThreadPool
    Supplier<Future<T>> futureSupplier = () -> executorService.submit(toRun::get);
    
    // ② 再用 ThreadPoolBulkhead 包装 Future 等待逻辑
    Callable<T> bulkheadCall = bulkheadProvider.decorateCallable(..., timeLimitedCall);
    ...
}

执行流程变成了:

  1. HTTP 线程提交任务 → ThreadPoolBulkhead 线程池
  2. ThreadPoolBulkhead 线程执行 ContextPropagator.copy() → 恢复 tenantId=5
  3. ThreadPoolBulkhead 线程调用 executorService.submit(toRun::get) → 提交到 第二个线程池
  4. executorService 线程(pool-5-thread-1)执行 Feign 调用
  5. executorService 线程 没有 经过 ContextPropagator 恢复,其 ThreadLocalnull
  6. 但由于 TransmittableThreadLocal 继承 InheritableThreadLocal,线程创建时可能继承了父线程的值,且 永不清理,导致旧值残留

2.4 为什么旧值有"粘性"

Resilience4JCircuitBreakerFactory 默认使用 Executors.newCachedThreadPool()

  • 线程创建时继承父线程(ThreadPoolBulkhead 线程)的 InheritableThreadLocal
  • 线程被缓存复用,永不销毁(空闲 60 秒)
  • 没有人清理 executorService 线程的 ThreadLocal
  • 因此该线程永远携带第一次被创建时的 tenantId

3. 排查思路(按优先级)

Step 1:确认问题范围

检查日志中 Feign 调用所在的线程名:

# 如果是 ThreadPoolBulkhead 线程,命名类似:
[bulkhead-xxx-1]

# 如果是 Spring 默认线程池,命名类似:
[pool-5-thread-1]

如果看到 [pool-N-thread-M],说明问题在 第二层线程切换

Step 2:确认 ContextPropagator 是否生效

TenantContextPropagatorretrieve() / copy() / clear() 方法中加日志:

@Override
public Supplier<Optional<TenantContextSnapshot>> retrieve() {
    return () -> {
        Long tenantId = TenantContextHolder.getTenantId();
        log.info("[ContextPropagator] retrieve: tenantId={} in thread={}", 
                 tenantId, Thread.currentThread().getName());
        ...
    };
}
  • 如果 retrieve() 日志不打印 → ContextPropagator 未被注册到 ThreadPoolBulkheadConfig
  • 如果 copy() 打印的线程名是 [pool-N-thread-M]ContextPropagator 被用在了错误的线程池上(本不应出现)

Step 3:确认是否存在多层线程池

OAuthRequestInterceptor 中加日志:

Long tenantId = TenantContextHolder.getTenantId();
log.info("[Feign] 当前线程={}, tenantId={}", Thread.currentThread().getName(), tenantId);

对比 HTTP 线程的 tenantId 和 Feign 线程的 tenantId

HTTP 线程 Feign 线程 结论
5 5 正常
5 null 上下文完全丢失
5 4 旧值残留(本文档描述的问题)
5 51(上一次的值) 线程复用且未清理

Step 4:检查 ThreadPoolBulkheadConfig 配置

断点或日志打印 ThreadPoolBulkheadConfig.getContextPropagator()

ThreadPoolBulkheadConfig config = threadPoolBulkheadRegistry.getDefaultConfig();
log.info("配置中的 ContextPropagator: {}", config.getContextPropagator());
  • 如果为空列表 → 配置未生效
  • 如果有 TenantContextPropagator → 配置已生效,但只能解决第一层线程切换

4. 完整修复方案

4.1 方案概览

需要 三个层面的修复 协同工作:

层面 修复目标 组件
第一层 HTTP 线程 → ThreadPoolBulkhead 线程 TenantContextPropagator + TenantContextThreadPoolBulkheadConfigCustomizer
第二层 ThreadPoolBulkhead 线程 → ExecutorService 线程 TtlExecutors + TtlResilience4JCircuitBreakerFactoryCustomizer
注册层 Feign 客户端被 Spring 正确扫描 META-INF/spring.factories 注册 Feign 接口

4.2 第一层:ThreadPoolBulkhead 内上下文传播

组件TenantContextPropagator(已存在)+ TenantContextThreadPoolBulkheadConfigCustomizer(新增 BeanPostProcessor

原理

  • Resilience4j ThreadPoolBulkhead 内部使用 ContextPropagator.decorateSupplier() 包装任务
  • 在任务提交时 retrieve() 捕获上下文,在线程池线程执行前 copy() 恢复,执行后 clear() 清理

实现要点

  • TenantContextThreadPoolBulkheadConfigCustomizer 实现 BeanPostProcessor
  • ThreadPoolBulkheadRegistry 初始化后,通过反射修改其默认配置,注入 TenantContextPropagator
  • 不能使用 AbstractRegistry.addConfiguration("default", config),因为该方法禁止修改 "default"
  • 必须直接修改内部的 configurations Map
public class TenantContextThreadPoolBulkheadConfigCustomizer implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (bean instanceof ThreadPoolBulkheadRegistry registry) {
            injectContextPropagator(registry);
        }
        return bean;
    }

    @SuppressWarnings("unchecked")
    private void injectContextPropagator(ThreadPoolBulkheadRegistry registry) {
        try {
            Field configurationsField = AbstractRegistry.class.getDeclaredField("configurations");
            configurationsField.setAccessible(true);
            Map<String, ThreadPoolBulkheadConfig> configurations =
                    (Map<String, ThreadPoolBulkheadConfig>) configurationsField.get(registry);

            ThreadPoolBulkheadConfig defaultConfig = configurations.get("default");
            if (defaultConfig == null || hasTenantPropagator(defaultConfig)) {
                return;
            }

            ThreadPoolBulkheadConfig newDefaultConfig = ThreadPoolBulkheadConfig.from(defaultConfig)
                    .contextPropagator(TenantContextPropagator.class)
                    .build();
            configurations.put("default", newDefaultConfig);
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new IllegalStateException("无法修改 ThreadPoolBulkheadRegistry 的默认配置", e);
        }
    }
}

注册为 Spring Bean(注意用 static 方法避免 BeanPostProcessor 警告):

@Bean
@ConditionalOnMissingBean
public static TenantContextThreadPoolBulkheadConfigCustomizer tenantContextThreadPoolBulkheadConfigCustomizer() {
    return new TenantContextThreadPoolBulkheadConfigCustomizer();
}

4.3 第二层:CircuitBreakerFactory ExecutorService 上下文传播

组件TtlResilience4JCircuitBreakerFactoryCustomizer(新增 Customizer

原理

  • 使用 Alibaba TtlExecutors 包装 ExecutorService
  • TtlExecutorssubmit() 时自动捕获当前线程的 TransmittableThreadLocal
  • 在目标线程执行前自动恢复,执行后自动清理
  • 支持线程池复用场景,无旧值残留

实现

public class TtlResilience4JCircuitBreakerFactoryCustomizer 
        implements Customizer<Resilience4JCircuitBreakerFactory> {

    @Override
    public void customize(Resilience4JCircuitBreakerFactory factory) {
        factory.configureExecutorService(
                TtlExecutors.getTtlExecutorService(Executors.newCachedThreadPool())
        );
        factory.configureGroupExecutorService(
                group -> TtlExecutors.getTtlExecutorService(Executors.newCachedThreadPool())
        );
    }
}

注册为 Spring Bean

@Bean
@ConditionalOnMissingBean
public static TtlResilience4JCircuitBreakerFactoryCustomizer ttlResilience4JCircuitBreakerFactoryCustomizer() {
    return new TtlResilience4JCircuitBreakerFactoryCustomizer();
}

4.4 第三层:Feign 客户端注册

组件META-INF/spring.factories

原理

  • 本项目使用自定义的 CustomFeignClientsRegistrar 注册 Feign 客户端
  • CustomFeignClientsRegistrarSpringFactoriesLoader.loadFactoryNames(CloudFeignAutoConfiguration.class, classLoader) 加载 Feign 接口类名
  • 如果 Feign 接口未在 spring.factories 中注册,Spring 容器中不会出现该 Bean,导致 NoSuchBeanDefinitionException

实现

在定义 Feign 接口的模块(如 rui-common-security)新增 META-INF/spring.factories

com.rui.common.feign.CloudFeignAutoConfiguration=\
    com.rui.common.security.feign.TokenManageFeign

4.5 模块依赖关系

确保 rui-common-security 添加 rui-common-feign 依赖:

<dependency>
    <groupId>com.rui</groupId>
    <artifactId>rui-common-feign</artifactId>
</dependency>

否则 spring.factories 中引用的 CloudFeignAutoConfiguration 类在编译期不可见。


5. 验证方法

5.1 本地验证

  1. 启动 rui-authrui-service-system
  2. 发送第一次请求:X-Tenant-Id: 5
  3. 观察 Feign 调用日志,确认 tenantId=5
  4. 发送第二次请求:X-Tenant-Id: 51
  5. 观察 Feign 调用日志,确认 tenantId=51(不是 5
  6. 发送第三次请求:X-Tenant-Id: 3
  7. 观察 Feign 调用日志,确认 tenantId=3(不是 5 也不是 51

5.2 关键日志断言

// 断言:HTTP 线程和 Feign 线程的 tenantId 必须一致
assertEquals("HTTP 线程和 Feign 线程的租户 ID 必须一致", 
             httpTenantId, feignTenantId);

// 断言:每次请求的 tenantId 必须不同(如果请求头不同)
assertNotEquals("线程池线程不应残留旧租户 ID", 
                previousTenantId, currentTenantId);

5.3 断点验证

在以下位置打断点,单步跟踪:

  1. TenantContextPropagator.retrieve() — 确认每次请求捕获的值不同
  2. TenantContextPropagator.copy() — 确认在线程池线程恢复的值正确
  3. TtlExecutors 内部 — 确认 executorService 线程恢复的值正确
  4. OAuthRequestInterceptor — 确认最终 Feign 调用时的值正确

6. 常见问题 FAQ

Q1:为什么 YAML 中配置 resilience4j.thread-pool-bulkhead.configs.default.contextPropagators 不生效?

A:该配置依赖 Spring Boot ConfigurationProperties 绑定 Class[] 类型。虽然 CommonThreadPoolBulkheadConfigurationProperties 支持该属性,但:

  1. Spring Cloud Circuit Breaker 动态创建的 Bulkhead 实例名不确定
  2. CompositeCustomizer 按实例名精确匹配,不存在通配符机制
  3. 因此编程式注入(BeanPostProcessor)更可靠

Q2:只用 TtlExecutors 不用 ContextPropagator 可以吗?

A:不可以。TtlExecutors 只能透传标准 ThreadPoolExecutor 的任务提交。Resilience4j 的 ThreadPoolBulkhead 内部使用自己的 ThreadPoolExecutor,不经过 TtlExecutors。因此第一层切换(HTTP → ThreadPoolBulkhead)必须由 ContextPropagator 处理。

Q3:为什么 TenantContextHolder 使用 TransmittableThreadLocal 而不是普通 ThreadLocal

A:因为项目中存在 @Async 异步任务、Feign 线程池切换等场景。TransmittableThreadLocal 配合 TtlExecutors 可以在线程池间自动透传上下文,而普通 ThreadLocal 只能在线程父子间继承(且对线程池无效)。

Q4:如果以后引入其他线程池(如 @Async),是否也会遇到同样问题?

A:是的。任何使用线程池的地方,如果任务提交方线程有 ThreadLocal 上下文,而执行方线程需要读取该上下文,都必须使用以下方案之一:

  • TtlExecutors 包装线程池(推荐)
  • 手动在任务提交前捕获上下文,在任务执行前恢复(类似 ContextPropagator 原理)
  • 使用 Project Reactor 的 Context + Hooks.onEachOperator(响应式场景)

最佳实践:所有业务线程池统一通过 TtlExecutors 包装。

Q5:如果关闭 ThreadPoolBulkhead,改用 SemaphoreBulkhead,能否避免此问题?

A:可以。SemaphoreBulkhead 在同一线程内执行,不存在线程切换。但会牺牲线程隔离的故障保护能力。

配置方式:

spring:
  cloud:
    circuitbreaker:
      resilience4j:
        enableSemaphoreDefaultBulkhead: true

7. 后续维护建议

  1. 新增 Feign 客户端时:务必在所在模块的 META-INF/spring.factories 中注册
  2. 新增线程池时:优先使用 TtlExecutors.getTtlExecutorService() 包装
  3. 新增 ThreadLocal 上下文时:考虑是否需要配套 ContextPropagator
  4. 日志规范:在上下文切换关键点(Filter、Interceptor、线程池任务)打印 tenantId + threadName,便于快速定位问题
  5. 自动化测试:编写并发测试,模拟多租户同时请求,断言各线程的 tenantId 与请求头一致

8. 相关代码文件

文件 作用
rui-common-core/holder/TenantContextHolder.java 租户上下文持有者(TransmittableThreadLocal
rui-common-security/feign/TokenManageFeign.java Feign 客户端示例
rui-common-security/feign/OAuthRequestInterceptor.java Feign 请求拦截器(透传租户 ID)
rui-common-feign/propagator/TenantContextPropagator.java Resilience4j ContextPropagator 实现
rui-common-feign/config/TenantContextThreadPoolBulkheadConfigCustomizer.java 注入 ContextPropagator 到 ThreadPoolBulkheadRegistry
rui-common-feign/config/TtlResilience4JCircuitBreakerFactoryCustomizer.java 用 TtlExecutors 包装 CircuitBreakerFactory 线程池
rui-common-feign/CloudFeignAutoConfiguration.java 注册上述 Bean
rui-common-security/META-INF/spring.factories 注册 Feign 客户端