想象一个场景:你给朋友寄了一封信,问他“周末有空吗”,信寄出去后你只能干等回信。这就是传统 HTTP 的交流方式——客户端发一个请求,服务器回一个响应,一来一回,像寄明信片。可如果朋友是个话痨,想一边想一边说、不间断地跟你分享想法,寄信就完全没法用。于是有了电话:拨号(握手)、接通(101)、然后双方你一言我一语随时开聊,谁想开口都能开口,这就是 WebSocket。它让服务器不再是被动等提问的“信箱”,而是能主动开口的“电话那头的真人”。本文就用“打电话”这个画面,带你拆解 WebSocket 的双向通信原理:怎么拨号、怎么通话、怎么挂断,以及它如何支撑 AI 大模型的流式输出——让 LLM 像真人一样边想边说,而不是憋一篇长信一次性寄给你。

岗位关联

一句话价值:搞懂 WebSocket 双向通信,你就能让 LLM 应用做到“边生成边显示”的流式体验,并掌握实时通知、连接保活与高并发管理这组 AI 应用工程师的核心基本功。

目录
  1. 可视化演示
  2. HTTP请求响应模式(寄信的旧常态)
  3. WebSocket握手过程(拨号接通)
  4. 消息帧格式(说话的音节)
  5. 双向通信数据流(全双工通话)
  6. 连接生命周期(通话的起止)
  7. 服务器推送机制(主动开口的服务员)
  8. LLM流式输出(AI 边想边说)
  9. 并发连接管理(总机调度)
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示WebSocket全双工双向通信机制、连接生命周期状态机、LLM实时交互模式,理解服务器主动推送消息的底层原理与应用。

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

WebSocket全双工双向通信 基于TCP全双工连接,服务器和客户端可同时收发数据互不干扰 服务器可主动推送消息,无需客户端轮询请求 效率 = 1 - (轮询开销/总带宽) 全双工实时通信

HTTP请求响应模式(寄信的旧常态)

想象“寄信”:你写一封“在吗?”,贴上邮票寄出去,然后望着邮筒等回信。HTTP 就是这样——客户端永远是提问方,服务器永远是答复方。在 LLM 时代这就尴尬了:让模型写一篇 1000 字的文章,它得花十几秒甚至更久。如果你想实时看到文字蹦出来,用 HTTP 就要不停寄信问“写完没?”,这就是“轮询”。

轮询就像每秒钟跑去邮筒看一次信,大多数时候空手而归,浪费邮费也浪费人力。长轮询稍微聪明些——邮递员在你家楼下等着,有新信立刻送来,但本质上还是“你问、它答”。真正的矛盾在于:服务器有消息时无法主动开口,只能干等下一次提问。

Tcost=Npoll×(Treq+Tresp)\displaystyle T_{\text{cost}} = N_{\text{poll}} \times (T_{\text{req}} + T_{\text{resp}})

1. 理解轮询次数:\(N_{\text{poll}}\) 指客户端在等待期间发起请求的总次数——次数越高,浪费越大。

Npoll=TwaitTintervalN_{\text{poll}} = \dfrac{T_{\text{wait}}}{T_{\text{interval}}}

2. 请求响应耗时:\(T_{\text{req}} + T_{\text{resp}}\) 是一次完整的 HTTP 请求加响应所花的时间,包含网络往返与服务器处理。

Treq+Tresp=2×Tnetwork+ThandleT_{\text{req}} + T_{\text{resp}} = 2 \times T_{\text{network}} + T_{\text{handle}}

3. 总开销倍增:把轮询次数与单次往返耗时相乘,得到整个等待过程付出的总时间成本。

Tcost=TwaitTinterval×(2Tnetwork+Thandle)T_{\text{cost}} = \dfrac{T_{\text{wait}}}{T_{\text{interval}}} \times (2T_{\text{network}} + T_{\text{handle}})

寄信模式就像只能你问一句、它答一句的单向线,问题在于:服务器声音再好,也得你主动打电话过去才听得到。这正是 WebSocket 要打破的旧常态。

WebSocket握手过程(拨号接通)

打电话的第一步是拨号。号码拨通后,接线员确认“你找谁、你身份对不对”,然后“咔哒”一声接通——之后你们才能畅聊。WebSocket 的握手就是这个“拨号 + 身份确认”。

握手使用 HTTP 协议发起:客户端发一个带 Upgrade: websocket 的 GET 请求,并在 Sec-WebSocket-Key 字段放一个随机密钥。服务器拿到密钥后,拼上一个规定的固定字符串(GUID),做一次 SHA-1 哈希,再 Base64 编码,得到 Sec-WebSocket-Accept。把它放进响应,状态码返回 101 Switching Protocols,表示“从现在起,这条链路升级为 WebSocket”。

这一步既是身份校验,也是“拨号成功”的信号。此后连接不再走 HTTP 的请求-响应,而是双工通道。

Accept=Base64(SHA-1(Key+GUID))\displaystyle \text{Accept} = \text{Base64}(\text{SHA-1}(\text{Key} + \text{GUID}))

1. 拼接密钥与固定 GUID:把客户端发来的 Key 与全局固定字符串 GUID 前后拼接成一个待哈希的原始字符串。

raw=Key+GUID\text{raw} = \text{Key} + \text{GUID}

2. SHA-1 哈希压缩:对拼接结果做 SHA-1 运算,无论输入多长都输出固定 20 字节的摘要。

digest=SHA-1(raw)\text{digest} = \text{SHA-1}(\text{raw})

3. Base64 编码传输:摘要转成 Base64 文本,便于放进 HTTP 头,完成握手响应,客户端再对比验证。

Accept=Base64(digest)\text{Accept} = \text{Base64}(\text{digest})

就像电话接通前双方互报姓名,握完手你俩都知道“对面是真人”,线路正式开放。从这一刻起,不再有“寄信人”和“收信人”之分,只有平等的两个通话方。

消息帧格式(说话的音节)

通了话,声音怎么传?说出的每句话被压缩成一组音节编码,对方耳朵解码还原。WebSocket 把每条消息切成“帧(frame)”来传输,帧就是编好码的音节。

一个 WebSocket 帧像一张表格:第一行是帧头,包含 FIN(这句话说完了吗)、RSV(扩展保留位)、Opcode(这是什么类型的帧——文本/二进制/关闭/Ping/Pong)、Mask(有没有掩码)、Length(数据多长),之后才是数据 Payload。文本消息的 opcode 是 1,二进制是 2,Ping 是 9,Pong 是 10,关闭连接是 8。

有个有趣的规则:客户端发给服务器的数据帧必须加掩码(Mask=1),服务器发给客户端的帧则禁止掩码。这是协议为了防止早期代理缓存投毒。就像打电话时,你这边要经过一个语音加密器,对方那边不需要。

FINRSVOpcodeMaskLengthPayload\displaystyle \begin{array}{|c|c|c|c|c|c|} \hline \text{FIN} & \text{RSV} & \text{Opcode} & \text{Mask} & \text{Length} & \text{Payload} \\ \hline \end{array}

1. FIN 与 Opcode 标定类型:FIN 表示这是不是最后一个分片,Opcode 告诉接收方帧的用途。

frame[0]=FIN×128+Opcode\text{frame}[0] = \text{FIN} \times 128 + \text{Opcode}

2. Mask 与 Length 描述数据:第 2 个字节的最高位是 Mask,低 7 位是短长度;超长时用扩展长度字段表示。

frame[1]=Mask×128+Lengthshort\text{frame}[1] = \text{Mask} \times 128 + \text{Length}_{\text{short}}

3. 载荷经过掩码还原:客户端数据用 4 字节掩码逐字节异或还原,得到真正的消息内容。

payload[i]=raw[i]⊕mask[i mod 4]\text{payload}[i] = \text{raw}[i] \oplus \text{mask}[i \bmod 4]

音节编得再炫,双方解码规则一致才能听懂——WebSocket 的帧格式就是通话双方共同的“语言编码表”。只要遵循同一张表,任何语言(文本、二进制文件)都能在这条电话线上传输。

双向通信数据流(全双工通话)

打电话和寄信最大的不同:电话里你可以同时说、同时听,不用等对方闭嘴。WebSocket 建立后就是这样的全双工通道——两个方向的流量互不阻塞,各走各的“车道”。

在 WebSocket 连接上,客户端可以随时发消息,服务器也可以随时发消息,双方平等。不存在“必须你先问,我才能答”的约束。整个链路由 TCP 保证字节顺序与不丢包,应用层只要把消息拆帧拼帧就行。传输层为每个连接维护两个方向的滑动窗口,数据抵达后由事件循环分发。

这种双向性让延迟大幅降低:服务器收到一个新事件(比如监控告警、新的 token),毫秒级就能把消息推到客户端,而不需要客户端先来问。

Rduplex=Bup+BdownTwindow\displaystyle R_{\text{duplex}} = \frac{B_{\text{up}} + B_{\text{down}}}{T_{\text{window}}}

1. 上行数据量:窗口内客户端发给服务器的字节总数,比如发送的聊天内容、控制指令。

Bup=∑j=1nbjupB_{\text{up}} = \sum_{j=1}^{n} b_{j}^{\text{up}}

2. 下行数据量:窗口内服务器推给客户端的字节总数,比如 LLM token、通知消息。

Bdown=∑j=1mbjdownB_{\text{down}} = \sum_{j=1}^{m} b_{j}^{\text{down}}

3. 除以时间窗口:用总数据量除以统计时长,得到平均双向吞吐速率。

Rduplex=Bup+BdownTwindowR_{\text{duplex}} = \frac{B_{\text{up}} + B_{\text{down}}}{T_{\text{window}}}

就像电话里你俩可以同时开口,虽然现实中人习惯轮流说,但线路本身支持真正的同时双向。WebSocket 的数据流也是如此——技术上完全对等,谁主动都不稀奇。

连接生命周期(通话的起止)

一次电话有完整的起止:拨号→接通→通话→挂断。中间如果信号不好,还会“喂,喂,还在吗?”。WebSocket 连接的生命周期也一样:打开(open)、消息往来(active)、关闭(close),还有“喂还在吗”的心跳。

连接打开后,服务器和客户端都会维护状态。为了探测死连接(比如对方悄悄断线、中间路由器把空闲连接清了),双方周期性地发 Ping 帧,收到 Pong 帧就说明对方还活着——这就是心跳保活。关闭连接时,一方发 Close 帧(opcode 8),另一方回 Close 帧,然后 TCP 才真正断开。

如果连接异常断开(网络抖动、进程崩溃),客户端需要自动重连,并恢复之前的订阅状态。这就是为什么工程上要有“断线重连 + 状态恢复”机制。

Theartbeat<min⁡(Tproxy,Ttcp−Tlatency)\displaystyle T_{\text{heartbeat}} < \min(T_{\text{proxy}}, T_{\text{tcp}} - T_{\text{latency}})

1. 预算时间计算:链路中代理空闲超时与 TCP 超时减网络延迟相比,取较小者作为心跳预算。

Tbudget=min⁡(Tproxy,Ttcp−Tlatency)T_{\text{budget}} = \min(T_{\text{proxy}}, T_{\text{tcp}} - T_{\text{latency}})

2. 心跳间隔上限:心跳周期必须严格小于这个预算,才能保证连接不被误杀。

Theartbeat<TbudgetT_{\text{heartbeat}} < T_{\text{budget}}

3. 留出安全余量:工程上通常取预算的一定比例,例如 60%,避免网络抖动导致误判。

Theartbeat=0.6×TbudgetT_{\text{heartbeat}} = 0.6 \times T_{\text{budget}}

会管理通话的人不会让电话线空转太久,定期“喂”一声,才能一直保持联系。WebSocket 的心跳就是这声“喂”——廉价、及时,让连接常青。

服务器推送机制(主动开口的服务员)

想象你坐在餐厅,服务员不会等你每次举手才来上菜,菜好了她端着盘子主动走过来。WebSocket 让服务器变成了“主动上菜的服务员”——推送机制是服务器端的技术核心。

服务器的推送通常是事件驱动的:内部有一个事件源(数据库变更、消息队列、LLM token 生成器),事件一产生,服务器立刻找到对应的连接把消息推出去。按范围分有“单播”(只发给指定用户)和“广播”(发给所有连接)。控制推送时机也很重要:如果客户端消费慢,服务端不能无限狂推,否则内存会爆,这叫“背压”(backpressure)——类似给服务员说“上菜慢一点,我这桌还没吃完”。

工程上常用发送队列实现背压:每个连接维护一个消息队列,当队列超过阈值就降速或断开慢消费者。

Qbacklog=∑i=1nλi⋅di\displaystyle Q_{\text{backlog}} = \sum_{i=1}^{n} \lambda_i \cdot d_i

1. 速率与等待时长:\(\lambda_i\) 是第 i 个连接上的消息到达速率,\(d_i\) 是消息在队列中的平均等待时长。

λi=消息数秒\lambda_i = \frac{\text{消息数}}{\text{秒}}

2. 每队列积压量:一个连接上速率与等待时长的乘积就是该队列的积压消息量。

qi=λi×diq_i = \lambda_i \times d_i

3. 汇总所有队列:累加所有连接得到总积压,用来判断是否需要触发背压策略。

Qbacklog=∑i=1nqiQ_{\text{backlog}} = \sum_{i=1}^{n} q_i

聪明的服务员会观察每桌出菜进度,该上就上、该缓就缓——服务器推送讲究的就是“时机”和“节奏”。事件驱动加背压控制,就是这桌菜的“上菜节奏”。

LLM流式输出(AI 边想边说)

真人聊天时是边想边说,说一半停顿一下、再继续,你不需要等他把整段话想完才听到开头。LLM 大模型生成回答也一样,它是一次一个 token(词元)地“想”出来的。如果等全部生成完再用 HTTP 一次性返回,用户对着转圈圈的面板等十几秒,体验极差。

WebSocket 让服务器把生成的 token 增量地实时推给前端:生成第一个 token 就推第一个,前端收到一个渲染一个,用户看到文字“打字机式”蹦出来。这个方案还支持双向控制——比如用户在生成过程中点击“停止”,客户端可以通过同一连接发一个停止指令,服务器立刻中断生成。对比 SSE(Server-Sent Events),SSE 是单向的(只能服务器推),WebSocket 是双向的,还可以传二进制。

从延迟角度,用户体验从“等全部”变成“等第一个字”。

Tperceived=TTTFT+NtokensRgeneration\displaystyle T_{\text{perceived}} = T_{\text{TTFT}} + \frac{N_{\text{tokens}}}{R_{\text{generation}}}

1. 首 token 延迟:\(T_{\text{TTFT}}\)(Time To First Token)是发请求到收到第一个 token 的时间,包含排队和模型首步计算。

TTTFT=Tqueue+TpromptT_{\text{TTFT}} = T_{\text{queue}} + T_{\text{prompt}}

2. 后续生成耗时:剩余 token 数除以生成速率,得到后续所有 token 输出的总耗时。

Tgen=NtokensRgenerationT_{\text{gen}} = \frac{N_{\text{tokens}}}{R_{\text{generation}}}

3. 感知延迟合计:两者相加就是用户从提问到看到完整答案的总时间,流式让等待变得“可感知地缩短”。

Tperceived=TTTFT+TgenT_{\text{perceived}} = T_{\text{TTFT}} + T_{\text{gen}}

就像电话辅导老师边说边讲,你听到第一个字就安心了,而不是等寄来的整封信。LLM 配上 WebSocket,用户听到的不是“信到了”的敲门声,而是 AI 开口说话的第一个音节。

并发连接管理(总机调度)

公司前台有根总机线,能同时接很多电话。每个 WebSocket 连接也占一份资源:一个文件描述符、一块收发缓冲区、一份连接状态。当应用有十万用户同时在线聊天或与 LLM 对话,服务器必须像总机一样调度好每条线路。

并发管理的关键有三件:一是限制与监控,设定最大连接数,防止内存被耗尽;二是高效的数据结构,用连接管理器(Map 或哈希表)快速找到某个用户的连接,实现单播推送;三是生命周期清理,连接关闭或心跳超时后立刻释放资源,否则会出现“幽灵连接”吃内存。

断线重连也是并发下的刚需:客户端掉线后自动重连,并重新注册订阅(比如重新加入某个房间或会话),服务端要幂等处理,避免重复订阅导致重复推送。

Cmax=MavailMper-conn\displaystyle C_{\text{max}} = \frac{M_{\text{avail}}}{M_{\text{per-conn}}}

1. 单连接内存开销:每个连接的缓冲区、消息队列、状态对象加起来的内存均值。

Mper-conn=Mtotal-connCcurrentM_{\text{per-conn}} = \frac{M_{\text{total-conn}}}{C_{\text{current}}}

2. 可用内存预算:服务器进程允许用于连接的内存上限,扣除其他业务后剩余的部分。

Mavail=Mlimit−MotherM_{\text{avail}} = M_{\text{limit}} - M_{\text{other}}

3. 容量上限计算:用总预算除以单连接开销,得到理论上限,超出则拒绝新连接或触发扩容。

Cmax=MavailMper-connC_{\text{max}} = \frac{M_{\text{avail}}}{M_{\text{per-conn}}}

总机线路再多也有上限,懂调度的工程师才能保证“人人能打通、个个不占线”。WebSocket 的并发管理,本质上就是为每一条通话线路分配合理的资源,并在挂断后及时回收。

AI 应用场景

LLM 流式对话

阶段:模型推理与流式传输

解决的问题:传统 HTTP 请求-响应模式下,用户必须等 LLM 完整生成几百字才能看到结果,等待十几秒极易流失。AI 应用需要“边生成边显示”的流式体验。WebSocket 建立一次连接后,服务器把生成的 token 增量推送,并支持用户中途发送“停止”指令,实现互动的流式对话。

具体案例:某智能客服机器人接入大模型后,用户提问的响应从“转圈 8 秒”变为“1 秒出第一个字”。服务端用 WebSocket 把大模型流式输出的 token 实时推送到前端,用户看到文字逐字出现,还能点击“停止回答”打断生成,显著提升满意度。

多人在线协作白板

阶段:实时数据同步与状态广播

解决的问题:多人同时编辑同一张 AI 生成的白板/文档时,需要把每个人的操作实时同步给其他人。HTTP 轮询延迟高且浪费资源,WebSocket 的服务器主动推送能力让编辑器状态秒级同步,还支持二进制图片与光标坐标的高频传输。

具体案例:一款 AI 绘画协作工具,所有成员在同一块画布上作画,AI 实时生成作品。每个画笔动作、每个 AI 增量结果都通过 WebSocket 广播给房间内所有成员,延迟低于 100ms,体验如同面对面在白板上画。

实时监控告警推送

阶段:事件驱动与智能预警

解决的问题:运维监控系统需要在异常发生时第一时间通知值班人员,而不是等客户端来轮询。WebSocket 让服务器在算法检测到指标异常后,立刻主动推送告警详情到浏览器大屏或手机端,并支持多通道广播。

具体案例:某公司的大模型服务监控平台,用 WebSocket 连接每个运维人员的浏览器。当模型推理延迟 P99 超过阈值或 GPU 显存告急时,服务器立即推送包含日志摘要的告警卡片,运维人员可点击“一键拉起”按钮反向控制,形成闭环。

Python 演示

python127 行
# -*- coding: utf-8 -*-"""mini_ws.py 迷你 WebSocket 服务器:演示握手与服务器主动推送"""import base64import hashlibimport socketimport structimport threadingimport time GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"clients = set()  def accept_key(key):    return base64.b64encode(        hashlib.sha1((key + GUID).encode()).digest()    ).decode()  def encode_frame(text):    payload = text.encode("utf-8")    n = len(payload)    if n < 126:        header = bytes([0x81, n])    elif n < 65536:        header = bytes([0x81, 126]) + struct.pack(">H", n)    else:        header = bytes([0x81, 127]) + struct.pack(">Q", n)    return header + payload  def decode_frame(data):    if len(data) < 2:        return None, data    b1, b2 = data[0], data[1]    opcode = b1 & 0x0F    masked = b2 & 0x80    length = b2 & 0x7F    off = 2    if length == 126:        length = struct.unpack(">H", data[off:off + 2])[0]        off += 2    elif length == 127:        length = struct.unpack(">Q", data[off:off + 8])[0]        off += 8    if masked:        mask = data[off:off + 4]        off += 4    raw = bytearray(data[off:off + length])    if masked:        for i in range(len(raw)):            raw[i] ^= mask[i % 4]    return {"opcode": opcode, "payload": bytes(raw)}, data[off + length:]  def broadcast(msg):    for c in list(clients):        try:            c.sendall(encode_frame(msg))        except Exception:            clients.discard(c)  def handle(conn):    data = conn.recv(65536)    headers = {}    for line in data.decode("iso-8859-1").split("\r\n")[1:]:        if ": " in line:            k, v = line.split(": ", 1)            headers[k.lower()] = v    key = headers.get("sec-websocket-key", "")    resp = (        "HTTP/1.1 101 Switching Protocols\r\n"        "Upgrade: websocket\r\n"        "Connection: Upgrade\r\n"        "Sec-WebSocket-Accept: " + accept_key(key) + "\r\n\r\n"    )    conn.sendall(resp.encode())    clients.add(conn)    conn.sendall(encode_frame("连接已建立,服务器会主动推送消息"))    buf = b""    while True:        try:            chunk = conn.recv(65536)        except OSError:            break        if not chunk:            break        buf += chunk        while buf:            msg, buf = decode_frame(buf)            if msg is None:                break            if msg["opcode"] == 8:                broadcast("客户端离开")                conn.close()                return            if msg["opcode"] == 1:                broadcast("收到: " + msg["payload"].decode("utf-8"))    clients.discard(conn)    conn.close()  def pusher():    while True:        time.sleep(2)        broadcast(time.strftime("服务器推送 %H:%M:%S"))  def main():    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)    server.bind(("127.0.0.1", 8765))    server.listen(5)    threading.Thread(target=pusher, daemon=True).start()    print("WebSocket 服务器运行在 ws://127.0.0.1:8765")    while True:        try:            conn, _ = server.accept()            threading.Thread(target=handle, args=(conn,), daemon=True).start()        except KeyboardInterrupt:            break    server.close()  if __name__ == "__main__":    main()
目的:用 Python 标准库实现一个迷你 WebSocket 服务器,演示握手过程、帧编解码,以及服务器每 2 秒主动推送消息的无掩码发送逻辑。
  1. GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"协议规定的固定魔数,握手计算 Accept 时与 Key 拼接。
  2. accept_key(key)实现 SHA-1 + Base64 的握手密钥计算:Base64(SHA-1(Key + GUID))。
  3. encode_frame(text)服务端发送帧的封装:0x81 表示 FIN + 文本帧,第二个字节直接写长度(服务端不掩码)。
  4. decode_frame(data)解析客户端发来的帧:读取 opcode、掩码位、长度,并用 4 字节掩码逐字节异或还原数据。
  5. length == 126` 与 `struct.unpack(">H", ...)当消息超过 125 字节时,用 16 位扩展长度字段表示真实长度。
  6. if masked:` 分支对 `raw[i] ^= mask[i % 4]去掉客户端掩码,还原真正的 payload。
  7. broadcast(msg)遍历所有连接并发送消息,实现“服务器主动推送”;发送失败就从集合中丢弃该连接。
  8. resp` 中 `"HTTP/1.1 101 Switching Protocols\r\n"握手成功的标志,告诉浏览器“电话接通了”。
  9. pusher()` 中 `time.sleep(2); broadcast(...)模拟服务器 2 秒一次主动推送,验证 WebSocket 双向通信。
  10. threading.Thread(target=handle, ...)每个客户端连接一个线程,互不阻塞,是并发管理的极简原型。

常见误区

误区我听说 WebSocket 很先进,以为它跟我们学的 HTTP 完全没关系,是凭空发明的新协议。握手只是象征性地走个 HTTP 流程。

正解WebSocket 的握手完全建立在 HTTP 之上:客户端发一个 HTTP 升级请求(Upgrade: websocket),服务器返回 101 状态码,之后才切换到 WebSocket 帧协议。它就像一个“借电话线拨号”的过程——拨号用的是电信网络,接通后才是通话。没有 HTTP 的升级机制,WebSocket 连门都进不去。

为何常见:因为看到协议名不同、端口常用 ws://,且帧格式与 HTTP 报文差异巨大,容易误以为两者毫无关系。

误区既然做双向通信,服务器往客户端发数据帧,肯定也要像客户端一样加掩码,否则不安全。

正解RFC 6455 明确规定:客户端→服务器的帧必须掩码,服务器→客户端的帧禁止掩码。掩码不是加密,而是为了防止早期代理缓存投毒。就像打电话时只有你的话要经过一个防窃听滤波器,对方传回来的声音不需要再滤一遍。如果服务端错误地加了掩码,客户端反而会解析出乱码。

为何常见:很多人在看协议文档时只记住了“数据帧要掩码”,忽略了掩码的传输方向限制,写服务端代码时照搬客户端逻辑。

误区WebSocket 连接建立后就一直挂着,服务器和客户端都在线,反正不会断,所以没必要搞什么心跳。

正解连接虽然持久,但链路上可能布满 Nginx、负载均衡、云厂商网关,它们往往会在一段时间空闲后自动断开连接。没有心跳,服务器可能守着一条早已死亡的“僵尸连接”,消息发出去石沉大海。心跳的意义是定期“喂”一声,让对方和中间设备都知道这条线还活着。

为何常见:本地开发时没有中间代理,连接总是好好的,一上生产环境才发现空闲连接被网关回收,才意识到心跳的必要性。

误区我看到 WebSocket 既能推送又能接收,功能全面,那所有需要实时的场景都该优先选它,SSE 是过时技术。

正解如果场景只需要服务器单向推送(比如股票价格、日志流),SSE 更轻量:基于普通 HTTP 实现,自动重连机制成熟,兼容性好;WebSocket 的优势在于全双工交互(比如生成中随时打断 LLM)和二进制传输。微信语音和短信各有适用场景,不是越复杂的工具越好。

为何常见:学习时只看到 WebSocket 的宣传亮点,忽略了“单向推送”这类高频场景用 SSE 更省成本、更稳。

面试题

Q1. HTTP 与 WebSocket 最主要的区别是?

单选题 · easy

  • A.WebSocket 基于 UDP 提供更快传输
  • B.WebSocket 建立后支持双向通信← 正确答案
  • C.WebSocket 无需任何网络连接
  • D.WebSocket 只能传输二进制

答案:B。WebSocket 在握手后建立持久连接,客户端和服务器双方可以随时发送消息,实现真正的双向通信。A 错在 WebSocket 基于 TCP;C 显然错;D 错在 WebSocket 支持文本和二进制两种帧。打个比方:HTTP 是寄信,WebSocket 是打电话。

Q2. WebSocket 握手成功后,服务器返回的状态码是?

单选题 · easy

  • A.200请求成功
  • B.301永久重定向
  • C.101协议切换成功← 正确答案
  • D.404资源不存在

答案:C。握手响应使用 101 Switching Protocols,表示“从 HTTP 切换到 WebSocket 协议”。200 表示普通 HTTP 响应成功,301 是重定向,404 是资源未找到。就像电话拨号后听到“咔哒”一声接通,101 就是那声接通提示。

Q3. Sec-WebSocket-Accept 是由 Sec-WebSocket-Key 与什么拼接后哈希生成的?

单选题 · easy

  • A.随机生成的临时串
  • B.固定的魔数GUID串← 正确答案
  • C.当前时间的时间戳
  • D.服务端会话ID串

答案:B。RFC 6455 规定了一个固定 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),服务器把客户端 Key 拼上该 GUID,做 SHA-1 哈希再 Base64 编码得到 Accept 值。使用固定魔数是为了让双方可验证,而不是随机值。就像接线员用统一的话术确认双方身份。

Q4. 关于 WebSocket 数据帧的掩码,下列说法正确的是?

单选题 · mid

  • A.客户端发送必须加掩码← 正确答案
  • B.服务器发送必须加掩码
  • C.掩码使用 2 字节密钥
  • D.掩码等同于加密保护

答案:A。协议规定客户端→服务器必须掩码(4 字节 Masking Key),服务器→客户端禁止掩码。掩码不是加密,而是防止代理缓存投毒。C 错在密钥是 4 字节,D 错在混淆了掩码与加密的关系。

Q5. WebSocket 连接生命周期中,用于探测对端是否存活的帧是?

单选题 · mid

  • A.心跳探测帧← 正确答案
  • B.心跳回复帧
  • C.文本消息帧
  • D.二进制数据帧

答案:A。Ping 帧(opcode 9)用于探测,对端收到后自动回复 Pong 帧(opcode 10),这就是“喂,还在吗?”的心跳机制。文本帧和二进制帧承载业务数据,不负责存活探测。心跳能及时发现死连接,避免推送石沉大海。

Q6. 关于 WebSocket 与 HTTP 轮询的对比,正确的有?

多选题 · mid

  • A.轮询会产生大量无效请求← 正确答案
  • B.WebSocket 只需一次握手升级← 正确答案
  • C.WebSocket 支持服务器主动推送← 正确答案
  • D.轮询的网络延迟一定更低

答案:ABC。轮询每次都要重复发起 HTTP 请求,大部分是空响应,开销大;WebSocket 一次握手后长连接复用,且支持服务器主动推送。D 错:轮询的延迟通常更高,因为服务器有新消息也要等客户端下一次来问,存在空白期。

Q7. 客户端发送 Close 帧请求关闭连接后,服务器应该怎么做?

单选题 · mid

  • A.立即断开底层 TCP 连接
  • B.回应 Close 帧后再关闭连接← 正确答案
  • C.忽略 Close 继续收发消息
  • D.发送 Ping 帧探测对方是否在线

答案:B。关闭握手是双方协作的:收到 Close 帧后应回一个 Close 帧,然后关闭 TCP 连接。立即断开会导致对端来不及处理残留数据;忽略请求则违反协议。就像先说“挂了”,等对方回“好”,再真正挂断电话。

Q8. 关于 WebSocket 分片传输(Fragmentation),下列说法正确的是?

单选题 · hard

  • A.每个分片都必须带掩码
  • B.控制帧不能插入分片之间
  • C.分片只能用于二进制消息
  • D.中间分片的结束标记必须为 0← 正确答案

答案:D。一个消息分多帧传输时,除最后一片外 FIN 均为 0,因此“中间分片 FIN=0”正确。掩码只看传输方向,不看分片;控制帧(如 Ping)可以插入分片流之间;分片同时支持文本和二进制。A、B、C 均与协议相悖。

Q9. 在 LLM 流式输出场景下,WebSocket 相比 SSE 的主要优势是?

单选题 · hard

  • A.浏览器原生支持自动重连
  • B.支持双向交互和二进制传输← 正确答案
  • C.兼容所有 HTTP 代理网关
  • D.不需要维护任何连接状态

答案:B。SSE 只能服务器向客户端单向推送,浏览器 EventSource 自带重连,但客户端无法通过同一通道发指令;WebSocket 是全双工,适合“生成中随时停止/追问”的交互,也能传二进制。A、C 是 SSE 的优势,D 对两者都不成立。

Q10. 关于 WebSocket 并发连接管理的正确做法有?

多选题 · hard

  • A.限制最大连接数防内存耗尽← 正确答案
  • B.用心跳检测连接是否失效← 正确答案
  • C.断线自动重连恢复订阅状态← 正确答案
  • D.连接关闭后及时清理释放资源← 正确答案

答案:ABCD。并发管理是一个闭环:限制容量(A)防止资源耗尽,心跳(B)及时淘汰无效连接,断线重连(C)保证业务连续性,最后还要清理资源(D)避免泄漏。四者缺一不可,就像总机既要限制线路总数,也要及时清理占线不挂的电话。