上个月给一个在线教育项目做技术支援,需求本身很朴素:一个白板协同页面,老师的每一次操作都要实时推到所有学生屏幕上,用 WebSocket 实现。前端写完,后端 Go 服务也起了,我用 Nginx 做反向代理,一切看起来很顺直到正式上课。
学生一进教室,页面提示"连接已断开,请刷新重连"。我查后端日志,明明看到握手请求进来了;再翻 Nginx 的 access log,状态码是 499。开会话的前十分钟,我盯着日志来回对照,发现一个规律:连接稳定存在大概 60 秒左右,然后就断一次。60 秒这个数字太眼熟了,它是 Nginx proxy_read_timeout 的默认值。
更麻烦的是,重连之后又能撑 60 秒,然后接着断。前端同学一开始以为是自己的心跳逻辑写错了,加了各种重连退避,越加越乱。真正的原因其实只有一句话:Nginx 反向代理 WebSocket 的时候,有 4 个地方必须改,漏掉任何一个,连接都不会稳。
长连接要稳,先看机器和线路。WebSocket 这类持续占用连接的场景,对回源线路的延迟和抖动特别敏感,一条拥塞的线路就能让握手和心跳频繁超时。digitalvirt(原生IP精品线路,CN2 GIA / 9929 / CMIN2) 提供多种优化线路选择,做实时推送、在线客服这类长连接业务时,可以拿来做线路和带宽的对照参考。
下面把这 4 个地方逐个拆开说,也顺便解释一下它们各自到底在解决什么问题,方便你遇到类似现象时能自己定位,而不是照着抄配置。

第一处:Upgrade 和 Connection 这两个头必须透传
WebSocket 握手本质上是借用了 HTTP 的 Upgrade 机制。客户端发起的是一个普通 HTTP 请求,只不过带上了两个特殊的头:Upgrade: websocket 和 Connection: Upgrade。服务端回一个 101 Switching Protocols,这个连接就"升级"成双向通道了,之后走的不再是"一次请求一次响应"的模型。
问题出在中间这一层。Nginx 默认的 proxy_pass 转发请求时,Host、Connection 这类头会被重写或者直接丢掉,Upgrade 头更是完全不会自动透传。后端收到的是一个普普通通的 HTTP 请求,它不知道客户端想升级协议,于是返回一个 200 加一段 JSON,Nginx 照样把 200 交回前端。前端一看不是 101,直接判定握手失败。
解决办法就是显式告诉 Nginx:这几个头,原样带过去。
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 "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
这里有个细节很容易漏:proxy_http_version 1.1 这行不能省。HTTP/1.0 的代理默认不带 keepalive,Nginx 会把每一对请求响应当成一个独立的连接处理,长连接根本维持不住。很多人只抄了 Upgrade 和 Connection 两行,握手能成功,但几秒之后连接莫名其妙地断,根因就是少了这一行。
至于为什么把 Connection 直接写成固定字符串 "upgrade" 而不是用 $connection_upgrade 变量,那是为了兼容没有定义 map 的老配置。规范一点的写法是先在 http 块里定义一个 map:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
然后 location 里用 proxy_set_header Connection $connection_upgrade;。这样同一个 server 块里既有 WebSocket 路径、又有普通 HTTP 路径时,普通请求不会被硬塞一个 upgrade 头过去。这是我后来改成 map 版本的原因。

第二处:proxy_read_timeout 必须大于心跳间隔
回到最开始那个 60 秒。Nginx 的 proxy_read_timeout 默认是 60s,含义是:两次从后端读取数据之间的最大间隔超过 60 秒,Nginx 就认为后端挂了,主动断开这条连接。
对普通的短请求来说这完全合理,一次请求几百毫秒就结束。但 WebSocket 是长连接,握手完成之后可能几分钟都没有数据流动用户只是在看白板,没人说话。这时候 Nginx 会忠实地在 60 秒处把连接掐掉,前端收到 close 事件,再重连,又是一个新的 60 秒。
解法有两个方向,取决于你的业务形态。
第一种是调大超时,比如:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
第二种是加心跳。后端每 30 秒往客户端发一个 ping 帧(WebSocket 协议原生支持 ping/pong),这样 Nginx 那边就一直有数据流动,永远不会触发读超时。这两种方式其实可以叠加:超时值给足余量,心跳负责让链路保持活跃、顺便探测对端是否真的还活着。
我最后把两个都做了:proxy_read_timeout 设成 3600s,后端每 25 秒发一次 ping。25 秒这个数字是特意选的,比 60 秒短一大截,就算中间网络抖一下、丢一两个心跳包,也不至于直接掉线。
要注意的是,如果你的 Nginx 前面还套了一层 CDN 或者四层负载均衡,那每一层都有自己的空闲超时,取整条链路里最短的那个值。这是我后来又栽过一次的坑:把 Nginx 调好了,结果 CDN 那边 100 秒断一次,白排查半天。改之前先确认链路上一共有几跳。
另外很多人爱用 proxy_send_timeout 之外的 client_body_timeout、send_timeout 这些全局值去覆盖,但作用域一大就容易误伤其他站点。写在 location 里精准一点,别动全局。

第三处:后端要能拿到真实客户端 IP
WebSocket 长连接建立起来之后,Nginx 到后端这条腿上,后端看到的连接来源永远是 127.0.0.1。如果你的业务逻辑里有基于 IP 的限流(比如同一 IP 最多 5 条连接)、有审计日志要记访客地址,或者排查问题时想确认到底是哪台客户端在重连,你会发现后端拿到的信息全是同一个本地地址,一无所获。
解决办法是把 X-Real-IP 和 X-Forwarded-For 都带上:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
$proxy_add_x_forwarded_for 这个变量的好处是它会把原有的 XFF 头追加进去,而不是覆盖。所以如果前面还有 CDN,回源请求本身已经带着访客 IP,Nginx 再把它拼到自己这一层,链路上的 IP 链条才是完整的。
后端代码里也要记得读这个头。很多 Go/Python 框架默认只认 RemoteAddr,你得显式从 header 里取。我见过一个后端框架把 XFF 解析成了列表,取了 [0],结果拿到的是 CDN 节点 IP 而不是访客 IP顺序反了,排查方向整个就跑偏。
第四处:连接数上限和后端容量要匹配
前面三处配置改完,连接确实稳了,但很快又冒出新问题:高峰期在线人数一多,就有一部分用户握手失败,日志里是 worker_connections are not enough 或者 upstream 发送缓冲区太小这类报错。
这是因为长连接和普通请求对资源的占用方式完全不一样。一个普通请求可能几十毫秒就结束,长连接会一直占着一个 worker 进程、一个文件描述符、一份后端侧的连接。1 万条长连接意味着 Nginx 这边和后端那边都得扛得住这个量级。
几个参数要一起看:
worker_connections 8192;
worker_rlimit_nofile 65535;
worker_connections 是单个 worker 进程能处理的最大连接数,worker_processes 默认是 CPU 核数。粗略估一下:如果一台机器要撑 2 万条长连接,worker_processes 设 4,那每个 worker 至少得扛 5000 条,8192 刚好够。反过来如果 worker 开了 16 个进程,每个给 4096 也够用重要的是总量,而不是单个数字好不好看。
worker_rlimit_nofile 必须大于 worker_connections,否则改完配置也不会生效,这是最常见的"我明明改了没用"。改完还得同步提高系统层的限制:
ulimit -n 65535
并且确认 /etc/security/limits.conf 里的 nofile 限制,以及 systemd 服务启动时 LimitNOFILE 的值,光改 Nginx 配置不改系统限制,等于没改。
后端那边同理。Go 的默认文件描述符上限不低,但如果用 Nginx 作为唯一入口,后端的连接数峰值就是并发长连接数,也要确认后端服务的 backlog 和 ulimit 撑得住。

改完之后怎么验证
配置改完别急着收工,我一般按这三步验一遍。
第一步,用 wscat 或者 websocat 直接连后端,绕过 Nginx,确认后端本身没问题:wscat -c ws://127.0.0.1:8080/ws。如果直连能通而走 Nginx 不通,那就是反代配置的问题;直连也不通,那是后端的锅。这是最快的分界点。
第二步,在浏览器 F12 的 Network 面板里筛 WS,看握手请求返回的状态码。必须是 101 Switching Protocols,如果看到 200,说明 Upgrade 头没透传;如果看到 502,说明后端压根没接住。
第三步,用一条连接持续挂着,观察超过 60 秒、超过 5 分钟,确认它不再自动断开。同时在 Nginx 的 access log 里看 upstream_response_time 和 status,499 和 504 都应该消失。
顺带说一句,499 这个状态码值得单独提一下。Nginx 记录 499 的意思是"客户端主动关闭了连接",它代表的是客户端先断,不是服务端断。排查长连接问题的时候,看到满屏 499 又找不到服务端错误,第一反应应该是怀疑超时配置或者客户端心跳超时,而不是去翻后端日志。方向搞反了,很容易白忙一天。
另外建议把长连接相关的指标接上监控:当前活跃连接数、握手失败率、心跳超时数、Nginx worker 的文件描述符占用。这些数字出问题时,能第一时间看出是客户端的问题还是服务端的问题,而不是等到用户投诉了才开始翻日志。
另一家可选的对比方案:cstonecloud(全球多数据中心,CN2 GIA / 9929 / CMIN2 线路) 在多个海外节点提供同类优化线路,适合需要就近接入、就近回源的场景。选线路时建议用同一条业务链路分别 ping 和 mtr 一遍,握手往返时间稳定了再上线,别只看带宽数字。
回过头看,那次故障的根因其实挺简单:默认配置是为短请求设计的,直接套到长连接上自然会出问题。WebSocket 反代真正需要注意的就是那 4 处Upgrade 头透传、proxy_http_version 1.1、读超时与心跳配合、连接数与文件描述符上限。逐条对照着检查一遍,基本能把长连接不稳定的九成问题解决掉。
最后再提醒一句:这类配置改完一定留一份记录,写清楚每行参数为什么这么设。半年后换人维护,看到 proxy_read_timeout 3600s 这种值,正常人都想上来就改小一点,然后问题就回来了。



下载 
下载 
下载
下载
下载
喜欢
顶
难过
囧
围观
无聊




