没有报错,就是查不到数据。
商户反馈某异步任务产出的数据是空的。接口返回 200,日志干净得像没跑过,监控一片绿。这类问题比抛异常难缠得多——异常会喊疼,它不会。
现象与定位
打开 SQL 日志,真相一行就够:
SELECT * FROM ss_goods WHERE tenant_id = NULL AND ...
tenant_id = NULL 在 SQL 里永远匹配不到任何行——NULL 与任何值比较结果都是 UNKNOWN,所以这是恒零行查询。不报错、不告警、返回空集合,业务层把它当”没数据”正常消化掉了。
根因在多租户插件的租户 ID 获取处。我们用 MyBatis-Plus 3.5.2 的 TenantLineInnerInterceptor 做行级租户隔离,TenantLineHandler.getTenantId() 的实现是:优先取 TenantContext(ThreadLocal),取不到再从 Shiro 主体取,都取不到就 return null。
关键认知在这里:MP 3.5.2 的 TenantLineInnerInterceptor#builderExpression 对 getTenantId() 的返回值不判空(我们做过字节码取证确认)。null 返回值不是”跳过租户过滤”,也不是报错,而是被直接渲染成 tenant_id = NULL——fail-silent,表现为”静默查不到”而非”全表放行”。知道这个行为差异,排查方向就瞬间收窄了。
诱因是异步线程。HTTP 请求线程里有 Shiro 主体,一切正常;但 @Async 线程、MQ 消费者线程、定时任务线程既没有 Shiro 主体,也没人往 ThreadLocal 里写过租户——任何无主体线程命中这段代码,都是查空。
背景约束
这是一个连锁零售 ERP SaaS,80+ 业务模块、7600+ Java 文件的存量。多租户隔离必须做在 SQL 层自动注入,不可能逐个 Mapper 手写 tenant_id 条件;同时有 53 张平台级表(全局参考数据、系统配置、开通台账等)确实没有 tenant_id 列,需要走 ignoreTable 豁免白名单——每张都注释豁免原因,这是另一条战线。
约束就三条:HTTP 线程上下文不能丢;异步线程上下文必须传过去;线程归还线程池后上下文必须清掉。
方案推演
第一反应是在 handler 里兜底一个默认租户——立刻否掉,这是跨租户数据泄漏的入口。第二反应是把出问题的表加进豁免白名单——也否掉,表本身是租户业务表,豁免等于放弃隔离。正确方向不是”容错”,而是让上下文永远在场:写、传、清三段一体,任何一段缺失都会以最安静的方式背叛你。
实现
传,靠 TaskDecorator。所有线程池统一挂一个装饰器:提交时在调用方线程快照 TenantContext 全部字段 + Shiro Subject + MDC,worker 线程先恢复快照再执行任务,finally 里统一清理:
// caller 线程快照
final Long tenantId = TenantContext.getTenantId();
final Long userId = TenantContext.getUserId();
final Map<String, String> mdcSnapshot = MDC.getCopyOfContextMap();
final Thread callerThread = Thread.currentThread();
return () -> {
// CallerRunsPolicy 内联(worker == caller):直接跑,禁 set/clear
if (Thread.currentThread() == callerThread) { runnable.run(); return; }
try {
if (tenantId != null) TenantContext.setTenantId(tenantId);
if (userId != null) TenantContext.setUserId(userId);
// ...恢复 Shiro Subject、MDC 后执行任务
runnable.run();
} finally {
TenantContext.clear(); // 全部业务 ThreadLocal 统一 remove
MDC.clear();
}
};
清,靠一个注册在 HIGHEST_PRECEDENCE 的 OncePerRequestFilter,finally 里做三件事:TenantContext.clear()、Shiro ThreadContext.remove()、MDC.clear()。它防的是另一面的鬼:Tomcat 线程池复用,上一个请求(租户 A)残留的 ThreadLocal 被下一个走匿名路径的请求读到——不是查空,是串号,更危险。
还有个易漏点:worker 入口若发现快照里 tenantId/userId/storeId 三者全空,按任务类维度限频 warn(60 秒一次),否则定时任务这类”合法无上下文”的场景会刷屏,真问题反而被淹没。
踩坑
一是”静默”不止一条路。后来同面审计还抓到一张全国行政区划表 mt_region:3704 行的 tenant_id 列全是基座残留 NULL,插件注入后 selectById 恒零行,症状是 eshop 订单地址的省市区名静默缺失——表结构里的 NULL 和上下文里的 NULL,殊途同归。二是 CallerRunsPolicy:队列满时任务在 caller 线程内联执行,此时 worker == caller,任何 clear() 都会把 caller 自己的上下文清掉,必须靠线程引用对比提前 return。
一句原则
上下文类中间件,“写、传、清”三段一体:谁来写、怎么跨线程传、谁负责清,三段都要有明确的代码归属——缺哪一段,损失都不是异常,而是安静的错误数据。