服务端已经死了,客户端却报"超时"

我模拟了一次发版:一个 8 秒的任务跑到第 2 秒,服务端收到 SIGTERM。

然后我盯着客户端,等它告诉我出事了。

它等了 58 秒,报了一句"请求超时"。

那一刻我意识到,服务端早就没了,客户端却还在等。等到它自己的超时用尽,才给出一个"超时"------而这个错误会让人以为"服务只是慢",于是重试,于是又打到另一个正在被杀的实例。

这篇把发版那一刻的事从头跑了一遍:三种关闭方式,同一个 8 秒的任务,看客户端到底收到什么。

结论先放这儿:server.close() 不是"等我一下",是"等连接关";而客户端分不清"服务死了"和"服务慢了"。

先搭一个能观察的靶子

一个 Express + Streamable HTTP 的 MCP Server,一个工具:slow_task,睡 N 毫秒后返回。它内部维护一个 inflight 计数,用来知道"现在还有几个任务在跑"。

javascript 复制代码
server.registerTool("slow_task", {
  title: "耗时任务",
  inputSchema: {
    durationMs: z.number().min(0).max(60000),
    label: z.string().default("task"),
  },
}, async ({ durationMs, label }) => {
  inflight++;
  log(`开始 ${label} 当前并发=${inflight}`);
  const started = Date.now();
  try {
    await new Promise((r) => setTimeout(r, durationMs));
    const text = `${label} 完成,耗时 ${Date.now() - started}ms`;
    return { content: [{ type: "text", text }] };
  } finally {
    inflight--;
  }
});

服务端用环境变量切换三种关闭策略:

ini 复制代码
SHUTDOWN=none        什么都不做,靠 Node 默认行为
SHUTDOWN=close-only  只调 httpServer.close()
SHUTDOWN=graceful    摘流量 + 等任务 + 主动断连

客户端是用脚本拉起来的:连接 → 发起一个 8 秒的任务 → 第 2 秒给服务端发 SIGTERM → 看客户端拿到什么、多久拿到。

第一种:什么都不做

这是最常见的写法,因为默认就跑成这样------你没注册 SIGTERM 处理器,Node 收到信号直接退出。

ini 复制代码
[ 5781ms] slow_task 开始 label=任务1 duration=8000ms 当前并发=1
[ 5710ms] >>> 给服务端发 SIGTERM(此时任务还剩约 6000ms)
[ 5709ms] 服务端进程退出: code=143

进程秒退,任务没了。那客户端呢?

ini 复制代码
[15711] 任务1 抛错 12003ms: code=-32001 name=McpError
        msg=MCP error -32001: Request timed out(距 SIGTERM 约 10002ms)

注意两件事。

第一,它没有立刻报错。服务端在 5.7 秒就死了,客户端到 15.7 秒才拿到异常,中间整整 10 秒,它以为请求还在路上。

第二,报的是 -32001 Request timed out,不是"连接断开" 。连接早就断了,但它说的是"超时"。

我把客户端的超时去掉再跑一次,这次它撑了整整一分钟:

ini 复制代码
[63508] 任务1 抛错 60001ms: code=-32001
        msg=MCP error -32001: Request timed out(距 SIGTERM 约 58000ms)

服务端在第 2 秒退出。客户端为这条已经死掉的连接,白等了 58 秒。

为什么"超时"比"连接断开"糟糕得多

这不是措辞问题,是两个完全不同的信号。

-32001 超时 的含义是:请求发出去了,服务端可能还在处理,只是慢。按这个理解,正确的动作是"重试"或者"再等等"。

连接断开 的含义是:这条链路没了。正确的动作是"换一个实例"。

客户端拿到的信息指向了前者,于是它会重试。而这时候有两种情况:

  • 如果原实例还在跑(close-only 那种),你就在另一台机器上又执行了一遍,两条重复记录------这就是上一篇文章里幂等键要解决的问题
  • 如果原实例已经死了,重试打到一个还没被摘掉的 Pod,继续超时

我在 10 个并发下重跑了一遍,没有例外:

ini 复制代码
===== 结果(模式 none,并发 10)=====
  成功 0 / 失败 10
  任务1 抛错: code=-32001 ... Request timed out(距 SIGTERM 约 10001ms)
  ...
  任务10 抛错: code=-32001 ... Request timed out(距 SIGTERM 约 10006ms)

十个任务,十条"超时",没有一条"连接断开"。

顺带说一句,这个现象不是 SIGTERM 特有的。我后面用服务端主动 closeAllConnections() 断连,客户端报的还是 -32001 超时。底层大概是 fetch 的响应流错误没有冒泡到请求的 promise 上------这一条是我的推测,没有验证到底层,但现象很稳定,你可以自己复现。

第二种:只调 server.close()

知道了问题,第一反应是加个信号处理:

dart 复制代码
process.on("SIGTERM", () => {
  httpServer.close(() => log("http server 已关闭"));
});

结果比想象中好,也比你想象中糟。

ini 复制代码
[ 2212ms] 收到 SIGTERM,开始退出流程
[ 2213ms] 已调用 server.close(),但不等在跑的任务
[ 8183ms] slow_task 完成 label=任务1 实际耗时=8002ms
[11724] 任务1 成功 8007ms: 任务1 完成,耗时 8002ms
  服务端进程退出: (3 秒内未退出)

任务保住了。 因为 server.close() 只是停止接受新连接,已经进来的请求照常处理完。

但注意最后一行:进程没有退出。而且日志里从头到尾没有出现"http server 已关闭"------那个回调根本没被调用。

为什么?因为 close() 的回调要等到所有连接都关闭才触发。客户端还连着,连接就不会关。

我单独做了个更干净的实验:客户端只连接、不调用任何工具,然后发 SIGTERM。

ini 复制代码
[ 4641ms] 收到 SIGTERM,开始退出流程 mode=close-only
[ 4642ms] 已调用 server.close(),但不等在跑的任务,也不主动断开连接
--- 6 秒后 ---
还活着
--- 又等了 14 秒,客户端主动断开 ---
[22766ms] http server 已关闭(close 回调触发)
已退出

当时一个任务都没有在跑,进程还是赖了 18 秒,只因为客户端保持着一条空闲连接。

这就是 close() 的真实语义:它不等"你的任务",它等"别人的连接"。退出时机完全由客户端决定,不在你手里。

在 Kubernetes 里,后果很具体:Pod 一直是 Terminating,terminationGracePeriodSeconds 默认 30 秒走完,SIGKILL。你以为的"优雅"其实只是"被拖到被强杀"。

第三种:完整版

一版能用的关闭流程,四步,顺序不能错:

scss 复制代码
async function shutdown() {
  draining = true;                    // 1. healthz 立刻转 503

  await sleep(DRAIN_DELAY_MS);        // 2. 留时间让负载均衡把你摘掉

  httpServer.close();                 // 3. 停止监听(不停已有的)

  while (inflight > 0 && !timeout) {  // 4. 等任务跑完,但有上限
    await sleep(100);
  }

  httpServer.closeAllConnections();   // 5. 主动断开所有连接
  for (const { transport } of sessions.values()) {
    await transport.close();          // 6. 逐个收掉 MCP 会话
  }
  process.exit(0);
}

同一个场景(8 秒任务,第 2 秒发 SIGTERM):

ini 复制代码
[ 2251ms] 收到 SIGTERM,开始退出流程 mode=graceful
[ 8226ms] slow_task 完成 label=任务1 实际耗时=7999ms
[ 8226ms] inflight 归零
[ 8319ms] 等待结束 inflight=0 等待用时=6067ms
[ 8320ms] closeAllConnections 已调用
[ 8320ms] 退出
  服务端进程退出: code=0 signal=null

任务成功,进程正常退出,等了 6 秒。

10 个并发也一样:

ini 复制代码
  成功 10 / 失败 0
  成功耗时: 8006ms, 8007ms, 8008ms ... 8012ms(十个都在 8 秒上下)
  等待结束 inflight=0 等待用时=6067ms
  服务端进程退出: code=0

对比一下三种:

策略 10 并发成功 进程退出 代价
什么都不做 0 / 10 立刻,code 143 任务静默丢失
只 close() 10 / 10 由客户端决定,实测拖 18 秒 等到被 SIGKILL
完整版 10 / 10 6.1 秒,code 0 慢 6 秒

inflight 不能靠手写

上面那个计数是在 handler 里手动 inflight++ / inflight--。这写法有个隐患:新加一个工具忘了写,它就不在等待范围里。

漏掉的那个工具,在发版时会被 closeAllConnections() 直接切断------而其他工具都好好的,你根本不会想到去查它。

与其每个 handler 记得写,不如在注册时统一包一层:

typescript 复制代码
function withInflightTracking(server: McpServer) {
  const raw = server.registerTool.bind(server);

  // 覆写注册方法,让每个工具都被自动计数
  (server as any).registerTool = (name: string, config: any, handler: any) =>
    raw(name, config, async (...args: any[]) => {
      inflight++;
      try {
        return await handler(...args);
      } finally {
        inflight--;
      }
    });

  return server;
}

之后所有 server.registerTool(...) 自动被计数,新增工具不用改关闭逻辑。我用一个同步工具和一个 200ms 的慢工具验过:调用中 inflight 是 1,结束后回到 0。

一个细节:计数要在 handler 一开始就加 ,不能等 await 之后。中间有 await 的话,事件循环会让出,那个极短的窗口里发版就会漏掉这个任务。

但完整版也有个坑,藏得很深

我第一次跑完整版时,故意让 inflight = 0(没有任务在跑)就发 SIGTERM,看它多久退出:

ini 复制代码
[ 1598ms] 收到 SIGTERM,开始退出流程 mode=graceful
[ 1598ms] 等待结束 inflight=0 等待用时=0ms
[ 1598ms] closeAllConnections 已调用
[ 1598ms] 退出

0 毫秒。 SIGTERM 到进程消失,中间什么都不剩。

那一刻我明白了上面为什么 curl 拿不到 503:draining = true 确实把 healthz 改成了 503,但没有任何人能查到它------进程在同一个 tick 里就没了。

shell 复制代码
$ curl -i http://localhost:3016/healthz
curl: (7) Failed to connect to localhost port 3016

这就是优雅关闭最容易被忽略的一步:close() 之前必须先"站一会儿"。

负载均衡不是实时感知的,它靠探针,探针有周期(常见 5~10 秒)。你 healthz 转 503 的瞬间,探针可能刚查过、下一次要等 8 秒。这 8 秒里新请求照样进来,进来就发现连不上。

加上缓冲之后:

ini 复制代码
draining = true;
await sleep(3000);   // DRAIN_DELAY_MS

同一个测试:

ruby 复制代码
$ curl -i http://localhost:3017/healthz
{"ok":false,"draining":true,"inflight":0}
HTTP 503

$ curl -i -X POST http://localhost:3017/mcp -d '{...initialize...}'
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Server is shutting down"},"id":null}
HTTP 503

服务端日志:

css 复制代码
[ 1580ms] 收到 SIGTERM,开始退出流程
[ 1580ms] 摘流量缓冲 3000ms(这期间 healthz 返回 503,已有请求照常处理)
[ 2676ms] 已进入退出流程,拒绝新建 session
[ 4581ms] 摘流量缓冲结束
[ 4582ms] 退出

区别不在"有没有返回 503",在于这 3 秒里,负载均衡有机会知道你不该再接客了。

connection refused 和 503 对客户端的意义完全不同:前者是"这里什么都没有",后者是"这里待会儿还有,先去别处"。前者会让客户端放弃整个服务,后者会重试。

还有一个边界:等不完怎么办

上面所有实验都建立在"任务能在 grace 时间里跑完"。跑不完呢?

把 GRACE_MS 设成 3000,任务还是 8 秒:

ini 复制代码
[ 2125ms] 等待中 inflight=1
[ 5157ms] 等待结束 inflight=1 等待用时=3032ms
[ 5157ms] closeAllConnections 已调用
[ 5158ms] 退出
[15506] 任务1 抛错 12003ms: code=-32001 (距 SIGTERM 约 10002ms)

等满 3 秒,放弃,断连,退出。任务丢了,客户端还是报"超时"。

优雅关闭不是"保证不丢",是"给能跑完的任务一个跑完的机会"。 超过这个时间的,必须靠别的手段兜:

  • 客户端侧要有幂等键(上一篇讲过),重试不会产生重复数据
  • 长任务不该占着一个同步请求,改成"提交-查询"两段式,发版只影响查询,不影响执行
  • GRACE_MS 必须小于 terminationGracePeriodSeconds,否则你的代码还在等,k8s 已经 SIGKILL 了

一个顺带踩到的坑:process.exit 会吞日志

第一次跑完整版时,我盯着日志看了半天:inflight 归零 打出来了,后面"等待结束"、"退出"一个字都没有,但进程确实退了。

原因是 console.log 写到 pipe 时是异步的,process.exit(0) 直接把缓冲区丢了。

arduino 复制代码
log("退出");
process.exit(0);   // 上面这行可能永远到不了终端

发版出问题时最需要日志,而恰恰是退出的那几条最容易被吞。要么在 exit 前 flush,要么用 process.exitCode = 0 让 Node 自己退。

三个动作,一个不要做

动作一:SIGTERM 里第一件事是 draining = true,然后等一下。

不是先 close()。先用健康检查把流量摘掉,再停监听。这个顺序反了,healthz 就等于没写。

动作二:等任务用 inflight 计数,不要用 close() 的回调。

close() 的回调等的是连接,不是任务。你有一个跑了 30 秒的工具,客户端早就断了,回调会在 30 秒时才触发------那时候 k8s 已经把你杀了。

动作三:最后一定要 closeAllConnections()。

SSE 长连接、keep-alive 空闲连接,都会让进程永远退不掉。这一步不是优化,是必须的。

一个不要做的动作:不要指望 server.close() 返回值或者回调告诉你"可以退了"。 它只告诉你"连接都关了"。你的任务在不在跑,它不知道。

一个反方观点

有人会说:我的 MCP Server 就跑在本地,Claude Desktop 启停,哪来的滚动更新?

对,这篇整篇都可以跳过。但只要你的 Server 部署在容器里、前面有负载均衡、会发版,这套就是底线。而且它不只是 MCP 的问题------任何长连接 + 长任务的 HTTP 服务都一样。

还有一点:如果你的工具全是毫秒级返回,graceful 的收益确实不大,close-only 也够。丢单只在"任务执行时间 > 发版间隔"时才发生。先算一下你的工具 P99 耗时,再看要不要做这一步。

总结

  • 发版杀掉正在执行的调用后,客户端不会立刻知道:实测服务端第 2 秒退出,客户端第 60 秒才报错,白等 58 秒
  • 客户端报的是 -32001 超时,不是"连接断开"。这两者对重试策略的意义完全相反,是发版丢单最隐蔽的一环
  • server.close() 等的是连接关闭,不是任务完成。实测一个空闲连接就让进程拖了 18 秒,退出时机不在你手里
  • 完整流程是四步:摘流量 → 停监听 → 等 inflight → 主动断连。顺序错了等于没做
  • draining 之后必须留缓冲时间。实测不留缓冲时,SIGTERM 到进程消失是 0 毫秒 ,healthz 的 503 没有任何人看得到
  • server.close() 不等任务,所以等任务必须自己用 inflight 计数,并且设上限
  • 超过 grace 时间的任务一定会丢,这一层只能靠幂等键和"提交-查询"两段式兜住
  • process.exit() 会丢未刷出的日志,而丢的正好是退出时那几条

适用边界 :本地 stdio 的 Server 完全不用管这些------进程是你自己启停的,没有"发版"这回事。部署在容器里、前面有负载均衡、工具里有超过 1 秒的调用,这三条同时成立时,这套流程才是必须的。如果你的工具全都毫秒级返回,close-only 加一个 DRAIN_DELAY 就够了,不必上 inflight 计数。

最后一句:这套东西的价值不在"写得优雅",在于发版时你知道丢了几个、丢在哪一步。没有这套,你只会看到用户说"刚才那个按钮没反应",然后什么都查不到。

相关推荐
阿屿9 分钟前
从零手搓 AI 伴侣(二):好感度通胀危机,与它的「货币政策」
java·人工智能
师夷长技以自愈25 分钟前
第 2 篇:AI 点菜,程序端菜——第一次学会"工具调用"
人工智能
数据集_深度学习28 分钟前
果园红绿苹果目标检测数据集介绍和论文参考
人工智能·目标检测
武子康30 分钟前
H3-World 的 11.3 小时训练数据,是怎样对齐成动作监督的?
大数据·人工智能·深度学习
楠风测开40 分钟前
两个任务同时跑,B 的登录态为什么被踢回登录页
程序员
泯泷1 小时前
大模型会吃掉 Harness 吗?Agent 天空上的“两朵乌云”
人工智能·llm·agent
吴名氏.1 小时前
Java转AI Agent教程(第十二周)检索与切分
java·开发语言·人工智能·ai·ai agent
LightBreeze20021 小时前
深入理解 Leaf-Spine:从网络拓扑到 AI 数据中心 Fabric
人工智能·fabric·spine·叶脊网络
codigger1 小时前
大厂git提交规范
git·程序员