Skip to content

WebSocket 协议 ​

#网络 · #WebSocket · #全双工 · #实时通信 · #RFC6455

WebSocket 是 HTML5 引入的全双工通信协议,在单个 TCP 连接上实现客户端与服务器之间的双向实时通信。相比 HTTP 轮询和长轮询,WebSocket 大幅降低了延迟和带宽开销,是聊天系统、实时推送、在线游戏等场景的基础协议。


1. 为什么需要 WebSocket ​

HTTP 的局限 ​

方案原理问题
短轮询客户端定时发 HTTP 请求大量无效请求,延迟 = 轮询间隔 / 2
长轮询客户端请求,服务端 hold 直到有数据服务端资源占用高,连接管理复杂
HTTP/2 SSE服务端单向推送事件流只能服务端 → 客户端,不支持二进制帧
WebSocketTCP 全双工✅ 双向、低延迟、二进制支持

WebSocket 的优势 ​

HTTP 半双工:               WebSocket 全双工:
Client ──请求──→ Server     Client ←──────→ Server
Client ←──响应── Server     双向同时发送,无需等待
  • 单 TCP 连接复用,无需反复握手
  • 头部开销仅 2-14 字节(HTTP 每次请求数百字节头)
  • 服务端可主动推送,延迟 < 1ms

2. 协议握手 ​

2.1 客户端请求(HTTP Upgrade) ​

http
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

关键字段:

  • Upgrade: websocket + Connection: Upgrade — 要求升级协议
  • Sec-WebSocket-Key — 16 字节随机值 Base64 编码,用于证明握手是有效的 WebSocket 请求
  • Sec-WebSocket-Version: 13 — 协议版本

2.2 服务端响应(101 Switching Protocols) ​

http
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

2.3 Accept 值的计算 ​

Sec-WebSocket-Accept = Base64(SHA1(
    Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
))

这是一个魔数字符串(GUID),作用是:

  • 确保客户端确实是 WebSocket 而非误发的 HTTP 请求
  • 防止代理缓存 WebSocket 响应
  • 客户端验证服务端确实理解了 WebSocket 协议

2.4 Go 实现握手 ​

go
func websocketHandshake(w http.ResponseWriter, r *http.Request) (net.Conn, error) {
    key := r.Header.Get("Sec-WebSocket-Key")
    if key == "" {
        return nil, fmt.Errorf("missing Sec-WebSocket-Key")
    }

    // 计算 Accept
    magic := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    hash := sha1.Sum([]byte(key + magic))
    accept := base64.StdEncoding.EncodeToString(hash[:])

    // Hijack HTTP 连接
    hj, ok := w.(http.Hijacker)
    if !ok {
        return nil, fmt.Errorf("hijacking not supported")
    }
    conn, bufrw, err := hj.Hijack()
    if err != nil {
        return nil, err
    }

    // 发送 101 响应
    bufrw.WriteString("HTTP/1.1 101 Switching Protocols\r\n")
    bufrw.WriteString("Upgrade: websocket\r\n")
    bufrw.WriteString("Connection: Upgrade\r\n")
    bufrw.WriteString("Sec-WebSocket-Accept: " + accept + "\r\n")
    bufrw.WriteString("\r\n")
    bufrw.Flush()

    return conn, nil
}

3. 数据帧格式 ​

3.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)     |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |      Payload Data             |
+-------------------------------+-------------------------------+

3.2 各字段含义 ​

字段大小说明
FIN1 bit最后一帧标志。消息分片时,最后一帧 FIN=1
RSV1-33 bit保留位(扩展用,如压缩)
opcode4 bit帧类型
MASK1 bit掩码标志。客户端→服务端必须 mask,服务端→客户端必须 unmask
Payload len7 bit载荷长度:0-125=实际长度,126=后续2字节,127=后续8字节
Extended0-8 B扩展长度字段
Masking-key0-4 B掩码 key(仅 MASK=1 时有)
Payload可变实际数据

3.3 Opcode 类型 ​

Opcode类型含义
0x0Continuation分片延续帧
0x1TextUTF-8 文本帧
0x2Binary二进制帧
0x8Close关闭连接
0x9Ping心跳 Ping
0xAPong心跳 Pong

4. 数据掩码(Masking) ​

4.1 为什么需要掩码 ​

客户端→服务端的帧必须 mask。原因:防止缓存投毒攻击。

攻击场景:
  恶意 JS 通过浏览器 WebSocket 发送精心构造的数据
  → 代理服务器缓存误以为是 HTTP 响应
  → 其他用户请求被投毒缓存返回恶意数据

掩码使每次发送的数据流随机化,代理无法将其错误缓存为 HTTP 响应。

4.2 掩码算法 ​

go
// 掩码/解掩码(对称操作)
func maskXOR(payload []byte, maskKey [4]byte) {
    for i := range payload {
        payload[i] ^= maskKey[i%4]
    }
}

4.3 服务端为什么不需要 mask ​

服务端→客户端的帧不需要 mask,因为信任方向不同:服务端返回的数据不会被代理误认为是 HTTP 请求。


5. Go 实现简洁的 WebSocket Frame 解析 ​

5.1 读取帧 ​

go
type Frame struct {
    FIN     bool
    Opcode  byte
    Masked  bool
    Payload []byte
}

func readFrame(conn net.Conn) (*Frame, error) {
    // 读取前 2 字节
    header := make([]byte, 2)
    if _, err := io.ReadFull(conn, header); err != nil {
        return nil, err
    }

    f := &Frame{
        FIN:    header[0]>>7 == 1,
        Opcode: header[0] & 0x0F,
        Masked: header[1]>>7 == 1,
    }

    // 读取 payload 长度
    payloadLen := uint64(header[1] & 0x7F)
    switch {
    case payloadLen == 126:
        buf := make([]byte, 2)
        io.ReadFull(conn, buf)
        payloadLen = uint64(binary.BigEndian.Uint16(buf))
    case payloadLen == 127:
        buf := make([]byte, 8)
        io.ReadFull(conn, buf)
        payloadLen = binary.BigEndian.Uint64(buf)
    }

    // 读取 mask key(如果有)
    var maskKey [4]byte
    if f.Masked {
        io.ReadFull(conn, maskKey[:])
    }

    // 读取 payload
    f.Payload = make([]byte, payloadLen)
    io.ReadFull(conn, f.Payload)

    // 解掩码
    if f.Masked {
        maskXOR(f.Payload, maskKey)
    }

    return f, nil
}

5.2 写入帧 ​

go
func writeFrame(conn net.Conn, opcode byte, payload []byte) error {
    var buf []byte

    // 第 1 字节:FIN + Opcode
    buf = append(buf, 0x80|opcode) // FIN=1

    // 第 2 字节 + 扩展长度
    n := len(payload)
    switch {
    case n < 126:
        buf = append(buf, byte(n))
    case n < 65536:
        buf = append(buf, 126)
        ext := make([]byte, 2)
        binary.BigEndian.PutUint16(ext, uint16(n))
        buf = append(buf, ext...)
    default:
        buf = append(buf, 127)
        ext := make([]byte, 8)
        binary.BigEndian.PutUint64(ext, uint64(n))
        buf = append(buf, ext...)
    }

    // 服务端→客户端不需要 mask
    buf = append(buf, payload...)
    _, err := conn.Write(buf)
    return err
}

6. 心跳机制(Ping/Pong) ​

6.1 为什么需要心跳 ​

TCP 连接的半开状态:一端已断开,另一端不知道。WebSocket 通过 Ping/Pong 帧检测连接存活。

无心跳:              有心跳:
Client ───────── Server    Client ──Ping──→ Server
   (断开)                    Client ←──Pong── Server
Server 不知道断开 → 资源泄漏   无 Pong 响应 → 关闭连接

6.2 Go 实现 ​

go
func readLoop(conn net.Conn) {
    // 设置读超时(比 Ping 间隔长)
    conn.SetReadDeadline(time.Now().Add(60 * time.Second))

    // 每 30 秒发一次 Ping
    go func() {
        ticker := time.NewTicker(30 * time.Second)
        defer ticker.Stop()
        for range ticker.C {
            writeFrame(conn, 0x9, nil) // Ping 帧
        }
    }()

    for {
        frame, err := readFrame(conn)
        if err != nil {
            return
        }

        switch frame.Opcode {
        case 0x9: // Ping
            writeFrame(conn, 0xA, frame.Payload) // 回复 Pong
        case 0xA: // Pong
            conn.SetReadDeadline(time.Now().Add(60 * time.Second)) // 刷新超时
        case 0x8: // Close
            writeFrame(conn, 0x8, frame.Payload) // 回复 Close
            return
        case 0x1, 0x2: // Text / Binary
            handleMessage(frame)
        }
    }
}

6.3 Ping/Pong 帧特点 ​

  • 载荷最大 125 字节
  • ping 可以被应用层检测,pong 由浏览器自动回复
  • 可以作为应用层 RTT 测量(载荷中带时间戳)

7. 关闭握手 ​

关闭方                       接收方
  │                            │
  │──Close Frame──→             │
  │                            │ 收到 Close → 应用层通知
  │                            │──Close Frame──→ (回复)
  │                            │ 关闭 TCP 连接
  │         ←──Close Frame───  │
  │ 关闭 TCP 连接              │

Close 帧的载荷前 2 字节是状态码:

状态码含义
1000正常关闭
1001端点离开(如关闭页面)
1002协议错误
1003收到不支持的数据类型
1008违反策略
1009消息过大
1011服务端异常

8. 使用 gorilla/websocket(生产级) ​

8.1 服务端 ​

go
import "github.com/gorilla/websocket"

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
    CheckOrigin: func(r *http.Request) bool {
        return true // 生产环境应校验 Origin
    },
}

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("upgrade err:", err)
        return
    }
    defer conn.Close()

    for {
        msgType, msg, err := conn.ReadMessage()
        if err != nil {
            log.Println("read err:", err)
            break
        }
        log.Printf("recv: %s", msg)

        err = conn.WriteMessage(msgType, msg)
        if err != nil {
            log.Println("write err:", err)
            break
        }
    }
}

8.2 客户端 ​

go
func connectWebSocket() {
    c, _, err := websocket.DefaultDialer.Dial("ws://localhost:8080/ws", nil)
    if err != nil {
        log.Fatal("dial:", err)
    }
    defer c.Close()

    // 发送消息
    err = c.WriteMessage(websocket.TextMessage, []byte("hello"))
    if err != nil {
        return
    }

    // 读取消息
    _, msg, err := c.ReadMessage()
    if err != nil {
        return
    }
    fmt.Printf("recv: %s\n", msg)
}

9. 生产环境注意事项 ​

9.1 连接数限制 ​

go
var (
    connCount int64
    maxConns  int64 = 10000
)

func handleWebSocket(w http.ResponseWriter, r *http.Request) {
    if atomic.LoadInt64(&connCount) >= maxConns {
        http.Error(w, "too many connections", http.StatusServiceUnavailable)
        return
    }
    atomic.AddInt64(&connCount, 1)
    defer atomic.AddInt64(&connCount, -1)
    // ...
}

9.2 消息大小限制 ​

go
conn.SetReadLimit(4096) // 单帧最大 4KB

9.3 反向代理配置(Nginx) ​

nginx
location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;  # 长连接超时
    proxy_send_timeout 3600s;
}

9.4 二进制 vs 文本帧 ​

go
// 文本帧:UTF-8 字符串
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"chat","msg":"hello"}`))

// 二进制帧:Protobuf/MessagePack 等
data, _ := proto.Marshal(&chatMsg)
conn.WriteMessage(websocket.BinaryMessage, data)

9.5 压缩扩展(permessage-deflate) ​

go
var upgrader = websocket.Upgrader{
    EnableCompression: true,  // 启用压缩,减少带宽
}

10. WebSocket vs 其他实时通信方案 ​

方案方向协议延迟适用
WebSocket双向TCP< 1ms聊天、游戏、实时推送
SSE单向(S→C)HTTP低新闻推送、股票行情
HTTP/2 Push单向(S→C)HTTP/2低资源预推送
gRPC Streaming双向HTTP/2低微服务间流式通信
WebRTC双向 P2PUDP极低音视频通话
MQTT双向TCP低IoT 设备

选型决策树:WebSocket vs SSE vs gRPC Stream ​

mermaid
flowchart TD
    Q["需要实时通信?"] --> Q1{"需要双向通信?"}
    Q1 -->|"是"| Q2{"微服务内部?"}
    Q2 -->|"是"| G["gRPC Bidirectional Stream<br/>HTTP/2 多路复用 + Protobuf<br/>自带流控/超时/重试"]
    Q2 -->|"否"| WS["WebSocket<br/>TCP 全双工, 最小开销<br/>浏览器/移动端通用"]
    Q1 -->|"否, 仅服务端推送"| Q3{"浏览器?"}
    Q3 -->|"是"| SSE["Server-Sent Events<br/>HTTP, 自动重连, 文本流<br/>比 WebSocket 简单"]
    Q3 -->|"否"| Q4{"微服务?"}
    Q4 -->|"是"| G2["gRPC Server Stream<br/>或消息队列"]
    Q4 -->|"否, IoT"| MQTT["MQTT<br/>轻量级, QoS, 遗嘱消息"]

百万连接的内核参数调优 ​

单个 WebSocket 服务器支撑百万连接时,瓶颈不在应用层,在内核和网络栈:

bash
# === fd 限制 ===
# 每个连接 = 1 个 fd
ulimit -n 2000000                    # 进程级 fd 上限
sysctl -w fs.file-max=20000000       # 系统级 fd 上限
sysctl -w fs.nr_open=20000000        # 单个进程 fd 硬限制

# === TCP 内存 ===
# 每个连接 ~4KB 读缓冲 + ~4KB 写缓冲 (可调)
sysctl -w net.ipv4.tcp_rmem="4096 87380 16777216"
sysctl -w net.ipv4.tcp_wmem="4096 65536 16777216"
# 100 万连接 × (4K+4K) = 8GB 内核内存

# === 端口范围 (如果出站连接多) ===
sysctl -w net.ipv4.ip_local_port_range="1024 65535"

# === 连接队列 ===
sysctl -w net.core.somaxconn=65535
sysctl -w net.ipv4.tcp_max_syn_backlog=65535

内存估算:

text
100 万 WebSocket 连接的内存开销:
  内核 TCP 缓冲区:     ~8 GB  (tcp_rmem + tcp_wmem)
  应用层 goroutine:    ~8 GB  (每个 goroutine 8KB 栈, Go 1.22+)
  应用层数据缓冲区:     ~4 GB  (每个连接 4KB 读写缓冲)
  合计:               ~20 GB

优化手段:
  - Go: 用 epoll 代替每连接一个 goroutine (如 gnet/evio)
  - 启用 tcp_tw_reuse 避免 TIME_WAIT 端口耗尽
  - 用 SO_REUSEPORT 多进程/多线程监听同一端口

WebSocket vs SSE vs gRPC Stream 深度对比 ​

维度WebSocketSSEgRPC Stream
协议层TCP (HTTP 升级后)HTTP/1.1HTTP/2
方向双向单向 (S→C)双向 / 单向
数据格式自定义 (二进制/文本)纯文本 (UTF-8)Protobuf (二进制)
浏览器支持✅ 所有现代浏览器✅ 所有现代浏览器❌ (需 grpc-web + Envoy)
自动重连❌ 需手动实现✅ 内置 EventSource✅ gRPC transparent retry
流控❌ 需手动控制❌ HTTP/1.1 无流控✅ HTTP/2 flow control
TLS 开销1 次握手HTTP 普通开销1 次 TLS + h2 协商
调试需 Chrome DevTools WS tabcurl 即可测试需 grpcurl/bloomRPC
适合聊天、游戏、协作编辑通知、行情推送、日志流微服务间 RPC 流

WebSocket 协议交互全流程 ​

mermaid
sequenceDiagram
    participant Browser as 浏览器
    participant Server as WebSocket 服务端

    Note over Browser,Server: 阶段1: HTTP Upgrade 握手
    Browser->>Server: GET /chat HTTP/1.1<br/>Upgrade: websocket<br/>Sec-WebSocket-Key: dGhlIHNhbXBsZQ==
    Server->>Browser: 101 Switching Protocols<br/>Sec-WebSocket-Accept: s3pPLMBiTxaQ9k=

    Note over Browser,Server: 阶段2: 全双工通信
    Browser->>Server: Text Frame: {"type":"chat","msg":"hello"}
    Server->>Browser: Text Frame: {"type":"chat","msg":"world"}

    Note over Browser,Server: 阶段3: 心跳保持
    Server->>Browser: Ping Frame (opcode=0x9)
    Browser->>Server: Pong Frame (opcode=0xA)

    Note over Browser,Server: 阶段4: 关闭握手
    Browser->>Server: Close Frame (opcode=0x8, code=1000)
    Server->>Browser: Close Frame (opcode=0x8, code=1000)
mermaid
flowchart TB
    START["收到 TCP 数据"] --> READ_HEADER["读取前 2 字节<br/>FIN + opcode + MASK + payload_len"]

    READ_HEADER --> LEN_CHECK{"payload_len?"}
    LEN_CHECK -->|"0-125"| READ_MASK{"MASK = 1?"}
    LEN_CHECK -->|"126"| READ_EXT2["读取扩展 2 字节长度"]
    LEN_CHECK -->|"127"| READ_EXT8["读取扩展 8 字节长度"]
    READ_EXT2 --> READ_MASK
    READ_EXT8 --> READ_MASK

    READ_MASK -->|"是"| READ_MASKKEY["读取 4 字节 Mask-Key"]
    READ_MASK -->|"否"| READ_PAYLOAD["读取 Payload"]

    READ_MASKKEY --> UNMASK["maskXOR 解掩码"]
    UNMASK --> READ_PAYLOAD

    READ_PAYLOAD --> OPCODE{"opcode?"}
    OPCODE -->|"0x1/0x2"| HANDLE_MSG["处理 Text/Binary 消息"]
    OPCODE -->|"0x8"| HANDLE_CLOSE["回复 Close + 关闭"]
    OPCODE -->|"0x9"| HANDLE_PING["回复 Pong"]
    OPCODE -->|"0xA"| HANDLE_PONG["刷新超时 + 记录 RTT"]
帧字段大小验证要点
FIN1 bit分片时中间帧 FIN=0,最后一帧 FIN=1
opcode4 bit0x1=文本 0x2=二进制 0x8=关闭 0x9=Ping 0xA=Pong
MASK1 bit客户端必为 1,服务端必为 0(协议强制)
Payload len7/7+16/7+64126 和 127 需读扩展长度

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记