WebSocket 与 SSE

本页目录

WebSocket提供持续的双向消息通道;SSE在HTTP响应中发送文本事件。两者都能让服务端主动发送数据,选择关键在于交互方向、消息类型、恢复语义及基础设施支持,而不是“谁能推送”。

例如订单页面主要接收状态变化,客户端修改订单仍用普通HTTP,SSE就能覆盖需求;协同编辑需要频繁双向操作和自定义消息协议,WebSocket更自然。无论选哪种,断线后恢复和慢消费者处理都需要应用设计。

问题查阅重点
WebSocket是否一定返回101HTTP/1.1 Upgrade与h2/h3扩展CONNECT
send一次是否等于一个网络包消息、帧与传输分段
mask能否保护秘密掩码不是加密,保密依赖TLS等
Pong是否证明应用任务健康协议栈存活与业务处理分开
SSE重连是否保证不丢事件ID、服务端日志和消费确认
页面越来越卡、内存增长发送队列、背压与消息合并

WebSocket 建连:先确认协议,再传消息

HTTP/1.1握手使用GET、Upgrade与Connection字段,客户端提供随机16 B值的base64形式作为Sec-WebSocket-Key。服务端返回101与正确的Sec-WebSocket-Accept;子协议只能从客户端提供的候选中选择。

Accept计算是将Key字段的文本值与规定GUID拼接,再做SHA1和base64;不是先把Key解码成16 B再拼接。它证明服务端理解这次协议升级,不承担证书认证或保密职责。

import base64
import hashlib

key = 'dGhlIHNhbXBsZSBub25jZQ=='
guid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
assert len(base64.b64decode(key)) == 16
accept = base64.b64encode(hashlib.sha1((key + guid).encode('ascii')).digest()).decode('ascii')
assert accept == 's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
wrong = base64.b64encode(hashlib.sha1(base64.b64decode(key) + guid.encode('ascii')).digest()).decode('ascii')
assert accept != wrong
print('RFC 握手向量通过:', accept)

在HTTP/2、HTTP/3中,还可在双方支持的条件下使用扩展CONNECT建立WebSocket,成功响应为相应2xx而不是HTTP/1.1的101升级。承载的是单条HTTP流,不是把整条多路复用连接全部改成WebSocket。RFC 8441、RFC 9220

消息、帧和掩码

一个 WebSocket 消息可以分帧;控制帧可插在片段之间 Text:FIN=0 消息第一段 Ping:FIN=1 独立控制帧 Continuation:FIN=1 消息剩余部分与结束 应用收到重组后的消息,而非每个 TCP 分段 文本消息整体必须是有效 UTF-8 控制帧 payload 最多 125 B 且不可分片;帧边界、消息边界与传输包边界不同。

基础帧头包含FIN、扩展保留位、opcode、MASK和长度编码。长度0到125可直接表达,126表示后续16位长度,127表示后续64位长度且最高位需为0;存在掩码时再跟4 B键。

客户端发送给服务端的帧必须mask,服务端发出的帧不能mask,包括TLS内的WebSocket帧也遵守该规则。每帧需要新且不可预测的掩码键;下例固定键只用于复现标准教学向量。

payload = b'Hello'
mask = bytes.fromhex('37fa213d')
masked = bytes(v ^ mask[i % 4] for i, v in enumerate(payload))
wire = bytes([0x81, 0x80 | len(payload)]) + mask + masked
assert wire.hex() == '818537fa213d7f9f4d5158'
assert bytes(v ^ mask[i % 4] for i, v in enumerate(masked)) == payload
assert len(wire) == 11  # 2 B基础头 + 4 B mask + 5 B内容
assert 2 + len(payload) == 7  # 服务端同内容、无mask的帧
print('客户端 / 服务端短帧字节:', len(wire), 2 + len(payload))

XOR掩码不能阻止旁观者还原内容;它用于特定基础设施攻击防护,保密需要wss等传输保护。FIN结束的是消息分片序列,控制帧可插入其间;控制帧不可分片、payload不超过125 B。文本消息的UTF-8合法性在重组语义下检查,不能要求每个片段恰好在字符边界结束。RFC 6455:帧与分片

心跳、关闭与业务确认

Ping/Pong检查协议通道是否有回应。Pong按要求回应Ping数据,但WebSocket库可以在应用线程繁忙时独立处理它,所以Pong不保证数据库可用、房间订阅仍有效或业务消息已提交。

浏览器WebSocket API不提供直接发送协议Ping的JavaScript方法。若需要应用心跳,可定义带序号与时间的业务消息,再由应用处理并回包;不要把一个JSON字符串叫ping就误认为它是opcode9控制帧。WebSockets API

关闭握手使用Close帧;有状态码时先写2 B网络序,再写可选UTF-8原因。1000表示正常关闭,1006用于本地表示异常关闭,不能作为Close帧状态码在线发送。关闭原因也不等于某条业务消息的最终结果。

应用确认应使用独立消息ID、提交结果或已处理序号。例如“操作17已接收”和“操作17已持久提交”是不同确认点;连接断开后应凭操作ID查询或重放,而不是只看上一次send是否返回。

SSE 是事件格式,不依赖 chunked

SSE响应使用Content-Type: text/event-stream,内容是UTF-8文本,字段行和空行形成事件。在HTTP/1.1中可使用chunked承载,在HTTP/2/3中由相应DATA流承载;不能定义成“必须使用HTTP chunked”。

行含义
data: ...事件数据,多行以换行拼接
event: alert事件类型;未指定时为message
id: 42更新事件ID,供恢复位置使用
retry: 3000以毫秒表达重连时间设置
: keepalive注释,不派发业务事件
空行触发完整事件的分派处理

下面生成两个data行的事件和一条注释心跳。事件格式的空行不能靠结束TCP连接代替;最后一条没有完成分隔的内容不会被当作完整事件自动交付。

wire = ('id: 42\nevent: alert\ndata: first line\ndata: second line\n\n'
        ': keepalive\n\n').encode('utf-8')
first, heartbeat, trailing = wire.decode('utf-8').split('\n\n')
fields = {}
values = []
for line in first.splitlines():
    name, value = line.split(':', 1)
    value = value[1:] if value.startswith(' ') else value
    if name == 'data':
        values.append(value)
    else:
        fields[name] = value
assert fields == {'id': '42', 'event': 'alert'}
assert '\n'.join(values) == 'first line\nsecond line'
assert heartbeat.startswith(':') and trailing == ''
print('事件类型 / ID / 多行数据:', fields['event'], fields['id'], repr('\n'.join(values)))

这段算例只验证本例LF格式和字段拼接,不是完整EventSource解析器。真实格式还定义CRLF/CR、空id、未知字段及NUL等处理规则。HTML:事件流解析

重连与恢复:ID 不会自动生成历史日志

Last-Event-ID 只传递恢复位置,补发能力来自服务端 客户端 事件服务 事件 id=42:客户端解析并分派 连接中断后重连:Last-Event-ID: 42 日志仍保留后续事件:按契约补发 43、44… 游标已超出保留窗口:返回明确的重新同步路径 浏览器事件分派不是业务提交确认;消费端仍需去重,缺口处理需应用约定。

EventSource在可恢复断开后尝试重连,带上已记录的Last-Event-ID;服务器需要自己实现按ID补发。若日志只保留一小时、客户端离线两小时,应提供重新获取快照及继续订阅的明确流程,而不是静默从最新事件开始。

浏览器分派事件并更新位置,不等于业务消费者已持久提交。页面崩溃、重复补发、处理失败都可能需要应用去重和检查点;仅依赖EventSource无法提供exactly-once业务交付。重新创建EventSource对象也不会自动保留旧对象的恢复状态。

SSE注释可提供链路活动,自动重连本身不是心跳。服务器还可用204结束重连意图,客户端也可调用close;应用需要区分暂时断网与权限失效、服务永久停止等条件。HTML:EventSource

有 Last-Event-ID,为什么还要重新取快照?

先把游标放在保留起点之前两格,金色事件已经无法从日志取回。把游标移到起点前一格时,下一条恰好仍在日志里,才具备完整补发条件。实际系统还要解决事件消费与快照订阅之间的一致性。

正在呈现知识画面
有 Last-Event-ID,为什么还要重新取快照?

游标只是恢复位置,补发能力来自实际保留的日志。缺失的一段已淘汰时,静默从最旧可见事件继续会遗漏变化。

浏览器认证与来源检查

浏览器WebSocket构造器和原生EventSource都不提供任意自定义请求头接口,不能在示例中假装能直接添加Authorization。EventSource可使用适用的cookie及withCredentials设置;跨源读取遵循CORS。若选择fetch自行读取事件流,则解析、恢复和重连也要由代码承担。

WebSocket握手不靠普通CORS允许头来认证来源。使用浏览器cookie认证时,服务端应验证Origin和业务授权,防止其他站点借用户凭据建立连接;非浏览器客户端可自报Origin,所以它也不能代替用户认证。

凭据过期后已有长连接怎样处理,需要协议约定:重新认证、断开重连或按消息校验权限。只在握手时检查一次而让连接无限存活,会使身份撤销与权限更新难以生效。

背压与代理:能推送不等于可以无限排队

经典WebSocket API的send会排入发送队列,bufferedAmount可帮助观察尚未发送的数据,但不会自动给业务生产者一个完整的可等待背压协议。接收侧也要限制消息大小和消费队列,防止慢处理造成内存增长。

例如仪表盘每秒更新100次但只需显示最新状态,可以按键合并中间更新;审计事件不能随意丢弃,则应设队列上限、持久日志与慢消费者恢复流程。两种需求不应使用相同的“队列满就丢”策略。

代理缓冲可能让SSE攒成一批才到,空闲超时可能关闭没有数据的长连接。心跳周期应考虑全链路最短空闲限制和网络抖动,而不是固定照抄15秒;部署排空还要定义最长等待和重连退避,避免同时重启形成重连风暴。

permessage-deflate可减少WebSocket文本体积,但增加CPU、状态和压缩信息泄漏的权衡;context takeover是否保留跨消息历史由协商决定,不应把“启用压缩”当无条件收益。RFC 7692

选型对照

需求WebSocketSSE
客户端与服务端频繁互发消息直接双向通道客户端操作通常另走HTTP
原始二进制内容支持二进制消息文本格式,需要另行编码或下载
浏览器恢复支持应用实现重连和恢复原生重连和Last-Event-ID,补发仍由服务端实现
基础设施要求支持实际采用的Upgrade/CONNECT承载支持长响应和及时刷新
业务可靠性自定义ACK、去重和重放同样需要消费语义、去重与日志

小规模低频更新还可能用轮询;只有服务端推送需求并不自动决定WebSocket。先明确延迟、消息方向、丢失容忍和恢复成本,再选承载方式。

继续查阅