WebSocket 是建立在 TCP 之上的全双工通信协议,它解决了 HTTP 请求-响应模型无法满足的实时通信需求。和 HTTP 长轮询、SSE(Server-Sent Events)不同,WebSocket 在客户端和服务端之间建立持久的双向通道,任何一方都可以主动发送数据。
但 WebSocket 不是”开箱即用”的——握手失败、连接断开、心跳超时、负载均衡器不支持、百万连接下的内存爆炸——这些工程问题决定了 WebSocket 服务能不能在生产环境中稳定运行。
一、WebSocket 握手:HTTP Upgrade 机制
WebSocket 连接的建立以一个 HTTP/1.1 Upgrade 请求开始。这个设计让 WebSocket 可以复用 HTTP 的基础设施(端口、代理、TLS)。
1.1 握手流程
客户端 服务端
│ │
│ GET /chat HTTP/1.1 │
│ Host: server.example.com │
│ Upgrade: websocket │
│ Connection: Upgrade │
│ Sec-WebSocket-Key: dGhlIHNhbXBsZS... │
│ Sec-WebSocket-Version: 13 │
│ Sec-WebSocket-Protocol: chat, superchat │
│─────────────────────────────────────────→│
│ │
│ HTTP/1.1 101 Switching Protocols │
│ Upgrade: websocket │
│ Connection: Upgrade │
│ Sec-WebSocket-Accept: s3pPLMBiTx... │
│ Sec-WebSocket-Protocol: chat │
│←─────────────────────────────────────────│
│ │
│ ═══════ WebSocket 帧双向传输 ═══════ │
│←────────────────────────────────────────→│
1.2 握手头字段详解
# 客户端请求(必须是 GET,HTTP/1.1)
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket # 必须:协议升级标识
Connection: Upgrade # 必须:表示需要升级连接
Sec-WebSocket-Key: dGhlIHN... # 必须:16 字节 Base64 编码的随机值
Sec-WebSocket-Version: 13 # 必须:协议版本(当前唯一有效值是 13)
Sec-WebSocket-Protocol: chat # 可选:子协议协商
Sec-WebSocket-Extensions: permessage-deflate # 可选:扩展协商
Origin: https://www.example.com # 浏览器自动添加,服务端可用于 CORS 检查
# 服务端响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
# Accept 值 = Base64(SHA-1(Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
Sec-WebSocket-Accept 的计算确保服务端确实理解 WebSocket 协议,而不是一个碰巧返回 101 的普通 HTTP 服务器:
import hashlib
import base64
key = "dGhlIHNhbXBsZSBub25jZQ=="
magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
accept = base64.b64encode(
hashlib.sha1((key + magic).encode()).digest()
).decode()
print(accept) # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=1.3 握手失败场景
失败码 │ 原因 │ 排查方法
─────────┼───────────────────────────────┼────────────────────────────
400 │ 缺少必需的 Upgrade 头 │ 检查请求头完整性
403 │ Origin 检查未通过 │ 检查服务端的 Origin 白名单
404 │ WebSocket 端点路径错误 │ 检查 URL 路径
426 │ 不支持请求的 WebSocket 版本 │ 确认使用版本 13
502/504 │ 代理不支持 WebSocket │ 检查 Nginx/LB 配置
连接重置 │ 防火墙拦截了 Upgrade 请求 │ 检查中间设备
二、WebSocket 帧格式
握手完成后,通信切换到 WebSocket 帧(Frame)协议。每个消息可以由一个或多个帧组成。
2.1 帧结构
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 = 后续还有帧
RSV1-3 (各1 bit): 保留位,扩展使用(如 permessage-deflate 用 RSV1)
Opcode (4 bits): 帧类型
MASK (1 bit): 客户端→服务端必须为 1;服务端→客户端必须为 0
Payload length: 7 bits (0-125) 直接表示长度
126 表示后续 2 字节为实际长度
127 表示后续 8 字节为实际长度
Masking-key: 4 字节,仅客户端发送时使用
2.2 Opcode 类型
Opcode │ 类型 │ 说明
───────┼──────────────────┼──────────────────────────────────
0x0 │ Continuation │ 延续帧(多帧消息的后续帧)
0x1 │ Text │ 文本帧(UTF-8 编码)
0x2 │ Binary │ 二进制帧
0x3-7 │ Reserved │ 保留给未来的数据帧
0x8 │ Close │ 关闭连接
0x9 │ Ping │ 心跳探测
0xA │ Pong │ 心跳响应
0xB-F │ Reserved │ 保留给未来的控制帧
2.3 Masking 机制
客户端发送的每一帧都必须使用 Masking Key 进行掩码处理。这不是加密——它是为了防止缓存投毒攻击(Cache Poisoning):
# Masking 算法
def mask_payload(payload: bytes, masking_key: bytes) -> bytes:
"""
WebSocket masking: payload[i] XOR masking_key[i % 4]
"""
return bytes(
b ^ masking_key[i % 4]
for i, b in enumerate(payload)
)
# 攻击场景解释:
# 如果客户端发送的 WebSocket 帧不做掩码处理
# 恶意 JavaScript 可以构造看起来像 HTTP 请求的 WebSocket 帧
# 中间的透明代理可能把它当作 HTTP 请求缓存
# 从而实现缓存投毒攻击2.4 分片消息
大消息可以分成多个帧发送,每个中间帧的 FIN=0,最后一帧 FIN=1:
消息 "Hello, World!" 分成 3 帧:
帧 1: FIN=0, opcode=0x1 (Text), payload="Hello"
帧 2: FIN=0, opcode=0x0 (Cont), payload=", "
帧 3: FIN=1, opcode=0x0 (Cont), payload="World!"
分片的工程用途:
1. 流式传输大文件,不需要先知道总长度
2. 降低内存峰值(不需要缓冲整个消息)
3. 允许在大消息传输中穿插控制帧(Ping/Pong/Close)
注意:控制帧(Ping/Pong/Close)不能被分片
三、Ping/Pong 心跳机制
WebSocket 连接是长期持有的 TCP 连接。如果没有心跳机制,连接可能在无数据传输时被中间设备(NAT、防火墙、负载均衡器)静默关闭,而两端都不知道。
3.1 心跳设计
心跳的三个作用:
1. 检测连接是否存活(对端进程是否还在)
2. 保持 NAT 映射表不过期(通常 NAT 超时 30-120 秒)
3. 检测网络路径是否通畅(排除半开连接)
协议级心跳(Ping/Pong 帧):
- 任何一端都可以发送 Ping 帧
- 收到 Ping 的一端必须尽快回复 Pong 帧
- Pong 的 payload 必须与 Ping 相同
- 如果收到多个 Ping,只需回复最后一个的 Pong
应用级心跳 vs 协议级心跳:
协议级 (Ping/Pong): 由 WebSocket 库自动处理,低开销
应用级 (自定义消息): 灵活,可携带业务数据,但增加复杂度
推荐做法:
两者结合使用
- Ping/Pong 做底层连接存活检测(间隔 30 秒)
- 应用级心跳做业务层状态同步(按需)
3.2 Go 语言心跳实现
package main
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
const (
pingInterval = 30 * time.Second
pongWait = 10 * time.Second
writeWait = 10 * time.Second
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
// 生产环境应检查 Origin
return true
},
}
func handleConnection(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade failed: %v", err)
return
}
defer conn.Close()
// 设置 Pong 处理器:收到 Pong 时重置读超时
conn.SetReadDeadline(time.Now().Add(pingInterval + pongWait))
conn.SetPongHandler(func(appData string) error {
conn.SetReadDeadline(time.Now().Add(pingInterval + pongWait))
return nil
})
// 启动 Ping 发送协程
done := make(chan struct{})
go func() {
ticker := time.NewTicker(pingInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Printf("ping failed: %v", err)
return
}
case <-done:
return
}
}
}()
defer close(done)
// 读消息循环
for {
_, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err,
websocket.CloseGoingAway,
websocket.CloseNormalClosure) {
log.Printf("read error: %v", err)
}
break
}
log.Printf("received: %s", message)
}
}3.3 心跳超时与重连策略
心跳超时判断:
服务端视角:
发送 Ping → 等待 pongWait 时间
如果超时未收到 Pong:
→ 连接可能已死,关闭连接
→ 清理该连接的资源(房间成员、在线状态等)
客户端视角:
如果超过 pingInterval + 容忍时间 未收到任何数据:
→ 主动发送 Ping 检测
→ 如果 Ping 也超时,判定连接断开
→ 触发重连逻辑
超时参数选择:
Ping 间隔: 20-30 秒(太短浪费带宽,太长检测慢)
Pong 超时: 5-10 秒(1-2 个 RTT 足够)
NAT 超时: 通常 30-120 秒(Ping 间隔必须小于此值)
四、断线重连设计
网络中断、服务端重启、移动网络切换——WebSocket 断线不可避免。一个可靠的重连策略是生产环境必须的。
4.1 指数退避重连
class WebSocketClient {
constructor(url) {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.baseDelay = 1000; // 初始延迟 1 秒
this.maxDelay = 30000; // 最大延迟 30 秒
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log("Connected");
this.reconnectAttempts = 0; // 连接成功重置计数器
};
this.ws.onclose = (event) => {
if (event.code === 1000) {
console.log("Normal closure, no reconnect");
return;
}
this.reconnect();
};
this.ws.onerror = (error) => {
console.error("WebSocket error:", error);
};
}
reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error("Max reconnect attempts reached");
return;
}
// 指数退避 + 抖动
const delay = Math.min(
this.baseDelay * Math.pow(2, this.reconnectAttempts)
+ Math.random() * 1000, // 0-1 秒随机抖动
this.maxDelay
);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
this.reconnectAttempts++;
setTimeout(() => this.connect(), delay);
}
}
// 重连延迟序列(含随机抖动):
// 尝试 1: ~1.5s (1000 * 2^0 + jitter)
// 尝试 2: ~2.8s (1000 * 2^1 + jitter)
// 尝试 3: ~4.3s (1000 * 2^2 + jitter)
// 尝试 4: ~8.7s (1000 * 2^3 + jitter)
// 尝试 5: ~16.2s (1000 * 2^4 + jitter)
// 尝试 6: ~30.0s (capped at maxDelay)4.2 消息补发与状态同步
断线重连后,客户端需要恢复到断线前的状态:
重连后的状态同步策略:
方案 A: 基于消息序号的增量同步
1. 客户端记录最后收到的消息序号 lastSeq
2. 重连后发送: {"type":"sync","lastSeq":12345}
3. 服务端从 lastSeq+1 开始补发缺失的消息
优点: 精确,不遗漏
缺点: 服务端需要缓存历史消息(内存/Redis)
方案 B: 全量状态快照
1. 重连后服务端发送当前完整状态
2. 客户端用新状态替换本地状态
优点: 简单可靠
缺点: 数据量大时浪费带宽
方案 C: 混合方案
- 短时间断线(<30s): 增量补发
- 长时间断线(>30s): 全量快照
- 超长断线(>5min): 重新加载页面
推荐: 对实时性要求高的场景(聊天、交易)用方案 A
对一致性要求高的场景(协同编辑)用方案 C
五、负载均衡与 WebSocket
WebSocket 是长连接,这和传统 HTTP 短连接的负载均衡模型有本质冲突。
5.1 Nginx WebSocket 代理配置
# /etc/nginx/conf.d/websocket.conf
upstream ws_backend {
# 使用 ip_hash 保证同一客户端连到同一后端
# WebSocket 连接一旦建立就不应该被转移
ip_hash;
server 10.0.1.10:8080;
server 10.0.1.11:8080;
server 10.0.1.12:8080;
}
server {
listen 443 ssl;
server_name ws.example.com;
# WebSocket 代理
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 关键: 设置足够长的超时(默认 60s 会断开空闲 WS 连接)
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# 不缓冲 WebSocket 帧
proxy_buffering off;
}
}
5.2 负载均衡的工程挑战
问题 1: 连接不均衡
HTTP 短连接: 每次请求都可能分配到不同后端,负载自然均衡
WebSocket 长连接: 连接一旦建立就固定在某个后端
后果: 时间越长,后端连接数差异越大
解决方案:
- 使用 Least Connections 算法而不是 Round Robin
- 实现优雅的连接迁移(通知客户端重连到新后端)
- 监控每个后端的连接数,设置上限
问题 2: 滚动发布时的连接断开
后端 Pod 重启时,所有连接都会断开
解决方案:
- 发布前发送 Close 帧(1001 Going Away)
- 客户端收到 1001 后等待 2-5 秒再重连
- Kubernetes: 利用 preStop hook + terminationGracePeriodSeconds
问题 3: 健康检查
传统 HTTP 健康检查无法检测 WebSocket 服务的真实状态
解决方案:
- 同时暴露 HTTP 健康检查端点(/health)
- 健康检查包含 WebSocket 连接数和内存使用量
- 当连接数接近上限时返回不健康,停止接受新连接
5.3 跨节点消息广播
多个 WebSocket 服务端实例之间需要同步消息(比如聊天室广播):
单节点(简单):
所有连接在一个进程内,直接遍历广播
多节点架构:
客户端 A ──→ WS Server 1 ──┐
客户端 B ──→ WS Server 1 ──┤
├─→ Redis Pub/Sub ←─→ 消息同步
客户端 C ──→ WS Server 2 ──┤
客户端 D ──→ WS Server 2 ──┘
流程:
1. 客户端 A 发送消息到 WS Server 1
2. Server 1 广播给本地连接(B)
3. Server 1 发布消息到 Redis channel
4. Server 2 订阅 Redis channel,收到消息
5. Server 2 广播给本地连接(C、D)
替代方案:
- NATS / Kafka 替代 Redis Pub/Sub(更高吞吐)
- 使用 gRPC 流在 WS 节点间直接通信(低延迟)
六、百万长连接的工程挑战
单机维持百万 WebSocket 连接在技术上是可行的,但需要系统性的内核调优和架构设计。
6.1 内核参数调优
# /etc/sysctl.d/99-websocket.conf
# 文件描述符限制
fs.file-max = 2000000
fs.nr_open = 2000000
# TCP 连接相关
net.ipv4.tcp_max_syn_backlog = 65535
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
# 内存优化(每个连接的缓冲区)
# 默认每个 TCP 连接约 3-6KB 内核内存
# 100 万连接 ≈ 3-6GB 纯内核内存开销
net.ipv4.tcp_rmem = 4096 4096 16384 # 最小 4KB
net.ipv4.tcp_wmem = 4096 4096 16384 # 最小 4KB
net.ipv4.tcp_mem = 786432 1048576 1572864
# 端口范围(默认 32768-60999 约 28000 端口)
# 如果是服务端监听,不受此限制
# 但如果作为客户端连接上游,需要扩大
net.ipv4.ip_local_port_range = 1024 65535
# TIME_WAIT 复用
net.ipv4.tcp_tw_reuse = 1
# Keepalive(WebSocket 通常自己做心跳,可适当延长)
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3# 用户级别文件描述符限制
# /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
# systemd 服务限制
# /etc/systemd/system/wsserver.service
[Service]
LimitNOFILE=10485766.2 内存优化
单连接内存开销分解:
组件 │ 默认开销 │ 优化后开销
──────────────────────────┼──────────────┼──────────────
内核 TCP 缓冲区 │ ~6KB │ ~4KB
goroutine(Go) │ ~8KB │ ~4KB(调栈大小)
WebSocket 读缓冲区 │ 4KB │ 按需分配
WebSocket 写缓冲区 │ 4KB │ 按需分配
应用层会话数据 │ ~2KB │ ~1KB
合计 │ ~24KB │ ~13KB
100 万连接内存估算:
默认: 24KB × 1,000,000 = 24GB
优化: 13KB × 1,000,000 = 13GB
关键优化策略:
1. 减小读写缓冲区(大部分连接是空闲的)
2. 使用 epoll 而不是每连接一个 goroutine
3. 延迟分配:只在有数据时才分配缓冲区
4. 使用 sync.Pool 复用缓冲区
// 优化方案:使用 epoll 替代 goroutine-per-connection
// gobwas/ws 库支持零分配的 WebSocket 升级
import (
"github.com/gobwas/ws"
"github.com/gobwas/ws/wsutil"
"golang.org/x/sys/unix"
)
// 使用 epoll 管理大量连接
// 相比 gorilla/websocket(每连接 2 个 goroutine)
// gobwas/ws + epoll 模式只需要少量固定的 goroutine
// 内存对比(100 万连接):
// gorilla/websocket: 每连接 2 goroutine × 8KB = 16GB goroutine 栈
// gobwas/ws + epoll: 固定 goroutine + 按需缓冲 ≈ 4GB6.3 架构设计
百万连接架构:
┌─────────────────────┐
│ DNS / CDN │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ L4 Load Balancer │
│ (IPVS / NLB) │
└──┬──────┬──────┬────┘
│ │ │
┌───────▼┐ ┌──▼────┐ ┌▼───────┐
│ WS GW 1│ │WS GW 2│ │WS GW 3 │
│ (300K) │ │(300K) │ │(300K) │
└───┬────┘ └──┬────┘ └───┬────┘
│ │ │
┌──────▼─────────▼──────────▼──────┐
│ Redis Pub/Sub │
│ (跨节点消息广播) │
└──────────────┬───────────────────┘
│
┌───────────▼───────────┐
│ Business Services │
│ (消息处理、持久化) │
└───────────────────────┘
设计要点:
1. WS 网关层只负责连接管理和消息路由,不做业务逻辑
2. 业务逻辑在独立服务中,通过 gRPC/Kafka 与网关通信
3. 每个网关实例承载 20-30 万连接(留余量)
4. L4 负载均衡(不解析 HTTP,直接转发 TCP)
5. 连接元数据(用户 ID → 网关节点映射)存在 Redis 中
七、WebSocket 关闭与状态码
7.1 正常关闭流程
关闭握手(Close Handshake):
端 A 端 B
│ Close Frame (code=1000) │
│─────────────────────────────────────────→│
│ │
│ Close Frame (code=1000) │
│←─────────────────────────────────────────│
│ │
│ TCP FIN │
│─────────────────────────────────────────→│
│ │
规则:
1. 任何一端都可以发起关闭
2. 收到 Close 帧后必须回复 Close 帧
3. 回复 Close 帧后不再发送数据帧
4. 发起关闭的一端等待对方的 Close 帧后关闭 TCP 连接
7.2 关闭状态码
状态码 │ 含义 │ 工程场景
────────┼─────────────────────────┼──────────────────────────
1000 │ Normal Closure │ 正常关闭(用户退出、页面关闭)
1001 │ Going Away │ 服务端关闭(重启/发布)
1002 │ Protocol Error │ 收到不合法的帧
1003 │ Unsupported Data │ 收到无法处理的数据类型
1005 │ No Status Received │ 未收到状态码(异常断开)
1006 │ Abnormal Closure │ 连接异常断开(无 Close 帧)
1007 │ Invalid Payload Data │ 文本帧不是有效 UTF-8
1008 │ Policy Violation │ 违反策略(通用拒绝码)
1009 │ Message Too Big │ 消息超出大小限制
1010 │ Mandatory Extension │ 服务端未协商客户端需要的扩展
1011 │ Internal Error │ 服务端遇到非预期错误
1012 │ Service Restart │ 服务端正在重启
1013 │ Try Again Later │ 临时过载,稍后重试
1014 │ Bad Gateway │ 代理从上游收到无效响应
1015 │ TLS Handshake Failure │ TLS 握手失败(仅用于日志)
客户端重连策略应基于状态码:
1000, 1001: 正常关闭,不重连(或延迟重连)
1006, 1012, 1013: 临时问题,立即重连
1008, 1003: 客户端行为有误,修正后重连
1011: 服务端错误,指数退避重连
八、WebSocket 安全
8.1 常见安全风险
风险 1: 跨站 WebSocket 劫持(CSWSH)
攻击: 恶意页面向目标网站的 WebSocket 端点发起连接
浏览器会自动携带目标网站的 Cookie
如果服务端不验证 Origin,攻击者可以冒充用户
防御:
- 服务端验证 Origin 头(白名单校验)
- 使用 Token 认证而不是 Cookie
- WebSocket URL 中携带一次性 ticket
风险 2: 拒绝服务
攻击: 客户端发送超大消息或大量小消息耗尽服务端资源
防御:
- 设置最大消息大小限制(如 1MB)
- 限制每个连接的消息频率(Rate Limiting)
- 限制单 IP 的最大连接数
风险 3: 数据注入
攻击: 通过 WebSocket 注入恶意数据
防御:
- 和 HTTP 一样,对所有输入进行验证和转义
- 使用结构化的消息格式(JSON Schema 验证)
- 输出到 HTML 时做 XSS 防护
8.2 认证方案
方案 1: URL 参数 Token
ws://server.example.com/ws?token=eyJhbGciOiJIUzI1NiJ9...
优点: 简单
缺点: Token 在 URL 中可能被日志记录
方案 2: 握手时的 Cookie
HTTP Upgrade 请求自动携带 Cookie
优点: 与现有 HTTP 认证体系一致
缺点: 容易受 CSWSH 攻击
方案 3: 连接后认证消息
先建立 WebSocket 连接,再发送认证消息
ws.send(JSON.stringify({type: "auth", token: "eyJ..."}))
优点: Token 不在 URL 中暴露
缺点: 未认证期间需要限制功能
推荐: 方案 3(连接后认证)+ Origin 验证
九、permessage-deflate 压缩
WebSocket 支持通过 permessage-deflate 扩展对消息进行压缩:
# 握手时协商压缩
GET /ws HTTP/1.1
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP/1.1 101 Switching Protocols
Sec-WebSocket-Extensions: permessage-deflate; server_no_context_takeover
压缩参数说明:
server_no_context_takeover
每条消息独立压缩,不保留压缩上下文
优点: 节省服务端内存(上下文约 300KB/连接)
缺点: 压缩率下降
client_no_context_takeover
同上,但是客户端侧
server_max_window_bits=N
服务端压缩窗口大小(9-15,默认 15)
窗口越大压缩率越高,但内存开销也越大
工程建议:
- 文本消息(JSON): 开启压缩(通常压缩率 60-80%)
- 二进制消息(Protobuf/图片): 关闭压缩(已压缩的数据再压缩无效)
- 大规模部署: 使用 server_no_context_takeover 控制内存
- 10 万连接 + context_takeover: 300KB × 100K = 30GB 仅压缩上下文
十、总结
WebSocket 在技术上很简单——一次 Upgrade 握手就建立了全双工通道。但工程上的复杂度远超协议本身。
握手是 HTTP Upgrade。理解 Sec-WebSocket-Key / Accept 的计算不重要,理解代理、负载均衡器是否正确透传 Upgrade 头才重要。
心跳是生命线。没有心跳的 WebSocket 连接在 NAT 后面活不过 2 分钟。Ping/Pong 间隔建议 20-30 秒。
断线重连必须实现。指数退避 + 随机抖动是标准做法。重连后的状态同步策略取决于业务需求。
负载均衡是难点。长连接让连接分布天然不均衡。L4 负载均衡 + Least Connections 是推荐方案。
百万连接需要系统级优化。内核参数、内存模型、epoll vs goroutine-per-connection、跨节点广播——每个环节都不能忽视。
安全不能忘。Origin 验证、消息大小限制、频率限制、认证方案——WebSocket 的安全风险与 HTTP 不同,需要专门处理。
参考文献
- RFC 6455: The WebSocket Protocol
- RFC 7692: Compression Extensions for WebSocket
- gorilla/websocket: Go 语言 WebSocket 库文档
- gobwas/ws: 零分配 WebSocket 库
- Nginx: WebSocket proxying
上一篇:HTTP 调试方法论:curl、DevTools 与 mitmproxy
下一篇:gRPC 深度剖析:HTTP/2 上的 RPC 框架
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】协议选型决策树:REST vs gRPC vs GraphQL vs WebSocket
从延迟、吞吐、开发效率、生态成熟度四个维度对比 REST、gRPC、GraphQL、WebSocket,给出微服务内部与面向客户端的选型决策树,讨论混合架构模式与迁移路径。
【网络工程】DNS 协议解剖:查询格式、记录类型与响应码
DNS 是互联网最基础的目录服务,也是最脆弱的单点之一。本文从 wire format 出发逐字段解析 DNS 报文结构,详解 A/AAAA/CNAME/MX/SRV/TXT/NS/SOA 等记录类型的工程用途,分析 EDNS0 扩展与 DNS over TCP 的触发条件,结合 dig +trace 完整实操展示 DNS 解析的真实链路。
【网络工程】SSE 与长轮询:服务端推送的轻量解法
系统讲解服务端推送的轻量方案:SSE 协议与事件流格式、自动重连机制、长轮询的实现模式与超时策略、短轮询/长轮询/SSE/WebSocket 四种推送方案的选型矩阵。
【网络工程】MQTT 工程:IoT 协议的 QoS 与 MQTT 5.0
系统剖析 MQTT 协议的工程实践:连接管理、Clean Session 与 Persistent Session、三种 QoS 级别的消息流与可靠性、Retained Message、Last Will、MQTT 5.0 新特性、Broker 架构设计。