《架构设计》目录

第 2 章

多租户系统最容易出事的 8 个位置——它们都绕开了你写好的拦截器

本章目标:

  1. 建立「旁路」意识:隔离模型选对只是及格线,真正的事故都发生在绕过租户拦截器的路径上
  2. 逐处看懂八 个位置的症状、机制与修法——缓存、异步线程、定时任务、搜索索引、消息、文件、日志、数据源
  3. 掌握租户上下文的传递纪律:TaskDecorator 怎么写、finally 里哪两行不能少
  4. 会做验收:两个租户、三条订单、三件事、三个数
  5. 按自己的客户量级决定先补哪几处,不为整齐一次做完

本章基线:Spring Boot 3.x + MyBatis-Plus(租户插件)+ Spring Data Redis + Elasticsearch 7.17 | 八处全部来自真实生产事故与评审记录 | 最后核对 2026-11-01。一租户一库的独立部署模式不在这个范围;隔离模型怎么选是多租户不是加一个 tenant_id 那篇的事

2.1 隔离模型选对只是及格线

多租户系统里,大家花在「隔离模型选共享表还是独立库」上的时间最多,出事最多的却不是它。拦截器拼 tenant_id 这件事,多数系统只覆盖了「走 ORM 的普通查询」这一条路——另外八条路不经过它:缓存、异步线程、定时任务、搜索索引、消息、文件、日志、数据源。它们的共同点是都从你写好的那层防护旁边绕过去,而且绕得无声无息:代码评审看不出来,因为那段代码本身没错,错的是它没有上下文。

按炸的时间排。上线第一天炸的,和第一次月结炸的,不是同一个。

八处绕过租户拦截器的位置,按第一次暴露的时间排成四段:上线第一天、第一个导出任务、第一次月结、之后的日常

八处不必一次补齐:客户数没到量级,有几处可以先欠着,分寸见 2.7 的表。

2.2 上线第一天:缓存先把两家混在一起

缓存串号是这批事故里最吵的,通常当天就暴露:租户 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 + ":");

拿不到租户上下文就让请求死掉,比拼出一个 tnull: 前缀安全得多——拼出 tnull: 前缀的写法会把所有丢失上下文的调用合并到同一个缓存空间,而且它们看起来都命中正常。

有个容易写错的对照写法要钉死:随手注册一个 KeyGenerator bean 是当不了全局兜底的——Spring 取全局 KeyGenerator 的唯一入口是 CachingConfigurer#keyGenerator() 接口,bean 的名字它不认;就算配对了,显式写了 @Cacheable(key = "#id") 的方法也完全不经过 KeyGenerator,显式 key 的场合,租户要写进表达式本身。

同一类问题还有三个藏处:

  • MyBatis 二级缓存:真正的雷是租户插件包在 CachingExecutor 内层,cache key 按改写前的原始 SQL 算,两租户同语句同参数就命中同一个 key——租户拦截器必须包在 CachingExecutor 外层,或者对多租户实体干脆关掉 <cache/>。
  • 进程内的 ConcurrentHashMap 当本地缓存:单机就能撞出跨租户,多实例更不用说。
  • ThreadLocal 之外「顺手存一下」的静态字段:没有解法,只有禁令——grep static 声明里带 Map 或 Cache 的行,命中即评审。
# 所有缓存注解整段过一遍眼睛,重点看 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

前两个藏处的解法一句话:租户并进 key 的算法里,别并进 value 的判断逻辑——key 决定你能不能命中,命中之后要不要再过滤一遍是权限的事。

2.3 第一个导出任务:上下文在线程池里丢了

异步这条是作者真实踩过的:接口把任务丢进线程池就返回了,工作线程里没有租户上下文,查询走到「没有租户条件」的分支,导出的是全量订单文件,文件再传到公共目录。排查花了一下午,因为看代码的人一直以为上下文是全链路都在的。

机制一句话:租户上下文放在 ThreadLocal,而线程池的线程不是你的请求线程。反复让人栽的是三个细节:

  • 继承失效:InheritableThreadLocal 只在创建线程的那一刻复制父线程的值,而 ThreadPoolExecutor 默认到提交第一个任务才建线程——继承到的是触发建线程那一次提交的租户,之后所有任务共用它。你看到的现象是「这个线程永远认为是租户 A」。
  • 绕过装饰器:CompletableFuture.supplyAsync 不传 executor 就走 ForkJoinPool.commonPool,你给业务线程池装的所有装饰器一概不生效;@Async 不指定 executor 用默认那个;第三方 SDK 内部的回调线程池根本不接受你的包装。
  • 结束不清:任务结束必须 clear()。少这一行,池里的线程带着上一个任务的租户跑下一个任务,故障从「查不到数据」变成「查到了别人的数据」,这一种贵得多——偶发、难复现,报上来的人还会说「我什么都没做」。

标准写法是给线程池配 TaskDecorator,提交时捕获、运行时恢复、结束时清理:

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,栈更难读

2.4 第一次月结:定时任务把三十家算成一家

定时任务的上下文天然是空的:没有 HTTP 请求,没有登录用户,拦截器拼不上条件。于是按租户汇总的语句扫成全表,或者更常见——扫全表之后连租户都不分组,直接把所有客户的数字加在一起。发现时间取决于你的月结是几号;这一条炸在钱上,报上来的是财务或代理商。

调度入口要显式列租户,循环内 set、finally 里 clear,一家失败不影响其余:

@Scheduled(cron = "0 30 2 * * ?")
public void settle() {
    for (long tid : tenantService.activeIds()) {        // 靠租户表驱动,不靠全表扫
        try {
            TenantContext.set(tid);
            settlementService.settlePeriod(tid, tenantService.billingPeriodOf(tid));
        } catch (Exception e) {
            log.error("settle failed, tenant={}", tid, e);   // 记下跑到哪一家
        } finally {
            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,
  settled_at  DATETIME    NOT NULL,
  PRIMARY KEY (tenant_id, period)     -- 配合 INSERT ... ON DUPLICATE KEY UPDATE
);

两个只在多租户下出现的问题:账期不一致——有的客户月初结、有的月底结、有的按自然周,cron 只有一个触发时刻,账期得从租户参数里读(上面 billingPeriodOf(tid) 干的事),别写死「上个月」;多实例撞车——两个实例同时到点,同一家的月结跑两遍。分布式锁的粒度要落到租户(lock:settle:{tenantId}:{period}),全局一把锁会让三十家排队等一个跑失败的。作者的做法是让账期主键兜底,锁只加在「同一个租户不能并发」这一层。

私有化部署后还有一层:客户自己的服务器时区可能和 SaaS 那边差八小时,写死的日期函数在两台机器上算出来的不是同一个月份——容器时区的坑在本地环境那一章的 1.6.4 有实拍。

2.5 之后的日常:索引、消息、文件、日志、数据源

剩下五处没有前三个准时炸,每一处都在等对应的业务第一次出现。

搜索索引

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,只要有一个入口「前端传什么索引名就查什么」,过滤就没了。约束要落在「谁能拼索引名」上——查询入口收进一个只接受 SearchIntent 的方法,索引名由服务端算。用通配符或 _all 的索引名等于绕过所有租户条件。

消息

租户放消息头(或消息体)里,消费端从头里取——不要拿业务 id 回数据库反查租户,反查正好又需要上下文,等于把问题挪到下一个环节。重试和死信重放是同一坑的第二层:重投时原始上下文早就没了,租户要么在消息里,要么在业务数据本身能推导出来的地方。顺序消息还有个附加要求:按租户选队列(Kafka 用 key = tenantId),跨租户的顺序本来就不该互相等。

文件

路径前缀按租户分段(export/{tenantId}/{yyyyMMdd}/{jobId}.xlsx),本地临时目录按任务分而不是按进程分,预签名 URL 给短有效期。下载链接那一侧还要再过一次权限:文件名里带租户不等于只有这家能下,签名 URL 过期之前谁都行,链接生成时要校验请求人属于这个租户。客户要源码买断或私有化时,前缀必须做成可配置项——目录结构在别人的机器上,硬编码的桶名会在交付当天改一遍。

日志

就一件事:MDC 里放 tenantId 并打进 pattern。放进 pattern 的值不会自己出现——线程池装饰器和定时任务循环里,TenantContext.set 的后面都跟着一行 MDC.put,finally 里先 MDC.remove 再 clear(2.3 的代码已经是这个形状)。MDC 是线程本地的,漏了这对动作,池化线程要么带着上一个任务的租户写日志,要么 t= 整段是空。跨租户的问题九成是「某个客户独有」,没有这个字段就只能靠时间点猜是谁。

<encoder>
  <pattern>%d{HH:mm:ss.SSS} [%thread] t=%X{tenantId} %-5level %logger{36} - %msg%n</pattern>
</encoder>

数据源

共享表和独立库混用(大客户独立库、小客户共享表)之后,切换必须发生在拿到连接之前:AbstractRoutingDataSource 在 getConnection() 的那一刻决定路由,而事务一开启连接就绑定了,@Transactional 方法内部再改上下文无效。这类 bug 长得像缓存问题——数字是对的,只是属于隔壁租户。切库放在最外层,租户解析完立刻确定数据源,整个请求生命周期不再改。

2.6 验收:不靠读代码,靠造数据

两个租户各灌三条订单,然后跑三件事:一次缓存预热、一个导出任务、一次月结。跑完只看三个数:

  • 返回的行数:租户 A 的导出必须是 3,出现 6 就是漏条件。
  • 日志里 t= 有没有值:异步任务尤其要看,线程名和租户的对应关系能告诉你装饰器有没有生效。
  • 落盘路径和缓存 key 前缀:redis-cli --scan 看实际生成的 key,比看代码诚实。

这条验收可以挂进 CI——它看的全是可观察的输出:

@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 是线程本地的,断言落在任务执行期间采集的日志行上,
    // 直接断言测试线程自己的 MDC 永远是空——那是在错误的线程上找值
    assertThat(logs.list).anyMatch(
        e -> String.valueOf(a).equals(e.getMDCPropertyMap().get("tenantId")));
}

再加一道静态检查更省事:把 2.2 的 grep 放进流水线,命中数超过上次就失败——缓存声明总数在一个项目里是缓慢增长的,用它当基线比当待办清单靠谱。先修命中数最多的那一类,别按文章的序号修。

2.7 按客户量级决定补哪几处

八处不必一次补齐,按你现在的规模挑,剩下的记进待办:

现在的情况 必须先补 可以先欠
三家以内,同一个库,没有异步任务 缓存 key、日志 MDC 索引 routing、数据源切换
十几家,有导出或报表 上面两处 + 线程池上下文传递 消息头(还没上 MQ 就无所谓)
开始按租户月结 定时任务的租户循环 + 幂等键 一租户一索引
客户要求私有化或源码买断 文件前缀可配置、缓存能按租户清干净 多租户查询路由优化
出现外部代理商结算 幂等键和归属快照 没有

反过来看更省事:只要系统里还没有异步任务、还没有导出、还没有月结,后几处的风险就都是潜在的。表从下往上读,停在你现在能负责的那一行。

2.8 八处自查清单

  • 缓存 key 带租户:全局前缀用 computePrefixWith 生成,别指望 KeyGenerator
  • MyBatis 二级缓存:租户插件包在 CachingExecutor 外层,或对多租户实体关掉 <cache/>
  • 异步任务:TaskDecorator 提交时捕获,finally 里 remove 和 clear 两行都得有
  • 定时任务:入口显式列租户循环,结果表主键带 (tenant_id, period) 配 upsert
  • 搜索索引:文档带 tenant_id,查询包最外层 filter,写入和查询用同一个 routing
  • 消息:头里带 tenant_id,消费端取头,不拿业务 id 反查租户
  • 文件:路径按租户分段,签名 URL 生成时校验请求人归属
  • 日志:MDC 放 tenantId 进 pattern,set 后跟 put,finally 先 remove 再 clear
  • 数据源:切库发生在拿到连接之前,整个请求生命周期不再改

2.9 本章小结

八处的一个共同点值得再念一遍:它们都不在「写 SQL」的那条路上,所以拦截器帮不上忙;它们的修法也高度一致——让租户跟着数据走(key、消息头、路径、文档字段),而不是指望执行现场能猜出租户。上下文传递的三条铁律:提交时捕获、运行时恢复、结束时清理,异步和定时任务一个都不能少。

2.10 练习题

  1. 在你的工程里跑 2.2 的两条 grep,数一遍命中:缓存注解里 key 表达式带租户的有几处?这就是你现在的真实水位。
  2. 写一个测试复现 2.3 的「继承失效」:用 InheritableThreadLocal + 线程池,第一个任务提交租户 A、第二个提交租户 B,打印两个任务各自看到的租户。
  3. 给月结任务造一次并发:同一租户两个线程同时跑,验证 (tenant_id, period) 主键 + upsert 下结果表只有一个版本的数字。
  4. 把 2.6 的验收测试抄进你的项目跑一遍——第一次跑不过的位置,就是你现在欠账最多的那一处。

2.11 延伸读