核心摘要
MCP Server 没有通用的 Node.js 与 Go 性能赢家。一次请求通常经过:
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:
- 握手与空闲 Session:建立 Session、发送心跳,测量内存和文件描述符;
- 小型只读调用:小参数、小结果,观察 framing 和路由开销;
- 有界大结果:固定 1 KiB、32 KiB、256 KiB Payload,并执行结果限制;
- CPU Tool:固定输入输出、无网络的确定性转换;
- I/O Tool:使用可控制延迟分布和错误率的依赖 Double;
- 混合流量:按真实比例加入空闲 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 比较,也不要给某个运行时不同的连接池配置。
固定调度
每个工作负载:
- 从干净进程开始;
- 预热到代码路径和连接池初始化完成;
- 使用固定到达率或并发曲线运行;
- 多次独立重复;
- 记录成功、失败、取消和超时;
- 保留原始样本,而不只是平均值;
- 注入依赖故障和重连风暴后再次运行。
报告 p50、p95、p99、最大值、吞吐、错误率、超时率、CPU、RSS、Heap、文件描述符、活跃 Session 和下游饱和度。平均值可能隐藏用户真正感受到的尾部。
保持比较等价
两套 Server 必须使用相同的:
- Method 和 Transport Profile;
- Tool 名称、Schema、鉴权决策和副作用;
- 输入 Fixture 和结果字节;
- 超时、重试、取消和幂等策略;
- 压缩和日志策略;
- 预热、持续时间、并发曲线和终止条件。
如果 SDK 在握手或 Transport 上存在差异,应将其作为边界差异报告,不能静默删掉某一方的昂贵阶段。
版本化工作负载清单
{
"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:
- 检查 Transport 和 Proxy Buffering;
- 限制请求和结果大小;
- 移除意外的同步工作;
- 修复数据库和下游调用模式;
- 增加取消、并发和重试预算;
- 减少日志和 Telemetry Payload;
- 测量鉴权与序列化;
- 只隔离已经证明的热点路径。
之后再评估语言迁移。保留低效查询、过宽 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,或者根本不迁移。