想象你经营着一家“全双工快递专线”。普通快递(HTTP)来回要排队:你发一单,等快递员回来,才能发下一单。而WebSocket是一条永不间断的双向传送带,双方可以同时往上传包裹、同时取包裹。开通专线前,你得先打电话给邮局(HTTP Upgrade握手),报上暗号(Sec-WebSocket-Key),邮局验证后回一句“专线通了”(101状态码)。之后每个包裹都要按统一规格打包:封口标记(FIN)、类型标签(Opcode)、防伪封印(Mask)、正文(Payload)。沉默太久时,邮局会寄来“还在吗?”的明信片(Ping),你回一张“在”(Pong),专线才不会被认为失联。想结束时就发一封“再会”挂号信(Close帧),礼貌下线。这就是WebSocket,一个全双工、低延迟的实时通信协议。

岗位关联

掌握WebSocket链路握手、帧格式与心跳机制,是搭建实时聊天、推送通知和协同编辑系统的底层能力,也是网络协议面试绕不开的考点。

目录
  1. 可视化演示
  2. 建立热线:Upgrade握手
  3. 读懂信封:帧格式基础
  4. 防拆封:Mask与负载
  5. 定期问候:心跳保活
  6. 从容告别:优雅关闭
  7. 实战应用:全双工专线的用武之地
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示WebSocket的Upgrade握手流程、帧格式FIN/Opcode/Mask/Payload字段解析、以及心跳Ping/Pong保活与关闭帧状态码处理机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+_captions.handshake+'

建立热线:Upgrade握手

想象你给邮局打电话,说“我不是要寄快递,我要开通一条专属双向管道”。电话那头是HTTP协议这位老接线员。你报上姓名和暗号:Sec-WebSocket-Key,这是一串随机生成的base64字符串。老接线员把暗号和固定的“魔法咒语”(GUID常量)拼在一起,用SHA1哈希,再base64编码,算出Sec-WebSocket-Accept。如果回复匹配,就返回101 Switching Protocols,专属管道正式打通。这之后,HTTP接线员退居幕后,把线路交给你和服务器直接对话。

这条暗号流程不是身份认证,而是防止“老代理缓存欺骗”:WebSocket的握手必须证明“这是新发起的请求,而非被缓存的旧响应”。RFC 6455规定了一个肉眼可见的GUID常量:258EAFA5-E914-47DA-95CA-C5AB0DC85B11。服务器将客户端发来的Key与GUID拼接后哈希编码,得到的值若与响应头一致,连接即通过校验。

邮局不是随便相信“我是老主顾”,它要核对暗号。任何客户端只要手握有效的Key,就能通过验证;一旦验证通过,HTTP接线员让出线路,WebSocket专线正式开工。这也解释了为什么WebSocket不是HTTP,却必须先借HTTP一只手完成开门。

Accept=base64⁡(SHA1⁡(Key+GUID))\text{Accept} = \operatorname{base64}\big(\operatorname{SHA1}(\text{Key} + \text{GUID})\big)
这个公式描述的是HTTP响应头Sec-WebSocket-Accept的生成过程。
  1. 拼接待确认密钥把客户端发来的Sec-WebSocket-Key字符串与固定的GUID拼接,得到一个待哈希字符串。
    Key+GUID\text{Key} + \text{GUID}
  2. 计算SHA1摘要对拼接结果做SHA1哈希,得到20字节的二进制摘要。
    SHA1⁡(Key+GUID)\operatorname{SHA1}(\text{Key} + \text{GUID})
  3. Base64编码将摘要编码成base64文本,这正是握手响应头要返回的值。
    base64⁡(SHA1⁡(Key+GUID))\operatorname{base64}\big(\operatorname{SHA1}(\text{Key} + \text{GUID})\big)

读懂信封:帧格式基础

专线开通后,每一次传输都像一个标准“信封”——WebSocket帧。信封封面上印着所有关键信息,依次是:FIN封口标记、RSV预留位、Opcode类型标签、MASK封印标记、PayloadLength正文长度,后面跟扩展长度、掩码密钥和正文。FIN告诉对方“这是不是故事最后一页”;Opcode告诉对方“信封里装的是文字、二进制还是控制指令”。

所谓帧格式,就是“信封规格”,收发双方都必须严格遵守。一个几十字节的控制帧,完整解析可能需要先读第1字节的FIN和Opcode,再读第2字节的MASK和短长度;若是大消息,还要扩展到16位或64位长度,再加上4字节掩码密钥。每一步都环环相扣,任何一位错位都会让整封信无法拆解。

信封各栏缺一不可。如果贴错类型标签,解析方可能把“指令”误当“正文”;如果漏了封口标记,对方会一直等你下一封信,消息永远不完整。帧格式就是把“信封规格”写死在协议里,收发双方才敢闭眼拆信。

Frame=FIN⏟1 bit RSV⏟3 bits Opcode⏟4 bits MASK⏟1 bit PayloadLen⏟7 bits [ExtendedLen] [MaskKey] PayloadData\text{Frame} = \underbrace{\text{FIN}}_{1\,\text{bit}} \ \underbrace{\text{RSV}}_{3\,\text{bits}} \ \underbrace{\text{Opcode}}_{4\,\text{bits}} \ \underbrace{\text{MASK}}_{1\,\text{bit}} \ \underbrace{\text{PayloadLen}}_{7\,\text{bits}} \ [\text{ExtendedLen}] \ [\text{MaskKey}] \ \text{PayloadData}
帧结构就像信封上的各个格子,每个格子有固定的位数和用途。
  1. FIN消息最后一帧标志。比如一条大消息分成5个帧,第5个的FIN=1,其余为0。
    FIN1 bit\text{FIN}_{1\,\text{bit}}
  2. RSV和OpcodeRSV是扩展预留位,固定为0;Opcode指明帧类型,0x1文本、0x2二进制、0x8关闭、0x9 Ping、0xA Pong。
    RSV3 bits+Opcode4 bits\text{RSV}_{3\,\text{bits}} + \text{Opcode}_{4\,\text{bits}}
  3. MASK和长度MASK标记负载是否被掩码,客户端到服务器的帧必须为1;PayloadLen表示正文长度,用7位、16位或64位扩展表示。
    MASK1 bit+PayloadLen7 bits\text{MASK}_{1\,\text{bit}} + \text{PayloadLen}_{7\,\text{bits}}
  4. 扩展与数据长度足够大时使用扩展位;如果MASK=1还要携带4字节MaskKey,随后才是真正的Payload数据。
    [ExtendedLen]+[MaskKey]+PayloadData[\text{ExtendedLen}] + [\text{MaskKey}] + \text{PayloadData}

防拆封:Mask与负载

为了防止老式代理服务器被“伪装请求”欺骗,协议规定:客户端发给服务器的帧必须加“防拆封封印”——掩码。做法很简单:4字节的MaskKey,把正文每个字节和Key循环异或。就像给信件内容套上一次性透明封膜,只有知道Key的服务器才能还原。

掩码把原文变成看起来随机的字节流,让恶意的中间代理无法伪造一个“看起来像HTTP请求”的数据包。服务器收到密文后,用同一个MaskKey再异或一次,正文就还原了。这是对称操作,也是WebSocket防缓存投毒的核心设计。

服务器收到密文后,用信封内附的MaskKey再异或一次,内容即还原。为什么服务器发回的数据不需要掩码?因为服务器是“可信地址”,客户端是公网上的未知寄件人,必须证明自己没夹带违禁品。这条看似简单的异或规则,是WebSocket防缓存投毒的重要防线。

payloadi′=payloadi⊕MaskKey[i mod 4]\text{payload}'_i = \text{payload}_i \oplus \text{MaskKey}[i \bmod 4]
异或是可逆的:服务器用同一个Key再异或一次,正文就复活了。
  1. 取掩码字节把正文下标i对4取模,循环取MaskKey中的对应字节。
    MaskKey[i mod 4]\text{MaskKey}[i \bmod 4]
  2. 逐字节异或将正文每个字节与掩码字节做异或,得到发送线上的密文。
    payloadi⊕MaskKey[i mod 4]\text{payload}_i \oplus \text{MaskKey}[i \bmod 4]
  3. 遍历全文对全部字节重复异或,完成整帧数据遮蔽。
    ∀i∈[0,len): payloadi′=payloadi⊕MaskKey[i mod 4]\forall i \in [0, \text{len}) : \ \text{payload}'_i = \text{payload}_i \oplus \text{MaskKey}[i \bmod 4]

定期问候:心跳保活

专线开通后,如果双方长时间沉默,中间的代理设备可能悄悄把连接回收,造成“半开连接”——线的两头都以为还通着,实际已经断了。WebSocket的心跳机制,就像在电话里每隔一段时间问一句:“喂,你还在吗?” 一方发Ping(Opcode=0x9),另一方必须回Pong(Opcode=0xA)。如果超过超时时间没收到Pong,就可以判定对端失联,主动关闭并清理资源。

心跳间隔要选得既省流量又及时。一般建议心跳周期小于中间代理的超时时间,比如30秒发一次Ping,留出足够等待窗口;如果连续多次没有Pong,就断开并触发重连。Ping/Pong帧属于控制帧,负载不能超过125字节,适合携带时间戳或随机ID用于匹配。

心跳不是“闲聊”,而是廉价的连接体检。Ping/Pong帧不携带业务数据,却能让双方避免在断掉的管线上继续空耗。生产环境里,心跳间隔通常设为30秒,正是为了在代理回收连接之前先发现异常。

alive=(tpong−tping)<Δ\text{alive} = \big(t_{\text{pong}} - t_{\text{ping}}\big) < \Delta
心跳的核心是时间差判断:在超时阈值内收到回复,连接才算活着。
  1. 发送Ping并记录发送方发出Ping帧,记录时间戳 t_ping。
    tping=time()t_{\text{ping}} = \text{time()}
  2. 接收Pong对端在收到Ping后必须尽快回复Pong,记录到达时间 t_pong。
    tpong=time()t_{\text{pong}} = \text{time()}
  3. 阈值比较若两次时间差小于超时阈值Δ,判定连接存活;否则触发断线重连。
    (tpong−tping)<Δ\big(t_{\text{pong}} - t_{\text{ping}}\big) < \Delta

从容告别:优雅关闭

天下没有不散的筵席。WebSocket关闭时,不是直接抡起斧头砍断网线,而是先发一封“再会挂号信”:Close帧(Opcode=0x8)。信封里至少有2字节状态码,说明为什么告别——1000表示“正常收工”,1001表示“我要离开去别的地方”,1002表示“协议出错”,1007表示“数据格式不对”。收到Close帧的一方,也回一帧Close,再关闭底层的TCP连接,才算体面分手。

状态码是双方都能读懂的告别理由。除了1000之外,还有各类异常码:1003表示收到无法接受的数据类型,1008表示违反策略,1009表示消息太大,1011表示服务端异常。客户端和服务器都能发送Close帧,发起方携带状态码,接收方通常回同样的状态码,表示“收到,晚安”。

直接用TCP RST断线,就像话没说完就摔电话,对端不知道你是“掉线”还是“主动挂断”。Close帧给了双方最后一次同步的机会:先告知,再等对方回执,最后同时放下话筒。这种优雅终止,让WebSocket不会留下“僵尸连接”。

Close=Opcode0x8⏟control+Status Code⏟2 bytes+Reason Text\text{Close} = \underbrace{\text{Opcode}_{0x8}}_{\text{control}} + \underbrace{\text{Status Code}}_{2\,\text{bytes}} + \text{Reason Text}
状态码是双方都能读懂的告别理由。
  1. 填类型把帧头Opcode设为0x8,向对方表明这是关闭帧。
    Opcode=0x8\text{Opcode} = 0x8
  2. 写状态码在负载前两字节写入关闭原因码,如1000代表正常关闭。
    Status Code2 bytes=1000\text{Status Code}_{2\,\text{bytes}} = 1000
  3. 附加原因可以追加一段UTF-8文本说明细节,例如"Service going down";没有原因时这部分可为空。
    Reason Text=UTF-8 string\text{Reason Text} = \text{UTF-8 string}

实战应用:全双工专线的用武之地

有了帧格式、心跳和关闭机制,WebSocket变成了实时应用的最佳快递员。实时聊天里,双方消息通过文本帧即时互推,无需轮询;推送通知里,服务器可以随时把新动态塞进客户端管道,客户端不用反复问“有新消息吗”;协同编辑里,多个客户端同时操作同一文档,各自通过二进制或文本帧广播操作,全双工的上下行通道让修改像水波一样扩散。

这三个场景共同点是“低延迟、双向、高频”:聊天要求消息秒达,推送要求服务器主动开口,协同编辑要求多端状态同步。如果换作HTTP轮询,每条消息都要重复建连、带Header,带宽浪费严重;而WebSocket在一条长连接上以最小帧头传输,省去大量重复握手开销。

HTTP像一问一答的传声筒,WebSocket则是双向传送带。聊天、推送、协作这些场景,天然需要“双方都能随时开口”,这正是全双工专线最闪耀的舞台。理解带宽使用,才能在容量规划时推断出连接上限和扩缩容策略。

Rtotal=Rup+RdownR_{\text{total}} = R_{\text{up}} + R_{\text{down}}
全双工总吞吐等于上行速率与下行速率之和,这是WebSocket对比HTTP的天然优势。
  1. 上行速率客户端→服务器的数据速率,记作 R_up。
    RupR_{\text{up}}
  2. 下行速率服务器→客户端的推送速率,记作 R_down。
    RdownR_{\text{down}}
  3. 求和因为上下行在同一时刻互不干扰,总吞吐可视为两者相加。
    Rtotal=Rup+RdownR_{\text{total}} = R_{\text{up}} + R_{\text{down}}

AI 应用场景

实时客服聊天系统

阶段:数据传输与状态同步

解决的问题:传统HTTP长轮询会频繁建立连接、浪费带宽,且消息延迟高。WebSocket建立一条全双工长连接,客服和用户的消息通过文本帧即时互推,配合心跳Ping/Pong及时发现掉线客服,避免消息石沉大海。

具体案例:电商平台在线客服中,顾客发送一条消息,网关立即通过WebSocket帧推送给客服工作台;客服回复时同样走这条管道,全程零轮询。若客服端网络中断,心跳超时后系统自动重新分配会话,并把未读消息暂存待重连后补推。

行情推送通知

阶段:服务端主动推送

解决的问题:股票行情、新闻快讯需要服务器主动推送,HTTP只能客户端先请求再响应。WebSocket的服务器→客户端下行通道让通知在事件发生的瞬间到达,配合二进制帧和掩码机制,确保低延迟且防篡改。

具体案例:证券App推送实时行情,服务器用二进制帧批量发送行情快照,客户端解析帧头并渲染K线。连接空闲时每30秒发送Ping,运维平台通过Pong响应率监控在线用户量,秒级发现异常连接。

多人协同文档编辑

阶段:并发操作广播与状态同步

解决的问题:多人同时编辑文档,需要将每个用户的光标、操作广播给所有协作者。HTTP难以维持大规模长连接,而WebSocket天然支持多客户端双向通信,配合分片帧(FIN=0)逐段同步大文档,保证编辑流畅。

具体案例:在线文档中,用户A输入一个字符,前端把操作封装成文本帧发给协同服务器;服务器通过WebSocket连接将操作转发给用户B和C,实现毫秒级所见即所得。某协作者网络抖动时,心跳超时触发自动重连并增量同步。

Python 演示

python46 行
import base64import hashlibimport structimport os GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" def compute_accept(key):    return base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode() def parse_frame(data):    b1, b2 = data[0], data[1]    fin = (b1 >> 7) & 1    opcode = b1 & 0x0F    masked = (b2 >> 7) & 1    length = b2 & 0x7F    offset = 2    if length == 126:        length = struct.unpack(">H", data[offset:offset + 2])[0]        offset += 2    elif length == 127:        length = struct.unpack(">Q", data[offset:offset + 8])[0]        offset += 8    mask_key = None    if masked:        mask_key = data[offset:offset + 4]        offset += 4    payload = data[offset:offset + length]    if masked:        payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))    return {        "fin": fin,        "opcode": opcode,        "masked": masked,        "length": length,        "mask_key": mask_key,        "payload": payload    } if __name__ == "__main__":    key = base64.b64encode(os.urandom(16)).decode()    print("原始 Key:", key)    print("Accept:", compute_accept(key))    raw = bytearray([0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x2b, 0x89, 0x4b, 0x4d])    parsed = parse_frame(bytes(raw))    print("解析结果:", parsed)
目的:演示WebSocket握手Accept值的计算,以及如何从二进制帧中解析FIN、Opcode、Mask和Payload。
  1. return base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()一行完成Key+GUID的SHA1哈希与Base64编码,输出握手的Accept值。
  2. b1, b2 = data[0], data[1]先读前两个字节,随后按位拆解FIN、Opcode、Mask和长度。
  3. fin = (b1 >> 7) & 1右移7位取最高位,即FIN封口标记。
  4. opcode = b1 & 0x0F取第一个字节低4位,得到帧类型。
  5. masked = (b2 >> 7) & 1取第二个字节最高位,判断是否带掩码。
  6. if length == 126:短长度不够用时,按16位扩展读取真实负载长度。
  7. mask_key = data[offset:offset + 4]抽出4字节掩码密钥,用于后续还原。
  8. payload = bytes(b ^ mask_key[i % 4] for i, b in enumerate(payload))用循环异或还原被Mask遮蔽的正文。

常见误区

误区我以为WebSocket就是HTTP加了个Connection: keep-alive,后面还是用HTTP请求收发消息。

正解握手阶段的确走HTTP报文,但101状态码之后协议就切换了。后续数据全部使用WebSocket自己的帧格式,没有HTTP方法、URL、请求头。如果继续按HTTP解析,帧头会被当成乱码。就像专线开通后,你不再走邮局窗口,而是直接通过传送带递包裹。

为何常见:因为浏览器开发者工具里握手请求看起来就是HTTP,让人误以为整个生命周期都是HTTP。

误区WebSocket帧有Mask位,我觉得客户端和服务器都应该对负载做掩码。

正解RFC 6455规定只有客户端→服务器的帧必须Mask=1;服务器→客户端的帧Mask必须为0,且不允许使用掩码。理由是被害者模型不同:客户端可能被恶意网页控制,需要防止伪造请求,而服务器是可信的。若服务器也加Mask,反而会被中间代理误解。

为何常见:因为协议解析代码里统一处理了Mask分支,初学者只看到“异或还原”这个动作,忽略了方向限制。

误区TCP有keepalive机制,WebSocket连接不会断,不需要自己发Ping/Pong。

正解TCP keepalive默认通常需要几小时才触发一次,而且中间NAT或负载均衡可能比TCP更早回收空闲连接。WebSocket的Ping/Pong是应用层心跳,能以秒级频率探测连接活性,及时发现半开连接并触发重连。

为何常见:学过TCP的人容易把底层保活机制错误地当作上层解决方案,忽略了中间代理的实际超时。

误区WebSocket关闭时直接调`close()`或关掉TCP socket就完事了,不用管Close帧。

正解优雅关闭要求先发送带状态码的Close帧,等待对方回一个Close帧,然后再关闭TCP连接。直接断开会让对端收到一个“半截帧”或无法判断是否为网络异常,导致资源泄漏。就好比挂电话前得说“再见”,等对方回应再放下话筒。

为何常见:很多编程库的close()方法隐藏了Close握手细节,让人误以为底层不需要协议协作。

误区只要定期主动发Pong帧,就能保持连接活跃。

正解Pong是Ping的被动响应,协议要求收到Ping后必须回Pong;但反过来,客户端不能主动用Pong来探测连接。主动探测必须发Ping并期待Pong。应用层心跳的正确姿势是“发Ping、收Pong、记时间差”,而不是单向广播Pong。

为何常见:Ping/Pong都出现在心跳代码里,初学者容易混用,只关心“定时发控制帧”而忽略了谁响应谁。

面试题

Q1. WebSocket成功切换协议后,服务器返回的状态码是什么?

单选题 · easy

  • A.200
  • B.101← 正确答案
  • C.404
  • D.301

答案:B。WebSocket通过HTTP Upgrade机制握手成功后,服务器返回101 Switching Protocols,表示“协议已切换”。200是普通HTTP成功响应,404是找不到资源,301是重定向,都不属于WebSocket成功建立的标志。

Q2. 客户端发给服务器的帧,Mask位取值必须是什么?

单选题 · easy

  • A.0
  • B.1← 正确答案
  • C.随机
  • D.由服务器决定

答案:B。RFC 6455规定客户端→服务器的帧必须Mask=1,携带4字节MaskKey用于负载异或。服务器→客户端则必须Mask=0。随机或与服务器协商都不符合协议,所以只有1是正确答案。

Q3. 表示正常关闭WebSocket连接的状态码是什么?

单选题 · easy

  • A.1000← 正确答案
  • B.1001
  • C.1002
  • D.1011

答案:A。1000表示正常关闭,1001表示端点离开,1002表示协议错误,1011表示服务端异常。正常业务收工应该用1000,这与HTTP 200类似,都是“一切顺利”的语义。

Q4. 一个WebSocket帧的第一个字节是0x81,它表示什么?

单选题 · mid

  • A.FIN=1,Opcode=0x1← 正确答案
  • B.FIN=0,Opcode=0x1
  • C.FIN=1,Opcode=0x8
  • D.FIN=1,Opcode=0x2

答案:A。0x81的二进制是10000001,最高位FIN=1表示消息结束,低4位Opcode=0001表示文本帧。选项B的FIN=0与字节不符,C的0x8是关闭帧,D的0x2是二进制帧,因此A正确。

Q5. 当帧的PayloadLen字段值为126时,真实负载长度存放在多少个字节中?

单选题 · mid

  • A.1
  • B.2← 正确答案
  • C.4
  • D.8

答案:B。7位长度只够表示0-125,126表示“后面有16位扩展长度”,占2字节;127表示“后面有64位扩展长度”,占8字节。1字节是普通短长度,4字节在WebSocket帧格式中不存在。

Q6. 心跳机制中,收到Ping帧后应当回复什么?

单选题 · mid

  • A.再发一个Ping
  • B.Pong← 正确答案
  • C.Close
  • D.不回复

答案:B。协议要求收到Ping后必须立刻回Pong,Pong的负载应尽量保持与Ping一致。再发Ping是主动探测动作,Close是关闭连接,不回复会导致对端超时判死。所以正确做法是回Pong。

Q7. 下列哪一个不属于WebSocket帧中的字段?

单选题 · hard

  • A.FIN
  • B.Opcode
  • C.Checksum← 正确答案
  • D.MaskKey

答案:C。WebSocket帧头包含FIN、RSV、Opcode、MASK、PayloadLen,以及可选的扩展长度和MaskKey,但没有校验和字段。传输正确性由下层TCP保证,因此协议不设计Checksum,这是与IP/TCP头部的显著区别。

Q8. 计算Sec-WebSocket-Accept时,"Key+GUID"之后的处理顺序是什么?

单选题 · hard

  • A.SHA1哈希,再Base64编码← 正确答案
  • B.Base64编码,再SHA1哈希
  • C.直接Hex编码
  • D.MD5哈希,再Base64编码

答案:A。协议规定先对"Key+GUID"做SHA1得到20字节摘要,再Base64编码得到Accept值。如果先Base64再SHA1,结果完全不同。Hex编码和MD5都不属于标准流程,因此A正确。

Q9. 以下哪些属于WebSocket控制帧的Opcode?

多选题 · mid

  • A.0x1
  • B.0x8← 正确答案
  • C.0x9← 正确答案
  • D.0xA← 正确答案

答案:BCD。0x8是Close关闭帧,0x9是Ping,0xA是Pong,三者都是控制帧,用于连接管理和心跳。0x1是文本数据帧,不属于控制帧。所以正确答案是B、C、D。

Q10. 下列哪些是WebSocket优雅关闭的必要步骤?

多选题 · hard

  • A.发送Close帧并携带状态码← 正确答案
  • B.等待对方返回Close帧回执← 正确答案
  • C.忽略状态码直接断开TCP
  • D.双方都关闭后再释放底层连接← 正确答案

答案:ABD。优雅关闭要求先发带状态码的Close帧,收到对方的Close回执后再关闭TCP连接,也就是选项A、B、D。直接断开TCP属于粗暴终止,既没有状态码也没有回执,可能让对端误判为网络故障,因此C错误。