异步任务一直读到上次 Locale:线程池不要依赖 inheritable
LocaleContextHolder 的 inheritable 只在线程创建时继承。复用线程池需要按任务捕获并恢复 LocaleContext,避免下一次请求继续使用旧语言。
先设置德语,再设置英语,两次异步任务却都读到德语。若代码使用 LocaleContextHolder.setLocale(locale, true),任务又运行在线程池中,应先检查线程是否被复用。
inheritable 依赖 JDK 的 InheritableThreadLocal,继承发生在创建子线程时,不是每次提交任务时。池中旧线程不会因提交者换了 Locale 就自动更新;把池大小改小,反而更容易稳定看到旧值。
在提交任务时捕获上下文
可给实际使用的 ThreadPoolTaskExecutor 配置 TaskDecorator,在提交线程捕获,执行前安装,结束后恢复。下面是配置已有执行器时的片段:
executor.setTaskDecorator(task -> {
var captured = LocaleContextHolder.getLocaleContext();
return () -> {
var previous = LocaleContextHolder.getLocaleContext();
try {
LocaleContextHolder.setLocaleContext(captured);
task.run();
}
finally {
LocaleContextHolder.setLocaleContext(previous);
}
};
});
executor 是已有的 ThreadPoolTaskExecutor,在初始化并接收任务前设置。LocaleContextHolder 来自 org.springframework.context.i18n。捕获整个 LocaleContext 可以保留其中已有的时区信息;只保存 Locale 时应明确业务不依赖时区。
setLocaleContext(null) 会重置线程上下文,所以无上下文任务也不会无条件沿用前一次请求的设置。恢复放在 finally 中,任务抛异常时仍应执行。不要照搬“执行后什么也不做”的包装,否则工作线程可能残留本次请求的语言。
配置必须落在实际执行器上
检查 @Async 的限定名、CompletableFuture 传入的 executor,以及任务是否走了另一套公共线程池。在一份未使用的执行器上安装 decorator,无法改变实际后台任务。
安全上下文包装器负责的不是 Locale;即使认证身份传播正确,语言仍可能没有传播。已有 decorator 还负责 MDC 等状态时,应组合其生命周期,不要直接覆盖后丢掉原功能。
应逐步撤掉这条线程池路径对 inheritable=true 的依赖,并让旧线程在受控重建后使用一致配置。虚拟线程每任务新建是另一种情况,不能把它的继承行为照搬到可复用的平台线程池。
验收可在同一个单线程池依次提交德语、英语、无显式上下文任务,并覆盖任务抛异常的分支。检查每次看到的语言、时区和任务结束后的原上下文;不能只验证第一条任务。保留原执行器配置,变更不应修改全应用默认语言来迁就一个后台入口。


