把在线聊天、实时推送、协同编辑、监控大屏这类 WebSocket 应用放到 Nginx 后面时,很多人会遇到一个经典现象:通过 Nginx 访问,连接建立后每隔 60 秒左右就断开一次,反复重连,但直连后端却一切正常。原因几乎总是同一类——Nginx 默认把它当成普通的 HTTP 请求,而 WebSocket 是一次"先 HTTP 握手、再升级为长连接"的特殊协议。
这篇文章讲清楚 WebSocket 协议和普通 HTTP 的区别,重点说清 Nginx 里那三个"缺一不可"的请求头(Upgrade、Connection、Host),解释 proxy_read_timeout 为什么会造成周期性断连,并给出一份可直接用于生产、含心跳与超时调优的配置片段。还会覆盖多实例负载均衡、WSS(TLS)转发、以及用日志和状态码定位断连的方法,读完你能把 WebSocket 稳定地代理起来,而不是靠不停加大超时碰运气。
先给一个结论:WebSocket 反向代理的完整解法 = 正确的升级头 + 合适的长连接超时 + 应用层心跳。三者缺任何一个,都会表现为"能连上,但过一会就掉"。
一、WebSocket 和普通 HTTP 的本质区别
普通 HTTP 是"一问一答":客户端发一个请求,服务端回一个响应,连接(HTTP/1.1 下)可以选择关闭或复用,但每次交互都是独立的。WebSocket 不一样,它的生命周期分两段:
客户端 ──HTTP Upgrade 请求──> Nginx ──> 后端
后端 ──101 Switching Protocols──> Nginx ──> 客户端
之后:客户端 <──── 全双工长连接 ────> 后端(双向随时收发)
- 握手阶段:客户端发一个带
Upgrade: websocket的普通 HTTP 请求,服务端回101 Switching Protocols,协议就此升级。 - 数据阶段:升级成功后,这条 TCP 连接变成全双工通道,双方可以随时互推消息,不再遵循请求-响应模式。
关键就在这个"协议升级"上。Nginx 默认对每个 location 按 HTTP 处理,如果它不认识 Upgrade/Connection 头,就不会把这条连接升级,而是当普通请求处理,结果就是连接被提前收尾或超时断开。所以配置的核心动作是:把升级相关的头透传给后端。
二、WebSocket 握手涉及的关键头
理解下面这几个头,配置就不会是靠背的。
| 请求头 | 作用 | Nginx 需要做什么 |
|---|---|---|
Upgrade: websocket |
声明要升级协议 | 透传给后端:proxy_set_header Upgrade $http_upgrade; |
Connection: Upgrade |
指示本次连接要升级 | 透传:proxy_set_header Connection "upgrade"; |
Host |
后端识别虚拟主机 | 透传:proxy_set_header Host $host; |
Sec-WebSocket-Key |
握手校验随机数 | 透传(默认已透传) |
Sec-WebSocket-Version |
协议版本,通常 13 | 透传 |
其中最容易忘、也最关键的是 Upgrade 和 Connection。Nginx 用内置变量 $http_upgrade 取到客户端的 Upgrade 值,再通过 proxy_set_header 把它转给后端;Connection 则要显式设成 upgrade(HTTP/1.1 的 proxy_http_version 1.1 也必须设,否则默认按 HTTP/1.0 走,升级头不被支持)。
三、最小可用配置:三个必须项
一个能让 WebSocket 正常工作的最小配置如下。注意它同时设了 proxy_http_version 1.1,这是前提。
location /ws/ {
proxy_pass http://127.0.0.1:8080;
# 1) 必须用 HTTP/1.1,WebSocket 升级才被支持
proxy_http_version 1.1;
# 2) 透传升级头(缺一不可)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 3) 透传 Host,后端虚拟主机/校验需要
proxy_set_header Host $host;
# 常规真实 IP 透传
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_read_timeout 3600s;
proxy_send_timeout 3600s;
}
改完务必校验并平滑重载:
nginx -t && nginx -s reload
如果你用的是 map 方式做通用配置(同一个 location 既服务普通 HTTP 又服务 WebSocket),可以这样写,更优雅:
# 放在 http {} 块里
map $http_upgrade $connection_upgrade {
default upgrade;
'' close; # 普通 HTTP 请求时,Connection 设为 close
}
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # 由 map 动态决定
proxy_set_header Host $host;
proxy_read_timeout 3600s;
}
四、超时设置:为什么恰好 60 秒断一次
proxy_read_timeout 是 WebSocket 断连的头号元凶。它的含义是:Nginx 在两次从后端读到数据之间的最大等待时间,默认 60 秒。如果这条长连接上有 60 秒没有任何数据流动,Nginx 就会认为连接空闲、把它关掉,前端于是看到断开并重连。
有两种应对思路,最佳实践是两者结合:
- 调大
proxy_read_timeout:把它设成比业务最长空闲时间更长的值。例如聊天场景可能几分钟才有一条消息,就设3600s。 - 应用层心跳(推荐):让客户端或服务端每隔 30~50 秒发一个 ping/心跳帧(文本帧或 WebSocket ping 帧),让链路上始终有数据,从而触发不了空闲超时。
关键超时参数对比:
| 参数 | 默认值 | 含义 | WebSocket 建议 |
|---|---|---|---|
proxy_read_timeout |
60s | 两次读之间的最大间隔 | 3600s 或配合心跳 |
proxy_send_timeout |
60s | 两次写之间的最大间隔 | 与上保持一致 |
proxy_connect_timeout |
60s | 与后端建连超时 | 5~10s 足够 |
keepalive_timeout |
75s | 客户端连接空闲超时 | WebSocket 场景可不依赖它 |
经验值:心跳间隔建议取 30~50 秒,既远小于常见 60 秒的中间设备(防火墙、负载均衡)空闲回收阈值,又不会产生太多空流量。移动网络下运营商 NAT 网关常在 1~5 分钟回收空闲 TCP,心跳对此尤其重要。
五、生产级完整配置示例
下面这份配置把上面的要点整合起来,包含 upstream 长连接复用、多种超时和代理缓冲的调整,可直接改地址使用。
upstream ws_backend {
server 127.0.0.1:8080 max_fails=3 fail_timeout=10s;
keepalive 32; # 与后端保持空闲长连接
}
server {
listen 80;
server_name chat.example.com;
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_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_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_connect_timeout 10s;
# WebSocket 场景关闭缓冲、禁用请求缓冲,避免消息被攒着不发
proxy_buffering off;
proxy_request_buffering off;
# 不要在 WebSocket 上做重试,避免握手混乱
proxy_next_upstream off;
}
}
几个要点说明:
keepalive 32让 Nginx 与后端复用连接,减少握手开销;注意它要求proxy_http_version 1.1且Connection头在非升级请求时为空(用上面的map处理)。proxy_buffering off对实时消息很重要,否则响应可能被缓冲、延迟下发。proxy_next_upstream off:升级请求不是幂等的,自动换上游可能造成异常,建议关掉。
六、多实例负载均衡下的 WebSocket
这是配置好之后最容易再踩的坑。WebSocket 是有状态长连接,一旦握手连到某个后端实例,后续这条连接的所有消息都会固定走这台实例。如果你有多个后端实例,就要考虑两件事:
| 方案 | 做法 | 适用场景 |
|---|---|---|
| 无状态 + 消息总线 | 后端不存会话,靠 Redis/MQ 广播消息 | 推荐,水平扩展友好 |
| 黏性会话(sticky) | 同一客户端固定到同一实例 | 后端有本地状态,难改造时 |
| IP Hash | upstream 里用 ip_hash 按 IP 固定 |
简单,但 NAT 后大量用户会聚到同一台 |
Nginx 开源版没有内置的商业级 sticky 模块,常用替代是 ip_hash:
upstream ws_backend {
ip_hash; # 同一 IP 尽量固定到同一后端
server 127.0.0.1:8080;
server 127.0.0.1:8081;
keepalive 32;
}
但 ip_hash 有局限:NAT 出口下大量用户共用一个公网 IP,会全部压到同一台实例;扩容缩容时映射也会变化。更推荐把会话状态外置(Redis 存 session、发布订阅广播),让后端无状态,再配合普通轮询即可,这样扩容、重启、故障切换都不丢会话。
七、HTTPS/WSS 场景与日志排障
WebSocket 在 HTTPS 下称为 WSS,配置上不需要特殊改动——只要 location 的代理头和超时设对了,TLS 由 Nginx 的 listen 443 ssl 处理,Nginx 内部再以明文 HTTP 连后端即可。
server {
listen 443 ssl;
server_name chat.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
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_read_timeout 3600s;
}
}
排查时,先看日志里握手是否返回 101:
# 在 http{} 里自定义日志,把升级头与状态码记下来
log_format wslog '$remote_addr - $status - upgrade=$http_upgrade '
'conn=$connection_upgrade rt=$request_time';
access_log /var/log/nginx/ws.access.log wslog;
# 用 curl 验证握手(-i 看响应头,应看到 101 Switching Protocols)
curl -i -N \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://127.0.0.1/ws/
# 看错误日志里的断连原因
tail -f /var/log/nginx/error.log | grep -i 'upstream\|timed out\|reset'
常见状态码含义:
| 状态码 | 含义 | 排查方向 |
|---|---|---|
| 101 | 协议已切换,握手成功 | 正常 |
| 400 | 握手请求非法 | 检查 Sec-WebSocket-* 头是否被中间层剥掉 |
| 426 | 需要升级协议 | 后端要求 Upgrade,检查 upgrade 头是否透传 |
| 502 | 后端不可用 | 后端进程、端口、协议问题 |
| 499 | 客户端提前断开 | 前端超时/主动关闭,常见于心跳缺失 |
| 504 | 后端超时 | proxy_read_timeout 或后端慢 |
常见问题
Q:加了配置还是 60 秒断一次,为什么?
先确认 proxy_http_version 1.1 和两个升级头都设了,且 location 匹配到了实际的 WebSocket 路径(比如 /ws/ 的大小写、末尾斜杠)。再确认 proxy_read_timeout 是否真的生效(nginx -T 看最终配置),最后考虑是中间还有一层负载均衡或防火墙有 60 秒空闲回收,需要在更外层也调大超时或加心跳。
Q:Connection "upgrade" 写死会不会影响同 location 的普通 HTTP 请求?
会。如果这个 location 同时处理普通请求,写死 upgrade 会导致普通请求也被声明为升级,可能引发异常。用 map $http_upgrade $connection_upgrade 的方式动态取值,普通请求自动变成 close。
Q:要不要开 proxy_buffering?
WebSocket 场景建议 proxy_buffering off。缓冲会让服务端的推送消息被攒着、延迟下发,实时性变差。开启缓冲对短请求优化明显,但长连接双向推送下弊大于利。
Q:多台后端时,为什么用户会看到"消息发给了别人"或"偶尔丢消息"?
典型的黏性问题:用户握手连到了 A 实例,而消息广播只发给了 B。解决方向是会话外置(Redis 订阅广播)或用黏性策略,让同一用户的消息始终落在一处并全局广播。
Q:WSS 需要为 WebSocket 单独配证书吗?
不需要。WSS 的 TLS 由 Nginx 的 ssl_certificate 统一处理,location 内仍以明文 HTTP 转发给后端。只有当你需要端到端加密到后端时,才用 proxy_pass https:// 或 grpc_pass 加相应配置。
总结
WebSocket 反向代理的本质是"透传协议升级":proxy_http_version 1.1 加 Upgrade、Connection 两个头,是能否连上的关键。
周期性断连(常见 60 秒)来自 proxy_read_timeout 空闲超时,正解是调大超时 + 应用层心跳双管齐下,中间设备(NAT、防火墙)也常有类似的空闲回收,需要一并考虑。
生产配置建议加上 keepalive 复用、proxy_buffering off(实时性)和 proxy_next_upstream off(避免升级请求重试出错)。
多实例场景优先让后端无状态、会话外置,配合消息总线广播;确需黏性再用 ip_hash,但要清楚 NAT 聚合的副作用。
排障先看状态码:101 正常,426/400 查升级头,502/504 查后端与超时,499 多半是前端超时或心跳缺失。
企业QQ咨询




