多客户共用一套系统: 隔离程度和成本永远在拔河 — 行级 tenant_id 最便宜也最容易串号, 配额与分池决定故障半径
多租户就是把一栋楼按"合租"运营: 独栋 (Separate DB) 最贵但隔壁着不了火, 合租大通铺 (行级 tenant_id) 最便宜但全靠每个人自觉关门——没有数据库层的门禁 (RLS), 一条忘写 WHERE 的 SQL 就是串号事故。
Noisy Neighbor 是合租的经典矛盾: 半夜开派对的大租户把水电网 (连接池/CPU/IO) 吃光, 全楼陪瘫。配额 (Quota) 与分池 (Bulkhead) 就是物业管理: 给每个租户装独立电表、限定最大功率——出了事只跳自家的闸。
tenant_id 列区分。本质是把隔离成本摊薄: 省钱的同时把"数据边界"从物理边界变成了每一条 SQL 的纪律。 SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id; -- 关键: 一张表服务所有租户, tenant_id 是第一等公民 -- → acme | 81234 ; beta | 421
# helm install tenant-acme ./chart --set tenant=acme # 关键: 一租户一套 release, 升级/故障半径=1, 但成本 ×N # → 200 个租户 = 200 套发布流水线, 没自动化别选这条路
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 也查不出别人
CREATE SCHEMA tenant_acme; -- schema 档: 1 个 DB, N 个 schema CREATE DATABASE tenant_acme; -- 独立档: 1 个租户 1 个 DB -- 关键: 三档 = 三种价格, DDL 迁移成本从 1 遍变 N 遍
ENABLE 只约束普通角色, 表 owner 默认绕过, 必须 FORCE 才连 owner 一起管。 ALTER TABLE orders FORCE ROW LEVEL SECURITY; -- 关键: 只 ENABLE 不 FORCE 时, 表 owner 直连可读全表 -- → owner 连接执行 SELECT 也只看得见 app.tenant_id 那一份
apiVersion: v1 kind: ResourceQuota spec: hard: requests.cpu: "50" # 关键: 硬顶, 超了创建 Pod 直接 Forbidden
SELECT usename, count(*) FROM pg_stat_activity GROUP BY usename ORDER BY count(*) DESC; -- 关键: 先定位谁占着连接, 再决定限流还是迁走 -- → acme_rw | 97 ; beta_rw | 3 (凶手一目了然)
BulkheadConfig.custom()
.maxConcurrentCalls(20)
.build();
// 关键: acme 的并发预算 20, 打满只堵自己
// → 第 21 个并发调用立刻 BulkheadFullExceptionThreadLocal, 数据层自动读取。致命弱点: 线程池复用与异步切换会丢上下文, 必须用装饰器传递、用完清理。 private static final ThreadLocal<String> TENANT = new ThreadLocal<>(); TENANT.set("acme"); // 关键: 进线程池/异步就丢, 必须传递版装饰器 // → @Async 线程里 TENANT.get() == null, 数据层查不到租户
def can_read(user, order): # 关键: 角色校验之外, 必须再校验数据归属 return user.has_role("reader") and order.tenant_id == user.tenant_id # → A 公司 admin 读 B 公司订单: 角色通过, 归属拒绝
kubectl create namespace tenant-acme kubectl get pods -n tenant-acme # 关键: namespace = 租户房间, 配额/网络策略都挂在它上面 # → No resources found in tenant-acme namespace.
key 不带租户段, 两个租户就读写同一份值。纪律: 租户上下文必须参与 key 生成, 且禁止手拼 key。 key := fmt.Sprintf("t:%s:orders:%s", tenantID, orderID) // 关键: 缓存 key 不带租户前缀 = 跨租户串数据 // → t:acme:orders:1001 命中的是 acme 自己的数据
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。
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 串号同样被拦在内核层。
每个租户一个 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 事件的告警, 容量卖超再也不会在业务高峰当天才被发现。
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 分钟, 分池改造后再未复发。
单一连接池是 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 关闲置池, 否则句柄和内存反而成为新瓶颈。
连接池拆不动时 (老系统), 先在应用层用舱壁按租户限并发, 5 分钟配置生效, 是性价比最高的隔离手段。
resilience4j: bulkhead: instances: tenant-acme: # 大租户独立隔间 maxConcurrentCalls: 20 # acme 的并发预算 maxWaitDuration: 100ms # 排队上限, 超时快速失败 tenant-default: # 其余租户共享默认池 maxConcurrentCalls: 50 # 超限抛 BulkheadFullException → 网关层映射为 429 + Retry-After
上线当周 acme 又发起 200 并发报表, 被稳稳挡在自己隔间内, 其他租户 P99 无变化。
配额分池救不了"体量本身超标"的租户, 终局是独占资源。迁移期共享集群不能停, 靠网关按租户 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 的报表高峰再也传不出自己的集群。
下游再健康, 也架不住某租户的爬虫 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 汇总出账。
导出是大数据量 + 常绕过 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。
全独占太贵, 全共享太险。主流 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。
-- 错: SELECT * FROM orders WHERE id = 1001; -- → 返回了别家租户的订单 -- 对: SELECT * FROM orders -- WHERE id = 1001 AND tenant_id = current_setting('app.tenant_id');
@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())
idx_status(status) 不含租户列, 每次查询扫描全部租户的行. 正解: 联合索引以 tenant_id 打头。 -- 错: CREATE INDEX idx_status ON orders (status); -- → 全表扫 -- 对: CREATE INDEX idx_tenant_status ON orders (tenant_id, status);
config:pay, 两个租户共用同一份缓存值. 正解: key 强制带租户段, 且由统一工具生成。 key := "config:pay" // 错: 两个租户读写同一份, 串数据 key := fmt.Sprintf("t:%s:config:pay", tenantID) // 对: 租户段参与 key
# 错: 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 ...)
// 错: log.info("order paid id={}", orderId); // → 不知道是谁的订单 // 对: MDC.put("tenant", TenantContext.get()); // pattern: %X{tenant} %msg
exceeded-quota 类错误没有映射成业务提示. 正解: 超限返回 429/402 + 升级入口。 # 错: 前端只显示"创建失败" —— 用户以为系统坏了 # 对: 网关把 Forbidden: exceeded-quota 映射为: # HTTP 429 + "租户 Pod 配额已满, 请升级套餐或清理资源"
-- 错: 报表直连主库: SELECT count(*) FROM orders; -- → 主库 CPU 100% -- 对: 报表走 replica + 强制 tenant 过滤 + 单租户 1 并发配额
-- 错: UPDATE tenant SET deleted = 1; -- → 行还在, 仍被扫描统计 -- 对: DELETE FROM orders WHERE tenant_id = :tid; -- + 清缓存 + 删除留审计
# 错: 前端 v-if="user.isAdmin" 隐藏按钮 # → 直接 curl 接口仍成功 # 对: @require_role("admin") + assert row.tenant_id == user.tenant_id
FORCE ROW LEVEL SECURITY + 应用一律用非 owner 角色。 -- 错: ALTER TABLE t ENABLE ROW LEVEL SECURITY; -- → owner 连接可读全表 -- 对: ALTER TABLE t FORCE ROW LEVEL SECURITY; -- → owner 也受策略约束
# 错: 每租户 maximumPoolSize: 10 × 200 租户 = 2000 连接 → 打爆 DB # 对: 热租户独享 10, 长尾共享池 50, 总预算 <= max_connections * 0.8
/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, 不泄存在性
-- 错: for schema in all_schemas: ALTER TABLE ... -- 串行 300 遍, 失败重头 -- 对: 每批 20 个并发执行 + 记录 schema 迁移版本, 顶到版本的跳过
-- 错: SELECT status, count(*) FROM orders GROUP BY status; -- 全租户聚合 -- 对: ... WHERE tenant_id = ? -- 走 (tenant_id, status) 索引, 毫秒级
// 错: 消费回调直接调 service // → TenantContext 为 null // 对: 消费入口先 TenantContext.set(msg.getTenantId()), finally 里 clear
# 错: alert: quota_exceeded severity: info # → 打满才有人看, 已是事故 # 对: 80% → P3 周报; 95% → P2 即时; 100% → P1 + 用户侧横幅提示
@Async 发出的通知偶发串租户或 NPE. 原因: ThreadLocal 不随线程池切换传递. 正解: TaskDecorator 先拷贝上下文, 执行完清理。 // 错: executor.submit(() -> notify(order)); // → 子线程 tenant == null // 对: executor.setTaskDecorator(r -> copyTenantAndRun(r)) // 提交时快照租户
# 错: 把生产 datasource 配置复制进测试环境 # → 测试直连生产库 # 对: 测试专用 namespace + 脱敏数据集 + 租户统一带 qa- 前缀隔离
# 错: export() 直接 db.execute("SELECT * FROM orders") # → 全租户泄露 # 对: rows = scoped(TenantContext.get()).all(); audit.log("export", rows=len)