核心摘要

MCP Server 没有通用的 Node.js 与 Go 性能赢家。一次请求通常经过:

text
Client -> Transport/Proxy -> MCP Framing -> Authorization -> Tool Code
       -> Database or API -> Result Encoding -> Client

如果请求有 400ms 在等待数据库,运行时相差 2ms 可能没有实际意义;如果服务长期维护大量空闲 Session 并执行 CPU 密集转换,运行时和内存行为就可能更重要。应先压测用户真正经历的链路,再用最小改动消除已测出的瓶颈。

本文能证明什么

旧版文章给出了连接、QPS、延迟和 GC 的精确数字,却没有可核验的 Harness、原始样本和统计方法。这些数字不能作为证据。本次修订改为定义如何生成证据。

本文可以帮助你:

  • 定义公平的工作负载;
  • 分离 Transport 成本、Tool 成本和下游依赖成本;
  • 选择能暴露尾延迟和资源饱和的指标;
  • 判断应该调优、隔离、增加 Gateway 还是重写。

它不能预测不同 SDK、Transport、Payload、操作系统、云实例、Proxy 或 Tool 实现下的容量。

先定义服务边界

选语言前先写清楚测量范围:

边界 纳入 分开观察
Transport 握手、Session、心跳、重连、取消 业务数据库延迟
Protocol JSON-RPC 解析、校验、关联、结果编码 模型推理时间
Tool 参数校验、鉴权、序列化、本地计算 Client 渲染
Dependency 数据库/API 延迟、失败、限流 纯运行时吞吐
Operations CPU、内存、文件描述符、日志、部署 单纯团队偏好

不同 MCP 版本和 SDK 可能支持不同 Transport Profile。当前 Transport 规范与旧式 SSE + POST 示例不能混在一套基准中。报告必须固定规范版本和 SDK 版本。

工作负载分类

不要只使用 Echo Tool:

  1. 握手与空闲 Session:建立 Session、发送心跳,测量内存和文件描述符;
  2. 小型只读调用:小参数、小结果,观察 framing 和路由开销;
  3. 有界大结果:固定 1 KiB、32 KiB、256 KiB Payload,并执行结果限制;
  4. CPU Tool:固定输入输出、无网络的确定性转换;
  5. I/O Tool:使用可控制延迟分布和错误率的依赖 Double;
  6. 混合流量:按真实比例加入空闲 Session、读写、取消、重试和重连。

只测 Echo Tool,测到的是 Echo 实现,不足以证明某个运行时会让数据库型或权限密集型 MCP 服务更快。

可复现的基准协议

固定环境

记录:

  • CPU、核心分配、内存、操作系统和 Kernel;
  • Node.js、V8、Go、编译器、MCP SDK 和依赖版本;
  • Build Flags、GC 设置、容器限制和 CPU Governor;
  • Proxy、TLS、HTTP/2 或 HTTP/1.1 设置与网络拓扑;
  • Tool Fixture、租户策略、鉴权路径和依赖 Double。

每次运行使用相同进程配置。不要拿 Debug Build 和优化 Build 比较,也不要给某个运行时不同的连接池配置。

固定调度

每个工作负载:

  1. 从干净进程开始;
  2. 预热到代码路径和连接池初始化完成;
  3. 使用固定到达率或并发曲线运行;
  4. 多次独立重复;
  5. 记录成功、失败、取消和超时;
  6. 保留原始样本,而不只是平均值;
  7. 注入依赖故障和重连风暴后再次运行。

报告 p50、p95、p99、最大值、吞吐、错误率、超时率、CPU、RSS、Heap、文件描述符、活跃 Session 和下游饱和度。平均值可能隐藏用户真正感受到的尾部。

保持比较等价

两套 Server 必须使用相同的:

  • Method 和 Transport Profile;
  • Tool 名称、Schema、鉴权决策和副作用;
  • 输入 Fixture 和结果字节;
  • 超时、重试、取消和幂等策略;
  • 压缩和日志策略;
  • 预热、持续时间、并发曲线和终止条件。

如果 SDK 在握手或 Transport 上存在差异,应将其作为边界差异报告,不能静默删掉某一方的昂贵阶段。

版本化工作负载清单

json
{
  "purpose": "illustrative-fixture",
  "protocol": "mcp",
  "revision": "pinned-in-repository",
  "transport": "pinned-profile",
  "tool": "report.get",
  "authorization": "same-tenant-read",
  "payload_bytes": [1024, 32768, 262144],
  "arrival_rate": [10, 100, 500],
  "duration_seconds": 180,
  "warmup_seconds": 60,
  "repetitions": 5,
  "dependency": {
    "mode": "deterministic-double",
    "latency_ms": {"p50": 20, "p95": 80},
    "error_rate": 0.01
  },
  "budgets": {
    "timeout_ms": 2000,
    "max_result_bytes": 262144
  }
}

上面的数值只是测试形状,不是推荐值。生产报告应包含真实清单、命令、原始样本、置信区间和被丢弃的运行记录。

应该测量什么

Transport 与 Session

  • 初始化到 Ready 的时间;
  • 空闲 Session 和活跃请求的内存;
  • 心跳带宽和 CPU;
  • 重连成功率与恢复时间;
  • 取消传播;
  • 重复或乱序消息;
  • 文件描述符和 Socket 错误。

长连接数量不等于请求吞吐。服务可以维持大量空闲 Session,却在重连风暴中失败。

Protocol 与 Tool

  • JSON-RPC 解析和校验时间;
  • 结果编码时间与分配量;
  • 鉴权和 Policy Check 时间;
  • Tool 执行 p50/p95/p99;
  • 结果超限;
  • 重试、幂等冲突和错误类别。

资源与成本

  • 按进程和 Tool 类别统计 CPU;
  • RSS、Heap、Allocator 和 GC Pause 分布;
  • Node.js Event Loop Lag;
  • Go Goroutine 数量和调度行为;
  • 网络字节、日志量和 Telemetry 导出时间;
  • 每个成功业务任务的成本。

运行时计数器是诊断证据,不是跨版本宣称某个 GC 或 Scheduler 永远更快的依据。

如何理解 Node.js 与 Go

Node.js / TypeScript

以下场景可能更适合 Node.js:

  • 服务主要是异步 I/O;
  • 团队和现有代码以 TypeScript 为主;
  • 选定 SDK 覆盖所需 Protocol 能力;
  • 快速迭代和共享应用代码能降低交付风险。

需要关注 Event Loop Lag、同步序列化、大对象分配、无界结果缓存和阻塞 Event Loop 的 CPU 工作。只有 Profiling 证明必要时,才把 CPU Tool 移到 Worker 或独立服务。

Go

以下场景可能更适合 Go:

  • 服务负责大量连接,需要更明确的资源管理;
  • 工作负载包含 CPU 密集转换;
  • 静态部署和内置 Profiling 符合运维模型;
  • 选定的 MCP Library 覆盖目标规范和生命周期。

需要关注 Goroutine 泄漏、无界 Channel、锁竞争、阻塞网络写和 Library 缺口。每个连接一个 Goroutine 也不能替代限制和取消。

选型不只是运行时速度

因素 问题
Protocol 覆盖 SDK 是否支持所需 Transport、取消、Session 和 Capability?
工作负载 瓶颈是 CPU、Framing、网络、存储还是外部 API?
尾部目标 用户真正关心哪个 p95/p99 和重连目标?
资源模型 先饱和的是内存、文件描述符、Event Loop 还是 Goroutine?
交付能力 哪个团队能可靠修复、测试和运维?
迁移方式 能否隔离 Tool 或 Gateway 而不改变鉴权语义?
成本 每个成功业务任务的成本是多少,而非单纯每请求成本?

重写前先调优

按以下顺序 Profiling:

  1. 检查 Transport 和 Proxy Buffering;
  2. 限制请求和结果大小;
  3. 移除意外的同步工作;
  4. 修复数据库和下游调用模式;
  5. 增加取消、并发和重试预算;
  6. 减少日志和 Telemetry Payload;
  7. 测量鉴权与序列化;
  8. 只隔离已经证明的热点路径。

之后再评估语言迁移。保留低效查询、过宽 Tool 或无界重试的重写,只会高价保留原瓶颈。

混合方案

中间方案包括:

  • 保留 TypeScript MCP Server,只把一个 CPU Tool 放到 Go 服务;
  • 只有在确实需要共享 Session 或 Policy 时才增加 Gateway;
  • 使用相同 Fixture 对 Go 实现做 Shadow Run,再切换流量;
  • 保持相同的对象级授权、幂等和审计契约,再替换实现。

不要让 Gateway 悄悄成为唯一授权层。每个下游服务仍需验证可信调用上下文并执行对象级策略。

基准测试常见错误

错误 为什么会误导 更好做法
比较不同 Transport 把协议差异当成语言差异 固定 Profile 或分轨报告
只用 Echo Tool 隐藏依赖和 Policy 成本 加入 CPU、I/O、混合和故障工作负载
只报告平均值 隐藏尾延迟和超时 报告分布和原始样本
预热不同 把初始化差异当性能 统一预热并重复
忽略重连 看不到长连接故障 注入重启和重连风暴
结果限制不同 改变序列化和内存工作 保持字节和行预算一致
使用合成鉴权 删除真实 Policy 成本 包含同样的租户与对象校验
发布无来源数字 无法复核 发布清单、Harness、版本和数据

上线检查清单

  • [ ] 固定 Protocol 版本和 Transport Profile。
  • [ ] 记录 Node.js、Go 和 MCP SDK/Library 版本。
  • [ ] Tool Schema、鉴权、副作用、重试和结果限制等价。
  • [ ] 存在空闲、CPU、I/O、大结果、混合、取消和重连工作负载。
  • [ ] 记录预热、调度、持续时间、重复次数和被丢弃运行。
  • [ ] 报告 p50/p95/p99、错误、超时、CPU、内存、文件描述符和下游饱和。
  • [ ] 保留原始样本与工作负载清单。
  • [ ] 用测出的瓶颈和服务目标解释语言选择。
  • [ ] 迁移方案保留租户隔离、审计、幂等和回滚。

常见问题

Go 一定比 Node.js 快吗?

不一定。要看测量的是哪条链路。对 CPU 和连接工作负载,运行时差异可能更明显;对被网络或数据库主导的 Tool,差异可能很小。

已有 Node.js Server 应该重写吗?

只有在 Profiling 证明运行时是瓶颈,并且更便宜的控制措施不足以解决时才重写。SDK、人员、运维、迁移和回滚都要纳入决策。

如何公平比较?

固定 Protocol、Tool Contract、Fixture、Policy、Dependency、硬件、调度和故障行为,多次运行并发布原始测量,而不是只发布一个标题数字。

有 Gateway 后还要压测 Server 吗?

要。分别压测 Gateway、Server 和组合链路,覆盖 Buffering、重连、取消、背压和重复投递。

目标怎么定?

使用工作负载特定的服务目标:用户可见尾延迟、并发 Session、每成功任务成本、错误预算和恢复行为。

结语

Node.js 与 Go 是工作负载和所有权决策,不是永久性能排行榜。可信的 MCP 对比应分离 Transport、Protocol Framing、Policy、Tool、Dependency 和 Operations,控制环境,报告尾部与失败,并公开足够证据复现结果。确定瓶颈后,最佳优化可能是缩小结果、改进查询、限制重试、增加 Worker,或者根本不迁移。

一手来源