多租户系统最容易出事的 8 个位置

杜恒 4 阅读 技术沉淀

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 糊弄 | 相关 线上出问题,先分清是所有人都慢还是一个人慢