3、WebSocket协议详解:WebSocket握手过程、数据帧格式、掩码机制、心跳维持
聊到交易所的实时行情推送,WebSocket 是绕不开的核心协议。说实话,我最早接触它是在做币安行情接入的时候——那时候直接用轮询,结果被限流封了IP,才老老实实研究WebSocket。今天咱们就把这个协议掰开揉碎,从握手到数据帧,再到心跳,一条龙讲清楚。
3.1 WebSocket 握手过程
WebSocket 的握手,说白了就是一次「升级版」的 HTTP 请求。客户端发一个 GET 请求,服务端回一个 101 状态码,连接就建立起来了。嗯,这里要注意:握手过程本身不传输业务数据,它只是确认双方都支持 WebSocket。
我习惯把握手拆成三步:
- 客户端发起握手:发送一个 HTTP Upgrade 请求,带上 Sec-WebSocket-Key(一个随机 base64 字符串)。
- 服务端计算响应:把 Sec-WebSocket-Key 加上固定 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做 SHA-1 哈希,再 base64 编码,得到 Sec-WebSocket-Accept。
- 服务端返回 101:客户端验证 Sec-WebSocket-Accept 正确,握手完成。
你想想看,为什么要有这个 Key 和 Accept 的校验?其实是为了防止缓存代理把旧的 HTTP 响应误发给 WebSocket 客户端。我在对接某所时,就遇到过代理服务器没正确处理 Upgrade 头,导致握手一直失败——后来加了 Connection: Upgrade 才解决。
核心要点:握手必须走 HTTP 80/443 端口,但握手成功后协议就从 HTTP 切换为 WebSocket。这个切换是单向的,服务端不能主动发起握手。
3.2 数据帧格式
握手完成后,数据就开始以「帧」为单位传输。WebSocket 的帧格式设计得很精巧,我当年第一次看 RFC 6455 时,觉得这结构简直是为低延迟量身定做的。
一个数据帧长这样:
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| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - -+
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - -+
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data (continued) |
+---------------------------------------------------------------+
我来拆解几个关键字段:
- FIN(1 bit):标记这是否是消息的最后一帧。为 1 表示结束,为 0 表示还有后续帧。
- opcode(4 bits):帧类型。0x1 是文本帧,0x2 是二进制帧,0x8 是关闭帧,0x9 是 ping,0xA 是 pong。
- MASK(1 bit):客户端发往服务端的数据必须为 1,服务端发往客户端必须为 0。这就是掩码机制。
- Payload length(7/16/64 bits):数据长度。7 位能表示 0-125 字节;如果值是 126,则后面跟 16 位长度;如果是 127,则跟 64 位长度。
我的经验:解析帧时,Payload length 的变长设计是个坑。我曾经写解析器时忘了处理 126 和 127 的情况,结果遇到大消息直接崩了。建议写个状态机来逐字节解析,别偷懒用固定长度。
3.3 掩码机制
掩码机制是 WebSocket 里一个容易被忽略但很重要的设计。为什么客户端发往服务端的数据必须加掩码?说白了,是为了防止缓存污染攻击。
你想想看:如果攻击者能控制 WebSocket 客户端发送的数据,而中间有 HTTP 缓存代理,它可能把 WebSocket 数据误认为是 HTTP 响应,从而污染缓存。加上掩码后,数据在传输过程中是「混淆」的,缓存代理无法直接利用。
掩码的计算方式很简单:
// 客户端生成 4 字节随机掩码 key
// 每个字节与 payload 对应位置做 XOR
for i in range(len(payload)):
payload[i] ^= masking_key[i % 4]
我在做量化交易系统时,曾经自己实现过 WebSocket 客户端。当时为了性能,直接用 C 扩展做掩码 XOR,比 Python 原生循环快了 10 倍。嗯,如果你用 Python 做高频行情解析,建议用 struct 模块或者 numpy 来批量处理掩码。
注意:服务端发往客户端的数据不能加掩码。如果服务端加了掩码,客户端收到后必须解掩码,但 RFC 规定服务端 MASK 必须为 0。我曾经见过某交易所的测试环境服务端误加了掩码,导致客户端解析乱码——排查了整整一天。
3.4 心跳维持
WebSocket 连接建立后,如果长时间没有数据交互,中间的网络设备(比如 NAT 网关、负载均衡器)可能会把连接断开。这就是为什么需要心跳。
WebSocket 原生支持两种心跳帧:
- Ping 帧(opcode 0x9):由任一方主动发起,可以携带应用数据。
- Pong 帧(opcode 0xA):收到 Ping 后必须回复 Pong,Pong 的数据必须与 Ping 相同。
我个人习惯的做法是:客户端每隔 30 秒发一个 Ping,服务端如果在 60 秒内没收到任何数据(包括 Ping),就主动断开连接。这个时间窗口可以根据网络环境调整——比如在海外节点,我会把间隔拉大到 45 秒,避免网络抖动导致误断。
避坑指南:我曾经在对接某所时,发现它的 WebSocket 服务端根本不响应 Ping 帧。后来查文档才知道,它要求客户端用「应用层心跳」——也就是定期发一个空的消息帧。所以,别盲目依赖协议层的心跳,先看交易所的 API 文档怎么说。
另外,心跳的定时器实现也有讲究。用 time.sleep 做定时任务?那在高并发场景下会阻塞事件循环。我建议用 asyncio 的 loop.call_later 或者 asyncio.create_task 配合 asyncio.sleep,这样不会阻塞其他协程。
3.5 知识体系总览
下面这张图把 WebSocket 协议的核心逻辑串起来了。从握手开始,到数据帧解析,再到心跳维持,每一步都有对应的处理策略。
这张图把 WebSocket 协议拆成了三个核心阶段。你从左边看到右边,就是一次完整的数据交互流程。我个人建议,在实现解析引擎时,把这三个阶段拆成独立的模块——握手模块、帧解析模块、心跳模块。这样后期维护和调试都方便很多。
好了,WebSocket 协议的核心内容就这些。下一节咱们会深入实战,用 Python 手写一个完整的 WebSocket 客户端,把今天讲的知识点全部落地。