什么技术用什么通信:TCP、HTTPS、gRPC、MCP 的选型原理

目录


一、为什么同样是通信,选型差别这么大

先把日常见到的现象摆在一起:

场景用的什么
浏览器 → 后端HTTP/1.1、HTTP/2 或 HTTP/3
后端 → 后端(同步、要流式)gRPC over HTTP/2 + Protobuf
后端 → 后端(Java 生态内)Dubbo Triple 或 REST
后端 → 后端(异步解耦)消息队列(Kafka / RocketMQ / RabbitMQ)
Agent → LLMHTTP + SSE 流式
Agent → 本地工具MCP over stdio
Agent → 远程工具MCP over Streamable HTTP
应用 → MySQLMySQL 自己的二进制协议,跑在 TCP 上

同样是”两个程序说话”,为什么有的走 HTTP、有的走自定义协议、有的干脆连网络都不要?

大部分技术文章会按”协议族谱”讲:先讲 TCP/IP 四层模型,再逐个讲每个协议的报文格式。这种讲法有个致命缺陷——你学完之后依然不知道下一个项目该选什么。

这篇文章换一个组织方式:每种通信方式,都是被一个具体问题逼出来的。 把问题讲清楚,选型自己就浮出来了。

打个比方:你不会问”汽车和飞机哪个好”,你会问”我要跨越的是城市还是大洋”。 协议选型也一样——没有优劣,只有”你要对付的是什么问题”。

所以全文有一个反复出现的句式:

它要解决的原始问题是什么?

四个判据贯穿全文,值得先记住:

判据问的是
边界在哪通信的两端是什么关系?进程内、同机、跨机、跨公网
方向谁主动说话?请求-响应、单向推送,还是双向
数据类型文本够用,还是需要强类型 / 二进制
失败代价丢一条消息要不要紧?重复一次行不行

二、域名:socket 之前还有一步

严格说,网络通信的第一跳不是 socket,是把一个名字变成一个 IP。

socket.connect() 需要的是 IP 和端口,但你知道的是 api.example.com。中间这一步就是 DNS。它经常被跳过不讲,但它出的问题一点都不比后面少。

2.1 同一个域名,两个进程可能解析出不同的结果

这是最容易被忽略的一点:解析走哪条路,取决于谁在问。

  • 浏览器通常有自己的 DoH(DNS over HTTPS,RFC 8484)配置——为了防运营商劫持,很多浏览器默认开启。
  • 你的 Java 进程走的是操作系统解析器,通常是 /etc/hosts → 本地 DNS 缓存 → 运营商 DNS 这条传统路径。
  • 如果服务器上跑了 systemd-resolved 之类,还会再多一层本地缓存。

后果:同一台机器上,浏览器能打开的域名,你的 Java 进程可能解析失败——或者解析到另一个 IP。排查时会怀疑人生,因为两边看起来”都对”。

另一条相关的路是 DoT(DNS over TLS,RFC 7858),在专用端口上用 TLS 跑 DNS,和 DoH 的区别是它还是纯 DNS 协议,只是套了加密。

2.2 为什么你会看到”偶发慢 200 毫秒”

现在 IPv6 和 IPv4 并存,但不是每台机器两条路都通。如果客户端傻等 IPv6 超时再回退 IPv4,用户就要白等一个超时。

解决办法叫 Happy Eyeballs(RFC 8305):两条路同时试,IPv6 先发,落后 250 毫秒还没连上就把 IPv4 也发出去,谁先成功用谁。

所以 250ms 这个数字不是随便来的——当你的服务出现”大部分请求很快、偶尔慢 250ms 左右”时,Happy Eyeballs 的回退逻辑是嫌疑人之一。

2.3 本节要记住的

  • DNS 是不可跳过的第一跳,它出错时现象是”连不上”,但根因不在网络层。
  • 解析结果可能因进程而异,排查时先确认两边用的是不是同一条解析路径。
  • IPv6/IPv4 并存带来了 250ms 级别的偶发延迟,这是设计使然,不是玄学。

三、TCP:在不靠谱的信道上做靠谱传输

DNS 给了你 IP,下一步是建立连接。这里要回答的问题是:

IP 网络不保证任何事——包会丢、会乱序、会重复、会迟到。怎么在它之上做出一条”看起来可靠”的管道?

3.1 问题有多严重

IP 层是**尽力而为(best-effort)**的:

  • 包可能丢失(路由器队列满了就丢,不通知任何人)
  • 包可能乱序(不同包走不同路径,先发的后到)
  • 包可能重复(重传导致)
  • 包可能到达时已损坏(虽然校验和能挡住大部分)

而绝大多数程序需要的心智模型是”我按顺序发出去,对方就按顺序收到”。TCP 的全部复杂度,都来自这个缺口。

3.2 TCP 的解法:给每个字节编号

TCP 的核心手段其实很朴素——给字节流编号,然后让对方确认:

机制解决什么
序列号 / 确认号乱序 → 接收方按序号重排
超时重传丢包 → 没收到确认就重发
校验和损坏 → 对不上就丢弃
滑动窗口性能 → 不必”发一个等一个”
连接状态双方对”现在进行到哪”有共识

注意最后一条——TCP 必须在两端维护状态,这就是它”面向连接”的含义。

3.3 那三次握手是干什么的

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: SYN (我要连,我的序号是 x)
    S->>C: SYN + ACK (同意,我的序号是 y,确认收到 x)
    C->>S: ACK (确认收到 y)
    Note over C,S: 连接建立

为什么是三次而不是两次? 因为网络里可能残留旧的、延迟到达的连接请求。如果只握两次,服务端单方面收到一个迟到的老 SYN 就会建立连接并等在那里,白白占用资源。

三次握手让双方都确认了对方的收发能力:

  • 服务端从第一次 SYN 知道:客户端的发送能力正常
  • 客户端从 SYN+ACK 知道:服务端的收发都正常
  • 服务端从最后的 ACK 知道:客户端的接收能力正常

关闭要四次,因为 TCP 是全双工的,两个方向各关各的。关完之后还有 TIME_WAIT——主动关闭方要等一段时间(通常 2 倍报文最大生存时间)才释放。它存在的理由是万一最后的 ACK 丢了,对方会重发 FIN,此时还能响应。

工程含义:如果服务器上大量连接处于 TIME_WAIT,说明这台机器在主动关连接。高并发短连接场景下端口会被耗尽。这在 Java 后端调用下游、以及 nginx 转发时都会碰到。

3.4 拥塞控制:从”丢包就是堵车”到”建个模型”

TCP 面对的第二个问题是:发多快? 发太快会把中间路由器挤爆,发太慢浪费带宽。这个自我调节机制就是拥塞控制。

第一代思路很直白:丢包 = 堵车。

经典算法是 CUBIC(RFC 9438,2023 年 8 月成为标准,取代 RFC 8312),它是 Linux、Windows、Apple 三家的默认算法。CUBIC 在拥塞避免阶段用一个三次函数来涨窗口:

W(t) = C(t − K)³ + Wmax

其中 Wmax 是上次拥塞时的窗口大小,C 取 0.4,乘性减小因子 β 取 0.7(传统 Reno 是 0.5——降得没那么狠)。

这张曲线的意思是:在接近上次拥堵点之前慢慢涨(凹段),过了拥堵点再加速试探(凸段)。

注意一件常被说错的事:CUBIC 仍然是丢包判据的算法。 RFC 9438 原文写得很清楚:

Same as Reno, CUBIC is a loss-based congestion control algorithm.

它换掉的是”怎么涨”,不是”看什么信号”。

第二代思路换掉了判据本身。

BBR(Bottleneck Bandwidth and Round-trip propagation time,Google 2016 年提出)不再把丢包当拥堵信号,而是建一个路径模型:用近期测到的投递速率、往返时延和丢包率,估算这条路的瓶颈带宽和传播时延,再据此决定发多快、在途多少数据。

它成立的理由是:丢包 ≠ 拥塞。在无线链路上,丢包可能只是信号抖动;在浅缓冲的路由器上,缓冲还没填满包就丢了,此时降速纯属冤枉。

BBR 的标准状态需要说清楚:它在 IETF 里没有正式地位。draft-cardwell-iccrg-bbr-congestion-control-02 已经 Expired & archived,被 draft-cardwell-ccwg-bbr 取代。也就是说,BBR 是工程上大量使用、但标准上仍未定稿的东西——写进文档时别说成”BBR 是标准”。

3.5 本节要记住的

  • TCP 的所有复杂度来自一个缺口:上层想要可靠有序,底层只给尽力而为。
  • 三次握手解决的是旧的重复连接请求,四次挥手和 TIME_WAIT 解决的是双向关闭的可靠性。
  • 拥塞控制的演进方向是换判据:从”丢包 = 拥堵”(CUBIC)到”建路径模型”(BBR)。
  • 常识性错误提醒:CUBIC 是丢包型算法,只是增长函数换成了三次曲线。

四、TLS:在人人可听的信道上悄悄说话

TCP 给了一条可靠的管道,但它是明文的。而这条管道会经过很多你控制不了的地方:路由器、运营商、代理、CDN。

问题:数据要经过一堆你不信任的中间节点,怎么保证它们看不到、也改不了?

4.1 一次 TLS 1.3 握手要几个来回

一个。 这是 TLS 1.3(RFC 8446)相对 TLS 1.2 最直观的改进。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: ClientHello (支持的版本、密码套件、密钥交换材料)
    S->>C: ServerHello + {EncryptedExtensions, Certificate, CertificateVerify, Finished}
    C->>S: {Finished}
    Note over C,S: 之后双方可以发应用数据

关键点:从 ServerHello 之后,服务端发的所有东西都是加密的——包括证书本身。

4.2 TLS 1.3 砍掉了什么,为什么

对比 TLS 1.2,TLS 1.3 做了几处减法,每一处都有明确理由:

变化理由
只剩 AEAD 密码套件老式的”先加密再校验”(MAC-then-Encrypt)有攻击面,认证加密一体才安全
去掉静态 RSA 和静态 DH 密钥交换它们不提供前向保密——私钥一旦泄露,历史流量全部可解
密码套件拆成两部分认证/密钥交换与 record 加密分开描述,避免组合爆炸
版本协商挪到 supported_versions 扩展老办法藏在记录层版本号里,中间件经常改坏它
ChangeCipherSpec 移除仅保留兼容中间件的最小实现

4.3 前向保密:为什么重要

没有前向保密时:攻击者今天录下你的加密流量,等哪天拿到服务器私钥,就能把历史流量全部解密。这叫”先存后解”(store now, decrypt later)。

有前向保密时:每次会话用临时密钥协商,会话密钥用完即弃。私钥泄露也解不开过去的会话。

这就是为什么”抗量子”这件事现在就要做——攻击者不需要今天就破解,他们只需要今天开始录。

4.4 0-RTT:省一个来回,代价是什么

TLS 1.3 允许对曾经连接过的服务端发 0-RTT 数据:客户端在第一个包里就带上应用数据,不用等握手完成。

代价很明确,RFC 8446 原文两句话:

  1. 这批数据不具备前向保密——它只用 PSK 派生的密钥加密。
  2. 不保证跨连接不可重放——攻击者可以把这包原样重放。

工程含义:0-RTT 只适合幂等且无副作用的请求。如果拿它发”下单""支付”这类请求,就等于开了一个重放攻击的口子。

4.5 抗量子:今天在线上跑到了哪一步

这是本文里可以当场验证的一条。在本机执行:

echo | openssl s_client -connect www.cloudflare.com:443 \
  -servername www.cloudflare.com -alpn h2,http/1.1 -brief

实测输出(2026-10-15):

Protocol version: TLSv1.3
Ciphersuite: TLS_AES_256_GCM_SHA384
Peer certificate: CN=www.cloudflare.com
Signature type: ecdsa_secp256r1_sha256
Verification: OK
Negotiated TLS1.3 group: X25519MLKEM768

注意最后一行:X25519MLKEM768。

这是一个混合密钥交换:经典的 X25519 加上抗量子的 ML-KEM-768。之所以是混合而不是纯抗量子,是因为万一 ML-KEM 被证明有缺陷,X25519 还在兜底——这是密码学上”不要押单一赌注”的常规做法。

部署现状(这是本号主张”抗量子已经来了”的依据):

  • Cloudflare 在 2025 年 10 月底公布:超过 50% 的人类流量已被抗量子密钥交换保护。
  • 客户端铺开的时间线:2024 年 3 月 Chrome 桌面默认开启 → 2024 年 8 月 Go 默认 → 2024 年 11 月 Chrome Android 与 Firefox 桌面 → 2025 年 4 月 OpenSSL 默认 → 2025 年 10 月 Apple 随 iOS/iPadOS/macOS 26 默认开启。

但同一份数据里有另一半,不写出来这句话就是误导:

  • 2025 年 9 月一项对 top 100k 域名的扫描显示,39% 支持抗量子(半年前是 28%)。
  • Cloudflare 背后的源站,只有 3.7% 支持抗量子(2023 年是 0.5%)。

边缘超过一半,源站不到 4%——13 倍差距。 所以真正该记住的判据不是”抗量子来没来”,而是:

TLS 在哪里终止,抗量子就是谁的责任。

流量在你的服务器上解密 → 抗量子是你的事。 流量在 CDN 边缘解密 → 抗量子是 CDN 的事,但边缘到源站那一段仍可能没保护。

4.6 本节要记住的

  • TLS 1.3 是减法:1-RTT、只剩 AEAD、去掉静态密钥交换。
  • 前向保密是对抗”先存后解”的关键,也是抗量子要提前做的原因。
  • 0-RTT 不安全,只能用于幂等请求。
  • 抗量子已经在边缘成为主流、在源站仍是少数——判据是 TLS 在哪终止。

五、HTTP:三次队头阻塞

有了 TCP 和 TLS,还需要一层约定”我说的是什么”。

问题:怎么让两边对”请求长什么样、成功失败怎么表示”有共识?

HTTP 的语义部分(方法、状态码、头部字段)从 1.1 到 3 几乎没变。真正反复被推翻的是怎么把这些语义放到连接上——而驱动这件事的,是同一个问题反复出现。

5.1 什么是队头阻塞

一条队列里,前面那件事没完成,后面所有事都得等。 这就是队头阻塞(Head-of-Line Blocking,HOL)。

HTTP 的三代演进,可以概括成把队头阻塞从三个不同的层次上逐个拆掉。

5.2 第一次:HTTP/1.1 的应用层队头阻塞

HTTP/1.1 默认一条连接上同时只有一个请求在途——你发请求、等响应、再发下一个。

流水线(pipelining)本来是解法:连发多个请求。但它在实践中基本失败了——一个响应慢,后面全被堵住,而且中间件支持极不一致,所以浏览器默认都关掉了它。

于是浏览器的实际做法是:同时开 6 条 TCP 连接。

这里埋下了两个问题:

  1. 每条连接都要各自握手、各自 TLS——开销乘以 6。
  2. TCP 的拥塞控制不会跨连接共享。 6 条连接各自探测各自的速度,谁也不知道另外 5 条也在发同样的数据。总吞吐不是 6 倍,而且更容易堵。

5.3 第二次:HTTP/2 解决了应用层,但把问题推到了 TCP 层

HTTP/2 的做法是二进制分帧 + 多路复用:把每个请求拆成帧,帧打上”我是第几条流”的标记,然后所有流的帧混在同一条 TCP 连接上发。

应用层的队头阻塞没了——慢请求不会挡住快请求。

但 TCP 层的问题浮出来了:TCP 不知道帧的边界。

TCP 眼里只有一串字节。如果任何一个包丢了,TCP 必须停下等它重传——因为它是按字节顺序交付的。在等待期间,所有流的数据都被堵在缓冲区里,哪怕这个丢包只属于其中一条流。

这就是 HTTP/2 的悖论:它把多路复用做得越好,一条连接上承载的流越多,一次丢包造成的连带伤害就越大。

5.4 第三次:HTTP/3 把流做进传输层

HTTP/3(RFC 9114)的做法是换掉下面的传输层,用 QUIC(RFC 9000/9001/9002,基于 UDP)。

QUIC 的核心创新是:可靠性是按流提供的,不是按连接提供的。 每条流自己管自己的重传,一条流丢包不影响其他流。同时拥塞控制仍是整条连接级别的——这样既避免了跨连接不共享信息的毛病,又避免了跨流互相堵。

代价也很实在:

  • UDP 常被中间设备封禁,所以客户端必须准备回退到 TCP 版 HTTP。
  • 因为 QUIC 不保证有序传输,HTTP/2 的头部压缩方案 HPACK 用不了(它依赖有序),HTTP/3 换成了 QPACK。
  • HTTP/3 不支持 Upgrade 和 101 状态码,也不能用 Transfer-Encoding。

5.5 HTTP/2 留下的一个教训:设计得好,不等于会被用

HTTP/2 曾经设计了一套精细的流优先级机制(用依赖树表达”先给我这些”)。RFC 9113 在 2022 年修定 HTTP/2 时,把它废弃了,理由写得非常直接:

the prioritization signaling in RFC 7540 was not successful.

具体是三个原因:机制过于复杂、各客户端表达优先级的方式不一致、大量服务端干脆忽略它。

顺便,同一份修订里 h2c 的 Upgrade 令牌也被废弃了:

This usage was never widely deployed.

这是本文最值得记住的一课:一个协议特性的成败,不取决于它设计得多优雅,而取决于实现了没有、用了没有。现在需要优先级表达的场景,指向的是另一个规范(RFC 9218)。

5.6 今天的占比

  • HTTP/3 已被 40.9% 的网站使用(W3Techs,2026 年 10 月报告)。
  • HTTP/2 仍是主流,但它相对 1.1 的优势只在并发请求多的场景成立——单请求场景下,多路复用没有用武之地,反而多了分帧开销。

5.7 本节要记住的

  • 三代 HTTP 演进 = 三次队头阻塞:应用层(1.1)→ TCP 层(2)→ 按流隔离(3)。
  • HTTP/2 的固有问题:TCP 看不见帧边界,一次丢包堵住所有流。
  • HTTP/3 换来的是按流可靠 + 连接级拥塞控制,代价是 UDP 可达性。
  • 设计得好不等于会被用——RFC 9113 废弃优先级机制就是证据。

六、微服务之间怎么调:gRPC 为什么赢

到这里为止讲的是”客户端和服务器”——两端是陌生人的关系。但微服务之间不一样:两端都是你自己写的。

这个差别把所有前提都改了:

维度浏览器 ↔ 后端后端 ↔ 后端
谁能改只有你,客户端是别人的浏览器两边都是你,可以同步升级
契约在哪文档 / OpenAPI,靠自觉共享的 IDL 文件,编译器强制
性能敏感度网络是主要成本序列化也是成本
调试方式人肉看、curl 手敲有工具,但要自动化的多

所以后端之间可以要求”先定义接口,再生成代码”——这正是 gRPC 的核心。

6.1 gRPC 要解决的三件事

  1. 跨语言:Java 服务调 Go 服务,接口定义必须语言中立 → .proto 文件(Protocol Buffers IDL)。
  2. 强类型:字段类型、必填可选,在编译期就检查,而不是等到线上。
  3. 流式:不只是”一问一答”,还要支持服务端持续推、客户端持续发、双向同时。

6.2 为什么要跑在 HTTP/2 上

因为多路复用。

一个 Java 服务要并发调用下游几百次,如果每次调用占一条 TCP 连接,连接数会爆炸,而且又是那个老问题——拥塞控制不跨连接共享。

HTTP/2 让这些并发调用共用一条连接、各自是一条流。谁先返回谁先交付,互不阻塞。

6.3 线缆上发生了什么(理解层次,不抠字节)

一次 gRPC 调用在 HTTP/2 上长这样:

部分内容
路径/<服务名>/<方法名>
content-typeapplication/grpc(可带 +proto、+json 后缀)
必需请求头te: trailers
超时grpc-timeout,数字 + 单位字母(H/M/S/m/u/n)
消息体一条或多条长度前缀消息
结果状态trailers 里

两个必须记住的点:

第一,消息边界和 DATA 帧边界无关。 gRPC 的每条消息是 1 字节压缩标志 + 4 字节大端长度 + 消息体,但这只是字节流里的格式——HTTP/2 的 DATA 帧怎么切,和消息边界没有任何关系。一条消息可能跨几个 DATA 帧,一个 DATA 帧也可能装不满一条消息。

第二,也是最大的工程坑——状态码不在 HTTP 状态码里,在 trailers 里。

  • grpc-status:十进制 ASCII 整数。
  • grpc-message:UTF-8 描述,百分号编码。
  • 调用成功时,状态也走 trailers。

这意味着:一次失败的 gRPC 调用,HTTP 状态码是 200。

grpc-status: 5 是 NOT_FOUND,grpc-status: 14 是 UNAVAILABLE——但粗看 HTTP 层,你只会看到 200 OK。拿普通 HTTP 客户端去调 gRPC、然后盯着 HTTP 状态码判断成败,是必然翻车的。

规范里还有一条专门为这个坑打的补丁:如果 content-type 不是 application/grpc 开头,服务端应该回 HTTP 415。理由写得很直接——因为 gRPC 的错误用 200 表示,不显式区分的话,别的 HTTP/2 客户端会把一个 gRPC 错误响应当成成功。

6.4 四种调用方式

gRPC 在 IDL 层面就把流式写进去了:

类型形状典型用途
一元(Unary)一问一答普通查询、下单
服务端流一个请求,一串响应拉大结果集、订阅变更
客户端流一串请求,一个响应上传、聚合上报
双向流两边各自读写,互不阻塞实时对话、协同

同一个 IDL 文件能表达这四种,这是 gRPC 相对 REST 最实质的优势——REST 想表达流式,得另找 SSE 或 WebSocket 拼上。

6.5 什么时候不该用 gRPC

  • 要给人调、被人肉调试:REST + JSON 更省事,curl 就能试。
  • 要暴露给不是你的客户端:浏览器对 gRPC 的支持需要额外代理层(gRPC-Web)。
  • 调用频率低、团队不熟:IDL 的编译链路是实打实的成本,低频调用摊不平。

七、RPC 里的 stub 到底是什么

上一节说 gRPC 的客户端代码是”生成的”。生成出来的那个东西,就叫 stub。

这个词很容易让人困惑,因为它在四个地方出现过,指的东西并不完全一样。

7.1 从 RMI 说起

Java RMI 是最早把这套讲清楚的。它的设计是 stub / skeleton 对称结构:

stub(客户端)skeleton(服务端)
角色远程对象的本地代理分派到真正的实现
参数序列化后发出去反序列化收进来
结果反序列化收进来序列化后发出去

关键性质:stub 实现的是和远程对象相同的那组接口。 所以调用方写 service.hello("world"),看起来和调本地方法一模一样——网络、序列化、等待,全部被藏在方法签名后面。

Java 文档的原话是:stub 是”远程对象的客户端本地代表或代理”,它”隐藏了参数的序列化和网络层通信,以便向调用者呈现一个简单的调用机制”。

7.2 gRPC 的 stub

同一个东西。 gRPC 官方文档的描述是:客户端持有”一个叫 stub 的本地对象”,它”实现了和服务相同的方法”。调用这些方法时,stub 把参数包成 Protobuf 消息、发出去、再把响应解回来。

语言里通常还有几种变体——阻塞式、Future 式、异步式——区别在怎么等,不在通信本身。

7.3 Dubbo 的本地存根

这个是变体,不是同一种东西,值得说清楚。

Dubbo 的”本地存根”是一个可选的装饰器:你在消费者进程里再包一层同接口的实现,它持有真正的远程代理,可以在远程调用前后做事情——参数校验、缓存、容错降级、打桩。

区别在哪? 本地准的”本地性”不是它提供的——它下面那层远程代理已经把远程调用包装成接口调用了。本地存根是在已有代理外面再套一层,位置比 stub 更靠外。

7.4 测试里的 stub——这个词是借来的

单元测试里的 stub 是 test double 的一种:提供预设返回值,不校验交互(那是 mock 干的事)。

它和”本地性”毫无关系。 一个完全本地的方法,它的测试替身也叫 stub。

7.5 怎么记才不混

别硬找一个定义统摄全部。记住来路和分岔:

graph LR
    A["RMI stub<br/>(1997)"] --> B["gRPC stub<br/>(生成的客户端代理)"]
    A --> C["Dubbo 本地存根<br/>(套在代理外面的装饰器)"]
    A -.->|借词| D["测试 stub<br/>(预设返回值的替身)"]
  • RPC 这条线上的不变量:stub 是客户端一侧的本地替身,让远程调用看起来像本地调用。
  • Dubbo 本地存根是这条线上的一个装饰器变体。
  • 测试 stub 是借的词,跟本地性无关——看到它别往 RPC 上想。

八、异步通信:MQ 为什么不用 HTTP

前面讲的都是同步调用:发出去,等结果。

但有三类问题,同步调用天生解决不了:

问题同步调用的表现
级联失败下游挂了,上游线程全部阻塞,故障往上蔓延
流量尖峰一秒来 10 万请求,下游只有每秒 1 万的容量,直接被压垮
本来不需要立刻有结果发个短信、写个日志、刷新个缓存——调用方并不关心结果

8.1 MQ 的本质:把”什么时候处理”转移走

消息队列的核心动作只有一句话:

发送方把消息交给代理就算完成,“什么时候处理”由接收方自己决定。

这一下解开了三个问题:

  • 下游挂了?消息存在队列里,下游恢复后再消费。
  • 流量尖峰?队列当蓄水池,下游按自己的速度拉。
  • 不需要立刻有结果?发送方根本不等。

这是”解耦”这个词真正的含义——不是”代码不互相依赖”这种抽象说法,而是时间上的解耦。

8.2 那 HTTP 能不能干这个?

能凑合,但每个语义都是拧着的。

需求HTTP 的表达拧在哪
消息要保序无此语义得自己实现
消费失败要重投无此语义得自己实现
消费者按自己的能力拉无此语义得自己实现背压
一个消息给多个消费者无此语义得自己实现广播
消息要能重放无此语义得自己实现
长时间不消费也不丢服务端要保持连接连接是有状态的、会断

HTTP 的语义是”我现在要一个答案”。 而 MQ 要的是”投递 + 保序 + 可重放 + 背压”。这是两套完全不同的关注点,硬套 HTTP 等于用 HTTP 当传输层,上面再自己实现一整套消息语义——那还不如直接定义一个协议。

至于”为什么每家的 MQ 协议都不一样”——这属于生态与历史原因(各家独立演化、绑定自己的客户端生态),不是技术必然。本文不展开。

8.3 三个主流 MQ 的通信底座

产品协议特征
Kafka自有的二进制协议,跑在 TCP 上为高吞吐设计,批量 + 零拷贝
RabbitMQAMQP 0-9-1标准化的消息协议,路由模型丰富
RocketMQ自有二进制协议为电商场景设计,事务消息是重点

共同点:都是自定义二进制协议跑在 TCP 上,没有一家用 HTTP 做主线。原因就是上面那张表——HTTP 的语义对不上。

8.4 本节要记住的

  • MQ 解决的是时间解耦:发送方不等结果,接收方自己决定何时处理。
  • HTTP 语义是”要答案”,MQ 语义是”投递”——对不上,所以各家自己定义协议。
  • “为什么每家协议都不一样”是生态原因,本文不展开。

九、Agent 与 LLM 之间的流式通信

现在换到 Agent 场景。这里的第一段通信是 Agent → LLM。

9.1 问题:LLM 的回答是”挤”出来的

一次 LLM 调用生成 2000 个 token,可能要几十秒。

如果用普通的请求-响应模式:客户端发请求 → 服务端生成完 2000 个 token → 一次性返回。用户在这几十秒里盯着一个空白界面。

但生成的本质是逐 token 的——第一个 token 可能 200ms 就出来了。白白浪费了这几十秒的可见性。

9.2 为什么是 SSE 而不是 WebSocket

第一反应通常是”要用 WebSocket 吧,实时嘛”。但这里不需要。

需求SSEWebSocket
服务端 → 客户端单向推✅✅
客户端 → 服务端❌(另发普通请求即可)✅
走标准 HTTP✅❌(升级后不再是 HTTP)
浏览器自动重连✅ 内置❌ 要自己写
穿过 HTTP 基础设施✅(就是 HTTP)⚠️(部分代理不认)

LLM 流式只需要单向——客户端发的那一次请求已经用普通 POST 发过了,回来只需要一条单向的流。用 WebSocket 会引入它全部的双向复杂度,却一点用不上。

这里有个通用原则:够用就好。 SSE 是”单向推送”这个问题的最小解。

9.3 SSE 的关键细节

流是纯文本,MIME 类型 text/event-stream,UTF-8:

event: delta
data: {"text": "你"}

data: {"text": "好"}

: keep-alive
  • 每条消息以空行结束。
  • 字段有 data(内容)、event(事件类型)、id(用于断线续传)、retry(重连间隔,毫秒)。
  • 以 : 开头的行是注释——这就是保活心跳的标准做法,服务端定期发一个冒号行,防止中间设备把空闲连接掐掉。

三个限制必须知道:

限制说明
只能传文本二进制要先编码(通常 base64)
不能自定义请求头浏览器的 EventSource 只允许带 Cookie(withCredentials)——认证只能靠 Cookie 或 URL 参数
HTTP/1.1 下每域名仅 6 条每浏览器每域名 6 条连接上限,Chrome 和 Firefox 都标了 wontfix。多标签页场景特别痛

最后那条正是 HTTP/2 的价值所在:走 HTTP/2 时,连接上限变成并发流上限,默认 100,问题自然消失。

9.4 工程坑:SSE 会被反向代理缓冲

SSE 的数据是一点一点挤出来的,但 nginx 默认会缓冲响应体——等攒够一批再发。结果是流式变成了”分几次大块到达”,实时性全没了。

解法是加一个头:

X-Accel-Buffering: no;

这个头专门告诉 nginx “别缓冲这条响应”。任何做 SSE 的服务端都应该带上它——这条经验在 MCP 的 Streamable HTTP 里也被写进了规范。


十、Agent 与工具:MCP 的两种传输

Agent 场景的第二段通信是 Agent → 工具。这是 MCP 要解决的问题:

模型怎么调用一个它从没见过的东西?

MCP 的答案是:工具自己描述自己(工具名、参数 schema、说明),Agent 在运行时读取这些描述,然后调用。

这套设计最值得讲的地方是:它定义了两种传输,而这两种的选择几乎是被场景唯一决定的。

⚠️ 本节依据 MCP 2026-07-28 规范文本。 该版本是一次破坏性修订,各 SDK 与服务端的跟进进度我没有逐一验证。

10.1 本地工具:为什么是 stdio

当 MCP 服务端和 Agent 在同一台机器上时,MCP 用的是 stdio——标准输入输出。

为什么不用 HTTP?因为那台机器上根本不需要网络。

用 HTTP 需要处理stdio 需要处理
端口分配冲突无
认证授权(谁能调)无(同机同用户)
TLS 加密无(数据不出机器)
服务发现无(父进程直接拉起来)
防火墙无

两个进程在同一台机器上通信,最自然的方式就是管道。 这属于前面说的”没得选”那一类——不是权衡后的选择,是这个场景下的唯一合理形态。

stdio 的工作方式:客户端把服务端拉成一个子进程,往它的 stdin 写 JSON-RPC 消息,从 stdout 读回消息,以换行分隔,且消息内不能含换行。

这里有一条约束值得单独说:

服务端绝不能往 stdout 写任何不是合法 MCP 消息的东西。日志只能写 stderr。

这条约束存在的原因很直接——stdout 是协议通道。往里面随便打印一行调试信息,就会把协议流冲垮。“日志走 stderr”不是风格建议,是协议要求。

10.2 远程工具:Streamable HTTP 为什么长这样

跨机器时就得走网络了。但 Streamable HTTP 的形状和普通 REST 很不一样:

  • 服务端只暴露一个 endpoint,而且只接受 POST(GET 和 DELETE 直接 405)。
  • 每个 JSON-RPC 请求/通知各发一次 POST。
  • 服务端每个请求二选一:回一个 JSON 对象,或者回一条 SSE 流。

为什么要允许 SSE? 因为工具执行可能要很久(跑测试、查数据库),中间需要发进度通知。这条 SSE 流的作用域严格限定于本次请求——不是一条全局的长连接。

安全上有一条必读:服务端必须校验 Origin 头,以防御 DNS rebinding 攻击——否则一个恶意网页可以把你的浏览器变成跳板,去操纵你本机的 MCP 服务端。本地运行时,服务端还应该只绑 127.0.0.1,而不是 0.0.0.0。

(还记得上一节的 X-Accel-Buffering: no 吗?它在 MCP 规范里同样要求——只要做 SSE,就得对付缓冲。)

10.3 2026-07-28 这次大改:为什么把握手和 session 都删了

这是本节最重要的增量。当前 MCP 规范版本是 2026-07-28,它对传输层做了几处破坏性修改。

理解这次改动的钥匙是一个词:无状态。

先看改之前是什么样:客户端连上后,必须先发一个 initialize 握手,服务端可能会分配一个 Mcp-Session-Id,之后所有请求都要带上它。服务端记住了每个客户端的状态。

这带来什么问题?

  • 很难水平扩展:客户端的状态绑在某一个服务端实例上,负载均衡不能随便转发——除非把所有实例的状态共享起来。
  • 基础设施不友好:普通的负载均衡器、网关、CDN 看到的是一堆带自定义 session 头的请求,很难做路由和缓存。
  • 重启即失联:服务端重启,所有 session 作废。

2026-07-28 的改法:

改动原因
移除 initialize 握手改为每个请求自带协议版本和客户端能力(放在 _meta 里)——请求变自包含
移除 Mcp-Session-Id需要跨调用状态时,改用服务端签发的显式 handle,当普通工具参数传
新增 server/discover一次性查出服务端支持哪些版本、能力、身份——替代原先握手完成的事
移除 GET 流和 resources/subscribe改为 subscriptions/listen:一条长活的 POST 响应流,按类型订阅变更
移除 ping、logging/setLevel日志级别改成每个请求在 _meta 里指定
服务端发起请求 → 改为 MRTR见下
移除 SSE 断流续传不再支持 Last-Event-ID

MRTR 是这次最烧脑的一处,值得单独说。

改之前,服务端想反过来问客户端(比如”我需要用户确认一下""帮我调一次模型”),会在 SSE 流上自己发一个 JSON-RPC 请求。这要求连接必须是双向、有状态的。

改之后,服务端不再自己发请求,而是返回一个特殊的中间结果 InputRequiredResult,里面带着”我需要什么”,然后客户端带着答案重试原请求。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    C->>S: POST tools/call (id: 1)
    Note over S: 需要用户确认
    S-->>C: InputRequiredResult (inputRequests: 需要确认)
    Note over C: 客户端去问用户
    C->>S: POST tools/call (id: 2) 原参数 + inputResponses
    S-->>C: 最终结果

为什么这么改? 因为一次请求-响应 + 一次重试,每一跳都是独立的、可路由的、无状态的。而原来的双向流要求整条连接从始至终保留状态。

代价也很实在:客户端的实现变复杂了(要处理重试),而且流断了就是在途请求丢了——必须换一个新的 request ID 重新发。

10.4 旧客户端撞上新服务端会怎样

这张表是给正在做升级的人用的:

旧客户端的行为新服务端的响应
GET MCP endpoint(开 SSE 流)405 Method Not Allowed
DELETE + Mcp-Session-Id(终止 session)405 Method Not Allowed
请求带 Mcp-Session-Id被忽略,不再签发或回显
请求带 Last-Event-ID(断流续传)被忽略,流不可恢复
先发 initialize 握手握手已不存在;每个请求自带版本与能力
发 ping / logging/setLevel已移除
调 resources/subscribe已移除,改用 subscriptions/listen
等服务端在流上发独立请求改为在 InputRequiredResult 里返回,客户端重试

错误码对照(写排查手册时可以直接抄):

情况HTTPJSON-RPC
不支持的协议版本400-32022,body 里列出支持的版本
头与 body 不一致400-32020
方法不存在404-32601
通知被接受202无 body

规范给的向后兼容探测流程:先直接发一个现代请求;如果回 400,先看 body——body 里是可识别的现代错误,说明对方是新服务端(该改请求,不是降级);body 空或不认识,才回退到 initialize 走旧版。

10.5 顺带的弃用:Roots、Sampling、Logging

同一版还把三个特性标记为弃用(Deprecated),一年内不会删除,但新实现不该再采用:

特性建议的替代
Roots改用工具参数、resource URI 或服务端配置来传目录
Sampling直接对接 LLM 厂商 API
Loggingstdio 写 stderr;可观测性用 OpenTelemetry

看这三条能读出一个方向:MCP 在收窄自己。Sampling 意味着服务端要反向调模型——那依赖一条双向有状态连接,正是本次要删的东西。把这类”反向依赖”拆出去,协议本体才可能做到无状态。

10.6 本节要记住的

  • 传输的选择由场景唯一决定:同机器 → stdio(没得选);跨机器 → Streamable HTTP。
  • stdio 的硬约束:stdout 是协议通道,日志必须走 stderr。
  • 2026-07-28 大改的主线索是无状态:删握手、删 session、服务端请求改为 MRTR 重试。
  • 改动的收益是基础设施友好(可路由、可缓存、可水平扩展),代价是客户端要处理重试。

十一、对外 API:TLS 的运维现实

前面讲的是后端之间的内部通信。对外 API 是另一回事——面对的是不可控的客户端和公开网络,所以这里的问题从”性能”变成了”运维”。

11.1 证书有效期正在快速缩短

这是最近几年对后端运维影响最直接的一条政策变化(CA/Browser Forum Ballot SC081,2025 年 4 月通过):

生效日证书最长有效期域名验证数据复用期
2026-03-15200 天200 天
2027-03-15100 天100 天
2029-03-1547 天10 天

这张表只有一个结论:手工签发已经不可能。 一年要签 8 次,而且验证数据 10 天就失效——ACME 这类自动化从”最佳实践”变成了唯一选项。

如果你的服务还在靠”到期前人工去换个证书”,现在就是迁移的时候,不用等到 2029。

11.2 HTTP 的缓存约定:三个最容易配错的头

这一块不属于”传输”(这次讲的通信方式),但属于对外 API 里最容易出事故的部分。三个头,每一个配错都会造成真实故障:

Cache-Control —— 语义最容易被想当然的一个:

值含义什么时候用错会出事故
no-store完全不存想表达”别缓存”时写了 no-cache
no-cache可以存,但每次必须回源校验和 no-store 不是一回事
private只有浏览器能存,中间代理不能忘写 → 带用户身份的响应被 CDN 缓存,串给别的用户
public谁都能存误用在登录后的接口上

最危险的是漏写 private。 一个返回用户个人信息的接口,只要 CDN 把它当公共资源缓存了,下一个访问者就会看到别人的数据。这是真实发生过的事故类型。

ETag —— 解决”内容没变就别重传”:

服务端给资源算一个指纹(ETag)返回,客户端下次带上 If-None-Match,服务端比对,没变就回 304 Not Modified、不带 body。

省的是带宽,不是往返——客户端仍然要问一次。真正想省往返,才需要配合 max-age。

Vary —— 一个漏了会串数据的头:

Vary 告诉缓存”这个响应还取决于哪些请求头”。最经典的漏配是压缩:

服务端按 Accept-Encoding 返回了 gzip 或未压缩的响应。如果没写 Vary: Accept-Encoding,CDN 可能把 gzip 版本存下来,然后发给一个不支持 gzip 的客户端——对方拿到一堆乱码。

同理,任何按请求头变化的响应都必须列进 Vary:语言、设备类型、认证状态。

📎 顺带区分一下:本号另一篇《Agent 的缓存保温》讲的是 LLM 的 prompt 缓存,和这里 HTTP 层的缓存是两套完全不同的东西——只是中文都叫”缓存”,容易串。那篇讲的是”同一次前缀别被逐出”,这篇讲的是”这个响应能不能复用”。同名不同物。


十二、中间件:注册中心、MySQL、Redis

内部调用的三大件,通信方式各有各的道理。

12.1 注册中心:为什么要长连接

注册中心解决的是:“服务 B 的实例有哪些、各自的 IP 是什么”——而且答案随时在变。

  • 如果客户端轮询:变更至少要等一个轮询周期才能被发现,而且大部分轮询是白跑。
  • 所以主流做法是长连接 + 服务端推送:客户端与服务端保持一条长连接,实例列表一变,服务端立刻推下去。

判据是”变更必须秒级到达”——这也是为什么注册中心几乎都自己造通信机制(Nacos 用自有的长连接 + gRPC),而不是”客户端定时调个 REST 接口”。

顺带一提 Redis 作为注册中心的方案:靠 Key 过期 + 订阅通知,是”能用但把问题推给中间件”的做法,延迟和可靠性都不如专用方案。

12.2 MySQL / Redis:为什么不用 HTTP

这两个是同一个道理的两个实例:

协议为什么不用 HTTP
MySQL自有二进制协议需要连接状态(事务、会话变量、准备语句、游标)——HTTP 的无状态语义对不上
RedisRESP需要极低开销(一次往返要做好几十条命令),文本协议头纯属浪费

共同判据:它们要的是”有状态的会话”。

一条 MySQL 连接上是有上下文的——你 BEGIN 了一个事务,后续所有语句都在这个事务里;你设置了一个会话变量,后续语句都受影响。这跟 HTTP”每个请求自包含”的设计是相反的。

而 Redis 的判据不同:它是为了极致性能——一次网络往返里塞满命令(pipipeline),协议本身越简单越好。

这是”判据不同、结论不同”的好例子:MySQL 不用 HTTP 是因为状态语义对不上;Redis 不用 HTTP 是因为开销。

12.3 一笔带过的部分

下面这些本文不展开,它们大多属于场景决定、没什么可权衡的:

  • 服务网格(Istio / Envoy):Envoy sidecar 接管流量,处理 HTTP/2 与 gRPC 代理,Istiod 兼作 CA 签发证书做网格内 mTLS。
  • 前端实时推送、浏览器渲染优化、移动端、音视频:与本文的”后端选型”主线关系较远。

十三、一张表:什么技术用什么通信

把全文的结论汇总成一张表。第三列才是这张表的价值——它给出的是判据,不是答案。 场景变了,答案会变,但判据不会。

场景通信方式判据(为什么)
客户端 → 服务端HTTP/1.1 → HTTP/2 → HTTP/3队头阻塞出现在哪一层,就在哪一层解决
后端 → 后端(强类型、要流式)gRPC over HTTP/2 + Protobuf两端都是自己人 → 可以要求 IDL;并发调用多 → 需要多路复用
后端 → 后端(Java 生态内)Dubbo Triple同上;差异主要在生态与历史,不是技术
后端 → 后端(异步解耦)消息队列判据是”发送方不需要立刻有结果”
对外 APIREST/JSON over HTTPS要给人调、要能 curl 手试
服务发现长连接 + 服务端推送判据是”变更必须秒级到达”
Agent → LLMHTTP + SSE生成是逐 token 的,且只需要单向
Agent → 本地工具MCP over stdio同一台机器,网络是多余的
Agent → 远程工具MCP over Streamable HTTP跨机器;要能穿过普通 HTTP 基础设施(故无状态)
应用 → MySQL自有二进制协议需要有状态的会话(事务、会话变量)
应用 → RedisRESP需要极低开销,协议越简单越好
传输层TCP上层要可靠有序,底层只给尽力而为
连接加密TLS 1.3中间节点不可信,且要防”先存后解”

必须说清楚的一句话:

这张表只讲技术判据。真实世界的选型里,生态、团队栈、运维成本、历史包袱的权重,常常不低于技术原因。“国内 Java 后端大量用 Dubbo、Go 生态更偏 gRPC”这类现象,主要是生态原因,不是技术优劣。

本文只讲技术那部分——不是因为它更重要,而是因为只有它是稳定、可迁移、可以教给你的。


十四、自检清单

读完这篇,如果下面每一条你都能答上来,说明这根线索接上了:

  • 为什么 socket.connect() 之前还有一步?同一台机器上两个进程解析同一个域名,结果一定一样吗?
  • 为什么 TCP 握手是三次而不是两次?TIME_WAIT 存在的理由是什么?
  • CUBIC 和 BBR 的根本差别是什么?为什么说 CUBIC 仍然是丢包型算法?
  • 前向保密防的是什么攻击?它和”抗量子”为什么要提前做有什么关系?
  • 0-RTT 快在哪,代价是什么?什么样的请求绝对不能用 0-RTT?
  • HTTP/2 解决了哪一层队头阻塞?为什么它反而可能让问题更严重?
  • gRPC 调用失败时,HTTP 状态码是什么?怎么拿到真正的错误码?
  • stub 在 RMI / gRPC / Dubbo / 单元测试里,哪个是借词?为什么不变量只适用于其中一部分?
  • 为什么 MQ 不用 HTTP?“时间解耦”具体解的是什么?
  • LLM 流式为什么用 SSE 而不是 WebSocket?SSE 的三个限制分别是什么?
  • MCP over stdio 为什么不能往 stdout 打日志?
  • MCP 2026-07-28 为什么把握手和 session 都删了?收益和代价分别是什么?
  • 漏写 Cache-Control: private 和漏写 Vary: Accept-Encoding,分别会造成什么后果?

附:本文的核查方式

带日期的数字都有出处(截至 2026-10-15):

数字来源
HTTP/3 覆盖 40.9% 网站W3Techs,2026 年 10 月报告
抗量子保护超 50% 人类流量;源站 3.7%Cloudflare,2025 年 10 月底
top 100k 域名 39% 支持抗量子Jan Schaumann 扫描,2025 年 9 月
证书 200/100/47 天时间表CA/Browser Forum Ballot SC081
MCP 当前规范版本 2026-07-28modelcontextprotocol.io

规范类内容与部署现状的区分:

本文中关于协议规范的论断依据官方文本(RFC、MCP 规范、gRPC 官方文档)。这些规范已经发布,但各语言 SDK 与线上服务的跟进进度我没有逐一验证——读到”规范已经这么定了”时,请把它理解为”标准如此”,而不是”你的服务器现在就是这样”。

本文不涉及(按范围有意略过):STOMP、STUN/TURN、AMQP 细节、服务网格展开、浏览器渲染与音视频。


附:延伸阅读