异常对象串号本质是状态残留,修复关键在于使其回归不可变本质:所有业务字段声明为final、构造时一次性注入、删除public/protected setter、禁止catch中修改已抛出异常,改用静态工厂方法返回新实例并设原异常为cause。

这不是异常对象本身的问题,而是把本该一次性的、不可复用的异常实例,错误当成了跨请求或跨线程共享的状态载体来用;串号本质是状态残留——前一个租户抛出的异常对象被后一个租户直接读取、复用甚至修改其字段,导致租户上下文泄露。
识别是否真由异常对象引发串号
先排除其他常见污染源(如 static 字段、单例 Bean 成员变量),再聚焦异常类使用场景:
- 同一服务实例上,租户A触发的异常日志中出现租户B的 userID、orderID 或 tenantId
- 捕获异常后未新建对象,而是直接调用 setXXX() 修改其字段(如
ex.setErrorCode("t2001_ERR")),再作为返回值或事件参数透传 - 自定义异常类中声明了非 final 的 public / protected 实例字段(如
public String tenantId;),且业务代码频繁写入 - 异常对象被放入线程池任务、CompletableFuture 回调、或消息体中异步传递,但未做深拷贝或上下文剥离
定位异常对象的生命周期与复用路径
异常不是“被动容器”,一旦被构造并携带业务数据,就可能成为状态污染入口:
- 检查所有 new XxxException(...) 调用点:是否重复复用同一个异常实例?是否在 for 循环或批量处理中反复 set 字段后 throw?
- 搜索 catch + set + rethrow 模式:例如
catch (BaseException e) { e.setTenantId(currentTenant); throw e; }—— 这会污染原始异常实例 - 用 Arthas
watch监控异常构造方法:watch com.example.exception.ApiException 'new' '{params, target}' -n 5,观察不同租户请求是否生成相同 hash 的实例 - 在异常类的构造器和 setter 中加日志,打印
Thread.currentThread().getName() + " | tenant=" + tenantId,确认是否跨租户混写
修复关键动作:让异常回归“不可变”本质
异常对象应是一次性快照,而非可编辑状态机:
- 将自定义异常的所有业务字段声明为 final,构造时一次性注入(如
public ApiException(String tenantId, String code, String msg)) - 彻底删除所有 public/protected 的 setter 方法;如需扩展信息,改用静态工厂方法返回新实例:
ex.withTenantId("t123") - 禁止在 catch 块中修改已抛出的异常对象;如需补充上下文,应 new 一个新的异常 并以原异常为 cause
- 若异常用于事件通知或异步传递,确保序列化前已剥离敏感字段(如重写
writeObject或使用 DTO 封装)
补充防御:运行时拦截与 CI 卡点
靠人审代码易漏,需机制兜底:
- 在 CI 流程中加入 Checkstyle 或自定义 SpotBugs 规则:禁止异常类含非 final 实例字段、禁止异常类含 public setter
- 在全局异常处理器(如 @ControllerAdvice)中校验异常对象字段:若发现 tenantId 等字段值与当前请求不匹配,记录告警日志并强制覆盖为当前租户
- 对高频使用的异常类做轻量级单元测试:用多线程并发构造不同租户参数的实例,验证字段不可变性与 hash 隔离性










