有状态单例是高并发雷区,因多线程共用导致数据交叉污染;Spring默认单例需无状态,含可变成员变量即成共享容器,引发脏读、覆盖等问题;ThreadLocal需谨慎使用,须手动remove且不自动跨线程传递;可靠方案是分层隔离:Filter初始化上下文、显式透传参数、存储层避免可变状态。

单例本身不是问题,问题出在“有状态的单例被多个线程共用且未隔离”。高并发下数据交叉污染,本质是把单例当成了可写共享内存,却忘了它没有天然线程边界。
有状态单例是高并发的雷区
Spring 默认所有 Bean 都是单例,但前提是——它们得是无状态的。一旦你在单例类里加了可变成员变量(比如 private String userId; 或 private Map cache;),这个对象就变成了“共享容器”。多个请求线程同时读写它,不加锁就会脏读、覆盖、丢失更新。
- 典型表现:A 用户刚设好 tenantId,B 用户请求进来把 tenantId 改成自己的,A 后续操作就误写进 B 的库
- 更隐蔽的是:哪怕只读不写,若依赖内部缓存或延迟初始化逻辑,也可能因竞态导致重复加载或空指针
- 关键判断标准:该单例是否在方法执行过程中修改自身字段?如果是,它就不适合做 Spring 单例
ThreadLocal 不是万能解药,而是双刃剑
有人立刻想到用 ThreadLocal 隔离,这方向没错,但容易踩新坑:
- Web 容器(如 Tomcat)复用线程,请求结束若没 remove,下次复用时 ThreadLocal 里还躺着上一个用户的上下文
- 异步调用(@Async、CompletableFuture)会切换线程,原 ThreadLocal 值不会自动传递,必须手动 copy 或重置
- 日志 MDC、链路 traceId 等轻量上下文适合 ThreadLocal;但用户身份、租户 ID 这类核心业务上下文,建议走 Filter + RequestScope Bean 或显式透传参数
真正可靠的防线是分层隔离
别指望一个机制兜底,要组合使用:
- 入口层:统一 Filter 初始化上下文(如从 Header 解析 tenantId),并校验合法性;拒绝非法或缺失关键字段的请求
- 传播层:RPC 调用、消息投递、异步任务中,显式将必要上下文(traceId、tenantId)作为参数透传,不依赖隐式绑定
-
存储层:避免在单例中维护可变状态;如需缓存,用 ConcurrentHashMap
, Object> 按类型隔离,或直接交给 Spring Cache 管理 - 出口层:在 finally 块或 @AfterReturningAdvice 中强制清理 ThreadLocal,remove 比 set(null) 更安全
测试和监控是最后一道闸门
线上出问题前,测试环境就该暴露隐患:
- 单元测试模拟线程复用:用 Executors.newFixedThreadPool(1) 复用同一根线程,连续跑两个不同用户的请求,看上下文是否残留
- 日志埋点检查 traceId 是否跨用户复用;Arthas watch 某个 ContextHolder 的 get() 方法返回值,确认是否混入旧数据
- 上线前加健康检查:在请求出口打点统计 ThreadLocal 未清理率,超阈值自动告警

















