结论:这不是单例本身的问题,而是把本该无状态的单例错误当成了线程或租户上下文的“状态容器”来用;串号本质是状态污染——前一个租户/请求写入的字段被后一个租户/请求读取到了。

直接说结论:这不是单例本身的问题,而是把本该无状态的单例,错误当成了线程或租户上下文的“状态容器”来用。串号本质是状态污染——前一个租户/请求写入的字段,被后一个租户/请求读取到了。
识别典型串号现象
先确认是不是真串号,而不是逻辑误判:
- 同一台服务实例上,租户A的操作触发了租户B的缓存更新、日志记录或数据库写入
- 异步任务(如CompletableFuture、@Async方法)中,获取到的tenantId、userId、requestId等上下文信息与当前请求不匹配
- 静态变量、static final Map、类级别缓存(如ConcurrentHashMap)里存了租户敏感数据,且未按tenant_id做key分片
- 单例Bean里声明了非final的成员变量(比如private String currentTenantId),并在业务中反复set/get
定位污染源的三步法
不靠猜,靠链路和快照:
- 查日志时间线:在出现串号的请求日志中,向上追溯最近一次“写入上下文”的操作(如TenantContext.set()、或对单例字段的赋值),再向下看该值是否被后续其他租户请求复用
- 抓内存快照:用jmap -dump生成堆转储,用MAT分析单例对象的实例数量和字段值。重点看:该单例是否只有一个实例(符合预期),但其某个String字段却同时存在多个租户ID的值(污染证据)
- 断点验证生命周期:在单例构造器、@PostConstruct方法、以及所有可能修改其字段的方法上加断点。观察同一个实例是否被多个不同tenant_id的请求反复调用并修改
修复关键动作
核心原则:单例只负责行为,不承载租户状态。
- 删掉单例里的tenantId、userId、requestId等运行时字段;改用ThreadLocal或TTL(TransmittableThreadLocal)在线程维度透传
- 如果必须缓存,确保key包含tenant_id,例如:cache.put("t123:config:feature_flag", value),而非cache.put("config:feature_flag", value)
- 检查所有@Async、CompletableFuture、线程池submit的调用点,确认是否显式包装了租户上下文(如用TenantAwareRunnable),或已接入TTL增强的线程池
- Spring Bean作用域别写错:需要按请求隔离的状态对象,必须声明为prototype,不能是singleton
Lambda或Serverless环境要额外防内存残留
在AWS Lambda、阿里云函数计算等场景,单例字段污染更隐蔽:
- 初始化阶段(Init)加载的静态配置没问题,但调用阶段(Invoke)写入的静态变量会留在热执行环境中
- 每次请求结束后,必须手动清空所有静态集合、重置标志位,不能依赖JVM自动回收
- 推荐做法:把租户上下文存在request.state(FastAPI)、InvocationContext(Lambda Runtime API)等框架提供的安全槽位,而不是自己new HashMap()存静态变量

















