网关与代理层:Upgrade 透传、超时与多级链路

线上 WebSocket 掉线,八成不是后端代码的错:要么链路上某一层没把升级头透传,握手直接退化成普通 GET;要么某一层的空闲超时比心跳间隔还短,连接被"正常"地掐掉。长连接要穿过 CDN、SLB、nginx、网关四层才到业务进程,任何一层没配对,症状都表现为"莫名其妙断线"。

链路与职责

浏览器/wscat → CDN 或云 SLB(四层/七层)→ nginx(七层,可多跳)→ 自研网关 → WS 业务进程
层组件要做的事常踩的坑
1客户端心跳 30s、重连抖动、带 seq 补拉心跳间隔大于代理超时
2云 SLB 四层(TCP)只转发字节流,无需理解 Upgrade连接空闲超时未确认
3云 SLB 七层 / WAF开启 WebSocket 支持安全插件缓冲请求体、鉴权超时
4nginx(七层)map 生成 Connection、HTTP/1.1、透传 Upgrade、调超时四项里漏一项
5nginx(四层 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_timeout300s默认 60s;两读之间的间隔
nginx 七层proxy_connect_timeout5s只管与上游建连
nginx 四层proxy_timeout300s无数据往来的空闲上限
后端 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。

笔记加载中…