WebSocket 协议
#网络 · #WebSocket · #全双工 · #实时通信 · #RFC6455
WebSocket 是 HTML5 引入的全双工通信协议,在单个 TCP 连接上实现客户端与服务器之间的双向实时通信。相比 HTTP 轮询和长轮询,WebSocket 大幅降低了延迟和带宽开销,是聊天系统、实时推送、在线游戏等场景的基础协议。
1. 为什么需要 WebSocket
HTTP 的局限
| 方案 | 原理 | 问题 |
|---|---|---|
| 短轮询 | 客户端定时发 HTTP 请求 | 大量无效请求,延迟 = 轮询间隔 / 2 |
| 长轮询 | 客户端请求,服务端 hold 直到有数据 | 服务端资源占用高,连接管理复杂 |
| HTTP/2 SSE | 服务端单向推送事件流 | 只能服务端 → 客户端,不支持二进制帧 |
| WebSocket | TCP 全双工 | ✅ 双向、低延迟、二进制支持 |
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 各字段含义
| 字段 | 大小 | 说明 |
|---|---|---|
| FIN | 1 bit | 最后一帧标志。消息分片时,最后一帧 FIN=1 |
| RSV1-3 | 3 bit | 保留位(扩展用,如压缩) |
| opcode | 4 bit | 帧类型 |
| MASK | 1 bit | 掩码标志。客户端→服务端必须 mask,服务端→客户端必须 unmask |
| Payload len | 7 bit | 载荷长度:0-125=实际长度,126=后续2字节,127=后续8字节 |
| Extended | 0-8 B | 扩展长度字段 |
| Masking-key | 0-4 B | 掩码 key(仅 MASK=1 时有) |
| Payload | 可变 | 实际数据 |
3.3 Opcode 类型
| Opcode | 类型 | 含义 |
|---|---|---|
| 0x0 | Continuation | 分片延续帧 |
| 0x1 | Text | UTF-8 文本帧 |
| 0x2 | Binary | 二进制帧 |
| 0x8 | Close | 关闭连接 |
| 0x9 | Ping | 心跳 Ping |
| 0xA | Pong | 心跳 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) // 单帧最大 4KB9.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 | 双向 P2P | UDP | 极低 | 音视频通话 |
| 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 深度对比
| 维度 | WebSocket | SSE | gRPC Stream |
|---|---|---|---|
| 协议层 | TCP (HTTP 升级后) | HTTP/1.1 | HTTP/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 tab | curl 即可测试 | 需 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"]| 帧字段 | 大小 | 验证要点 |
|---|---|---|
| FIN | 1 bit | 分片时中间帧 FIN=0,最后一帧 FIN=1 |
| opcode | 4 bit | 0x1=文本 0x2=二进制 0x8=关闭 0x9=Ping 0xA=Pong |
| MASK | 1 bit | 客户端必为 1,服务端必为 0(协议强制) |
| Payload len | 7/7+16/7+64 | 126 和 127 需读扩展长度 |
登录后即可发表评论 👇