
前置:篇(六)自建 MCP 网关、篇(八)无状态生产落地、篇(九)6层安全防护 技术基线:MCP Java SDK 2.0.0(非 Spring AI,同篇六网关);多租户在传输拦截器层实施,不依赖 Spring AI 注解 实测环境:本地 Docker + 内置 k8s,双网关实例 + Redis(本文实测项均在该环境验证)
引子:网关接了第三个团队,开始串味
篇(六)我把网关搭起来了,门店运营的工具先挂上去,跑得挺好。后来供应链团队说"我们也想用",再后来财务团队也来了。
第三个团队接进来那天出了两件事:
- 财务的 gen_finance_report 被供应链误调——不是故意,是工具目录没隔开,搜出来就点了;
- 供应链写了个脚本狂刷 get_inventory,把门店那边的配额全吃光,门店大促时调不动。
这一刻我懂了:一个网关服务多团队,不隔离就是事故。本文就讲怎么在已有网关上,把"多租户"这层补上——租户路由、凭证隔离、配额、数据隔离,四件套。
范围说明:本文是逻辑隔离(单网关按 tenantId 过滤),实例级强隔离只在对比里提,不展开部署。预算计量、错误处理属于另一回事,见配套散点文。

一、为什么需要多租户
| 成本 | 一套网关养多个团队,远比每团队各起一套省运维 |
| 隔离 | 工具、权限、数据、配额都按租户隔开,互不串味 |
| 合规 | 财务/HR 这类敏感租户,出了泄漏能定位到"哪个租户" |
不隔离的三类后果,正是引子里的真实事故:串调(工具可见性)、超额(配额)、泄漏(数据)。下面四个小节一一对应解决。
二、三种多租户方案对比
| 共享实例逻辑隔离 | 单网关,一切按 tenantId 过滤 | 中(同进程) | 最低 | 本文做法,绝大多数企业够用 |
| 实例级隔离 | 每租户独立网关实例 / 命名空间 | 高(进程级) | 高 | 强合规(金融/政务) |
| 混合 | 核心租户独立实例 + 长尾共享 | 高 | 中 | 多数中大型企业 |
选共享实例逻辑隔离,因为它改动最小、复用篇六网关、且配额/凭证天然能集中。实例级隔离只是把"逻辑"换成"物理",过滤逻辑一样,本文不重复。
三、租户路由:请求怎么认出是哪个租户
传递方式(三选一,推荐第三种):
本文用方案 1+2 组合演示(Key 映射为主,X-Tenant-Id 为辅覆盖服务间调用),生产建议升级到方案 3。
在传输层提取 tenantId(实测落地版,代码见 13-multitenant/TenantFilter.java):
// 传输层拦截:每个请求先解析租户,缺失/非法直接拒;再做配额计数
@Component
public class TenantFilter extends OncePerRequestFilter {
private final TenantStore store;
private final QuotaService quota;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String apiKey = firstNonBlank(request.getHeader("X-Api-Key"), request.getHeader("X-Tenant-Id"));
TenantProperties.TenantDef tenant = store.resolve(apiKey); // Redis 查映射
if (tenant == null) { reject(response, 401, "unknown tenant"); return; }
if ("POST".equalsIgnoreCase(request.getMethod()) && !quota.allow(tenant)) {
reject(response, 429, "tenant quota exceeded"); return;
}
MDC.put("tenant", tenant.getId()); // 日志全链路带租户
TenantContext.set(tenant.getId()); // 下游拦截器/工具可取
try {
chain.doFilter(request, response);
} finally {
TenantContext.clear();
MDC.remove("tenant");
}
}
}
✅ 已实测核对(结论与初稿假设不同):MCP Java SDK 2.0.0 不提供 ServerTransportInterceptor 接口——篇(六)网关实际用的是 HttpServletStatelessServerTransport + McpServer.sync(…)(SDK 暴露的是 transport provider,不是拦截器)。因此按预案把拦截等价迁移到 Servlet Filter:位置同样在传输层,逻辑完全一致(解析租户 → 拒非法 → 配额 → 写上下文/日志)。若后续 SDK 提供装饰器式扩展点,把这四步搬过去即可,业务代码零改动。
四、凭证隔离:不同租户不同 API Key
- 每个租户签发独立 Key,Key→tenantId 映射存 Redis(tenant:key:{hash} → tenantId)。
- 吊销/轮换:删 Redis 键即生效,无需重启网关——这是 Redis 集中存的好处。
- 与篇(九)L2 客户端身份衔接:本文的 Key 是"客户端身份"那一层;租户内的"用户身份"走篇九 L3(X-User-Id / ID-JAG)。
// 凭证签发与校验(示意)
String key = "sk-" + TenantId + "-" + random32();
redis.opsForValue().set("tenant:key:" + hash(key), tenantId, Duration.ofDays(90)); // 90天过期
// 校验:拦截器里 resolve(apiKey) 命中即过,未命中 401
五、配额管理:别让一个租户刷爆
这是引子里"供应链刷爆门店配额"的直接解。计数放 Redis,天然跨实例一致——呼应篇(八)“多实例下状态要外置”:配额计数属于"下一次请求打到另一个实例还必须对"的数据,所以必须进 Redis。
固定窗口配额(Redis + Lua 原子自增 + TTL;非真滑动窗口):
// 固定窗口:窗口=60s TTL,首调用起算(每次自增会续期 TTL,故是"固定窗口"而非真滑动窗口)
// 真滑动窗口需用 ZSET 记每次时间戳,本文为可读性用固定窗口近似,生产可按精度升级
String lua = """
local cnt = redis.call('INCR', KEYS[1])
if cnt == 1 then redis.call('EXPIRE', KEYS[1], 60) end
return cnt
""";
Long now = redis.eval(lua, List.of("quota:" + tenantId + ":" + toolName), List.of());
if (now > limit) throw new QuotaExceededException(429, "tenant quota exceeded");
超限返回 429,与篇(九)L5 限流一致;limit 按租户配置(长尾租户低配、核心租户高配)。
多实例一致性:因为计数在 Redis,两个网关副本(Docker/k8s 双实例)看到的是同一份数字——不会出现"副本A 限了、副本B 还放"的裂隙。这恰好是篇(八)"会话数据外置"思想在配额上的落地。
✅ 已实测(双副本 + Redis):起两个网关副本(18080/18081,本机 8080 被占故改用高位端口)+ 共享 Redis,从副本A 连续打满门店租户配额(limit=20 / 窗口60s),副本B 立即同样 429——实测输出:
GW1 hit 1~20 -> 200
GW1 hit 21~23 -> 429
GW2 store -> 429 # 同一租户,计数在 Redis,换副本同样被限
GW2 supply -> 200 # 未超限租户不受影响(证明是租户级而非全局限流)
故障演练:Redis 挂了怎么办?两种策略——fail-open(放行,保可用,适合内部可信)或 fail-closed(全拒,保隔离,适合多租户公网)。本文选按租户分级:核心租户 fail-closed,长尾租户 fail-open。配置示例:
# 配额降级策略(按租户分级)
quota.failover.policy.core=closed # 核心租户:Redis 不可用时全拒,保隔离
quota.failover.policy.longtail=open # 长尾租户:Redis 不可用时放行,保可用
六、数据隔离:日志 / 配置 / 缓存
| 日志 | MDC 带 tenant,按租户分索引;敏感租户(财务/HR)行级脱敏后再落盘 |
| 配置 | 租户级配置命名空间 cfg:{tenantId}:…,避免共享 map 串改 |
| 缓存 | 所有缓存 key 前缀 tenant:{id}:,即使同 key 不同租户也不串;与篇(八)"会话数据外置 Redis"同一套键规范 |
redis.opsForValue().set("tenant:" + tenantId + ":ctx:" + sessionKey, ctx, Duration.ofMinutes(30));
七、实战:在现有网关上加多租户
拓扑(本地 Docker + 内置 k8s 验证环境):
┌──────────── LB / Ingress ────────────┐
│ │
client ──┤ gw-1 (MCP 网关副本1) ┐ │
│ gw-2 (MCP 网关副本2) ├─ 共享 ── Redis(租户映射 + 配额计数)│
│ (k8s Deployment 2副本)┘ │
└───────────────────────────────────────┘
两个网关副本无状态(篇八),状态全在 Redis;扩副本只改 replicas,隔离逻辑不变。
改造步骤清单:
目录过滤是关键:引子事故①(财务工具被供应链误调)的根因是"目录没隔开"。租户 list 工具时,网关只返回该租户 allowed_tools 集合内的工具——看不见就点不到。
✅ 已实测:以上拓扑与演练已跑通,真实输出如下(本机双副本 + Redis 容器)。
==== 1) 租户路由 ====
no-key -> 401 bad-key -> 401 store -> 200
==== 2) 目录隔离(看不见就点不到)====
store 目录: {"tools":["get_store_sales","get_inventory"],"tenant":"store"}
finance 目录: {"tools":["gen_finance_report"],"tenant":"finance"}
PASS: 门店目录已过滤(不含 gen_finance_report)
==== 3) 凭证隔离 ====
supply -> get_store_sales -> 403
==== 5) 故障演练(Redis 不可用,按租户分级)====
finance(closed) -> 429 # 核心租户全拒,保隔离
supply(open) -> 200 # 长尾租户放行,保可用
恢复 Redis 后 -> 200 # 自愈,无需重启
八、诚实边界
- mTLS 未实测:租户间传输加密依赖篇(九)L1,本文不重复,本机未跑真实证书体系。
- 实例级隔离未展开:仅对比提及,强合规场景的部署成本需另文。
- 真实压测待补:双副本下的配额吞吐基线(P95)待压测填充,避免拍脑袋给数字。
- SDK API 已核对:MCP Java SDK 2.0.0 无 ServerTransportInterceptor 接口,第三节已按预案改为 Servlet Filter 落地并实测跑通(结论见该节)。
- 预算计量不在此篇:按租户成本归因见配套散点文(三大坑·坑二)。
九、与全季的关系 + 多租户自查清单
互链(点击跳转对应篇章):
- → 篇(六)自建 MCP 网关:本文在其基础上加租户层,不重造网关。
- → 篇(八)无状态生产落地:双副本无状态 + 状态外置 Redis,思路同源。
- → 篇(九)6层安全防护:Key 是 L2 客户端身份,租户内用户身份走 L3。
- → 散点文《企业级 MCP 部署三大坑》(待发布):预算(坑二)、错误码(坑三)、Schema 版本(隐藏坑)是本文未覆盖的企业部署另一半。
多租户自查清单:
| 请求能可靠解析 tenantId(缺失/非法即拒) | 必做 |
| 每租户独立凭证,可独立吊销 | 必做 |
| 工具目录按租户过滤(看不见点不到) | 必做 |
| 配额计数跨实例一致(Redis 集中) | 必做 |
| 超限返回 429 + 降级策略(fail-open/closed 分级) | 必做 |
| 日志/缓存按租户隔离、敏感租户脱敏 | 必做 |
| 双副本 failover 演练过 | 建议 |
一句话收尾
多租户不是"再起一套",而是"在同一套网关上把 tenantId 当成第一公民"——路由认租户、凭证分租户、配额限租户、数据隔租户。四件套做完,一个网关就能安稳服务好几个团队。
代码已开源:本篇四件套实现见 13-multitenant/ 模块(Spring Boot 4.0.0 + Java 21 + Redis),tag v13 —— Gitee / GitHub。mvn clean package 后 docker compose up -d 起双副本,./verify-multitenant.sh 一键复现上文全部实测输出;觉得有用欢迎 star。
相关阅读(第二季 · 企业级 MCP 生产落地)
- 篇(六)自建 MCP 网关:本文多租户层直接搭在它上面
- 篇(八)无状态MCP Server生产落地清单:双副本无状态 + 状态外置 Redis,思路同源
- 篇(九)生产化MCP Server的6层安全防护:Key 是 L2 客户端身份,租户内用户身份走 L3
- 篇(十二)企业级Toolbox治理:同季前作,工具治理与多租户是企业落地的两块拼图(本篇 tenantId 字段即由它定义)



