多租户系统最容易出事的 8 个位置
01 那篇把隔离模型选完,这一篇讲选完之后出事的地方。
拦截器拼 tenant_id 这件事,多数系统只覆盖了“走 ORM 的普通查询”这一条路。另外八条路不经过它:缓存、异步线程、定时任务、搜索索引、消息、文件、日志、数据源切换。它们的共同点是都从你写好的那层防护旁边绕过去了,而且绕得无声无息,代码评审的时候看不出问题,因为那段代码本身没错,错的是它没有上下文。
按炸的时间排。上线第一天炸的,和第一次月结炸的,不是同一个。

上线第一天,缓存先把两家混在一起
缓存串号是这批事故里最吵的一个,所以它通常当天就暴露:租户 A 的客户列表里出现租户 B 的客户,打电话过来的人当场就说清了现象。
真正麻烦的是不吵的那一半。只缓存字典、系统配置、行政区划的 key 也会串,但没人发现,因为那些数据本来就该共享。等某天有个开发往同一份缓存里塞了“按租户配置的业务参数”,它还是命中之前的共享 key,读出来是别的租户的值。这种串号的表现形式是“某个客户的开关莫名失效”,排查的人会先怀疑数据库。
@Cacheable 的 key 要带租户维度,而且只能在生成 key 的时候带。这里有个容易写错的地方:
// 对:全局兜底。这个 lambda 每次读写缓存都在当前线程求值一遍,
// Spring Data Redis 源码在这行上面的注释原话是 computing the key
// prefix on every call。它只覆盖 Redis 缓存,本地缓存不走它
RedisCacheConfiguration.defaultCacheConfig()
.computePrefixWith(name -> "t" + TenantContext.require() + ":" + name + ":");
// 错:这个 bean 谁都不会取。Spring 取全局 KeyGenerator 的唯一入口
// 是 CachingConfigurer#keyGenerator(),随手指起的名字运行期落回
// SimpleKeyGenerator,key 里没有租户
@Bean
public KeyGenerator tenantKeyGenerator() {
return (target, method, params) -> "t" + TenantContext.require() + ":"
+ target.getClass().getSimpleName() + ":" + method.getName() + ":"
+ Arrays.hashCode(params);
}
require() 是故意抛异常的写法。拿不到租户上下文就让请求死掉,比拼出一个 tnull: 前缀安全得多,后者会把所有丢失上下文的调用合并到同一个缓存空间,而且它们看起来都命中正常。
先把两段的对错钉死。前缀那条是对的,理由就在注释里,配好之后全局生效,它是租户前缀的标准做法。KeyGenerator 那条是坑:真要用它,得实现 CachingConfigurer#keyGenerator(),或者在每一个 @Cacheable 上写 keyGenerator = "tenantKeyGenerator" 逐个点名,Spring 不认 bean 的名字。就算配对了它也当不了全局兜底——显式写了 @Cacheable(key = "#id") 的方法完全不经过 KeyGenerator,显式 key 的场合,租户要写进表达式本身。
同一类问题还有三个藏处。MyBatis 的二级缓存:CacheKey 里含绑定参数,只差 tenant_id 值的两条语句本来不同 key。真正的雷是租户插件包在 CachingExecutor 内层,cache key 按改写前的原始 SQL 算,两租户同语句同参数就命中同一个 key——租户拦截器必须包在 CachingExecutor 外层,或者对多租户实体干脆关掉 <cache/>。进程内的 ConcurrentHashMap 当本地缓存用,单机就能撞出跨租户,多实例更不用说。
ThreadLocal 之外那些“顺手存一下”的静态字段没有解法,只有禁令:grep static 声明里带 Map 或 Cache 的行,命中即评审。前两个的解法一样:租户并进 key 的算法里,别并进 value 的判断逻辑。key 决定你能不能命中,命中之后要不要再过滤一遍是权限的事,02 那篇讲过了。
# 所有缓存注解整段过一遍眼睛,重点看 key 表达式里有没有租户。
# 别用 grep -v tenant 过滤:缓存名里带 tenant、key 里没租户的行会被整行放行
grep -rn -A2 -E "@Cache(able|Put|Evict|Config)" src/main/java
grep -rn -A3 "<cache" src/main/resources
第一个导出任务,上下文在线程池里丢了
异步这条是我自己踩过的,就是01 那篇开头那个事故:接口把任务丢进线程池就返回了,工作线程里没有租户上下文,查询走到“没有租户条件”的分支,导出的是全量订单文件,文件再传到公共目录。排查花了一下午,因为看代码的人一直以为上下文是全链路都在的。
机制很简单:租户上下文放在 ThreadLocal,而线程池的线程不是你的请求线程。真正会让人反复栽的是三个细节。
InheritableThreadLocal 在线程池里等于没继承。它只在创建线程的那一刻复制父线程的值,而 ThreadPoolExecutor 默认到提交第一个任务才建线程,继承到的是触发建线程那一次提交的租户,之后所有任务共用它,你看到的现象是“这个线程永远认为是租户 A”。
CompletableFuture.supplyAsync(supplier) 不传 executor 就走 ForkJoinPool.commonPool,你给业务线程池装的所有装饰器一概不生效。同理还有 @Async 不指定 executor 时用默认那个,以及第三方组件(比如某些 SDK 内部的回调线程池)根本不接受你的包装。
传进去不等于完事,任务结束必须 clear()。少这一行,池里的线程带着上一个任务的租户去跑下一个任务,于是故障从“查不到数据”变成“查到了别人的数据”,后者比前者贵得多,因为它偶发、复现困难,而且报上来的人会说“我什么都没做”。
public class TenantAwareTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Long tenantId = TenantContext.getOrNull(); // 提交任务的那个线程的上下文
return () -> {
if (tenantId != null) {
TenantContext.set(tenantId);
MDC.put("tenantId", String.valueOf(tenantId));
}
try {
runnable.run();
} finally {
MDC.remove("tenantId");
TenantContext.clear(); // 两行都得有
}
};
}
}
取上下文用 getOrNull(),空就原样留空。这里不能调 require():有些全局任务本来就没有租户,装饰器的职责是把提交线程的上下文原样搬到工作线程,不是替工作线程做校验;工作线程里的查询拿不到租户会自己失败,那个堆栈离问题更近。
三种传递写法的适用范围不重叠,按你的线程池是谁建的挑:
| 写法 | 够用 | 不够用 |
|---|---|---|
InheritableThreadLocal |
一次性脚本、手动 new Thread |
任何线程池:值在创建时就定死了 |
TaskDecorator |
自己造的池、@Async 指定了 executor |
CompletableFuture 不传 executor 时绕过它;组件内部线程池管不到 |
TransmittableThreadLocal |
要同时透传租户、操作人、traceId | 得挂 agent 或手动 capture,出问题时栈更难读 |
第一次月结,定时任务把三十家算成了一家
定时任务的上下文天然是空的。没有 HTTP 请求,没有登录用户,拦截器拼不上条件,于是一条 select ... group by tenant_id 之类的语句扫的是全表,或者更常见,扫全表之后连租户都不分组,直接把所有客户的数字加在一起。
发现时间取决于你的月结是几号。这一条炸在钱上,报上来的人是财务或者代理商,而不是运营。
写法上的要求得硬编码进流程里,靠自觉一定会漏。
调度入口要显式列租户,循环内 set 和 clear,一家失败不影响其余:
@Scheduled(cron = "0 30 2 * * ?")
public void settle() {
for (long tid : tenantService.activeIds()) { // 靠租户表驱动,不靠全表扫
try {
TenantContext.set(tid);
MDC.put("tenantId", String.valueOf(tid)); // 日志里 t= 的来源
settlementService.settlePeriod(tid, tenantService.billingPeriodOf(tid));
} catch (Exception e) {
log.error("settle failed, tenant={}", tid, e); // 记下跑到哪一家
} finally {
MDC.remove("tenantId");
TenantContext.clear();
}
}
}
结果表按租户加账期做幂等。主键自己只会把重复写入挡成一场报错,覆盖要靠写入端写成 upsert:
CREATE TABLE biz_settlement (
tenant_id BIGINT NOT NULL,
period CHAR(7) NOT NULL, -- 2026-11
amount DECIMAL(14,2) NOT NULL,
detail_path VARCHAR(255) NULL,
settled_at DATETIME NOT NULL,
PRIMARY KEY (tenant_id, period) -- 配合写入端 upsert,重跑才是覆盖
);
还有一个只在多租户下才出现的问题:账期不一致。有的客户月初结,有的月底结,有的要求按自然周。cron 表达式只有一个触发时刻,账期得从租户参数里读,上面那行的 billingPeriodOf(tid) 就是干这个的,别图省事写回“上个月”。私有化部署之后这条更麻烦,客户自己的服务器时区可能和 SaaS 那边差八个小时,写死的日期函数在两台机器上算出来的不是同一个月份。
多实例部署还叠了一层:两个实例同时到点,同一家的月结跑两遍。用分布式锁的话锁的粒度要落到租户上(lock:settle:{tenantId}:{period}),全局一把锁会让三十家排队等一个跑失败的。不用锁的话,写入写成 INSERT ... ON DUPLICATE KEY UPDATE amount = VALUES(amount), settled_at = NOW(),顶在 (tenant_id, period) 主键上,重跑就是覆盖——主键配纯 INSERT 只会每家抛一次 DuplicateKeyException。
前一种在租户多的时候会明显拖慢批处理,我倾向于让账期主键兜底,锁只加在“同一个租户不能并发”这一层。
剩下五处:索引、消息、文件、日志、数据源
搜索索引。ES 的文档里必须有 tenant_id 字段,查询强制包一层 term filter。routing=tenantId 让一次查询只打一个分片,前提是写入端也用同一个 routing:写入走默认路由时文档散在所有分片上,只改查询端会把别的分片上本租户的文档静默查丢,比查得慢难发现得多。
long tenantId = TenantContext.require();
BoolQueryBuilder q = QueryBuilders.boolQuery()
.filter(QueryBuilders.termQuery("tenant_id", tenantId)); // 包在最外层
SearchRequest req = new SearchRequest("idx_order").routing(String.valueOf(tenantId));
req.source().query(q);
这段代码的问题不在写法,在于它能不能被绕开。多数系统的搜索入口不止一个:列表页、全局搜索框、报表导出、内部 RPC。只要有一个入口是“前端传什么索引名就查什么”,过滤就没了。用通配符(idx_order_*)或者 _all 的索引名等于绕过所有租户条件,而这类语句在 review 里很难看出来,因为它只是把一个变量拼进了索引名。约束要落在“谁能拼索引名”上:把查询入口收进一个只接受 SearchIntent 的方法,索引名由服务端算。
一租户一索引在租户少的时候很省事,按客户清理就是删索引,私有化搬迁就是复制索引。几百个租户往上,集群元数据和分片数会先出问题,搜索变慢往往是从这里开始的,不是从数据量开始的。按租户重建索引(改 mapping 之后)也要注意范围:一次全量 reindex 会把所有客户的数据读一遍,跑在同一个集群上会把在线查询挤死。
消息。消息头(或消息体)里要带 tenant_id,消费端从头里取,不要拿业务 id 回数据库反查租户,反查正好又需要上下文,等于把问题挪到了下一个环节。
template.execute(msg -> {
msg.getMessageProperties().setHeader("tenantId", String.valueOf(TenantContext.require()));
return msg;
});
@RabbitListener(queues = "order.settled")
public void onSettled(OrderSettled payload, Message message) {
long tenant = Long.parseLong(String.valueOf(message.getMessageProperties().getHeader("tenantId")));
TenantContext.set(tenant);
try {
handle(payload);
} finally {
TenantContext.clear(); // 和线程池那一段是同一个 finally
}
}
重试和死信重放是同一个坑的第二层:重投的时候原始上下文早就没了,如果消费逻辑依赖线程里残留的值,它会读到空。所以租户要么在消息里,要么在业务数据本身能推导出来的地方,两者都得有。顺序消息还有个附加要求:按租户选队列(Kafka 用 key = tenantId,RocketMQ 用 MessageQueueSelector),跨租户的顺序本来就不该互相等。
文件。路径前缀按租户分段,导出临时目录按任务分而不是按进程分,预签名 URL 给一个短有效期。01 里那次事故的最后一公里就在这里:文件本身串了租户,落到公共目录之后,任何知道路径的人都能下载。
export/{tenantId}/{yyyyMMdd}/{jobId}.xlsx 对象存储按前缀隔离,便于按租户清理
/tmp/export/{jobId}/ 本地临时目录按任务分,避免同名互踩
下载链接那一侧还要再过一次权限:文件名里带租户不等于只有这家能下,签名 URL 过期之前谁都行。链接生成时要校验请求人属于这个租户,这件事和 02 里“把地址栏 id 改一位数字”是同一个漏洞的两个入口。客户要源码买断或者私有化时,前缀必须做成可配置项,因为目录结构在别人的机器上,硬编码的桶名会在交付当天改一遍。
日志就一件事,MDC 里放 tenantId 并且打进 pattern:
<encoder><pattern>%d{HH:mm:ss.SSS} [%thread] t=%X{tenantId} %-5level %logger{36} - %msg%n</pattern></encoder>
放进 pattern 的值不会自己出现。MDC.put 要跟着上下文设置走:线程池装饰器和定时任务循环里,TenantContext.set 的后面都跟着一行 MDC.put,finally 里先 MDC.remove 再 clear,上面两段代码已经是这个形状。MDC 是线程本地的,漏了这对动作,池化线程要么带着上一个任务的租户写日志,要么 t= 整段是空。
跨租户的问题九成是“某个客户独有”,没有这个字段就只能靠时间点猜是谁,凌晨的定时任务尤其猜不出来。
数据源。共享表和独立库混着用(大客户独立库、小客户共享表)之后,切换必须发生在拿到连接之前。AbstractRoutingDataSource 在 getConnection() 的那一刻决定路由,而事务一开启连接就绑定了,所以在 @Transactional 方法内部再改上下文是无效的,读到的还是原来那个库。这类 bug 长得很像缓存问题:数字是对的,只是属于隔壁租户。
真正的解法是把切库放在最外层:租户解析完立刻确定数据源,整个请求生命周期不再改。异步和定时任务同理,任务提交前就要知道它跑在哪个库上,这也是为什么 02 那篇说上下文要在 Controller 之前设置好。多库并存还带来一个隐性成本:跨库的报表 join 不了,只能靠应用层聚合,报表慢的第一原因是这个,不是索引没建。

挂进 CI 之前先手动跑一次
上面这些不靠读代码验收,靠造数据。两个租户各灌三条订单,然后跑三件事:一次缓存预热、一个导出任务、一次月结。跑完只看三个数。
返回的行数对不对(租户 A 的导出必须是 3,出现 6 就是漏条件);日志里 tenantId 这个字段有没有值、值对不对(异步任务尤其要看,线程名和租户的对应关系能告诉你装饰器有没有生效);落盘路径和缓存 key 的前缀(redis-cli --scan 看实际生成的 key,比看代码诚实)。
@Test
void exportJobKeepsTenant() {
long a = seedTenant("A", 3), b = seedTenant("B", 3);
ListAppender<ILoggingEvent> logs = attachCollectorToRootLogger();
runExportJob(a);
assertThat(orderIdsIn(lastExportFile())).allMatch(id -> belongsTo(a, id));
// MDC 是线程本地的,测试线程自己的永远是空;断言落在任务执行期间采集的日志行上
assertThat(logs.list).anyMatch(e -> String.valueOf(a).equals(e.getMDCPropertyMap().get("tenantId")));
}
这条测试能挂 CI,因为它看的全是可观察的输出:文件里是谁的单子,任务执行期间采到的日志行里 t= 是谁。直接断言测试线程的 MDC 永远是空——那是在错误的线程上找值;挂在采集到的日志上,顺带把 MDC.put 有没有真挂上验掉了。同类写法可以再抄一份给月结,同一个租户跑两次,断言结果表里行数不变(幂等),跑完换另一个租户,断言数字没被上一次污染。
再加一道静态的更省事:把开头那条 grep 放进流水线,命中数量超过上次就失败。缓存声明的总数在一个项目里是缓慢增长的,用它当基线比用它当待办清单靠谱,因为待办清单没人会看完。异步侧同理,扫 supplyAsync(、@Async、CompletableFuture.runAsync 三个词,看有多少处没带 executor 参数。这类检查的价值不在于它抓得多,在于新写的代码进不来。
要是拿不准这八处现在哪几处是虚的,跑一遍那条 grep。命中的 cache name 和异步调用点会自己排出来,先修命中数最多的那一类,别按文章的序号修。
客户数不到那个量,可以先欠着哪几处
八处不是同时开始要命的。按你现在的规模挑,剩下的记在 TODO 里,别为了整齐一次做完。
| 现在的情况 | 必须先补 | 可以先欠 |
|---|---|---|
| 三家以内,同一个库,没有异步任务 | 缓存 key、日志 MDC | 索引 routing、数据源切换 |
| 十几家,有导出或者报表 | 上面两处 + 线程池上下文传递 | 消息头(还没上 MQ 就无所谓) |
| 开始按租户月结 | 定时任务的租户循环 + 幂等键 | 一租户一索引 |
| 客户要求私有化或源码买断 | 文件前缀可配置、缓存能按租户清干净 | 多租户查询路由优化 |
| 出现外部代理商结算 | 幂等键和归属快照,见 03 | 没有 |
反过来看更省事:只要系统里还没有异步任务、还没有导出、还没有月结,后四处的风险就都是潜在的,补的意义不大。表从下往上读,停在你现在能负责的那一行;下一行等那类业务真出现了再补。
系列:上一篇 部门、门店、代理商,一张 sys_dept 表撑不住这三层 | 下一篇 订单状态机:别用 status=1,2,3 糊弄 | 相关 线上出问题,先分清是所有人都慢还是一个人慢