WebSocket 握手,第一条消息之前到底发生了什么

下面这个场景,任何人第一次遇到时都会被搞糊涂。
一位开发者正在生产环境调试一个聊天应用。用户之间的消息无法可靠地送达。在浏览器 DevTools 的 Network 标签页中,他们筛选出 WebSocket 连接,看到了奇怪的东西:与聊天服务器的连接显示状态为 "101 Switching Protocols" —— 一个在普通 API 中从未见过的状态码。没有 "Response" 响应体可供查看。"Preview" 标签页是空的。URL 以 ws:// 而非 http:// 开头。但这条连接确实在传输数据 —— 他们可以在 "Messages" 子标签页中看到,一行行标注为 "text" 和 "binary" 的数据飞速掠过。
这位开发者开始提出疑问。这到底是不是 HTTP?如果是 HTTP,为什么看起来不像其他 HTTP 请求?如果不是 HTTP,为什么又以一个 HTTP 请求开头?"Switching Protocols" 是从什么切换到了什么?为什么浏览器把 "text" 和 "binary" 显示为不同的帧类型?服务器究竟做了什么才接受了一条 WebSocket 连接?
所有这些问题的答案,就藏在 WebSocket 握手期间发生的事情里 —— 也就是那段短暂时刻:一条 TCP 连接以 HTTP 开始,围绕一个名为 Sec-WebSocket-Key 的请求头完成一套特定动作,收到一个状态码为 101 的响应,然后转变为一个双向消息通道,从此再也不像 HTTP。理解这套动作能解开 WebSocket 的大量困惑,尤其是为什么 WebSocket 连接在只认识 HTTP 的代理、负载均衡器和 TLS 终结器面前表现异常。
本文介绍客户端与服务器在 WebSocket 握手期间真正发生了什么,并用真实 PHP 代码对照 RFC 6455 逐一验证。文章还覆盖了后续的帧格式、为什么 WebSocket 要求客户端侧 masking,以及 PHP 应用通常如何实现 WebSocket 服务器(Ratchet、Swoole、RoadRunner)。读完之后,"101 Switching Protocols" 将不再神秘。
速览
WebSocket 连接以 HTTP 开始。客户端发送一个普通 HTTP GET 请求,附带特殊请求头:Upgrade: websocket、Connection: Upgrade,以及 Sec-WebSocket-Key(一个 base64 编码的 16 字节随机值)。服务器响应 HTTP 101 Switching Protocols 和一个计算得到的 Sec-WebSocket-Accept 响应头。101 之后,同一条 TCP 连接不再说 HTTP,而是开始说 WebSocket 帧。
Sec-WebSocket-Key/Sec-WebSocket-Accept 这套动作不是安全机制。服务器计算 base64(SHA1(client_key + magic_guid)),其中 magic GUID 是常量 258EAFA5-E914-47DA-95CA-C5AB0DC85B11。对照 RFC 6455 验证:对于客户端 key dGhlIHNhbXBsZSBub25jZQ==,预期的 accept 值是 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。其目的是证明服务器真的理解 WebSocket 协议,而不是一个会把任何 GET 都返回 HTTP 200 的糊涂 HTTP 服务器或缓存代理。
握手之后,通信使用帧而非 HTTP 消息。每一帧都有一个 opcode(text、binary、ping、pong、close)、一个长度字段,以及(从客户端到服务器的方向)一个 mask key。文本帧承载 UTF-8;二进制帧承载任意字节;ping/pong 用于保活;close 帧承载一个可选的状态码。
客户端到服务器的帧必须 masking;服务器到客户端的帧禁止 masking。mask 是一个与载荷做 XOR 的 32 位随机值。根据 RFC 6455 Section 5.3,这不是安全机制 —— 任何读取数据包的人都能轻松解除 masking。它存在的目的是防御一种针对旧式 HTTP 代理的、相当隐蔽的缓存投毒攻击。
PHP 实现 WebSocket 服务器的选项:Ratchet(纯 PHP,底层使用 ReactPHP)、Swoole(内置 WebSocket 支持的 C 扩展,高性能)、RoadRunner(带 WebSocket 插件的基于 Go 的应用服务器)。三者在简洁性与性能之间各有取舍。PHP 的同步请求模型并不适合 WebSocket;三种方案都用事件循环。
负载均衡器和代理需要支持 Upgrade。Nginx 只需少量配置即可很好处理 WebSocket(proxy_set_header Upgrade / Connection)。AWS ALB 原生支持 WebSocket。HAProxy 需要 option http-keep-alive 和适当的超时设置。只懂 HTTP 的旧式代理会丢弃 WebSocket 连接,或把它们降级成 HTTP,这会让一切崩溃。
TLS 终结会影响握手。wss://(WebSocket Secure)是跑在 TLS 之上的 WebSocket —— 同样的握手,外面包一层 TLS。在负载均衡器处终结,意味着负载均衡器能看到握手,并且必须正确透传。有些云厂商要求特定配置,才能让 wss:// 穿过它们的 L7 负载均衡器。
你将学到什么
- 建立 WebSocket 的确切 HTTP 请求/响应
- 为什么存在 Sec-WebSocket-Key/Accept 这套计算
- 握手之后承载消息的帧格式
- 为什么客户端到服务器的帧要 masking
- PHP 专属的 WebSocket 实现选项
WebSocket 快速入门
如果读者已经知道 WebSocket 是什么,只想看握手细节,可以直接跳到下一节。否则,这里给出精简版。
WebSocket 是一种在单条 TCP 连接上进行持久、双向、面向消息通信的协议。标准 HTTP 是请求-响应式的 —— 客户端发问,服务器回答,连接通常关闭或等待下一条请求。WebSocket 则不同:初始建立之后,任一侧都可以在任何时刻发送消息,连接一直保持打开,直到被显式关闭。
WebSocket 的常见用例:
- 聊天应用(双方独立发送消息)
- 从服务器推送到浏览器的实时通知
- 实时仪表盘和股票行情
- 多人游戏状态同步
- 协作编辑(Google Docs 风格的实时光标)
这些场景中 WebSocket 的替代方案通常是:
- 长轮询(Long polling):客户端发出 HTTP 请求,服务器保持连接打开直到有数据可发,然后重复。
- 服务器发送事件(SSE):仅服务器到客户端,基于 HTTP。比 WebSocket 简单,但是单向的。
- HTTP/2 push:如今基本被废弃;浏览器已经不能很好地支持它。
- 常规轮询:客户端每隔 N 秒问一次"有更新吗?"。很浪费。
当需要低延迟双向通信时,WebSocket 胜出。代价是复杂度:持久连接需要在负载均衡器中特殊处理,需要保活管理,而且无法干净地融入请求-响应框架。
升级握手——客户端请求
WebSocket 连接以一条看起来像普通 HTTP GET 请求开始。当 JavaScript 调用 new WebSocket('ws://server.example.com/chat') 时,浏览器发送的确切请求如下:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com各行的含义:
GET /chat HTTP/1.1:标准 HTTP 请求行。URL 路径就是 WebSocket 端点在服务器上的位置。使用 HTTP/1.1;从 HTTP/2 升级 WebSocket 也存在,但机制不同。Host: server.example.com:标准 HTTP Host 请求头。Upgrade: websocket:声明切换协议的意图。这使用了 HTTP Upgrade 机制(RFC 7230 Section 6.7),那是一个用于在既有连接上切换协议的通用框架。Connection: Upgrade:告诉中间设备不要把这条连接当作普通的长连接 HTTP。Upgrade 请求头和Connection: Upgrade两者缺一不可,升级才能生效。Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==:16 个随机字节,base64 编码。客户端为每条连接新生成。这是握手验证的输入。Sec-WebSocket-Version: 13:WebSocket 协议版本。版本 13 是现行标准(RFC 6455)。更早的草案(版本 0、7、8)曾经存在,但已废弃。Origin: http://example.com:发起连接页面的来源(origin)。浏览器会自动设置;服务器可以用它做类似 CORS 的源检查。
可选请求头包括 Sec-WebSocket-Protocol(子协议协商,例如 "chat" 或 "wamp")和 Sec-WebSocket-Extensions(压缩等)。同域的 Cookie 会自动附带,这正是许多应用用来认证 WebSocket 连接的方式。
服务器响应
一个支持 WebSocket 的服务器如果接受升级,会响应:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=各部分的含义:
HTTP/1.1 101 Switching Protocols:协议切换成功的特定状态码。这是 HTTP 状态码中少数几个 1xx 段之一。Upgrade: websocket和Connection: Upgrade:回显客户端的请求头,确认切换。Sec-WebSocket-Accept: ...:服务器理解了客户端请求的证明。由 Sec-WebSocket-Key 计算得出。- 没有其他内容。没有响应体。没有 Content-Length。这些响应头后面的空行之后,连接就不再是 HTTP —— 而是 WebSocket。
如果服务器不想接受这条 WebSocket 连接,它会用普通 HTTP 错误(400、401、403 等)响应。客户端看到后,WebSocket 连接就会失败。只有带正确响应头的 101 Switching Protocols 才能建立 WebSocket。
验证:Sec-WebSocket-Accept 的计算
Sec-WebSocket-Accept 的值按 RFC 6455 Section 4.2.2 定义的特定算法,从客户端的 Sec-WebSocket-Key 推导而来:
- 把客户端 key 与 magic GUID 拼接:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11 - 计算拼接后字符串的 SHA-1 哈希
- 对原始 SHA-1 输出做 base64 编码
在 PHP 中:
<?php
function computeWebSocketAccept(string $clientKey): string {
$magic = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
return base64_encode(sha1($clientKey . $magic, true));
}
// Test with RFC 6455 example
$clientKey = 'dGhlIHNhbXBsZSBub25jZQ==';
echo computeWebSocketAccept($clientKey);验证输出(PHP 8.3.6):
s3pPLMBiTxaQ9kYGzzhZRbK+xOo=这与 RFC 6455 中的期望值完全一致。
为什么会有这个 magic GUID?
GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 只是 RFC 作者选定的一个特定值。它没有特殊的数学意义 —— 它只是客户端和服务器都知道的一个任意常量。它的作用:
- 服务器必须知道这个特定的 GUID 和特定的 SHA-1 + base64 算法,才能产生正确的 Accept 值。
- 一个不理解 WebSocket 的通用 HTTP 服务器不会产生正确的值。它可能返回 200 OK、404 Not Found 或其他东西 —— 但不会是带正确 Accept 的正确 101。
- 客户端在接受连接之前会校验 Accept。如果值不对,客户端会拒绝切换到 WebSocket 模式。
这不是身份验证。任何人只要拿到客户端 key 就能算出 Accept 值 —— 它是一个公开的、确定性的函数。要点不在于保密,而在于协议正确性。它防止与实际上不理解 WebSocket 的服务器建立连接,从而避免当客户端与预期 WebSocket 端点之间存在代理或配置错误的服务器时出现各种隐蔽 bug。
对于真正的身份验证,WebSocket 连接依赖:
- Cookie:浏览器会在初始 HTTP 请求时自动发送
- URL 中的令牌:
ws://server/chat?token=abc123 - 连接后的第一条消息:客户端把凭据作为第一条 WebSocket 消息发送
- Sec-WebSocket-Protocol 子协议协商:有时用于传递令牌
握手之后——帧
一旦 101 响应被发出并被收到,TCP 连接就不再说 HTTP。双方现在按照 WebSocket 协议交换帧。
帧格式(RFC 6455 Section 5.2):
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 ... |
+---------------------------------------------------------------+验证:发送一个未 masking 的文本帧 "Hello" 恰好产生下面这 7 个字节:
81 05 48 65 6c 6c 6f逐字节拆解:
0x81= 二进制10000001。FIN=1(这是该消息的最后一个分片),RSV1-3=0(保留位,除非协商了扩展,否则必须为 0),opcode=0x1(文本帧)。0x05= 二进制00000101。MASK=0(未 masking,仅对服务器→客户端方向合法),payload 长度 = 5。0x48 0x65 0x6c 0x6c 0x6f= ASCII 的 "Hello"。
Opcodes(4 位字段):
- 0x0:延续帧(用于消息分片)
- 0x1:文本帧(UTF-8 编码)
- 0x2:二进制帧(任意字节)
- 0x3-0x7:保留给未来的非控制帧
- 0x8:连接关闭
- 0x9:ping
- 0xA:pong
- 0xB-0xF:保留给未来的控制帧
Payload 长度编码(这部分比较特别):
- 如果 7 位长度字段是 0–125,那就是实际 payload 长度。
- 如果是 126,接下来 2 个字节(16 位无符号整数)给出实际长度。用于不超过 65,535 字节的 payload。
- 如果是 127,接下来 8 个字节(64 位无符号整数)给出实际长度。用于非常大的 payload。
验证:一个 150 字节的 payload 产生的帧以 81 7e 00 96 ... 开头 —— 0x7e 就是十进制的 126,0x0096 就是大端 16 位表示的 150。
Masking 规则(以及它为何存在)
根据 RFC 6455 Section 5.3:
- 所有从客户端到服务器的帧必须 masking。
- 所有从服务器到客户端的帧禁止 masking。
Masking 的工作方式如下:客户端为每一帧生成一个 4 字节随机 mask key。每个 payload 字节与对应的 mask 字节做 XOR(按模索引在 4 个 mask 字节间轮换)。mask key 被包含在帧中,这样服务器才能解除 masking。
伪代码如下:
mask_key = random_bytes(4)
for i in range(len(payload)):
masked_payload[i] = payload[i] XOR mask_key[i % 4]验证示例:"Hello"(5 字节),mask key 为 0x583a4a35:
Frame: 81 85 58 3a 4a 35 10 5f 26 59 37
^ ^ ^^^^^^^^^^ ^^^^^^^^^^^^^^
| | mask key masked "Hello"
| |
| Byte 1: MASK=1, length=5
Byte 0: FIN, TEXT opcode已 masking 的 payload 10 5f 26 59 37 与 58 3a 4a 35 58(重复的 mask key)做 XOR,得到 48 65 6c 6c 6f = "Hello"。
Masking 为什么存在?
不是为了安全。任何能抓包或读取线上流量的人都能轻松解除 masking(mask key 就在帧里)。根据 RFC 6455 及其相关文档,原因是防御针对不理解 WebSocket 的旧式 HTTP 代理的缓存投毒攻击。
具体担忧是:公网上的攻击者让一条 WebSocket 连接穿过一个缓存 HTTP 代理。如果 WebSocket 帧看起来像合理的 HTTP 请求,恶意客户端就能诱骗代理缓存某个 URL 的特定响应 —— 从而毒化其他用户的缓存。Masking 让 WebSocket 帧看起来像随机噪声,确保代理不会误把它们当作 HTTP 来解析。
这个威胁模型很大程度上已成为历史。现代代理能正确处理 WebSocket(通过 wss:// 的 CONNECT 式隧道,或显式的 WebSocket 支持)。但 RFC 无论如何都强制要求 masking,所有符合规范的实现都会执行。服务器收到未 masking 的客户端到服务器帧,必须关闭连接。
为什么服务器到客户端方向不 masking:这个方向不存在等价的缓存投毒攻击。服务器的输出不会经过客户端那边的缓存代理,构不成可利用的场景。省掉 masking 可以节省 CPU。
PHP WebSocket 实现方案
PHP 标准的每请求一进程模型并不适合 WebSocket —— 连接是长连接的,而不是请求-响应的。三种常见方案:
Ratchet(纯 PHP,底层使用 ReactPHP):
<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
class ChatServer implements MessageComponentInterface {
protected \SplObjectStorage $clients;
public function __construct() {
$this->clients = new \SplObjectStorage;
}
public function onOpen(ConnectionInterface $conn) {
$this->clients->attach($conn);
echo "Connection opened: {$conn->resourceId}\n";
}
public function onMessage(ConnectionInterface $from, $msg) {
foreach ($this->clients as $client) {
if ($client !== $from) {
$client->send($msg); // broadcast to others
}
}
}
public function onClose(ConnectionInterface $conn) {
$this->clients->detach($conn);
}
public function onError(ConnectionInterface $conn, \Exception $e) {
$conn->close();
}
}权衡:纯 PHP 意味着不需要 C 扩展,可以在任何 PHP 主机上运行。但它通过流使用非阻塞 I/O,存在性能上限 —— Ratchet 每个进程只能处理几千个并发连接,而不是几万个。对中小型应用够用;不适合高规模的实时系统。
Swoole(C 扩展):
<?php
$server = new Swoole\WebSocket\Server('0.0.0.0', 9502);
$server->on('open', function ($server, $req) {
echo "connection open: {$req->fd}\n";
});
$server->on('message', function ($server, $frame) {
// Broadcast to all connected clients
foreach ($server->connections as $fd) {
if ($fd !== $frame->fd) {
$server->push($fd, $frame->data);
}
}
});
$server->on('close', function ($server, $fd) {
echo "connection closed: {$fd}\n";
});
$server->start();权衡:性能高得多(每个进程几万个连接),真正的协程,原生 WebSocket 支持。但需要 C 扩展(从源码编译或通过 PECL 安装),而且持久化运行模型比传统 PHP 更需要谨慎的内存管理。
RoadRunner(带 PHP worker 的 Go 应用服务器):
WebSocket 连接由 RoadRunner 的 Go 层处理;PHP worker 通过 broker(Redis、KV 等)处理事件,而不是直接处理帧。运维复杂度更高,但扩展性好,而且大多数工作可以使用标准的 PHP-FPM 风格 worker。
常见部署问题
Nginx 代理配置:
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}每行的原因:
proxy_http_version 1.1:Upgrade 需要 HTTP/1.1。proxy_set_header Upgrade $http_upgrade:把客户端的 Upgrade 请求头透传过去。proxy_set_header Connection "upgrade":告诉上游这是一次升级请求(Nginx 否则会按自己的逻辑设置 Connection)。proxy_read_timeout和proxy_send_timeout:WebSocket 连接是长命的。默认超时(60 秒)会把它们断开。要设成很大的值。
负载均衡器粘性会话:WebSocket 状态保存在处理该连接的进程里。如果负载均衡器以轮询(round-robin)方式分发新的 HTTP 请求,对 HTTP 没问题,但会毁掉 WebSocket。需要会话亲和性("粘性会话"或"会话持久化"),确保同一个客户端的连接始终落在同一个后端上。
TLS 终结:wss://(WebSocket Secure)是跑在 TLS 上的 WebSocket。如果 TLS 在负载均衡器处终结,负载均衡器看到的是明文握手,必须把 Upgrade 请求头透传过去。AWS ALB、Google Cloud Load Balancer 和 Cloudflare 都能原生处理这一点。一些较老的代理做不到。
空闲超时:中间设备可能会关闭空闲的 TCP 连接。用周期性的 ping 帧(opcode 0x9)保持 WebSocket 连接存活,对端会用 pong(opcode 0xA)应答。大多数 WebSocket 客户端库会自动处理;服务器端要配置合适的 ping 间隔。
需要避免的陷阱
不要假设 WebSocket 连接永远是"打开的"。网络会断连。超时会发生。电池优化模式的移动操作系统会挂起后台连接。客户端必须优雅地重连;服务器必须处理突发断连而不泄漏资源。
不要不发 ping。穿过负载均衡器、代理或企业 NAT 设备的空闲连接可能被静默丢弃。每 30–60 秒发一次 ping 保持连接存活。在移动端,要注意操作系统级电源管理可能连活动中的连接也挂起。
不要为了安全而忽略 Origin 请求头。浏览器会在 WebSocket 握手中发送 Origin。服务器端要检查 Origin 是否匹配预期来源,防止来自不可信站点的跨源 WebSocket 连接。这是一种类似 CSRF 的防护。
不要在 101 之后在 WebSocket 连接上使用 HTTP 状态码。握手之后,HTTP 就结束了。应用层错误(请求格式错误、权限不足)应该通过带相应状态码(1000–4999 段)的 WebSocket close 帧来传达,而不是 HTTP 状态码。
不要不认证。WebSocket 握手携带 cookie,但并不强制认证。服务器端 WebSocket 处理器必须从 cookie、URL 参数或第一条消息来验证身份。否则任何能访问到端点的人都能连上来。
不要不分片就发送大帧。非常大的消息(数兆字节)应该分片成多个帧,除最后一个外 FIN=0。有些中间设备有帧大小限制。
不要忽略背压(backpressure)。如果慢客户端跟不上服务器发送的消息,服务器的发送缓冲区会不断增长。实时系统需要背压处理 —— 要么丢弃消息,要么带限制地排队,要么放慢生产者。
不要假设 WebSocket 到处都可用。企业防火墙,尤其是做深度包检测的,有时会阻断 WebSocket 连接。如果必须在这种环境工作,要准备一条回退路径(长轮询、SSE)。
迷你问答
为什么 WebSocket 用 HTTP 来做握手,而不是自定义协议?
实际原因:为了能在现有 HTTP 基础设施上工作(443 端口、标准代理、负载均衡器、DNS 解析)。以 HTTP 开始,意味着连接可以通过任何允许 HTTPS 流量的东西建立。升级之后,连接便使用自己的协议以保证效率。
ws:// 和 wss:// 有什么区别?
ws:// 是跑在裸 TCP 上的 WebSocket(默认端口 80)。wss:// 是跑在 TLS 上的 WebSocket(默认端口 443)。同样的握手,同样的帧格式 —— wss:// 只是把整套东西包进 TLS。现代应用应该始终使用 wss://;浏览器会阻止 HTTPS 页面发起 ws://(混合内容)。
可以直接用 PHP-FPM 跑 WebSocket 吗?
基本不行。PHP-FPM 的模型是每请求一进程 —— 响应结束后,worker 就去处理下一条请求。WebSocket 连接是长命的。需要一个事件循环,而 PHP-FPM 不提供。可以使用 Ratchet、Swoole、RoadRunner,或者把 WebSocket 放在独立服务(Node.js、Go 等)里,通过共享存储(如 Redis)与 PHP-FPM 通信。
一台服务器能处理多少条 WebSocket 连接?
高度依赖实现。Ratchet(纯 PHP):几千条。Swoole:每进程几万条。原生 Go 或 Node.js WebSocket 服务器:几十万条。瓶颈通常在于每条连接的内存(缓冲区、应用状态),再加上操作系统文件描述符上限(ulimit -n)。
如果在握手完成之前就发送消息,会怎样?
客户端 WebSocket API(浏览器、大多数库)会把消息排队,直到握手完成才发送。如果试图在收到 101 响应之前通过原始 TCP socket 发送数据,服务器会把它当作 HTTP 数据的延续,通常会报错关闭连接。
如何认证一条 WebSocket 连接?
可选方案:(1)依赖握手时发送的 cookie(当 WebSocket 服务器与认证 cookie 同源时有效);(2)把令牌作为 URL 查询参数传递(ws://server/chat?token=xxx);(3)连接后把认证信息作为第一条 WebSocket 消息发送;(4)使用 Sec-WebSocket-Protocol 做基于子协议的认证。对基于浏览器的应用,cookie 最简单。
为什么连接之后 WebSocket 错误没有 HTTP 状态码?
101 升级之后,HTTP 就结束了。连接通过携带状态码(1000–4999)的 WebSocket close 帧终止,用状态码指示错误(RFC 6455 Section 7.4)。1000 是正常关闭,1001 是离开(页面关闭),1002 是协议错误,1003 是不支持的数据,1006 是异常关闭(未收到 close 帧),1008 是策略违规,等等。
WebSocket 能在 HTTP/2 上跑吗?
可以,通过 RFC 8441(WebSocket over HTTP/2)。它使用 HTTP/2 的流机制,而不是经典的 Upgrade 握手。采用情况参差不齐 —— 大多数 WebSocket 服务器仍然期望 HTTP/1.1 升级。如果基础设施要求 HTTP/2,请确认 WebSocket 实现支持它。
总结
WebSocket 握手就是那种互联网管道里看起来不透明、一旦仔细看就豁然开朗的东西。带特殊请求头的 HTTP 请求、带计算 accept 的 HTTP 101 响应,然后同一条 TCP 连接上换成完全不同的协议。accept 计算那个具体细节(SHA-1 哈希配一个 magic GUID,再 base64 编码)的存在是为了抓协议错误,而不是认证。后面的帧格式紧凑、面向消息,还有关于 masking 的具体规则,用来绕开历史 HTTP 代理的行为。
对 PHP 应用而言,WebSocket 支持需要与传统 PHP-FPM 不同的运行时模型。Ratchet、Swoole 和 RoadRunner 以不同方式提供了这一点。选哪个取决于应用的规模需求和运维偏好 —— 小型应用用 Ratchet 就很好;高规模应用通常用 Swoole,或者干脆把 WebSocket 处理移出 PHP。
更深的领悟是:协议是工程产物,每一块都有具体原因。WebSocket 握手里的 magic GUID 看起来随意,但存在的理由是为了协议正确性。masking 规则看起来像安全特性,但存在的理由是为了防御缓存投毒。理解协议为什么会是现在这个样子 —— 而不是死记硬背它的形状 —— 能让协议出问题的时候更容易调试。一旦了解 101 Switching Protocols 两侧各自发生了什么,这个状态码就永远不会再显得神秘。
闭环收尾
那位曾被 101 Switching Protocols 搞糊涂的开发者,最终理解了全貌。他们不再把 WebSocket 连接当作神奇的黑盒。当聊天应用在生产环境出现间歇性连接问题时,他们知道怎么调试:检查初始握手是否成功(浏览器 DevTools Network → Response Headers),确认 Sec-WebSocket-Accept 存在且正确,检查负载均衡器配置是否正确处理 Upgrade 请求头。
六个月后,团队把聊天后端从长轮询(扩展性一直很差)迁移到基于 Swoole 的真 WebSocket。这位开发者主导了迁移。他们理解握手、帧格式、masking 规则和部署要求。迁移花了两周。聊天延迟从 300ms 降到 30ms。服务器负载下降 60%,因为不再有长轮询的请求反复折腾。
一年后,团队要加一个实时仪表盘功能,WebSocket 成了显而易见的选择。基础设施已经就位。仪表盘几天就上线,而不是用长轮询需要几周。这些具体的技术知识 —— 握手如何工作、约束是什么 —— 支撑了更快的功能交付。
工程文化由此推广开来:协议不是魔法。HTTP、WebSocket、gRPC、自定义协议 —— 每一种都有可以理解和调试的具体机制。理解这些机制的团队部署起来更有信心,也会为需求选择更合适的协议。
"People Also Ask"
什么是 WebSocket 握手?WebSocket 握手是建立 WebSocket 连接的、基于 HTTP 的初始交换。客户端发送一个带
Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key(16 个随机字节,base64 编码)和Sec-WebSocket-Version: 13的 HTTP GET 请求。服务器响应HTTP/1.1 101 Switching Protocols和Sec-WebSocket-Accept(由客户端 key 计算得出)。这次交换之后,TCP 连接不再说 HTTP,而是开始交换 WebSocket 帧。定义于 RFC 6455。什么是 Sec-WebSocket-Accept,它是怎么计算出来的?Sec-WebSocket-Accept 是服务器对客户端 Sec-WebSocket-Key 的回应,证明服务器理解 WebSocket 协议。计算方式:把客户端 key 与 magic GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11拼接,计算 SHA-1,再 base64 编码。对照 RFC 6455 验证:对于客户端 keydGhlIHNhbXBsZSBub25jZQ==,正确的 accept 值是s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。这不是安全机制 —— 任何人都能算出来 —— 但它能防止连接意外"升级"到不支持 WebSocket 的服务器或配置错误的代理。什么是 HTTP 状态码 101 Switching Protocols?101 是协议升级成功的 HTTP 状态码。当客户端发送 Upgrade 请求头请求切换到另一个协议(比如 WebSocket),且服务器接受时,服务器用 101 表示切换已经发生。101 响应及其后的空行分隔符之后,连接使用协商好的协议 —— 不再是 HTTP。这是为数不多的 1xx 段 HTTP 状态码之一;其余有 100 Continue、102 Processing(WebDAV)和 103 Early Hints。
为什么从客户端到服务器的 WebSocket 帧要 masking?根据 RFC 6455 Section 5.3,所有客户端到服务器的帧必须 masking(与一个包含在帧里的随机 32 位 key 做 XOR)。这不是为了安全 —— 任何读到数据包的人都能轻易解除 masking。目的是防御一种特定的缓存投毒攻击:旧式 HTTP 代理可能把明文的 WebSocket 帧误当成 HTTP 请求。服务器到客户端的帧禁止 masking,因为那个方向没有对等的攻击。masking 要求是被严格执行的:服务器收到未 masking 的客户端帧必须关闭连接。
ws:// 和 wss:// 有什么区别?ws:// 是跑在裸 TCP 上的 WebSocket;wss:// 是跑在 TLS 上的 WebSocket。同样的协议,同样的握手,同样的帧格式 —— wss:// 只是把整条连接包进 TLS。标准端口是 ws:// 用 80,wss:// 用 443。浏览器限制 HTTPS 页面发起 ws://(混合内容警告),所以生产应用应该用 wss://。TLS 在负载均衡器终结很常见;负载均衡器解密后把 WebSocket 握手作为明文 HTTP 转发给后端。
如何用 PHP 实现 WebSocket 服务器?三个主要选项:Ratchet(纯 PHP,底层用 ReactPHP,适用于任何 PHP 主机,可处理几千条并发连接)、Swoole(提供高性能 WebSocket 支持的 C 扩展,每进程几万条连接)、RoadRunner(带 WebSocket 插件的基于 Go 的应用服务器,PHP worker 通过 broker 处理事件)。PHP-FPM 每请求一进程的模型不适合 WebSocket 的长连接;三种方案都用事件循环。选哪个取决于规模需求和可接受的运维复杂度。
为什么 WebSocket 连接穿过负载均衡器就会断?几个原因:(1)负载均衡器不转发 Upgrade/Connection 请求头(在 Nginx/HAProxy 里需要显式配置);(2)空闲超时 —— WebSocket 连接会无限期保持打开,但负载均衡器可能有 60 秒空闲超时把它杀掉;(3)未启用粘性会话 —— 轮询路由会把不同请求分到不同后端,而 WebSocket 状态必须留在同一个后端;(4)TLS 终结配置错误 —— 有些代理不把 Upgrade 请求头穿过 TLS。解决办法是显式配置负载均衡器支持 WebSocket(Nginx 里用 proxy_set_header 指令,HAProxy 里用 option http-keep-alive 和长超时)。
什么是 WebSocket 关闭码?关闭码是 WebSocket close 帧(opcode 0x8)里携带的 2 字节状态码,用于说明连接关闭的原因。标准码(RFC 6455 Section 7.4):1000 正常关闭、1001 离开、1002 协议错误、1003 不支持的数据、1006 异常关闭(未收到 close 帧时使用)、1007 无效帧载荷数据、1008 策略违规、1009 消息过大、1010 缺少扩展、1011 内部服务器错误。应用自定义码在 4000–4999 段。当 WebSocket 连接干净地关闭时,双方交换带解释原因的码的 close 帧。
说明:本文中所有 PHP 行为、RFC 6455 计算和 WebSocket 帧示例,均对照 PHP 8.3.6 与现行 RFC 6455 规范验证。magic GUID、Sec-WebSocket-Accept 算法和帧格式都是稳定的协议细节,不太可能改变。负载均衡器配置示例(Nginx、HAProxy)反映当前的标准实践;具体语法可能因版本而异。PHP WebSocket 库 API(Ratchet、Swoole)反映写作时的一般形态;具体的方法签名和库能力会不断演进。性能特征(每进程连接数、延迟、内存占用)高度依赖硬件、网络状况和应用逻辑;请针对自己的具体负载做基准测试,而不要依赖笼统的说法。对于生产级 WebSocket 部署,用真实的连接数量和网络条件做测试,比从合成测试外推更可靠。