第 14 章 WebSocket:如何让一个协议”长出”另一个协议

WebSocket 最精彩的地方,不是它实现了双向通信,而是它演示了协议设计里一个极其优雅的模式——如何在一个已经无处不在的协议之上,安全地”长”出一个全新的协议。


14.0 引子:轮询把服务器打垮了

小美:老哥,要做一个”在线客服”功能,客服要能实时收到用户消息。

你:简单,前端每隔 1 秒轮询一次”有没有新消息”。

老张:上线一小时,服务器 CPU 打满。2000 个用户同时在线,每秒 2000 个轮询请求,99% 的响应是”没有新消息”——全是无效请求。

你:那……把轮询间隔改大点?

老张:改大就”不实时”了,客服要 10 秒才能看到消息,用户早跑了。

墨叔:来了。这是”实时通信”这个需求,逼着 HTTP 暴露它的短板:HTTP 是”请求-响应”模型,天生不擅长”服务器主动推消息”。 而解决这个问题的终极答案,就是今天的主角——WebSocket。


14.1 先把问题问对:实时通信的四种方案

墨叔:先别急着上 WebSocket。实时通信有四种方案,从简单到复杂,各有适用场景。选错方案,比不会用更可怕。

方案一:轮询(Polling)

客户端定时发请求:“有新消息吗?” “没有。” “有新消息吗?” “没有。”

  • 优点:最简单,纯 HTTP,无任何特殊处理。
  • 缺点:延迟和流量不可兼得——间隔短(延迟低但流量爆炸),间隔长(省流量但延迟高)。而且大量”无意义”请求。

方案二:长轮询(Long Polling)

客户端发请求,服务器不立即响应,而是”挂起”这个请求,直到有新消息才响应。响应后,客户端立即再发下一个长轮询。

客户端                   服务器
  |--- 请求(挂起)-------->|   (服务器憋着不响应)
  |                        |   ... 30 秒后,有新消息了
  |<-- 响应(新消息)-------|
  |--- 请求(再挂起)------>|
  • 优点:接近实时(有消息立刻推),省流量(没消息就不响应)。
  • 缺点:每个”消息周期”仍是一次完整的 HTTP 请求-响应;服务器要维护大量”挂起”的连接;实现复杂。

方案三:SSE(Server-Sent Events)

第 10 章讲过:服务器在一个永不结束的 HTTP 响应里,持续”流式”写数据。

  • 优点:纯 HTTP,简单,浏览器原生支持自动重连。
  • 缺点:单向(只能服务器→客户端),不能客户端→服务器。

方案四:WebSocket

HTTP 握手后,升级成一个双向、全双工、持久的连接。

  • 优点:真正的双向、低延迟、低开销(没有 HTTP 头那一大堆)。
  • 缺点:最复杂;需要协议升级;中间设备(代理/LB)可能不友好。

你:所以怎么选?

墨叔:记住一张决策表:

需求推荐方案
单向推送(通知、行情、日志流)SSE(够用就别上 WebSocket)
双向、低频、可接受秒级延迟长轮询
双向、高频、毫秒级(聊天、游戏、协同编辑)WebSocket
只是”要最新数据”,不需要实时轮询

核心思想:

不是所有”实时”都需要 WebSocket。 WebSocket 是重武器,它的复杂度(心跳、重连、消息协议)全都要你自己扛。“够用就好”是协议选型的第一原则。


14.2 核心设计问题:怎么”长”出第二个协议?

墨叔:现在讲 WebSocket 真正的设计精髓。它要解决的核心问题是:

WebSocket 是一个和 HTTP 语义完全不同的协议(双向、持久、帧),但它必须”寄生”在已经无处不在的 HTTP 基础设施上。

你:为什么必须寄生在 HTTP 上?不能自己开个端口吗?

墨叔:问得好。这正是关键。如果 WebSocket 自己开一个新端口(比如 8080),会怎样?

灾难:

  1. 防火墙封杀:企业网络、家用路由器的防火墙,默认只放行 80(HTTP)和 443(HTTPS)。你开个 8080,大概率被拦。
  2. 代理不支持:无数代理、负载均衡器默认只处理 HTTP。非 HTTP 流量,它们不认。
  3. 无法复用认证:你已经登录了(Cookie 在),换端口还得重新认证。
  4. 运维成本:开新端口 = 改安全组、改防火墙、改监控……

所以 WebSocket 的选择是:寄生在 80/443 端口上,借 HTTP 的”外壳”完成握手,握手成功后,再把连接”升级”成 WebSocket。

你:这个”借壳 + 升级”的思路,就是它最优雅的地方?

墨叔:对。WebSocket 复用了 HTTP 的”端口、代理、认证、TLS”全套基础设施,只在”应用层语义”上做了切换。 这就是”协议升级”的核心价值——新协议不需要重建地基,只需要一个”借壳”的握手。


14.3 Upgrade 机制:一次握手,两种协议

墨叔:看 WebSocket 的握手过程。它始于一个普通的 HTTP 请求:

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket                    <- 关键!请求"升级"成 WebSocket
Connection: Upgrade                   <- 声明 Upgrade 是逐跳头(第 5 章)
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==   <- 一个随机串
Sec-WebSocket-Version: 13             <- 协议版本
 
HTTP/1.1 101 Switching Protocols      <- 101!协议切换(第 4 章)
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  <- 对 Key 的"应答"

握手成功后,这个 TCP 连接就不再是 HTTP 了,它变成了 WebSocket——之后传的是 WebSocket 帧,不再是 HTTP 报文。

你:101 Switching Protocols 就是”协议切换成功”?

墨叔:对。第 4 章讲过 1xx 是”临时响应”,其中 101 就是”我要切换协议了,切换完成”。这个 101,就是”HTTP 和 WebSocket 的分界线”。

为什么要 Upgrade 和 Connection: Upgrade 两个头?

墨叔:这个细节值得讲。为什么不是只用一个 Upgrade 头?

你:因为 Connection: Upgrade 是声明”Upgrade 是逐跳头”?

墨叔:精确。第 5 章讲过,Connection 头的作用是”声明下面这些头是逐跳的”。这里 Connection: Upgrade 的意思是:

“Upgrade 这个头是逐跳的,请各代理正确处理(如果支持升级,就跟着升级;不支持,就把它从转发里拿掉,别透传给源站)。”

为什么这很重要:因为 Upgrade 是逐跳的——协议升级是”相邻两跳”之间的事。如果代理不支持 WebSocket,它看到 Connection: Upgrade,就知道”这个 Upgrade 头我不该透传”,从而避免”源站以为要升级、代理却没升级”的错乱。

这是一个非常精妙的设计:它用”声明逐跳性”的方式,让”不支持升级的老代理”也能安全地参与进来——老代理看到 Connection: Upgrade,就”知趣地”不碰 Upgrade 头。


14.4 Sec-WebSocket-Key 到底防什么?—— 不是安全

墨叔:现在回答引子里问的那个细节:Sec-WebSocket-Key 是干嘛的?

你:我一开始以为它是”加密密钥”或者”认证”。

墨叔:都不是。它不是为了安全,也不是为了加密。

它的真实目的有两个,都很”防御性”:

目的一:防止”误连”。

想象一个场景:一个非 WebSocket 的客户端(比如一个老的 HTTP 客户端),误连到了一个 WebSocket 服务器。如果没有一个”握手验证”,服务器可能误以为”这是个 WebSocket 客户端”,于是用 WebSocket 帧格式发数据——而老客户端根本看不懂,两边就”鸡同鸭讲”。

Sec-WebSocket-Key 机制让这种”误连”能被发现:

  • 客户端发一个随机的 Sec-WebSocket-Key。
  • 服务器必须按规则计算出一个 Sec-WebSocket-Accept(就是把 Key 拼上一个固定的 GUID 字符串,做 SHA-1,再 base64)。
  • 客户端验证这个 Accept 是否正确。

只有”真正理解 WebSocket 协议”的客户端,才能生成正确的 Key 并验证正确的 Accept。 老 HTTP 客户端不会发这个 Key,也就通不过握手——“误连”被挡在门外。

目的二:防止”缓存投毒”(这是更关键、更隐蔽的)。

这是 WebSocket 设计里最精彩的部分。想一个攻击场景:

代理缓存投毒攻击:中间有一个老代理(只懂 HTTP/1.1,不懂 WebSocket)。攻击者想办法让这个代理缓存了一个”伪造的 WebSocket 响应”,然后把伪造响应”喂”给别的用户。

Sec-WebSocket-Key 的防御思路是:握手必须”一来一回”,且 Accept 必须和 Key 匹配。 如果代理缓存了响应,那么”请求里随机的 Key”和”缓存里固定的 Accept”就对不上——客户端发现 Accept 不匹配,就拒绝握手。于是,被缓存的、陈旧的、不匹配的响应,无法被误用。

你:所以它本质是”一个随机数 + 一个必须匹配的应答”,用来证明”这不是缓存来的,而是服务器实时计算的”?

墨叔:精确!记住这句话:

Sec-WebSocket-Key 的本质,不是安全,而是”防误连 + 防缓存投毒”。它用”一个随机挑战 + 一个必须匹配的应答”,证明了”这次握手是新鲜的、实时的、双方都理解协议的”。

这揭示了协议设计里一个重要的思想:很多”看似安全”的握手字段,其实是在防御”历史遗留的中间设备”——老代理、老缓存、老客户端。 协议不是只和”对的实现”打交道,还要和”满世界的错的、老的实现”打交道。


14.5 WebSocket 帧:轻量、双向、还带了个”掩码”

墨叔:握手之后,数据用帧传输。看帧格式:

0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |          (16/64)              |
|N|V|V|V|       |S|             |                               |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+

关键字段:

  • FIN:这个帧是不是消息的最后一帧(支持分片)。
  • opcode:帧类型(text=1、binary=2、close=8、ping=9、pong=10)。
  • MASK:负载是否被掩码。
  • Payload len:负载长度,用 7 位,超了就扩展 16 位或 64 位。

你:这个 MASK(掩码)是干嘛的?加密吗?

墨叔:不是加密! 这个 MASK 是全章另一个精彩的设计,而且它不是给服务器看的,是给”中间的老代理”看的。

为什么客户端必须掩码?

规则:客户端→服务器的帧,必须 MASK(掩码);服务器→客户端的帧,不掩码。

你:为什么不对称?

墨叔:还是为了防缓存投毒(和 Sec-WebSocket-Key 同一个动机)。

攻击场景:攻击者是一个客户端。他想让一个老代理(只懂 HTTP,不懂 WebSocket)的缓存中毒。

思路:攻击者精心构造一个 WebSocket 帧,让它的字节恰好看起来像一段 HTTP 响应(比如开头是 HTTP/1.1 200 OK... 加上一个危险的 body)。如果老代理误把这段字节当成 HTTP 响应缓存起来,就会把攻击者的伪造内容”投毒”给别的用户。

掩码的防御:客户端必须对负载做掩码(用一个 4 字节的掩码 key 对负载做异或)。这样:

  • 客户端发出的负载是掩码后的、随机化的字节。
  • 攻击者无法精确控制”掩码后”的字节长什么样——因为他发的数据会被掩码打乱,他不能构造出”恰好看起来像 HTTP 响应”的字节流。

你:所以掩码是”让客户端无法伪造出’看起来像 HTTP’的字节”,从而防止它投毒老代理?

墨叔:对!这又是”防御历史遗留中间设备”的设计。WebSocket 的很多”看似奇怪的规则”(Key、掩码),都是为了防止”不懂 WebSocket 的老设备”被攻击者利用。

一句话:

WebSocket 的设计者清楚地知道:你的连接会经过无数”只懂 HTTP”的老设备。所以它在协议里内置了各种”防止老设备被糊弄”的机制。 这和第 13 章”中间件僵化”是同一个世界——协议必须和”满世界的、老的、不完全懂的中间设备”共存。


14.6 心跳:为什么 TCP keepalive 不够

墨叔:WebSocket 连接建立后,怎么保持”活着”?引子里有个常见现象:“连上几分钟就断了”。

你:TCP 不是有 keepalive 吗?

墨叔:TCP keepalive 对 WebSocket 来说几乎没用,三个原因:

  1. 默认间隔太长:TCP keepalive 默认 2 小时才探测一次。而 WebSocket 连接通常在几分钟内就被中间设备(LB、NAT)空闲超时断掉了。
  2. 不能穿透代理:TCP keepalive 是”端到端”的,但中间代理可能自己会断连接,TCP keepalive 救不了。
  3. 探测不到”应用层死”:TCP 连接可能”物理上还通着”,但应用层已经死了(比如对端进程挂了但没关 socket)。TCP keepalive 发现不了这种”应用层死”。

所以 WebSocket 要有自己的心跳——ping/pong 帧(opcode 9/10):

客户端 → 服务器:ping 帧
服务器 → 客户端:pong 帧

你:ping/pong 是应用层的心跳?

墨叔:对。它解决两件事:

  1. 保活:定期发 ping,让中间设备知道”这连接还活跃”,别断它。
  2. 检测死连接:如果发了 ping 没收到 pong,就认为连接已死,主动重连。

关键:心跳间隔要设得比中间设备的空闲超时短(这和第 7 章”IdleConnTimeout 设得比 LB 短”是同一个思路)。这是”幽灵连接”问题(第 7 章)在 WebSocket 里的翻版。


14.7 WebSocket 放弃了什么:它为什么不是银弹

墨叔:WebSocket 很强,但它放弃了很多 HTTP 白给的东西。你必须知道代价:

HTTP 白给的WebSocket 放弃了你要自己做
自动重连无断线重连 + 指数退避
消息幂等/去重无消息序号 + 去重
缓存无自己做
请求-响应的自然配对无消息 ID + 响应匹配
状态码/错误语义无自定义错误协议
代理友好差配置 LB 放行长连接

你:所以 WebSocket 只是”把 HTTP 那套方便的东西,换成了双向实时”?

墨叔:对。WebSocket 是一个”裸的、双向的、持久的字节管道”,除此之外什么都没有。 消息重传、断线恢复、消息去重、错误处理——全都要你在应用层重新造。

这就是为什么说”不是所有场景都该上 WebSocket”:你上一个 WebSocket,等于签了一份”我要自己实现一整套消息可靠性协议”的合同。


14.8 设计者视角:协议升级模式的通用价值

墨叔:最后,把 WebSocket 提炼成一个可复用的设计模式——协议升级(Protocol Upgrade)。

它的通用步骤是:

1. 用现有协议(HTTP)完成初始握手(顺便完成认证、TLS、协商)
2. 通过某个"升级"信号(Upgrade 头)声明"我要切换协议"
3. 双方确认(101 响应)
4. 从这一刻起,切换到新协议

你:这个模式的价值在哪?

墨叔:它解决了一个协议设计的根本难题:

新协议怎么”搭便车”复用旧协议已经建好的基础设施(端口、认证、代理、TLS),而不需要从零重建?

答案是:用旧协议做”握手和协商”,用新协议做”数据传输”。 旧协议管”怎么建立连接”,新协议管”连接上跑什么”。

你:这和第 12 章 ALPN 有点像?

墨叔:好眼力。它们是同一个思想的两种实现:

  • ALPN:在 TLS 握手里协商应用协议(h2/1.1)——“顺路”协商。
  • Upgrade:在 HTTP 层里升级协议(WebSocket)——“借壳”升级。

核心思想都是:“复用已有的握手/协商通道,来切换到新的协议”,而不是”为新协议单独搞一套握手”。

这个模式,你以后设计自己的协议时,会反复用到。 第 16 章我们设计协议时,就要用它。


14.9 Go 落地:WebSocket 的三个关键点

墨叔:Go 里 WebSocket 的标准做法是用 gorilla/websocket。三个关键点:

// 1. 升级握手
var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true // 生产环境要校验 Origin,防跨站 WebSocket 劫持
    },
}
conn, _ := upgrader.Upgrade(w, r, nil)
 
// 2. 读写泵(read/write pump):一个 goroutine 读,一个 goroutine 写
//    因为 WebSocket 连接是"并发读 + 串行写"的
 
// 3. 心跳
go func() {
    for {
        conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second))
        time.Sleep(30 * time.Second)
    }
}()

你:这三个点各解决什么?

墨叔:

  1. CheckOrigin:防”跨站 WebSocket 劫持”——恶意网站诱导浏览器连你的 WebSocket。必须校验 Origin 头。
  2. 读写泵:WebSocket 底层不支持并发写(两个 goroutine 同时写会 panic)。所以标准做法是”一个读 goroutine + 一个写 goroutine”,写操作通过 channel 串行化。
  3. 心跳:上面讲的,保活 + 检测死连接。

一句话:WebSocket 的”连接管理”(心跳、重连、串行写)全要你手写,这是它”放弃 HTTP 便利”的直接代价。


14.10 排查清单:WebSocket 问题

第 1 步:握手成功了吗?
        → 看有没有 101 响应;Nginx 后连不上 = 没配 Upgrade 放行

第 2 步:连上几分钟就断?
        → 中间设备空闲超时 → 心跳间隔设得比它短

第 3 步:并发写 panic 了吗?
        → WebSocket 不支持并发写,要用写泵串行化

第 4 步:跨域/劫持防护了吗?
        → CheckOrigin 校验了 Origin 吗?

第 5 步:断线重连、消息去重做了吗?
        → WebSocket 不提供这些,要应用层自己做

思考题

第 1 题(概念)

WebSocket 为什么选择”寄生在 HTTP 上”而不是”自己开一个新端口”?请说出至少三个具体原因。

第 2 题(概念)

WebSocket 握手时的 Sec-WebSocket-Key 和帧里的”掩码(MASK)“,分别防御什么问题?请说清它们共同的设计动机是什么。

第 3 题(设计)

你要设计一个”协同编辑”功能(多人同时编辑一个文档,任何人的修改要实时同步给其他人)。请回答:

  1. 用 WebSocket 还是 SSE?为什么?
  2. WebSocket 连接断了怎么办(断线重连策略)?
  3. 如何保证”消息不丢、不重”(提示:消息序号、幂等)?
  4. 并发写怎么处理?

第 4 题(生活类比)

墨叔:打电话时,“先拨号、对方接听、然后通话”,和 WebSocket 的”握手、升级、通信”,有什么相似之处?

提示:想想——

  1. “拨号 + 振铃 + 接听”对应 WebSocket 的什么阶段?
  2. 电话接通后,你们说的话,和”拨号”时的信令,用的是**同一种”语言”**吗?
  3. 通话时”喂喂喂”确认对方还在,对应 WebSocket 的什么?

第 5 题(更深一层的发问)

WebSocket 的”掩码”和”Sec-WebSocket-Key”,本质都是在防御”不懂 WebSocket 的老中间设备”被攻击者利用。

请深入思考:

  1. 这暴露了协议设计里一个什么根本困境?(提示:协议必须和”满世界的、老的、不完全懂它的设备”共存)
  2. 为什么”换掉 TCP”(QUIC,第 13 章)和”在 HTTP 里长出新协议”(WebSocket),都绕不开”历史遗留中间设备”这个问题?
  3. 结合第 13 章”中间件僵化”,说说你对”协议演进永远被历史包袱拖累”的理解。

参考答案

第 1 题答案

三个原因:

  1. 防火墙封杀:企业网络、家用路由器的防火墙默认只放行 80(HTTP)/443(HTTPS)。开新端口(如 8080)大概率被拦,用户根本连不上。
  2. 代理/负载均衡只认 HTTP:无数代理、LB、网关默认只处理 HTTP 流量,非 HTTP 流量它们不认、甚至直接丢弃。
  3. 复用认证与 TLS:借 HTTP 的壳,就能复用”已登录的 Cookie”、“已建立的 TLS 加密”——换端口就得重新认证、重新加密,代价巨大。

一句话:“寄生”让 WebSocket 零成本复用了 HTTP 的”端口、代理、认证、TLS”全套基础设施,只需在”应用层语义”上做一次切换。

第 2 题答案

机制防御原理
Sec-WebSocket-Key误连 + 缓存投毒随机挑战 + 必须匹配的 Accept,证明”握手是新鲜实时的、双方都懂协议”
掩码(MASK)缓存投毒客户端负载被掩码随机化,攻击者无法构造”恰好看起来像 HTTP 响应”的字节流去投毒老代理

共同的动机:

它们都不是为了”安全/加密”,而是为了防御”不懂 WebSocket 的历史遗留中间设备”——老代理、老缓存、老客户端。 WebSocket 的设计者清楚地知道:你的连接会经过无数”只懂 HTTP、不懂 WebSocket”的老设备,攻击者会利用这些”老设备的无知”来做坏事。所以协议内置了机制,防止”老设备被糊弄”。

这揭示了协议设计的深层现实:协议不是只和”正确的实现”打交道,它必须和”满世界的、老的、错的、不完全懂的实现”共存,并为它们做好防御。

第 3 题答案

1. 用 WebSocket。协同编辑是”双向、高频、毫秒级”的场景(任何人编辑,实时同步给所有人),SSE 是单向的,不满足。

2. 断线重连策略:

  • 用**心跳(ping/pong)**检测连接死活(发了 ping 没收到 pong = 死了)。
  • 检测到断开后,指数退避 + 抖动重连(第 3 章的重试策略),避免”断线后所有客户端同时重连”的重连风暴。
  • 重连成功后,同步最新的文档状态(因为断线期间可能错过了别人的修改)。

3. 保证消息不丢、不重: 这是 WebSocket 最大的坑——它是个裸管道,不保证可靠性。要自己做:

  • 消息序号:每条消息带一个递增的序号,接收方根据序号判断”有没有丢”。
  • 断线补拉:重连时,客户端报”我最后收到的是序号 N”,服务器把 N 之后的消息补发。
  • 幂等/去重:每条消息带唯一 ID,接收方去重(防止”重发导致重复应用”)。

4. 并发写:WebSocket 底层不支持并发写(两个 goroutine 同时 Write 会 panic)。标准解法是写泵(write pump):所有要发的消息,通过一个 channel 交给唯一一个写 goroutine,串行写出。

核心要点:WebSocket 只提供”双向字节管道”,可靠性(重连、补拉、去重)全靠应用层自己造。 这就是它”不是银弹”的体现。

第 4 题答案

电话WebSocket
拨号 + 振铃 + 接听HTTP 握手 + 101 升级(“建立连接”阶段)
通话内容WebSocket 帧(“数据传输”阶段)
“喂喂喂”确认对方还在ping/pong 心跳

关键洞察——信令和数据用的是”两种语言”:

打电话时,拨号/振铃/接听用的是电话系统的信令(一种协议),而接通后说的话是自然语言(另一种协议)。它们不是同一种”语言”,只是共用同一条电话线。

这和 WebSocket 一模一样:

  • **握手(HTTP)**是”信令”——它用 HTTP 的语言(GET、Upgrade、101)来”建立连接”。
  • **通信(WebSocket 帧)**是”通话”——它用 WebSocket 的语言(帧)来”传输数据”。
  • 两者共用同一条连接(同一条电话线),但语言(协议)完全不同。

这恰恰就是”协议升级”的本质:用协议 A(HTTP)完成”建立连接”的信令,然后切换到协议 B(WebSocket)进行”数据传输”。 就像打电话:用电话信令建立通话,然后用自然语言说话。

“喂喂喂”对应心跳:电话里说”喂喂喂”是在确认”对方还在线、线路还通”;WebSocket 的 ping/pong 完全一样——应用层主动确认”连接还活着”,因为物理层的”连接存在”不代表”应用层还活着”(对方可能已经挂了但没挂断)。

第 5 题答案

1. 根本困境:

协议必须和”满世界的、老的、不完全懂它的中间设备”共存,而这些”老设备的无知”恰恰是攻击面。

具体来说:WebSocket 要跑在现有的网络上,而网络上有海量的只懂 HTTP、不懂 WebSocket 的老代理、老缓存。这些老设备:

  • 它们看不穿 WebSocket(以为自己在处理 HTTP),于是会被攻击者利用(缓存投毒)。
  • 你无法升级它们(遍布全球、无人维护)。

所以协议设计者陷入一个困境:你的协议要安全,就不得不为”沿途那些不懂你的老设备”擦屁股——在协议里内置各种机制,防止”老设备被糊弄”。

2. 为什么都绕不开”历史遗留中间设备”?

  • QUIC(第 13 章):是因为 TCP 被中间设备”锁死”(中间件僵化),所以绕开中间设备(选 UDP,因为中间设备不深查 UDP)。—— 这是在”逃避”中间设备。
  • WebSocket(本章):是寄生在 HTTP 上(复用端口/代理),所以必须迁就中间设备(防老代理被糊弄)。—— 这是在”迁就”中间设备。

共同点:无论”逃避”还是”迁就”,中间设备都是那个绕不开的、必须认真对待的存在。 因为中间设备是”既有的、遍布的、无法升级的”现实,任何新协议要么被它锁死(TCP),要么为它防御(WebSocket),要么绕开它(QUIC)。

3. 对”协议演进被历史包袱拖累”的理解:

协议演进不是”白纸作画”,而是”在既有基础设施上缝缝补补”。 每一个新协议,都背负着:

  • 历史实现的包袱(老客户端、老服务器还在用)。
  • 中间设备的包袱(老代理、老防火墙还在检查)。
  • 部署惯性的包袱(80/443 端口、HTTP 的认知惯性)。

所以真正的高手,设计协议时考虑的第一件事不是”怎么做得最好”,而是”怎么和这些历史包袱共存”。 这和第 1 章”协议的成功取决于允许多少人犯错还活下来”、第 15 章”兼容性”是同一个主题:

协议的第一性原则,不是”正确”,而是”能落地”。而”能落地”的前提,是”和满世界的老设备和平共处”。


一页纸小结

四种实时方案:轮询 → 长轮询 → SSE → WebSocket,复杂度递增,各有适用场景。不是所有实时都需要 WebSocket。

协议升级(Upgrade):WebSocket 用 HTTP 握手(复用端口/代理/认证/TLS),握手后 101 切换到 WebSocket 帧。新协议搭便车复用旧协议基础设施。

Sec-WebSocket-Key 与掩码:不是为了安全,是为了防误连 + 防缓存投毒——防御”不懂 WebSocket 的老中间设备”被攻击者利用。

心跳:TCP keepalive 不够(间隔太长、不穿代理、不测应用层死活),要 ping/pong。

WebSocket 放弃的东西:自动重连、幂等、缓存、请求配对、错误语义——全要应用层自己造。

核心思想:协议必须和”满世界的老的、不懂它的设备”共存,为它们防御。这和”中间件僵化”是同一个世界的两面。


下一章:WebSocket 展示了”怎么加新协议”。但还有最后一个、也是贯穿全书的终极问题——HTTP 运行了 30 年,改了这么多次,为什么没崩? 答案藏在”兼容性设计”里。我要讲一个你会反复用到的思想:协议设计里,“扩展点”比”功能”更重要,而”优雅降级”比”扩展”更重要。 以及那个救过 HTTP 无数次、也坑过它无数次的法则——Postel 法则。