某个工作日的下午,客服工单突然炸了:多个企业客户反馈在自己的控制台里看到了别家公司的订单明细和联系人手机号。这类敏感数据串号属于最高级别的安全故障,涉及越权访问和用户隐私泄露。经过两天的紧急排查,最终定位到的原因让人哭笑不得——某位开发同学为了图方便,在一个Service类的成员变量上加了static修饰,用来暂存当前请求的租户信息,而这个服务恰恰是以单例方式部署的。本文把这个案例的排查过程和修复方案完整梳理出来,希望能给正在做多租户系统的团队提个醒。

为什么static变量会成为多租户系统的隐形炸弹
要理解这次故障的根因,得先从Spring框架的Bean作用域说起。在Spring容器中,所有Bean默认都是单例的,整个应用生命周期内只有一个实例。而这个实例会被并发访问它的所有请求共享。如果在这个Bean里定义了一个普通成员变量,那么这个变量的值就不再是某个请求私有的,而是所有请求公共的。
static修饰的变量更特殊,它甚至不依附于任何对象实例,而是挂在类上,被整个JVM进程共享。我们事故中的代码大致长这样:
public class TenantOrderService {
// 错误示范:static变量保存当前租户ID
private static String currentTenantId;
public List<Order> listOrders() {
String tenantId = currentTenantId;
return orderMapper.selectByTenantId(tenantId);
}
public void setTenant(String tenantId) {
currentTenantId = tenantId;
}
}</code>这段代码在功能测试中几乎不可能暴露问题,因为单线程的测试请求一进一出,currentTenantId的值是正确的。但在生产环境高并发下,线程A刚执行完setTenant设置了租户A的ID,还没来得及执行查询,线程B就把这个值改成了租户B的ID,线程A随后执行的查询拿到的就是租户B的ID——数据串号就这么发生了。
更隐蔽的是,这种问题的触发是概率性的。当并发量低的时候,set和query之间的时间窗口很小,撞上的概率不高;大促或者流量高峰期间,窗口被放大,串号频率骤增。这解释了为什么故障发生在下午三点而不是凌晨,也给排查增加了难度。
完整排查路径:从用户反馈到代码定位
第一步是确认影响范围。我们通过审计日志反查所有访问过涉事订单的请求,发现这些请求的来源用户和订单所属租户确实不匹配,排除了前端缓存展示错误的可能,确认是服务端返回了错误数据。
第二步是核对数据访问链路。多租户系统的数据隔离通常有几道防线:网关层的租户鉴权、业务层的租户上下文传递、数据层的查询条件强制拼接。我们逐层检查SQL日志,发现最终执行的查询语句里,tenant_id条件有时候是对的,有时候是错的,而且是随机的。这说明问题出在租户ID的传递环节,而不是数据层拼装环节。
第三步是抓现场。在预发环境用两个不同租户的账号,通过压测工具模拟并发请求,复现了串号问题。用jstack和Arthas的watch命令观察目标方法入参和返回值:
// 使用Arthas观察方法调用时的实际参数
watch com.demo.TenantOrderService listOrders '{params, returnObj, @com.demo.TenantOrderService@currentTenantId}' -x 2 -n 5输出结果一目了然:请求参数里的用户属于租户A,但静态字段currentTenantId的值是B。至此根因坐实,就是static变量在并发请求间被共享导致的。
这里有一个排查技巧值得分享:当怀疑某个类有类似问题时,可以直接在代码里全局搜索static加成员变量的组合,重点排查那些类型为业务上下文对象(租户ID、用户ID、权限信息)的静态字段。也可以借助SonarQube之类的静态分析工具,规则会标记出可变的静态字段,输出一份待审查清单。
正确的多租户上下文传递方案
修复的第一原则是:任何与请求上下文绑定的数据,都不能存放在会被共享的位置。正确做法分几层。
最基础的方案是使用ThreadLocal。每个线程有自己独立的变量副本,请求进来时在过滤器里写入租户ID,请求结束时务必清理,否则线程池复用线程时会读到上一个请求的残留数据,这同样会造成串号,只是从并发串号变成了先后串号:
public class TenantContext {
private static final ThreadLocal<String> TENANT_HOLDER = new ThreadLocal<>();
public static void setTenantId(String tenantId) {
TENANT_HOLDER.set(tenantId);
}
public static String getTenantId() {
return TENANT_HOLDER.get();
}
// 必须在请求结束时调用,防止线程池污染
public static void clear() {
TENANT_HOLDER.remove();
}
}对应的过滤器实现:
public class TenantFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
try {
String tenantId = parseFromToken((HttpServletRequest) req);
TenantContext.setTenantId(tenantId);
chain.doFilter(req, resp);
} finally {
// finally块保证异常场景下也能清理
TenantContext.clear();
}
}
}需要注意的是,如果业务里用了异步线程、CompletableFuture或者消息队列,ThreadLocal的值不会自动传到子线程,需要手动传递或者使用阿里开源的TransmittableThreadLocal来兜底。
更彻底的方案是把租户ID作为显式参数在方法间传递。虽然写起来啰嗦,但没有任何隐式状态,代码可测试性也更好。折中的做法是业务层用上下文机制,但在数据访问层做强制校验:通过MyBatis拦截器自动在所有SQL中追加tenant_id条件,条件值从上下文获取,这样即使某个开发漏写了租户过滤,兜底机制也能拦住越权查询。
事后复盘:如何防止同类问题再次发生
这次故障的技术修复只花了一天,但防御机制的建设花了两周。我们做了三件事。
第一,在代码规范中明确禁止使用可变的静态变量承载任何请求相关状态,并接入CI流水线检查,SonarQube扫描到Mutable Static Field直接阻断合并。第二,在数据层增加兜底断言:查询结果返回后,逐条校验记录的tenant_id与当前上下文一致,不一致则打告警日志并抛出安全异常,宁可让请求失败也不能让数据漏出去。第三,建立了越权自动化测试用例,用两个不同租户的账号并发压测核心查询接口,纳入每日回归。
多租户系统的数据隔离是安全底线,它不能依赖某一段代码恰好写对了,而要靠分层防御:入口处鉴权、传递时上下文隔离、出口处校验。static变量本身没有错,错的是把它放到了一个它承担不了的角色上。理解类加载机制和并发内存模型,是每个写后端代码的工程师都绕不过去的基本功。