WebSocket 与普通 HTTP 请求的本质差异
普通 HTTP 代理是一问一答:Nginx 收到请求、转发给后端、等后端返回响应、回给客户端,连接随之可以关闭。WebSocket 则是客户端发出带 `Upgrade: websocket` 的握手请求后,连接语义被**永久改变**——从此这条 TCP 连接变成双向全双工通道,任何一端都不能主动关闭它。
这个语义改变正是所有 WebSocket 反代问题的根源。Nginx 的 `proxy_pass` 默认按 HTTP/1.0 方式与上游通信,会在转发时把 `Connection` 头重写为 `close`,并且不认识 Upgrade 这种「协议切换」语义,于是握手阶段要么被后端拒绝,要么握手成功但连接建立后立刻被 Nginx 判定为可关闭的空闲连接。
还有一个容易被忽略的点:握手成功之后,Nginx 不再解析这条连接上的内容,它只是双向搬运字节。因此任何与超时、缓冲相关的默认配置,都会直接变成「莫名其妙断线」的用户体验,而不是一条明确的错误日志。
所以排查 WebSocket 问题的正确顺序是:先确认握手阶段是否成功(看响应码是否为 101),再确认连接建立后能存活多久(这才是超时陷阱登场的地方),最后才查业务层的粘性会话问题。
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
http://127.0.0.1:8080/ws
# 期望看到:HTTP/1.1 101 Switching ProtocolsUpgrade 头透传:最小可用配置与常见错误
Nginx 官方文档给出的标准做法是在 `location` 里补上两个指令:`proxy_set_header Upgrade $http_upgrade;` 和 `proxy_set_header Connection "upgrade";`。前者把客户端发来的 Upgrade 头原样带到上游,后者强制把 Connection 头设为 upgrade。
官方文档里还有一个改良写法:把 `Connection` 设为 `map` 的结果——当客户端没有 Upgrade 头时回落到 `close`,有 Upgrade 头时才是 `upgrade`。这样做的好处是同一个 `location` 可以同时服务普通 HTTP 请求和 WebSocket 握手,不必为两类流量拆两套配置。
最常见的错误是只写了 `proxy_pass` 加 `proxy_http_version 1.1` 就以为完成了升级。`proxy_http_version 1.1` 本身不会带上 `Connection: upgrade`,后端看到的是一个普通 GET,返回 200 而不是 101,客户端握手直接失败。其次是漏掉 `proxy_set_header Host $host`,导致后端的虚拟主机匹配出错。
还有一个坑:`$http_upgrade` 是从请求头取的原值,如果客户端发的是 `WebSocket`(大写 W),某些严格的后端框架大小写敏感匹配会失败。稳妥做法是用 `map` 配合默认值,缺失时给出小写的 `websocket`。
# http 级:集中管理 Upgrade 映射,避免每个 location 重复写
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
}proxy_read_timeout 默认 60 秒:最隐蔽的断连原因
Nginx 的 `proxy_read_timeout` 默认值是 **60 秒**,含义是「上游连续 60 秒没有向 Nginx 发送任何数据,则关闭这条上游连接」。对普通 HTTP 请求这毫无影响,因为响应很快就结束;但对 WebSocket 这是一条定时炸弹:只要客户端 60 秒内没有收到任何服务端推送,Nginx 就单方面关闭连接。
典型症状是「一切正常,然后用着用着突然断了,而且断得很有规律」。日志里通常能看到一条 `upstream timed out (110: Connection timed out) while reading upstream`,错误码 110 与客户端侧表现为「大约每分钟断一次」。用户往往误以为是服务端崩溃,实际只是超时。
解决方案分两层。第一层是显式调大 `proxy_read_timeout`(例如 300s 到 3600s),并同时设置 `proxy_send_timeout` 覆盖反向方向。第二层更根本:让应用层发心跳 ping。Nginx 的超时是基于「无任何字节」,而心跳帧本身就有字节流过,定时器会被不断重置,因此一个 30 秒一次的 ping 就能让超时永远不触发。
还要注意 `proxy_buffering` 的影响。开启缓冲时 Nginx 会把上游数据攒起来再发给下游,对 WebSocket 这种低延迟小包场景会引入额外延迟,某些情况下还会与超时判断产生难以理解的交互。WebSocket 反代通常显式设 `proxy_buffering off`。
location /ws {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_buffering off; # WebSocket 关闭缓冲,避免额外延迟
proxy_read_timeout 3600s; # 默认 60s 必须调大
proxy_send_timeout 3600s;
}多实例部署与粘性会话
单实例时一切正常,一旦水平扩容到多个后端实例,WebSocket 立刻出现新问题:某条连接建立后,收到的推送和发布的消息对不上,或者订阅状态莫名丢失。根因是 WebSocket 是有状态的长连接——连接建立在实例 A 上,此后所有消息必须都从实例 A 发出,但负载均衡器不知道哪个客户端连着哪个实例。
解决方案有三种,按推荐度排序。**首选粘性会话**:用 Nginx 的一致性哈希(`ip_hash` 或 `hash $remote_addr consistent`)保证同一客户端 IP 总是落到同一实例,实现最简单、语义最正确。**其次应用层共享状态**:把订阅关系放进 Redis,所有实例都能查到任何客户端的订阅信息,从而消除对粘性的依赖,可扩展性最好。**最后是消息总线**:实例之间通过 Redis Pub/Sub 或 Kafka 广播消息。
需要特别注意的是 Nginx 开源版**不提供**会话保持(sticky cookie)的编译选项,`nginx-plus` 才有。如果你在网上抄到 `sticky` 指令而服务器上直接报 `"sticky" directive is not allowed here`,不是配置写错,而是版本不支持。开源环境下应该用 `hash` 指令实现等价效果。
另外,如果用 Nginx 的 **stream** 模块做四层 TCP 代理(比如 WebSocket 走 443 直连),配置思路和 HTTP 模块完全不同:需要 `proxy_pass` 配合 `proxy_timeout`,而且 stream 模块天然按连接分配,不存在粘性问题。本文讨论的是 HTTP 模块应用层反代的场景,两者不要混淆。
upstream ws_backend {
hash $remote_addr consistent; # 开源版实现粘性会话
server 10.0.0.11:3000;
server 10.0.0.12:3000;
server 10.0.0.13:3000;
}
server {
listen 80;
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
}排障清单与验证命令
第一步永远是看握手响应码。`curl` 带 Upgrade 头请求,若返回 `101 Switching Protocols` 说明 Nginx 到后端这一段完全正常,问题在连接建立之后;若返回 200 或 400,说明握手阶段就失败了,先查 `Upgrade`/`Connection`/`Host` 三个头是否齐全。
第二步看 Nginx 错误日志。`upstream timed out` 对应超时,`upstream prematurely closed connection` 通常意味着后端主动关闭了连接(后端进程重启、心跳超时、负载过高被拒),`recv() failed (104: Connection reset by peer)` 则是后端把连接重置了。不同错误码指向完全不同的根因,不要笼统归为「代理问题」。
第三步确认连接实际存活时长。可以用 `wscat` 或浏览器 DevTools 的 Network 面板看 WebSocket 帧,若发现断开间隔恰好是 60 秒、120 秒这类整齐的数值,几乎可以断定是超时配置。第五步是压测多实例场景:连续建立多条连接,观察是否有订阅丢失,再对比单实例行为。
最后是把配置固化下来。`nginx -t` 校验语法、`nginx -s reload` 平滑重载,改动后立即用 `wscat` 复测一次握手。WebSocket 配置的关键项不多——Upgrade 透传、Host 头、超时、缓冲、粘性——把这五项固定成模板,之后新增服务直接复制即可。
nginx -t # 校验语法
nginx -s reload # 平滑重载
tail -f /var/log/nginx/error.log | grep -i "ws\|upstream"
# 用 wscat 复测:npx wscat -c ws://127.0.0.1:8080/ws