长连接撑不住了,我们花了三周做无状态化
我们最早那版 MCP Server 用的是 SSE 传输,从 2025 年 6 月跑到现在。四月份开始出问题:Agent 数量从 3 个涨到 11 个,MCP Server 的连接数冲到 4,000+,然后就是各种诡异的断连和内存上涨。
三月份决定做无状态化改造,前后三周。这篇记录改造的动机、具体改了什么、以及对架构和运维的实际影响。数据都是我们生产环境的真数。
先说长连接给了我们什么麻烦
原来的架构是这样:每个 Agent 实例和每个 MCP Server 实例之间维持一条 SSE 长连接,服务端用 sessionId 区分会话,会话状态存在内存里。
问题出在规模上来之后:
| 指标 | 2025-09 | 2026-03 | 变化 |
|---|---|---|---|
| Agent 实例数 | 6 | 42 | 7× |
| MCP Server 实例 | 4 | 9 | 2.3× |
| 活跃长连接 | 24 | 378(理论 378) | — |
| 实际连接数 | 31 | 4,127 | 133× |
| 单实例内存 | 1.2 GB | 4.8 GB | 4× |
| 日均断连次数 | 12 | 1,840 | 153× |
「理论连接数 378,实际 4,127」这一行是问题核心。连接泄漏了。
原因有几层:
- Agent 实例在 K8s 里滚动发布,旧实例的连接没有优雅关闭,服务端要等心跳超时(我们设的 90 秒)才回收;
- Agent 侧的重连逻辑有 bug,断连后重连但不释放旧连接对象;
- 中间的 Nginx 有连接数限制,偶尔会主动断开,触发雪崩式重连。
4,127 条连接,每条对应一个会话状态对象(含对话历史、工具执行上下文),平均 1.2 MB。这就是 4.8 GB 内存的来源。
更麻烦的是断连。1,840 次/天意味着平均每分钟就有 1.3 次。每次断连如果正好有工具调用在飞,就丢失了,Agent 侧要重试。我们的工具调用失败率从 0.3% 涨到了 2.7%。
无状态化到底改什么
MCP 的 Streamable HTTP 传输在 2025 年就取代了 SSE 成为推荐方式,我们是拖到今年才动。它的核心变化:
| 维度 | SSE(HTTP+SSE 双通道) | Streamable HTTP |
|---|---|---|
| 通道 | POST 发请求 + GET /sse 收响应 | 单个 POST,响应可选流式 |
| 会话 | 必须,Mcp-Session-Id 必需 | 可选,支持完全无状态 |
| 服务端状态 | 内存里存会话 | 可以什么都不存 |
| 断线重连 | 需要客户端维护 | 天然支持,请求自带全部上下文 |
| 负载均衡 | 需要会话粘滞 | 随便轮询 |
| 水平扩容 | 扩了旧连接还在老实例上 | 随时扩,无影响 |
最关键的一条是「会话可选」。我们选的是完全无状态模式:每次请求自带全部所需信息,服务端不存任何跨请求的状态。
服务端的改造点
改造分四块,从易到难。
一、传输层配置
最直接的一步,改配置就行:
# 改前
spring:
ai:
mcp:
server:
protocol: SSE
type: SYNC
capabilities:
tool: true
# 改后
spring:
ai:
mcp:
server:
protocol: STREAMABLE
type: SYNC
# 关键:不保留会话,每次请求独立
keep-alive-interval: 30s # 流式响应时的心跳,防中间设备超时
capabilities:
tool: true
resource: true
prompt: false
server:
tomcat:
threads:
max: 400 # IO 密集,调大
connection-timeout: 20000
keep-alive-timeout: 30000
# 关键:允许 POST 返回 SSE 格式的流式响应
max-http-response-header-size: 16KB
keep-alive-interval 这个参数要说一下。Streamable HTTP 在流式模式下还是 SSE 格式,如果长时间没有数据,中间的 Nginx / ELB 会掐断。我们设了 30 秒心跳。
二、会话状态外置
这是工作量的大头。原来存在内存里的会话状态,全部要挪到 Redis。
先看我们原来存了什么:
// 改造前的内存态
public class McpSession {
private String sessionId;
private String tenantId; // 租户标识
private String userId; // 用户
private List<ToolCallRecord> history; // 本会话的工具调用历史
private Map<String, Object> scratchpad; // 工具之间的临时数据
private Instant lastActive;
}
改造后,按用途拆成了三份,存的地方不一样:
| 数据 | 原位置 | 新位置 | 理由 |
|---|---|---|---|
| tenantId / userId | 会话内存 | JWT claims(请求头带) | 天然无状态,无需存储 |
| 工具调用历史 | 会话内存 | Redis(TTL 30 分钟) | 审计和限流需要,但不需要永久 |
| scratchpad | 会话内存 | 不存,改为工具返回值传递 | 这是我们做的最大的简化 |
第三行值得展开。scratchpad 是工具之间共享数据的地方——比如「查询订单」的结果给「申请退款」用。有状态模式下很自然,无状态模式下必须改。
我们的解法是:不允许工具之间有隐式共享状态,需要传递的数据通过返回值显式传递。
// 改前:靠 scratchpad 共享
@McpTool(name = "queryOrder")
public OrderResult queryOrder(String orderNo, McpSyncRequestContext ctx) {
Order order = orderService.query(orderNo);
ctx.session().setAttribute("currentOrder", order); // 隐式状态
return toResult(order);
}
@McpTool(name = "applyRefund")
public RefundResult applyRefund(String reason, McpSyncRequestContext ctx) {
Order order = (Order) ctx.session().getAttribute("currentOrder"); // 依赖上一步
return refundService.apply(order, reason);
}
// 改后:显式传递句柄
@McpTool(name = "queryOrder", description = "查询订单,返回 handle 供后续操作使用")
public OrderResult queryOrder(String orderNo) {
Order order = orderService.query(orderNo);
// handle 是一个自包含的加密串,包含订单号和校验信息
String handle = handleEncoder.encode(order.getOrderNo(), order.getVersion());
return OrderResult.of(order).withHandle(handle);
}
@McpTool(name = "applyRefund", description = "申请退款,需要 queryOrder 返回的 handle")
public RefundResult applyRefund(
@McpToolParam(description = "queryOrder 返回的 handle") String handle,
@McpToolParam(description = "退款原因") String reason) {
OrderRef ref = handleEncoder.decode(handle); // 无状态解码
return refundService.apply(ref.orderNo(), reason);
}
handle 的设计要点:自包含(不需要查库就能解出必要信息)、带签名(防篡改)、带版本(乐观锁)。我们的实现是 Base64(AES(orderNo + version + 签名)),128 字节。
public String encode(String orderNo, long version) {
byte[] payload = (orderNo + "|" + version + "|" + System.currentTimeMillis()).getBytes(UTF_8);
byte[] encrypted = aes.encrypt(payload);
byte[] sig = hmac(encrypted); // 防篡改
return Base64.getUrlEncoder().encodeToString(
ByteBuffer.allocate(encrypted.length + sig.length)
.put(encrypted).put(sig).array());
}
这个改动带来的一个额外好处:工具之间的依赖关系变显式了,模型更容易理解。原来 applyRefund 的签名里没有任何线索表明它依赖 queryOrder,现在 handle 参数明明白白写在那里,description 里也说了。工具调用的顺序错误率从 4.2% 降到 1.1%。
三、鉴权方式改变
有状态模式下,鉴权在建立 SSE 连接时做一次,之后复用会话。无状态模式下每个请求都要鉴权,所以必须做得足够轻。
我们改成了 JWT + 本地验签,不查库不查 Redis:
@Component
public class McpAuthFilter implements Filter {
// 公钥本地缓存,10 分钟刷新一次,验签完全本地化
private volatile PublicKey publicKey;
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
String token = extractBearer((HttpServletRequest) req);
if (token == null) {
reject(resp, 401, "missing token");
return;
}
Claims claims;
try {
claims = Jwts.parser().verifyWith(publicKey).build()
.parseSignedClaims(token).getPayload();
} catch (JwtException e) {
reject(resp, 401, "invalid token");
return;
}
// 校验 scope,MCP 工具按 scope 授权
RequestContext.set(new Principal(
claims.getSubject(),
claims.get("tenant", String.class),
claims.get("scope", List.class)));
try {
chain.doFilter(req, resp);
} finally {
RequestContext.clear(); // 务必清理,避免线程复用串号
}
}
}
实测验签耗时 0.08ms,相对 200ms 的工具调用可以忽略。如果这里要查一次 Redis 或数据库,无状态化的收益就损失大半了。
另外令牌 TTL 从 2 小时缩短到 15 分钟。无状态模式下刷新令牌的代价很低(就是重新发一个请求),没必要给长 TTL。
四、流式响应的处理
这一块踩了个坑。Streamable HTTP 的响应可以是普通 JSON,也可以是 SSE 流。我们有些工具(比如长文本生成)是流式的。
问题在于:流式响应中间失败了,无状态模式下没法「接着上次的进度继续」。有状态模式下服务端记得输出到哪了,客户端可以带 Last-Event-ID 续传。
我们的选择是:工具调用结果不走流式。工具返回的是数据,不是给用户的文本,本来就没必要流式。真正需要流式的(模型生成的回答)发生在 Agent 侧,不经过 MCP。
// MCP Server 侧:工具调用一律同步返回完整结果
@McpTool(name = "generateReport")
public ReportResult generateReport(String templateId) {
return reportService.generate(templateId); // 同步,不流式
}
// Agent 侧:拿到工具结果后再流式输出给用户
return chatClient.prompt(...)
.toolCallbacks(mcpCallbacks)
.stream()
.content(); // 这里才流式
这个分工理清之后,MCP Server 的实现简单了很多。整个改造过程中,这块反而是删代码。
对扩展性的影响
改造完成后最直观的收益是扩容变简单了。
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 负载均衡策略 | 必须会话粘滞(NGINX sticky) | 轮询(round-robin) |
| 扩容生效时间 | 等旧连接自然断开,约 15 分钟 | 立即 |
| 缩容安全性 | 要手动 drain,否则丢会话 | 直接杀 |
| 单实例内存 | 4.8 GB | 1.1 GB |
| 实例数(同流量) | 9 | 5 |
| 连接数 | 4,127 | 0 长连接(短连接按需) |
内存从 4.8 GB 降到 1.1 GB,主要是没了会话对象。实例从 9 个缩到 5 个,算力没变(工具执行本身不重),只是不再需要为连接数买单。
还有个意外的好处:K8s 的 HPA 终于能正常工作了。以前用连接数做扩容指标,但连接数在扩完容之后不会立刻下降(旧连接还在老实例上),导致扩了又扩。现在用 CPU 和 QPS 指标,响应及时。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 60
behavior:
scaleDown:
stabilizationWindowSeconds: 180 # 缩容慢一点
scaleUp:
stabilizationWindowSeconds: 0 # 扩容立即
对运维的影响
运维侧的变化比架构侧更让人舒服。
排查问题变简单了
以前出问题要问「这个 sessionId 在哪个实例上」,然后去那台机器翻日志。现在每个请求都是独立的,任何一个实例上的日志都包含完整信息。
我们把 sessionId 换成了一个贯穿全链路的 conversationId,它只是一个日志字段,不再对应任何服务端状态:
// Agent 侧生成,通过请求头传递,服务端只用来打日志
POST /mcp HTTP/1.1
Host: order-tools.mcp.svc
Authorization: Bearer eyJhbGciOi...
X-Conversation-Id: 7f3a2c91-4b6e-4f21-9a8d
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"queryOrder","arguments":{"orderNo":"SO20260115001"}}}
日志查询从「先找实例再查日志」变成了「按 conversationId 全局搜」。
发布变安全了
以前发布 MCP Server 要挑业务低峰,因为滚动发布会断掉一批连接,而断连可能导致正在进行的工具调用失败。现在随便发。
我们的发布流程从「每周四晚上 10 点,人工值守」变成了「CI 自动发布,随时可发」。这个变化对团队节律的影响比预想的大。
多了个 Redis 依赖
代价也要说清楚:无状态不是真的零状态,我们把状态推到了 Redis。目前 Redis 里存的是:
- 工具调用历史(审计用,TTL 30 分钟);
- 限流计数(TTL 按需,1 分钟~1 天);
- 幂等键(TTL 24 小时)。
Redis 的内存占用 3.2 GB,QPS 峰值 1.8 万。这是个新的故障点,我们给它加了主从和降级策略——Redis 挂了的时候,限流降级为本地限流,审计降级为写日志,核心的工具调用不受影响。
改造的成本和坑
三周时间,1 个人(我)+ 半个人(另一个同事兼职)。工作量分布:
| 工作项 | 人日 | 备注 |
|---|---|---|
| 配置和传输层切换 | 1 | 最顺利 |
| 会话状态外置 | 5 | scratchpad 改造占 3.5 天 |
| 鉴权重构 | 2.5 | 包括 Agent 侧的令牌刷新逻辑 |
| 工具签名改造(23 个工具) | 4 | 主要是加 handle、改 description |
| 压测和灰度 | 3 | |
| 文档和回滚预案 | 1 | |
| 合计 | 16.5 |
踩的坑:
- Nginx 的
proxy_buffering又来了。 流式响应被缓冲,客户端收不到。这个问题我们在别的地方踩过一次,这次又踩,说明团队没有沉淀。后来加到了部署检查清单里; - Agent 侧的 SDK 版本不一致。 11 个 Agent 用了 4 个不同版本的 MCP 客户端,其中 2 个版本的 Streamable HTTP 实现有 bug(不发送
Accept头导致服务端不知道该返回流式还是非流式)。统一升级到最新版解决; - 幂等键忘了做。 无状态模式下重试更频繁,没有幂等会重复执行。我们在改造中期才想起来,返工了 1.5 天;
- handle 的大小没控制好。 第一版把整个订单对象序列化进去,handle 长 2 KB,塞在参数里很难看。后来改成只放 ID 和版本。
最终数据
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 活跃长连接 | 4,127 | 0 |
| 单实例内存 | 4.8 GB | 1.1 GB |
| 实例数 | 9 | 5 |
| 日均断连 | 1,840 | 0(无连接概念) |
| 工具调用失败率 | 2.7% | 0.4% |
| 工具 P99 延迟 | 420ms | 180ms |
| 扩容生效时间 | ~15 分钟 | 即时 |
| 发布窗口 | 每周四 22:00 | 随时 |
| 机器成本 | — | -44% |
P99 从 420ms 降到 180ms,这个提升超出预期。分析下来主要是两个原因:一是内存压力小了,GC 从 3.4 次/秒降到 0.7 次/秒;二是轮询负载均衡比会话粘滞更均匀(粘滞模式下有些实例被热点会话压着)。
小结
这件事的本质是:我们为了「保持会话状态」付出的代价,远高于状态本身的价值。
回过头看,当初选 SSE + 内存会话,是因为「对话式交互天然有会话」这个直觉。但 MCP 的工具调用其实不是对话——它是一次性的、独立的请求。把对话模型套在无状态的操作上,从一开始就错了。
无状态化逼着我们做的最大改变,是把工具之间的隐式耦合(scratchpad)改成显式传递(handle)。这个改动表面上是为了无状态,实际收益最大的是可理解性——模型更容易理解工具之间的依赖,我们自己也更容易维护。
给要做同类改造的人两个建议:第一,先把工具之间的共享状态梳理清楚,这是工作量的大头,也决定了改造后的设计质量;第二,鉴权一定要做成纯本地验签,任何一次远程调用都会把无状态化的收益吃掉一部分。
还有一点:无状态不等于没有状态,只是把状态推到了该待的地方(Redis、令牌、请求参数)。想清楚每一份状态「该待在哪」,比纠结「要不要无状态」更重要。