网关与代理层:Upgrade 透传、超时与多级链路
线上 WebSocket 掉线,八成不是后端代码的错:要么链路上某一层没把升级头透传,握手直接退化成普通 GET;要么某一层的空闲超时比心跳间隔还短,连接被"正常"地掐掉。长连接要穿过 CDN、SLB、nginx、网关四层才到业务进程,任何一层没配对,症状都表现为"莫名其妙断线"。
链路与职责
浏览器/wscat → CDN 或云 SLB(四层/七层)→ nginx(七层,可多跳)→ 自研网关 → WS 业务进程
| 层 | 组件 | 要做的事 | 常踩的坑 |
|---|---|---|---|
| 1 | 客户端 | 心跳 30s、重连抖动、带 seq 补拉 | 心跳间隔大于代理超时 |
| 2 | 云 SLB 四层(TCP) | 只转发字节流,无需理解 Upgrade | 连接空闲超时未确认 |
| 3 | 云 SLB 七层 / WAF | 开启 WebSocket 支持 | 安全插件缓冲请求体、鉴权超时 |
| 4 | nginx(七层) | map 生成 Connection、HTTP/1.1、透传 Upgrade、调超时 | 四项里漏一项 |
| 5 | nginx(四层 stream) | 调大 proxy_timeout | 四层不做升级,但会按空闲超时掐连接 |
| 6 | 自研网关 | 透传头部、鉴权在升级前完成、不缓冲 | 自己按 HTTP/1.0 解析后转发 |
| 7 | 后端 Go 进程 | 握手期鉴权、读超时回收死连接 | 读超时小于心跳间隔 |
原理:Upgrade 是逐跳头部
客户端握手请求带 Connection: Upgrade 与 Upgrade: websocket,服务端回 101 Switching Protocols。这两个头属于逐跳(hop-by-hop)头部,代理不会自动转发,每一层都必须显式透传——这就是多级链路最常见的翻车点:某层转发时"清理"了逐跳头,后端只收到一个普通 GET,返回 200 或 400,客户端看到"握手失败"。
101 之后,这条 TCP 连接退化成双向原始字节流,代理不再按 HTTP 语义解析。含义很实在:
- nginx 的 L7 能力(按 URL 限流、缓存、gzip、access_log 里的 response body 统计)对已升级的连接不再生效;
- 只有四层的 stream 模块能继续"看见"这条连接的全部字节;空闲超时从此按"两次读写之间的间隔"计时,任何方向的一个字节(含协议层 ping 帧)都会重置计时器。
nginx 七层配置(完整片段)
# 反代时每条客户端连接占用两条连接额度(客户端 + 上游),容量按 2 倍预留
worker_rlimit_nofile 1048576;
events {
worker_connections 65535; # 8 worker × 65535 ÷ 2 ≈ 26 万,够撑 10 万在线
use epoll;
multi_accept on;
}
map $http_upgrade $connection_upgrade {
default upgrade; # 客户端要求升级时转发 upgrade
'' close; # 普通请求置 close,避免误伤 keepalive
}
upstream im_backend {
server 10.0.1.11:8081;
server 10.0.1.12:8081;
keepalive 64; # 只对普通 HTTP 短请求有效;WS 升级后连接被长期独占
}
server {
listen 443 ssl;
server_name ws.example.com;
location /ws/ {
proxy_pass http://im_backend;
proxy_http_version 1.1; # 升级依赖 HTTP/1.1,默认 1.0 不行
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 300s; # 两次"读"之间的最大间隔,不是连接总时长
proxy_send_timeout 300s;
proxy_buffering off; # 101 后 nginx 已进入隧道模式,这行只影响该 location 的普通请求
}
}
worker_connections 的算法要连带数一起算:nginx 反代时,一条客户端连接对应一条到上游的连接,两者都算在 worker_connections 里,所以 10 万在线至少需要 worker_processes × worker_connections ÷ 2 ≥ 100000,同时 worker_rlimit_nofile 要大于 worker_connections。
超时怎么定:一条链路上的层次关系
原则有三条:代理的空闲超时要比心跳间隔长(否则代理先掐连接);后端的读超时要比代理的空闲超时短(让最内层先回收死连接,而不是等代理来断);每一层都要能被任何方向的字节重置(协议层 ping 同时起到保活与认证活性两个作用)。
| 位置 | 关键参数 | 建议值 | 说明 |
|---|---|---|---|
| 客户端 | 心跳间隔 | 30s | 见第 06 章 |
| 云 SLB 七层 | 连接空闲超时 | ≥ 90s | 默认常见 60s,比心跳短就会整批断线 |
| 云 SLB 四层 | 连接空闲超时 | ≥ 90s(常见默认 900s) | 控制台确认,别只看 nginx |
| nginx 七层 | proxy_read_timeout / proxy_send_timeout | 300s | 默认 60s;两读之间的间隔 |
| nginx 七层 | proxy_connect_timeout | 5s | 只管与上游建连 |
| nginx 四层 | proxy_timeout | 300s | 无数据往来的空闲上限 |
| 后端 Go | 读超时(ReadDeadline) | 90s | 必须是心跳间隔的 2~3 倍 |
| 后端 Go | 写超时(WriteDeadline) | 10s | 单帧写不完说明客户端太慢,直接断开 |
实操:验证头部真的透传了
# 手写一次握手,返回 101 表示这一层升级成功
curl -i -N --max-time 5 \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
-H "Sec-WebSocket-Version: 13" https://ws.example.com/ws/
# 输出:HTTP/1.1 101 Switching Protocols
nginx -T | grep -A12 'location /ws/' # 确认生效配置(reload 后)
ss -tn state established '( sport = :443 )' | wc -l # 443 已建立连接数
tail -f /var/log/nginx/error.log # 关注 upstream timed out / prematurely closed
后端把收到的头部打出来,一眼就能判断是哪一层丢了:
// 期望输出:upgrade="websocket" connection="Upgrade" xff="1.2.3.4"
log.Printf("upgrade=%q connection=%q xff=%q",
r.Header.Get("Upgrade"), r.Header.Get("Connection"), r.Header.Get("X-Forwarded-For"))
日志里的典型信号:upstream timed out (110: Connection timed out) while reading response header from upstream 说明上游静默超过 proxy_read_timeout;upstream prematurely closed connection 说明上游先关了连接(常见于后端读超时比代理短,或后端进程重启);client prematurely closed connection 则是客户端侧主动断开。三者指向完全不同的层,别混为一谈。
常见坑
| 现象 | 原因 | 处理 |
|---|---|---|
| 每 60 秒整批断线、重连 | 某一层沿用默认空闲超时 60s,比心跳短 | 逐层调超时,含控制台上的 SLB |
| 后端只收到普通 GET | 逐跳头未被透传(多级链路中漏了某层) | 每层都显式 proxy_set_header Upgrade |
| 一直 400 / 502 | 忘了 proxy_http_version 1.1 | 显式声明 1.1 |
| 普通 HTTP 请求 keepalive 失效 | 把 Connection 硬编码成 upgrade | 用 map $http_upgrade 动态生成 |
| 连接数只有预期一半 | worker_connections 没按"代理翻倍"计算 | 按 连接数 × 2 预留,并同步 worker_rlimit_nofile |
| 客户端重连后消息漏收 | 靠 ip_hash 做粘性,重连换了节点 | 会话与消息落库,重连带 seq 补拉,不依赖粘性 |
| 加了 WAF/网关后大规模超时 | 插件缓冲了请求体、鉴权串行耗时 | 升级路径放行、鉴权改到握手期并设短超时 |
小结:WebSocket 穿过代理链路的四件套是——map 生成 Connection 头、proxy_http_version 1.1、逐层透传 Upgrade/Connection、把超时按"心跳 30s → 代理 ≥ 90s(常用 300s)→ 后端读超时 90s"排成从外到内递减的顺序;容量核算记得反代按两倍连接计算 worker_connections,排障先看 nginx error log 里是 timed out 还是 prematurely closed。