AIGC标识 smux 与 HTTP/2 长连接的区别

结论:smux 和 HTTP/2 都在做"一条 TCP 上并发 N 个会话",但 smux 是一个透明的字节流复用库(管子里装什么不管),HTTP/2 是一门标准化的 HTTP 协议(管子里跑的就是 HTTP,连头部压缩、流控、优先级都规定死了)。两者不是竞争关系,而是分别解决不同层的问题。

涉及版本:

  • smux:github.com/xtaci/smux,基于 Yamux 协议(HashiCorp Yamux 的精简衍生版),Go 语言实现
  • HTTP/2:IETF RFC 7540(2015 年 5 月发布),二进制分帧层由 nghttp2、golang.org/x/net/http2 等实现

smux 与 HTTP/2 长连接对比

为什么说 smux "不懂" HTTP

smux 暴露给上层的接口就是一个 net.Conn——你拿到它之后 Read / Write 什么字节都行。它内部只做三件事:给每段数据打上 StreamID 标签、按 StreamID 分发给对应的 goroutine、流打开时发 SYN、关闭时发 FIN。它从头到尾没有"HTTP 头部"、"请求方法"、"状态码"这些概念,所以你完全可以在 smux 的一条 stream 里塞一段 SSH 握手,另一条 stream 里塞 HTTP/1.1,第三条里塞自定义二进制协议,smux 一视同仁。

HTTP/2 反过来——它从一开始就是 HTTP 的第二个大版本,stream 上的第一个帧必须是 HEADERS(HPACK 压缩后的请求头),后面跟着若干 DATA 帧(请求体或响应体),结束时用 END_STREAM 标志位收尾。它规定的不是"怎么切管子",而是"管子里跑的生意该怎么记账"。

帧头那几个字节,买到了什么

smux 的帧头只有 8 字节:Version(1) + Cmd(1) + Length(2) + StreamID(4),后面直接跟 payload。这个设计的取舍是极致薄——每多一条 stream 的复用开销接近零,FRP、KCP 这种对每字节都敏感的隧道场景才选它。

代价是 smux 把几乎所有"聪明活"都甩给了底层 TCP 和上层业务:

  • 接收方消费不过来怎么办?smux 不管,数据堆在发送方的内存里,直到 TCP 自己的窗口压住为止——但 TCP 的窗口是整条连接共享的,一条慢 stream 会把其他快 stream 也一起拖住,这等于 smux 自己把多路复用在"流级隔离"上做的功课又还回去了一半。
  • 想给某个请求插队?没这个机制,帧到了就按顺序进各自 stream 的缓冲区。
  • 想优雅地告诉对方"我这侧要关了,还没处理到的 stream ID 你别再发了"?没有这种帧,只能靠 RST 粗暴打断。

HTTP/2 用多出的那一截帧头(Type、Flags)和一堆额外帧(WINDOW_UPDATE、PRIORITY、GOAWAY、SETTINGS),把上面这三件事全补上了:

  • HPACK:HTTP 头里大量重复字段(Cookie、User-Agent、Accept)用静态表 + 动态表 + 霍夫曼编码压缩,一次完整的请求头经常能压到几十字节,而 HTTP/1.1 下是几百字节明文。在高频小请求场景,这才是 HTTP/2 真正省带宽的地方,而不是多路复用本身。
  • WINDOW_UPDATE:每个 stream 独立维护一个接收窗口,接收方每次消费数据后发一个 WINDOW_UPDATE 帧告知"我又能吃多少了",发送方才发多少——这才是真正的流级背压。
  • GOAWAY:优雅重启、灰度切流时,服务端发一个 GOAWAY 告诉客户端"StreamID ≤ N 的请求我都处理了,新请求别再来这条连接了",客户端就可以安全地把后续请求切到新连接上。smux 想做这件事,只能自己在上层再包一层协议。

两者都没解决的问题:TCP 队头阻塞

这是最容易被忽略的一点。smux 也好、HTTP/2 也好,底层都是 TCP。TCP 是字节流、按序交付,一旦某个 TCP 段丢包,后面已经到达的段必须在内核缓冲区里等着重传,这条 TCP 连接上所有 stream 的所有数据都得停在那里。

也就是说,它们解决的是应用层的队头阻塞:

  • HTTP/2 解决的是 HTTP/1.1 下"一个连接一次只能服务一个请求"的问题;
  • smux 解决的是"每个业务连接都要做一次 TCP 三次握手 + TLS 握手"的成本问题。

但它们都没碰传输层的队头阻塞。这就是为什么后来 IETF 又搞出 QUIC / HTTP/3——把传输层搬到用户态 UDP 之上,让每个 stream 独立丢包、独立重传,互不阻塞。你在 frp 里如果开了 smux 又跑在弱网高丢包链路下感受到的卡顿,根因就在这里,不在 smux 本身。

补:smux、HTTP、WebSocket 到底在 OSI 哪一层

一个常见的误解是"HTTP 在 7 层、WebSocket 在 7 层、smux 在 4 层"。前两个对一半,最后一个是错的:smux 不在 4 层,它跑在 4 层 TCP 的上面,严格按 OSI 模型是第 5 层(会话层)。

OSI 分层:HTTP / WebSocket / smux 位置

为什么 smux 不能算 4 层:4 层协议要自己负责"怎么把字节从 A 机送到 B 机、按什么顺序、丢了怎么办、网络堵了怎么减速"——也就是端口、可靠字节流、按序交付、拥塞控制、重传。这些活 smux 一概不管,全甩给底下的 TCP。smux 拿到的已经是一条可靠、有序、不丢字节的管道了,它做的只是"把这条管道切成 N 份"——这正是 OSI 第 5 层会话层的典型工作:在一条底层连接上建立、管理、终止 N 条逻辑会话。

WebSocket 的位置更微妙:它是跨层的。握手阶段是一个标准的 HTTP GET 请求带 Upgrade: websocket 头,这是 7 层;服务器回 101 Switching Protocols 之后,就降级成 5 层的双向消息通道,不再讲 HTTP 语义。所以工程上大家把它归在 7 层,是因为浏览器的 WebSocket API 直接暴露给 JS 应用,应用开发者感受不到"我在第 5 层"。

TLS 也不在 4 层,它在第 6 层表示层——负责加密、解密、协商 cipher suite。这就是为什么 wss:// 是 WebSocket over TLS:握手经过 TLS,升级成 WebSocket 后数据也走 TLS。

工程上之所以会产生"smux 在 4 层"这个错觉,是因为负载均衡领域有另一套术语:"7 层负载均衡"指按 HTTP Host/Path 转发,"4 层负载均衡"指按 IP+端口转发。smux 不解析 HTTP 语义,在 4 层 LB 眼里确实是"一条裸 TCP 流"。但这是负载均衡术语,不是 OSI 分层——OSI 第 4 层是 TCP/UDP 本身,smux 是它上面的复用子层。

写 Go 时这三层叠得很清楚:net.Listen("tcp", ...) 给你一个 4 层的 TCP listener;上面包一层 smux,就有了 5 层的 mux listener;再在 mux stream 上跑 HTTP 或 gRPC,就到了 7 层。frp 的代码里这三层一目了然。

什么时候选谁

工程上判断很简单:

  • 你需要的是"传输管道":做隧道、代理、内网穿透、在 KCP 或自定义可靠流之上再叠一层多路复用,两端都是自己的代码——选 smux。要的就是薄、快、省握手。
  • 你需要的是"HTTP 协议":对外 Web 服务、gRPC、要被浏览器/CDN/第三方客户端直接接入——选 HTTP/2。HPACK、流控、优先级、生态互通这些是刚需,自己用 smux 造轮子不划算。

sing-box 把 h2mux(基于 golang.org/x/net/http2 的 mux,借用 HTTP/2 分帧但不要 HTTP 语义)、yamux、smux 三者并列可选,正好落在这个光谱上:h2mux 是"既要 HTTP/2 的成熟分帧,又不要它的 HTTP 包袱";smux 则是"连 HTTP/2 那套重家伙都不要,只要一根管子切 N 根"。

posted on 2026-10-05 16:55  王景迁  阅读(5)  评论(0)    收藏  举报

导航