土法炼钢兴趣小组的算法知识备份

【网络工程】WebSocket 工程:握手、帧格式与大规模运维

文章导航

分类入口
network
标签入口
#websocket#real-time#long-connection#protocol

目录

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=1048576

6.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 + 按需缓冲 ≈ 4GB

6.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 握手就建立了全双工通道。但工程上的复杂度远超协议本身。

  1. 握手是 HTTP Upgrade。理解 Sec-WebSocket-Key / Accept 的计算不重要,理解代理、负载均衡器是否正确透传 Upgrade 头才重要。

  2. 心跳是生命线。没有心跳的 WebSocket 连接在 NAT 后面活不过 2 分钟。Ping/Pong 间隔建议 20-30 秒。

  3. 断线重连必须实现。指数退避 + 随机抖动是标准做法。重连后的状态同步策略取决于业务需求。

  4. 负载均衡是难点。长连接让连接分布天然不均衡。L4 负载均衡 + Least Connections 是推荐方案。

  5. 百万连接需要系统级优化。内核参数、内存模型、epoll vs goroutine-per-connection、跨节点广播——每个环节都不能忽视。

  6. 安全不能忘。Origin 验证、消息大小限制、频率限制、认证方案——WebSocket 的安全风险与 HTTP 不同,需要专门处理。


参考文献


上一篇:HTTP 调试方法论:curl、DevTools 与 mitmproxy

下一篇:gRPC 深度剖析:HTTP/2 上的 RPC 框架

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-07-28 · network

【网络工程】DNS 协议解剖:查询格式、记录类型与响应码

DNS 是互联网最基础的目录服务,也是最脆弱的单点之一。本文从 wire format 出发逐字段解析 DNS 报文结构,详解 A/AAAA/CNAME/MX/SRV/TXT/NS/SOA 等记录类型的工程用途,分析 EDNS0 扩展与 DNS over TCP 的触发条件,结合 dig +trace 完整实操展示 DNS 解析的真实链路。


By .