系统架构 · 隔离多租户与资源治理

多客户共用一套系统: 隔离程度和成本永远在拔河 — 行级 tenant_id 最便宜也最容易串号, 配额与分池决定故障半径

多租户隔离与资源治理 · 三档隔离模式 × 配额分池 × Noisy Neighbor 事故链 隔离度与成本永远在拔河, 配额分池决定故障半径 隔离模式梯度 · 横轴=成本, 纵轴=隔离强度 越往上越安全, 越往右越贵 隔离强度 成本 行级隔离 Row-level 共享表 · WHERE tenant_id = ? 最便宜 · 最易串号 Schema 隔离 共享 DB · 独立 schema 中等成本 · 迁移跑 N 遍 独立 DB Separate 一租户一库 · 最强隔离 最贵 · 迁维最重 1 套 DB · 上线改一个 WHERE N 套 DB · 迁移/备份/告警 ×N K8s 原生隔离原语 namespace + 配额 # 租户 = namespace + 硬顶配额 kind: ResourceQuota # ns: tenant-acme spec: hard: requests.cpu: "50" requests.memory: 100Gi pods: "300" --- kind: LimitRange # 没写 resources 给默认值 default: cpu: 500m memory: 512Mi # 超配额: 创建 Pod 直接 Forbidden 报错原文: pods "demo" is forbidden: exceeded-quota 事故链 · 共享池无租户配额 (Noisy Neighbor) 大租户 acme 月度报表 SELECT * 全表扫, 无租户过滤 08:00 定时任务并发 200 打满 抓连接 HikariCP 共享连接池 maximumPoolSize = 100 被 acme 抢占 97 个, 其余排队 PostgreSQL 共享库 max_connections = 100 报表长事务阻塞 autovacuum 其他租户拿不到连接 FATAL: sorry, too many clients already 网关 5xx → 全租户 503 09:03 P1 · 12 分钟后限流恢复 连接拒绝 共享资源没有租户配额 → 一个租户的报表 = 全站的事故 正确路径 · 配额 + 分池, 故障半径 = 单租户 请求带租户上下文 X-Tenant-ID → TenantContext 网关注入, 全链路透传 分池路由 Resilience4j Bulkhead acme ≤ 20 并发 · 其余池独立 打满只堵自己, 不外溢 正常通行 acme 超限 429 DB 侧按租户限额 ALTER ROLE acme CONNECTION LIMIT 20; MySQL 对应 max_user_connections 租户配额 Quota API 1000 req/min · 报表 1 并发 超限显式 429, 不静默 配额闸门 同一波流量: 大租户打满自己的池, 小租户无感 rose = 事故路径: 无配额的共享池, 一个大租户拖垮全站 emerald = 正确路径: 租户上下文 → 配额闸门 → 分池隔离, 故障半径 = 单租户 行级隔离必须配 DB 强制过滤: PostgreSQL RLS (FORCE ROW LEVEL SECURITY) 或 ORM 全局过滤器 + 导出审计, 缺一处就是串号 读法: 上排 = 三档隔离模式按钱排布; 下排 = 同一波流量, 有无配额分池的两种命运

机制视角 — 三档隔离模式

  • • 独立 DB > 独立 Schema > 行级 tenant_id: 隔离强度与成本严格成正比, 没有免费午餐
  • • 行级隔离的全部安全性押在"每条 SQL 都带 tenant_id"这一条纪律上, 必须有 RLS/全局过滤器兜底
  • • K8s 的 Namespace + ResourceQuota + LimitRange 是同一思想在资源层的投影
  • • Sandbox 是另一条战线: 插件/脚本执行要进程或容器级隔离

行为视角 — Noisy Neighbor

  • • 共享池无配额时, 一个租户的报表能吃光 100 个连接, 故障半径 = 全站
  • • 分池 (Bulkhead) 把"别人打满"变成"只堵自己", 配额把"无限用"变成"有限额"
  • • 大租户的终局通常是迁独占集群: 共享池保弹性, 独占实例保底线
  • • 判断标准: 拔掉大租户的流量, 小租户的 P99 应该毫无变化

生产价值 — 落地清单

  • • 租户上下文: 网关注入 → 全链路透传 → 异步线程用装饰器复制
  • • 数据面: RLS FORCE 兜底 + 索引以 tenant_id 打头 + 缓存 key 带租户段
  • • 治理面: 配额 80/95/100 三级告警 + 导出走审计 + 注销走数据清理流水线
  • • 权控面: RBAC 角色与租户归属双重校验, 前端隐藏不算权限

💡 一句话理解

多租户就是把一栋楼按"合租"运营: 独栋 (Separate DB) 最贵但隔壁着不了火, 合租大通铺 (行级 tenant_id) 最便宜但全靠每个人自觉关门——没有数据库层的门禁 (RLS), 一条忘写 WHERE 的 SQL 就是串号事故。

Noisy Neighbor 是合租的经典矛盾: 半夜开派对的大租户把水电网 (连接池/CPU/IO) 吃光, 全楼陪瘫。配额 (Quota) 与分池 (Bulkhead) 就是物业管理: 给每个租户装独立电表、限定最大功率——出了事只跳自家的闸。

🧠 必知必会 必考 & 必会

Multi-tenancy 多租户
一套系统服务多个客户, 数据同住一张表、靠 tenant_id 列区分。本质是把隔离成本摊薄: 省钱的同时把"数据边界"从物理边界变成了每一条 SQL 的纪律。
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id;
-- 关键: 一张表服务所有租户, tenant_id 是第一等公民
-- → acme | 81234 ; beta | 421
Single-tenant 单租户
一租户一套部署 (独立进程/独立库), 隔离最彻底、故障和升级互不影响, 代价是成本随租户数线性增长, 适合付费能力强的金融/大客户。
# helm install tenant-acme ./chart --set tenant=acme
# 关键: 一租户一套 release, 升级/故障半径=1, 但成本 ×N
# → 200 个租户 = 200 套发布流水线, 没自动化别选这条路
Row-level Isolation 行级隔离
所有租户共用表, 行上打 tenant_id 标。是 SaaS 主流: 迁移只跑 1 遍、成本最低; 风险也最集中——任何一条漏过滤的 SQL 都能读到别人。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY t_isolation ON orders
  USING (tenant_id = current_setting('app.tenant_id'));
-- 关键: 应用每个连接先 SET app.tenant_id, 漏 WHERE 也查不出别人
Shared DB / Schema / Separate DB
三档隔离: 共享库共享表 (最省)、共享库独立 schema (中等, 迁移跑 N 遍)、独立库 (最贵最稳)。选型问题本质是客单价问题: 大客户买得起隔离, 长尾租户买不起。
CREATE SCHEMA tenant_acme;   -- schema 档: 1 个 DB, N 个 schema
CREATE DATABASE tenant_acme; -- 独立档: 1 个租户 1 个 DB
-- 关键: 三档 = 三种价格, DDL 迁移成本从 1 遍变 N 遍
RLS 强制策略 (PostgreSQL)
行级安全策略把租户过滤下沉到数据库内核, 应用想忘都忘不掉。注意两层开关: ENABLE 只约束普通角色, 表 owner 默认绕过, 必须 FORCE 才连 owner 一起管。
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
-- 关键: 只 ENABLE 不 FORCE 时, 表 owner 直连可读全表
-- → owner 连接执行 SELECT 也只看得见 app.tenant_id 那一份
Resource Quota 资源配额
给租户的资源上限 (CPU/内存/实例数/请求数)。配额是硬顶不是排队: 超了直接拒绝, 所以必须配告警和用户侧提示, 否则用户只会看到"创建失败"。
apiVersion: v1
kind: ResourceQuota
spec:
  hard:
    requests.cpu: "50"    # 关键: 硬顶, 超了创建 Pod 直接 Forbidden
Noisy Neighbor 喧闹邻居
共享环境里一个租户的突发负载 (大报表、死循环、爬虫) 侵蚀其他租户的资源。定位靠按租户聚合的资源视图, 治理靠限流+分池+迁出三连。
SELECT usename, count(*) FROM pg_stat_activity
  GROUP BY usename ORDER BY count(*) DESC;
-- 关键: 先定位谁占着连接, 再决定限流还是迁走
-- → acme_rw | 97 ; beta_rw | 3  (凶手一目了然)
Bulkhead 舱壁隔离
像船的水密隔舱: 把资源 (线程/连接/并发额度) 按租户切成独立隔间, 一个隔间进水不沉全船。代价是资源利用率下降——隔间留了余量就不能被别人借用。
BulkheadConfig.custom()
    .maxConcurrentCalls(20)
    .build();
// 关键: acme 的并发预算 20, 打满只堵自己
// → 第 21 个并发调用立刻 BulkheadFullException
Tenant Context 租户上下文
请求进来的租户身份 (通常来自 JWT/网关 header) 存进 ThreadLocal, 数据层自动读取。致命弱点: 线程池复用与异步切换会丢上下文, 必须用装饰器传递、用完清理。
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
TENANT.set("acme");   // 关键: 进线程池/异步就丢, 必须传递版装饰器
// → @Async 线程里 TENANT.get() == null, 数据层查不到租户
RBAC + 租户双维权控
角色 (能不能做) 与租户归属 (是不是你的数据) 是两个正交维度, 角色对不等于数据对: 同是 admin, A 公司的 admin 也不该读 B 公司的行。
def can_read(user, order):
    # 关键: 角色校验之外, 必须再校验数据归属
    return user.has_role("reader") and order.tenant_id == user.tenant_id
# → A 公司 admin 读 B 公司订单: 角色通过, 归属拒绝
Namespace 命名空间
K8s 里逻辑分组的"房间": 配额、网络策略、RBAC 都挂在 namespace 上, 常用来隔离租户或测试/生产环境。它只是逻辑隔离——节点、内核仍是共享的, 强隔离要上容器 runtime 或独立集群。
kubectl create namespace tenant-acme
kubectl get pods -n tenant-acme
# 关键: namespace = 租户房间, 配额/网络策略都挂在它上面
# → No resources found in tenant-acme namespace.
缓存 key 租户前缀
多租户系统里缓存是仅次于数据库的串号高发地: key 不带租户段, 两个租户就读写同一份值。纪律: 租户上下文必须参与 key 生成, 且禁止手拼 key。
key := fmt.Sprintf("t:%s:orders:%s", tenantID, orderID)
// 关键: 缓存 key 不带租户前缀 = 跨租户串数据
// → t:acme:orders:1001 命中的是 acme 自己的数据

🏭 生产实战 real world

场景 1 · SaaS 订单系统的行级隔离全链路: 从网关到数据层一根线穿到底

50 个租户共用一套订单服务。行级隔离能不能活, 取决于租户身份有没有"缝": 中间任何一层丢了上下文, 后面就全靠 SQL 自觉。

// 网关已校验 JWT, 服务入口只做上下文装载与清理 (Spring)
public class TenantInterceptor implements HandlerInterceptor {
  public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object h) {
    String tid = req.getHeader("X-Tenant-ID");
    if (tid == null) throw new MissingTenantException();  // 无租户上下文一律拒绝
    TenantContext.set(tid);
    return true;
  }
  public void afterCompletion(HttpServletRequest req, HttpServletResponse resp,
                              Object h, Exception ex) {
    TenantContext.clear();  // 关键: Tomcat 线程复用, 不清理下一个请求就继承上个租户
  }
}

上线半年, "查询串号"类工单归零——之前每次都靠逐条排查漏写 WHERE 的 SQL。

场景 2 · 给 30 张核心表上 PostgreSQL RLS: 数据库层兜底串号

ORM 过滤器只能管住走 ORM 的代码, 直连 SQL、临时脚本都管不住。RLS 把租户过滤压到内核, 是行级隔离的保险丝。

-- 迁移脚本: 应用连接用非超级用户 app_rw (RLS 对超级用户无效)
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;  -- 连表 owner 也强制
CREATE POLICY orders_tenant ON orders
  USING (tenant_id = current_setting('app.tenant_id'))
  WITH CHECK (tenant_id = current_setting('app.tenant_id'));
-- 每次业务请求开始: SET app.tenant_id = 'acme'
-- 连接归还连接池前必须 RESET app.tenant_id, 否则下个请求继承

WITH CHECK 让"写别人的租户 ID"也直接报错, INSERT 串号同样被拦在内核层。

场景 3 · K8s 多租户配额: ResourceQuota 硬顶 + LimitRange 默认值

每个租户一个 namespace, 共享集群。没有配额时, 一个租户的 300 个无 limits 的 Pod 能把节点内存吃光, 全集群被驱逐。

apiVersion: v1
kind: ResourceQuota
metadata: { name: tenant-acme-quota, namespace: tenant-acme }
spec:
  hard:
    requests.cpu: "50"        # 客户买的是 50 核, 超了创建直接 Forbidden
    requests.memory: 100Gi
    pods: "300"
---
apiVersion: v1
kind: LimitRange              # 没写 resources 的容器给默认值, 防裸奔吃节点
spec:
  limits:
    - type: Container
      default: { cpu: 500m, memory: 512Mi }

配合对 exceeded-quota 事件的告警, 容量卖超再也不会在业务高峰当天才被发现。

场景 4 · 早高峰全站 503: 一张报表打满 100 个连接的事故排查

09:00 起所有租户接口报 503, 应用侧 Hikari 连接拿不到, 现象却像"数据库挂了"。第一步永远是看连接被谁占着。

# 应用侧报错: HikariPool-1 - Connection is not available, request timed out after 30000ms.
# DB 侧报错: ERROR 1040 (HY000): Too many connections
# MySQL: 谁占着连接? 按持有时间倒序
SELECT host, db, command, time, state FROM information_schema.processlist
ORDER BY time DESC LIMIT 10;
-- → 975 行来自 acme-report, command=Query, time=180+ (报表全表扫)
# 处置: kill 报表会话止血 → 报表迁只读副本 → 连接池按租户分池收尾

根因是共享池没有租户配额; 止血 8 分钟, 分池改造后再未复发。

场景 5 · HikariCP 按租户分池: 大租户打满只堵自己

单一连接池是 Noisy Neighbor 的放行闸。按租户拆池后, 每个租户的连接预算独立, 报表再慢也借不到别人的连接。

Map<String, HikariDataSource> pools = new ConcurrentHashMap<>();

HikariDataSource poolOf(String tenant) {
  return pools.computeIfAbsent(tenant, id -> {
    HikariConfig c = new HikariConfig();
    c.setJdbcUrl("jdbc:mysql://db:3306/saas?connectTimeout=1000");
    c.setMaximumPoolSize(10);       // 关键: 每租户上限, 总和必须 ≤ DB max_connections
    c.setConnectionTimeout(2000);    // 拿不到连接快速失败, 不无限排队拖垮线程
    return new HikariDataSource(c);
  });
}

冷租户多时配合 LRU 关闲置池, 否则句柄和内存反而成为新瓶颈。

场景 6 · Resilience4j 舱壁: 给大租户限并发, 小租户零感知

连接池拆不动时 (老系统), 先在应用层用舱壁按租户限并发, 5 分钟配置生效, 是性价比最高的隔离手段。

resilience4j:
  bulkhead:
    instances:
      tenant-acme:              # 大租户独立隔间
        maxConcurrentCalls: 20   # acme 的并发预算
        maxWaitDuration: 100ms   # 排队上限, 超时快速失败
      tenant-default:            # 其余租户共享默认池
        maxConcurrentCalls: 50
# 超限抛 BulkheadFullException → 网关层映射为 429 + Retry-After

上线当周 acme 又发起 200 并发报表, 被稳稳挡在自己隔间内, 其他租户 P99 无变化。

场景 7 · 大租户迁独占集群: nginx 按租户分流灰度迁移

配额分池救不了"体量本身超标"的租户, 终局是独占资源。迁移期共享集群不能停, 靠网关按租户 header 分流, 一夜只切一个租户。

# nginx: 带 X-Tenant-ID: acme 的流量进独占集群, 其余进共享池
upstream shared       { server 10.0.1.10:8080; server 10.0.1.11:8080; }
upstream acme_cluster { server 10.2.0.10:8080; }

map $http_x_tenant_id $backend {
    acme      acme_cluster;
    default   shared;
}
# server 块里 proxy_pass http://$backend; 切流 = 改 map + reload, 可秒回滚

迁移后共享集群 CPU 从 75% 降到 40%, acme 的报表高峰再也传不出自己的集群。

场景 8 · 网关租户级限流: Redis 计数器实现每租户配额

下游再健康, 也架不住某租户的爬虫 10 倍超额调用。限流必须在网关做, 且超限要显式返回 429, 不能静默吞掉。

# 每租户每分钟 1000 次: Redis INCR + EXPIRE (分钟窗口足够用)
key="rl:t:${TENANT_ID}:$(date +%Y%m%d%H%M)"
n=$(redis-cli INCR "$key")
[ "$n" -eq 1 ] && redis-cli EXPIRE "$key" 60
if [ "$n" -gt 1000 ]; then
  # 关键: 超配额必须显式告知 + 升级入口, 静默失败用户只会骂你
  echo "HTTP 429; Retry-After: 60"
fi

同一模板复用到 API 配额计费: 用量数据直接从这些 key 汇总出账。

场景 9 · 导出功能: 最容易漏租户过滤的口子, 配审计补齐

导出是大数据量 + 常绕过 ORM 直写 SQL 的组合, 是行级隔离事故的常客。导出代码必须走统一收口 + 留审计。

def export_orders(tenant_id, user):
    # 导出禁止绕过租户过滤: 参数化 SQL + 强制 tenant 谓词
    rows = db.execute(
        "SELECT id, amount FROM orders WHERE tenant_id = %s",
        (tenant_id,),
    )
    audit.log("export", tenant=tenant_id, user=user.id, rows=len(rows))
    # 关键: 导出走独立限流 + 行数入审计, 出事能定位到人
    return to_excel(rows)

审计上线三个月后拦截了一起运营账号越权导出, 追溯到具体请求 ID。

场景 10 · 混合池: 大客户独占保底, 长尾共享弹性

全独占太贵, 全共享太险。主流 SaaS 的终态是混合: 付费大客户独占实例组, 长尾租户共享弹性池, 网关按租户路由。

# 路由配置: X-Tenant-ID 决定去向
tenants:
  acme:    { pool: dedicated, replicas: 4 }  # 付了专属费
  beta:    { pool: dedicated, replicas: 2 }
  default: { pool: shared, maxTenants: 200 }  # 共享池设上限, 防无限塞
# 共享池 CPU 超 70% 时, 优先把"增长率最大"的租户迁出为独占

该策略让单租户平均成本降 60%, 同时保住了 TOP5 客户的独占 SLA。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 一条 SQL 忘带 tenant_id, 客服收到"看到别家数据"的工单 — 症状: 用户 A 公司刷新列表出现 B 公司订单. 原因: 手写 SQL 漏了租户谓词, 而系统安全性依赖"每条 SQL 自觉". 正解: 统一走带全局过滤器的仓储层, 再用 RLS 在 DB 层兜底。
-- 错: SELECT * FROM orders WHERE id = 1001;   -- → 返回了别家租户的订单
-- 对: SELECT * FROM orders
--     WHERE id = 1001 AND tenant_id = current_setting('app.tenant_id');
坑 2 · ORM 全局过滤器被原生 SQL 绕过 — 症状: 管理台报表接口偶发混入跨租户行. 原因: Hibernate @Filter 只对 ORM 查询生效, createNativeQuery 完全不看过滤器. 正解: 原生 SQL 统一收口到带租户参数的封装方法。
// 错: em.createNativeQuery("SELECT * FROM t_order WHERE id = ?1")  // 过滤器失效
// 对: em.createNativeQuery("SELECT * FROM t_order WHERE id = ?1 AND tenant_id = ?2")
//         .setParameter(2, TenantContext.get())
坑 3 · 索引缺 tenant_id 前缀, 千万行表查询 5ms 变 4s — 症状: 数据涨到 5000 万行后所有列表页变慢. 原因: idx_status(status) 不含租户列, 每次查询扫描全部租户的行. 正解: 联合索引以 tenant_id 打头。
-- 错: CREATE INDEX idx_status ON orders (status);                  -- → 全表扫
-- 对: CREATE INDEX idx_tenant_status ON orders (tenant_id, status);
坑 4 · 缓存 key 无租户前缀, 租户 B 读到租户 A 的配置 — 症状: 切换租户后页面显示上一家的费率. 原因: key 写成 config:pay, 两个租户共用同一份缓存值. 正解: key 强制带租户段, 且由统一工具生成。
key := "config:pay"                          // 错: 两个租户读写同一份, 串数据
key := fmt.Sprintf("t:%s:config:pay", tenantID) // 对: 租户段参与 key
坑 5 · 凌晨定时任务遍历全表, 账单跨租户群发 — 症状: 用户收到别家公司的账单邮件. 原因: 批处理直查全表没有租户边界, 循环体里又用错了行归属. 正解: 按租户分片, 每片显式装载上下文再处理。
# 错: for row in db.execute("SELECT * FROM bills"): send(row)  # → 跨租户群发
# 对: for tid in all_tenants():
#         with tenant_scope(tid): send_all(... WHERE tenant_id = %s ...)
坑 6 · 日志百家租户混在一起, 出事翻不出证据 — 症状: 排查串号投诉时, 日志里看不出哪条属于哪个租户. 原因: MDC/日志字段没放 tenant_id. 正解: 日志 pattern 强制输出租户段, 入口统一写入。
// 错: log.info("order paid id={}", orderId);       // → 不知道是谁的订单
// 对: MDC.put("tenant", TenantContext.get());      // pattern: %X{tenant} %msg
坑 7 · 配额超限静默失败, 用户只会"转圈" — 症状: 租户创建第 201 个 Pod 一直失败, 用户不知道是配额满了. 原因: exceeded-quota 类错误没有映射成业务提示. 正解: 超限返回 429/402 + 升级入口。
# 错: 前端只显示"创建失败" —— 用户以为系统坏了
# 对: 网关把 Forbidden: exceeded-quota 映射为:
#     HTTP 429 + "租户 Pod 配额已满, 请升级套餐或清理资源"
坑 8 · 一个大租户的报表把共享 DB 拖垮, 全租户超时 — 症状: 每天固定时段全站变慢. 原因: 共享实例无资源上限, 大租户报表吃满 CPU/连接. 正解: 报表走只读副本 + 租户配额 + 体量超标迁独占。
-- 错: 报表直连主库: SELECT count(*) FROM orders;    -- → 主库 CPU 100%
-- 对: 报表走 replica + 强制 tenant 过滤 + 单租户 1 并发配额
坑 9 · 租户注销后数据残留, 合规审计点名 — 症状: 注销半年的租户数据仍出现在统计与新版本备份里. 原因: 只做了逻辑删除打标, 缓存/备份未清理. 正解: 注销走流水线: 导出 → 删行 → 清缓存 → 备份轮转声明。
-- 错: UPDATE tenant SET deleted = 1;          -- → 行还在, 仍被扫描统计
-- 对: DELETE FROM orders WHERE tenant_id = :tid;  -- + 清缓存 + 删除留审计
坑 10 · 权限只做前端隐藏, 改 URL 直接进管理页 — 症状: 普通用户拼出管理接口路径就能调用成功. 原因: 菜单藏了, 后端没拦, 前端可见性不是权限. 正解: 后端逐接口做 RBAC + 租户双校验。
# 错: 前端 v-if="user.isAdmin" 隐藏按钮        # → 直接 curl 接口仍成功
# 对: @require_role("admin") + assert row.tenant_id == user.tenant_id
坑 11 · RLS 只 ENABLE 没 FORCE, 表 owner 直连读全表 — 症状: DBA 用 owner 账号导数据, 导出了所有租户. 原因: PostgreSQL 的 ENABLE 对表 owner 默认不生效. 正解: FORCE ROW LEVEL SECURITY + 应用一律用非 owner 角色。
-- 错: ALTER TABLE t ENABLE ROW LEVEL SECURITY;  -- → owner 连接可读全表
-- 对: ALTER TABLE t FORCE ROW LEVEL SECURITY;   -- → owner 也受策略约束
坑 12 · 按租户分池后总和超 max_connections, 反而报错更多 — 症状: 分池上线当天 DB 连接数打满. 原因: 200 租户 × 池 10 = 2000 远超 DB 上限 500. 正解: 预算制: 活跃租户独享池 + 长尾共享池, 总预算 ≤ 80%。
# 错: 每租户 maximumPoolSize: 10 × 200 租户 = 2000 连接 → 打爆 DB
# 对: 热租户独享 10, 长尾共享池 50, 总预算 <= max_connections * 0.8
坑 13 · 租户 ID 可枚举, 被横向遍历拖库 — 症状: 安全团队发现 /api/orders/1001..1050 被顺序扫描. 原因: 自增 ID 外露且接口只校验登录不校验归属. 正解: 对象级归属校验, 对外暴露不可枚举 ID。
# 错: order = get(id); return order          # → 改 id 就能读别家数据
# 对: order = get(id)
#     if order.tenant_id != ctx.tenant: abort(404)   # 404 而非 403, 不泄存在性
坑 14 · Schema 隔离迁移跑 300 遍, 一次失败从头再来 — 症状: 300 个 schema 的 DDL 迁移跑了 4 小时还没完, 中途失败只能重来. 原因: 逐租户串行执行且无断点. 正解: 分批并发 + 版本表记录 + 失败续跑。
-- 错: for schema in all_schemas: ALTER TABLE ...  -- 串行 300 遍, 失败重头
-- 对: 每批 20 个并发执行 + 记录 schema 迁移版本, 顶到版本的跳过
坑 15 · 共享表大租户统计拖慢小租户首页 — 症状: 小租户打开首页从 200ms 变 3s. 原因: 首页聚合对全表 GROUP BY, 大租户行数占 90%, 缓冲池被它刷满. 正解: 预聚合表 + 查询强制租户谓词走窄索引。
-- 错: SELECT status, count(*) FROM orders GROUP BY status;   -- 全租户聚合
-- 对: ... WHERE tenant_id = ?  -- 走 (tenant_id, status) 索引, 毫秒级
坑 16 · MQ 消费者/定时任务没有租户上下文 — 症状: 消费者偶发 NPE 或查错租户. 原因: 消息体不带 tenant_id, 消费线程上下文为空, 数据层拿到 null. 正解: 消息头强制携带, 消费入口统一还原与清理。
// 错: 消费回调直接调 service                       // → TenantContext 为 null
// 对: 消费入口先 TenantContext.set(msg.getTenantId()), finally 里 clear
坑 17 · 配额告警无分级, 用满当天才有人看 — 症状: 租户配额 99% 使用率持续两周无人知晓, 打满当天业务报障. 原因: 只在"已超限"时发低优先级工单. 正解: 80/95/100 三级告警并通知到租户。
# 错: alert: quota_exceeded  severity: info      # → 打满才有人看, 已是事故
# 对: 80% → P3 周报;  95% → P2 即时;  100% → P1 + 用户侧横幅提示
坑 18 · 异步线程丢租户上下文, 通知发给错误租户 — 症状: @Async 发出的通知偶发串租户或 NPE. 原因: ThreadLocal 不随线程池切换传递. 正解: TaskDecorator 先拷贝上下文, 执行完清理。
// 错: executor.submit(() -> notify(order));       // → 子线程 tenant == null
// 对: executor.setTaskDecorator(r -> copyTenantAndRun(r))  // 提交时快照租户
坑 19 · 测试环境共用生产租户数据, "真实客户"收到测试邮件 — 症状: QA 触发的批量任务把账单发给了真实客户. 原因: 测试环境连了生产库或拷贝未脱敏. 正解: 测试租户独立 + 脱敏快照 + 环境级隔离标记。
# 错: 把生产 datasource 配置复制进测试环境     # → 测试直连生产库
# 对: 测试专用 namespace + 脱敏数据集 + 租户统一带 qa- 前缀隔离
坑 20 · 导出 Excel 无行级权限校验, 运营导出全租户订单 — 症状: 运营后台导出的表格里出现别家租户的订单与金额. 原因: 导出接口为"性能"绕过 ORM 过滤器拼了裸 SQL. 正解: 导出走统一收口 + 归属校验 + 审计留痕。
# 错: export() 直接 db.execute("SELECT * FROM orders")   # → 全租户泄露
# 对: rows = scoped(TenantContext.get()).all(); audit.log("export", rows=len)