搞实时推送的,谁没被WebSocket教育过。你辛辛苦苦把后端搭好,客户端连上了,结果往Nginx后面一放,连接一秒就断;或者后端起了两个节点,消息只在其中一台上有响应;再或者长连接挂着挂着,服务器没崩,前面的网关先把连接掐了。这些问题我从WebSocket上线第一天踩到今天,最后都归结到一个点上:到底怎么用Nginx给WebSocket实时长连接做一套能上生产的负载均衡方案。这篇东西不是科普WebSocket协议本身,而是把生产环境里真正会踩的坑、真正要配的参数、真正需要理解的设计逻辑一次讲清楚,适合正在做即时通讯、在线客服、协同编辑、股票行情、游戏对战这类功能的开发或运维同学。
1. 先理清楚:WebSocket在Nginx眼里到底是什么
1.1 握手是HTTP,升级之后就不是了
WebSocket的设计很巧妙,它没有另起炉灶造一套连接建立流程,而是借用了HTTP的Upgrade机制。客户端发一个普通的HTTP GET请求,带着两个特殊头:Connection: Upgrade和Upgrade: websocket,服务端返回101状态码,这一个TCP连接从此刻开始就从HTTP协议切成了WebSocket协议。
这个细节决定了Nginx的所有行为。Nginx首先是一个HTTP反向代理,它能识别握手阶段的请求,所以proxy_pass可以正常转发;但握手完成之后,这条连接上的数据就不再是HTTP请求了,而是WebSocket帧——二进制帧也好、文本帧也好,Nginx默认情况下不懂这些帧的内容,也根本不需要懂。
问题就出在这里:如果按照普通的HTTP代理逻辑,Nginx会在一次请求响应结束后关闭upstream连接。但WebSocket是一次请求、持续通信,如果你没有做任何特殊配置,Nginx会把这条连接当成普通HTTP请求处理完毕,然后掐断,客户端就看到了连接被重置或者1006。这不是Nginx的问题,而是配置姿势不对。
1.2 为什么负载均衡策略不能照搬HTTP
普通HTTP负载均衡很简单:来一个请求,分配给一台后端,处理完就结束。哪怕同一个用户连续发十个请求,每个请求落在不同节点上也没有关系,因为每次请求都是独立的、无状态的。
WebSocket完全不同。一个用户连上来之后,这条TCP连接可能存活几个小时甚至一整天。在这段时间里,客户端和服务端之间会持续收发消息。更要命的是,很多业务场景下,服务端是在进程内存里维护这个连接对象的——比如用户A在Node.js进程1里建立了连接,用户B发给A的消息广播到了进程2,如果进程2上没有A的连接对象,这条消息就发不出去。
这就是为什么WebSocket的负载均衡必须考虑“粘性会话”,也就是要让同一个客户端的连接始终落在同一个后端节点上。否则,高并发一上来,节点间要么疯狂同步状态,要么直接丢消息。这是生产环境里最常见的隐性故障。
1.3 先选七层还是四层
Nginx代理WebSocket有两种主流姿势:一种是七层的HTTP反向代理,也就是http块里的location加proxy_pass;另一种是四层的TCP透传,也就是stream模块直接转发TCP流量。
七层的优势是灵活,能看到HTTP握手阶段的信息,可以做基于URL的路由、鉴权、限流、HTTPS终结;劣势是Nginx需要维护两条连接——客户端到Nginx一条,Nginx到后端一条,连接数翻倍,转发性能打折扣。
四层的优势是性能更好、占用资源低,而且因为不解析应用层协议,理论上什么语言、什么框架都能透传;劣势是Nginx看不到业务信息,没法做精细路由,也没法在握手阶段做一些HTTP层的处理。
我自己的经验是:业务初期、并发量在几千到几万的情况下,直接上七层,省心;等长连接数大到Nginx成了瓶颈、又不需要复杂路由逻辑的时候,再切到四层或者在前面再加一层四层LVS/TCP负载。别一上来就追求极致性能,复杂度和收益要匹配。
2. 七层方案:Nginx代理WebSocket的标准配置
2.1 核心配置逐行拆解
Nginx从1.3.13版本开始原生支持WebSocket代理,所谓“支持”其实就是允许你修改Upgrade相关的请求头。标准配置长这样:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ws_backend { ip_hash; server 10.0.1.11:8080; server 10.0.1.12:8080; server 10.0.1.13:8080; } server { listen 80; server_name ws.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_connect_timeout 60s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }这段配置里最关键的是前三行和map指令。map的作用是:如果客户端请求里带了Upgrade: websocket,Nginx转发的Connection头就设置为upgrade;如果客户端没有带Upgrade头,说明这就是一个普通HTTP请求,Connection头会被设置成close,走正常的HTTP短连接逻辑。
proxy_http_version 1.1也必须有。WebSocket握手依赖HTTP/1.1的Upgrade机制,而Nginx默认转发请求用的还是1.0版本,HTTP/1.0不支持Upgrade头,配置了这个参数才能真正把协议版本变成1.1。
2.2 负载均衡策略怎么选
upstream块里的默认策略是轮询。轮询对于普通HTTP没问题,但对WebSocket就是灾难,因为每个新连接会被分到不同的后端节点。
生产中我用得最多的是ip_hash。它的原理是对客户端IP做哈希计算,同一个IP的请求始终落在同一台后端上。这能保证同一个用户的所有连接都进同一个节点,天然满足会话粘性要求。
但是ip_hash有两个需要注意的场景。一个是同一个NAT出口下有大量用户,比如某个公司整个办公网出口就一个公网IP,那这些用户全部会被哈希到同一台后端,造成热点。另一个是后端节点扩缩容时,哈希结果会变化,已经建立的连接不受影响,但新连接会被重新分配。
如果你的业务对连接分布要求更精细,也可以考虑least_conn加hash $remote_addr consistent这种一致性哈希组合。不过说实话,对于大多数WebSocket场景,ip_hash够用,不需要过度设计。
2.3 超时参数才是长连接稳定性的命门
很多人配置完Upgrade头之后,发现连接能建立,但没过多久就断了。我排查过不少这样的问题,最后发现是Nginx默认的proxy_read_timeout只有60秒导致的。
这个参数的含义是:Nginx从upstream后端读取数据的超时时间。如果在这段时间内,后端没有向Nginx发送任何数据,Nginx就认为连接空闲太久了,主动断开。对于普通HTTP请求,60秒足够了;但对于WebSocket长连接,哪怕客户端和后端都很健康,只要双方暂时没有消息往来——比如一个在线客服系统,用户看完消息去填表了,5分钟都没有新消息——Nginx就会在60秒时把连接掐断。
所以生产环境的配置里,这个值一定要调大。具体调多大没有标准答案,取决于你的业务心跳频率和空闲容忍度。我一般先调到3600秒,也就是一小时,然后结合客户端的应用层心跳机制,保证这个时间窗口内至少有一次数据交互。如果你的业务允许长时间静默,甚至可以设置成更大的值,但别设成0,0表示完全不超时,万一后端连接挂了却没人发现,连接对象会一直堆积,内存迟早爆掉。
3. 四层方案:stream模块做TCP透传
3.1 什么时候必须上四层
七层方案承担代理和转发有一个硬成本:每一条客户端连接,Nginx都要在后端再建立一条TCP连接。如果同时在线长连接数到了五万、十万这个量级,Nginx需要维护的连接数就是十万、二十万,再加上握手、缓冲、协议解析消耗的内存和CPU,压力非常大。
我遇到过一个实际场景:后端是Golang写的语音实时通信服务,每条连接不仅要传文本,还要持续传输音频流,单条连接的数据量很大,而且QPS还高。这种场景下,七层代理的转发代价就很明显了,延迟增加了几毫秒,丢包率也有上升。后来把代理层改成stream模块的TCP透传,Nginx不再解析HTTP和WebSocket帧,只做四层转发,性能立刻上来了。
另外还有一种情况也适合四层:后端服务本身需要拿到客户端的真实源IP,而应用层协议特殊,或者后端不支持通用的X-Forwarded-For约定。TCP透传模式下,后端拿到的源地址就是真实客户端IP,除非你再用proxy_protocol做一层协议包装。
3.2 stream配置实例
四层配置比七层简单得多,核心就是一个stream块:
stream { upstream ws_tcp_backend { server 10.0.1.11:8080 max_fails=3 fail_timeout=10s; server 10.0.1.12:8080 max_fails=3 fail_timeout=10s; server 10.0.1.13:8080; } server { listen 8080; proxy_pass ws_tcp_backend; proxy_connect_timeout 10s; proxy_timeout 7200s; proxy_buffer_size 32k; } }这里没有proxy_http_version、没有Upgrade头、没有map,因为四层转发根本不关心这些,它只负责把TCP字节流从客户端搬到后端,再搬回来。
需要注意两个参数:proxy_timeout表示代理连接的空闲超时,作用类似七层里的proxy_read_timeout和proxy_send_timeout,生产环境一定要调大,否则长连接照样会被静默掐断;proxy_buffer_size决定内核缓冲区大小,如果你的业务单条消息比较大,比如语音帧或者大图,这个值可以适当调大,我一般在32k到128k之间选择。
还有一个隐藏问题:stream模块默认没有会话粘滞。如果后端有状态,你需要用hash $remote_addr consistent之类的指令来固定后端节点。
3.3 四层和七层的完整对比
很多同学在选型时纠结,我直接给一个对比表格,方便按需选择:
| 对比项 | 七层HTTP代理 | 四层stream透传 |
|---|---|---|
| 性能 | 相对较低,连接数翻倍 | 高,Nginx只做流量搬运 |
| 灵活性 | 可按URL、Header路由 | 只能按IP和端口 |
| HTTPS终结 | 可以在Nginx层做 | 无法做,必须透传TLS |
| 会话粘滞 | ip_hash、cookie | 按IP哈希 |
| 协议感知 | 能识别HTTP/WS帧 | 完全无感知 |
| 配置复杂度 | 较高 | 低 |
我的建议是:如果你的业务需要做多域名、多路径的服务拆分,或者需要Nginx统一做HTTPS证书管理,那就用七层;如果所有流量都进一个端口,后端自己处理一切,优先考虑四层。毕竟四层少做一层协议解析,故障点也少一个。
4. 生产级配置实战:完整示例和参数调优
4.1 一份可以直接落地的nginx.conf
下面这份配置是我在一个日活几十万的消息推送项目里实际用过的,去掉了业务无关的内容,保留核心思路:
user nginx; worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 20480; use epoll; } http { include mime.types; default_type application/octet-stream; log_format main '$remote_addr [$time_local] "$request" ' 'status=$status ' 'upstream_addr=$upstream_addr ' 'upstream_connect_time=$upstream_connect_time ' 'upstream_response_time=$upstream_response_time ' 'request_time=$request_time ' 'bytes_sent=$bytes_sent ' 'http_upgrade=$http_upgrade'; access_log /var/log/nginx/access.log main; error_log /var/log/nginx/error.log warn; map $http_upgrade $connection_upgrade { default upgrade; '' close; } upstream ws_backend { ip_hash; server 10.0.1.11:8080 max_fails=3 fail_timeout=10s; server 10.0.1.12:8080 max_fails=3 fail_timeout=10s; server 10.0.1.13:8080 max_fails=3 fail_timeout=10s; } server { listen 80; server_name ws.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name ws.example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; 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 Origin $http_origin; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 60s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffer_size 4k; proxy_buffers 8 4k; proxy_busy_buffers_size 8k; } } }这里我先用80端口强制跳转HTTPS,然后WebSocket走443的wss://。proxy_set_header Origin $http_origin这一行很多人会漏掉,如果你的后端要做跨域校验或者防跨站WebSocket攻击,这个头必须透传。
4.2 健康检查与节点摘除
Nginx开源的http模块自带的健康检查很弱,默认只有max_fails和fail_timeout这套被动检查:一段时间内转发失败达到阈值,就把节点标记为不可用。对于HTTP服务,这种被动检查够用;但对于长连接服务,一旦某台后端内存满了或者死锁了,其实是很久之后才暴露出来的。
商业版Nginx Plus提供主动健康检查,可以定时发请求探测后端,开源版的话有两种常见选择:一个是给Nginx打nginx_upstream_check_module补丁,一个是自己在外面跑一个监控脚本,定期检查后端端口,失败就调Nginx API或者改配置reload。
我在生产环境比较推荐的是补丁方案,因为打了补丁后,配置很干净:
upstream ws_backend { ip_hash; server 10.0.1.11:8080; server 10.0.1.12:8080; check interval=5000 rise=2 fall=3 timeout=3000 type=tcp; }这段配置的意思是:每5秒主动连接一次后端,连续成功2次判定为健康,连续失败3次判定为宕机。type=tcp就是只检查端口通不通,不检查业务状态。如果后端业务启动比较慢,比如要加载几十MB的配置,rise=2可以适当调大,避免Nginx把还在启动的后端误判为宕机。
4.3 TLS终结与WSS的坑
WebSocket走HTTPS之后就成了WSS,ws://变wss://,端口从80变成443。这个切换看起来简单,但有几个坑:
第一个是证书链不全。有些证书厂商只给你发域名证书,没有附带中间证书,你直接配上,浏览器访问没问题,但很多App或者服务端SDK在做TLS握手时会校验完整链,直接报错。解决方法是把域名证书和中间证书拼在一个pem文件里。
第二个是混合内容限制。如果前端页面是https://的,那么页面里发起的WebSocket请求也必须是wss://,浏览器不允许在HTTPS页面里连ws://。很多前端同学调试时没注意,页面是HTTPS,代码里写ws://,结果报Mixed Content错误。
第三个是HTTP/2的兼容问题。如果你在443端口同时开启了HTTP/2,注意WebSocket改造走Upgrade机制时,HTTP/2本身有自己处理WebSocket的方式,早期Nginx版本对WebSocket over HTTP/2的支持不完善。我的经验是WebSocket专用域名不要开HTTP/2,或者用Nginx 1.25以上的版本再考虑,否则可能遇到奇怪的行为。业界标准还没有统一,稳妥为上。
5. 客户端配合:重连、心跳与状态同步
5.1 心跳机制为什么必须做
负载均衡配好了,但长连接不可能永远不断。运营商网络设备对长连接很不友好,尤其是移动网络下,NAT表项可能几分钟就过期,连接看似还在,实际上数据包根本发不出去。这个时候,客户端和服务端如果没有心跳机制,是感知不到连接已经死亡的。
所谓心跳,就是双方定期发送一个很小的数据包来确认连接还活着。WebSocket协议本身有ping和pong控制帧,但很多后端框架没有默认开启,需要你自己实现。我见过最简单的做法是前后端约定一个JSON格式的{"type":"ping"}消息,客户端每30秒发一次,服务端收到后立即回一个{"type":"pong"},这样一层业务心跳,两层保障。
心跳间隔的选择有讲究。太频繁会浪费带宽和CPU,太长又会延长故障发现时间。我的经验是:心跳间隔设为Nginx/四层代理超时时间的三分之一左右。比如proxy_read_timeout设了3600秒,那业务心跳30到60秒发一次完全够用;如果你用的是云厂商的负载均衡,它有默认的空闲超时,比如某些云LB是900秒,那你心跳最少也要300秒以内发一次,否则照样被掐。
5.2 重连策略与粘性冲突
客户端断线后自动重连是标配功能,但重连的时机如果控制不好,会把服务器打崩。
我遇到的经典故障是这样:某天凌晨Nginx升级,批量断开了所有旧连接,客户端检测到断线后同时发起重连,所有请求几乎在同一秒打到服务器上,后端连接数瞬间翻了几倍,直接把进程打挂了。这就是典型的“重连风暴”或者“惊群效应”。
正确的做法是使用指数退避加随机抖动:
function scheduleReconnect(attempt) { const baseDelay = 1000; // 第一次重连延迟1秒 const maxDelay = 30000; // 最大延迟30秒 const jitter = Math.random() * 1000; const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay) + jitter; setTimeout(() => connect(), delay); }这样每次重连的延迟都不同,分散了对服务器的冲击。另外,重连成功后要把重试次数清零,否则下一次断线会从很高的指数开始,影响体验。
这里再说一个很多人忽略的粘性冲突问题。你用了ip_hash做粘滞,正常情况下同一个用户重连会落到同一个后端。但如果用户的IP发生了变化——比如手机从WiFi切到4G——哈希结果就变了,新连接会被分配到另一个后端节点。如果你的业务在节点内存里维护着用户状态,新节点上什么都没有,用户就会遇到“登录状态丢失”或者“消息收不到”的诡异问题。这时候就必须在应用层做状态同步,最简单的方式是把会话状态放到Redis里,后端进程启动时拉取,或者收到消息时再查一次,不要在进程内存里长期保存关键状态。
5.3 多节点广播到底怎么设计
很多后端框架,比如Spring WebSocket、Netty自己封装的服务、Gin的WebSocket库,默认只能向当前进程内维护的连接广播消息。你部署了三个节点,用户A在节点1上,用户B在节点2上,用户A发一条群消息,节点1只把消息推给了和自己相连的用户,节点2上的用户就收不到。
这个问题不是Nginx能解决的,这是分布式会话管理问题。我见过三种方案:
第一种是粘滞会话加广播转发,也就是节点收到消息后,再通过内部RPC或者消息队列把消息转发给其他节点,其他节点再推送给自己维护的连接。这种方案实现简单,但消息在节点间有重复投递的可能,需要做去重。
第二种是统一走Redis Pub/Sub或者消息队列,每个节点都订阅同一个频道,收到推送消息后只看自己本地有没有目标用户的连接。这是目前最通用的方案,Spring的WebSocket + Redis Message Broker就是这种做法。
第三种是引入独立的推送网关层,所有WebSocket连接都连到这个网关层,业务服务通过API调网关来发消息。这种方案隔离最彻底,但也多了一层要维护的基础设施。
三种方案没有绝对优劣,我建议先评估你的消息量级和团队维护能力。日活百万以下,Redis Pub/Sub足够;再往上,再考虑独立网关。
6. 常见问题与排查实录
6.1 刚连上就断,报1006是怎么回事
WebSocket的1006错误码表示“连接异常关闭”,也就是说没有正常的关闭帧,连接就直接没了。这个现象我排查过很多次,原因五花八门,但最常见的就是Nginx超时。
排查思路是这样:先看Nginx的error.log和时间戳。如果有“upstream timed out”的字样,那基本就是proxy_read_timeout或者proxy_send_timeout设得太短;如果没有,再看是不是后端主动断的。后端打日志,看看有没有连接被关闭的记录。我用tcpdump抓包比较多:
tcpdump -i eth0 -n -A 'tcp port 8080' -w ws.pcap抓包看TCP的RST标志,如果看到RST,说明是某一边主动重置了连接。RST来自Nginx出去的IP,那就是Nginx干的;来自后端IP,那就是后端或后端所在云厂商的负载均衡干的。
还有一种情况是Nginx和后端之间还有一层云负载均衡,云LB默认空闲超时是4到900秒不等,如果云LB把连接断了,Nginx的日志里反而看不出明显报错。这种问题最隐蔽,我后来直接在Nginx后面再挂一层LVS或者让Nginx直连后端,才算彻底解决。
6.2 H5能连上,打包成App连不上
这个问题在热词里也出现了,非常典型。H5页面是在浏览器里跑的,浏览器对网络要求没那么严格;但App里的WebSocket客户端,尤其是安卓端,会遇到两类问题:
一类是明文流量限制。从Android 9开始,系统默认禁止应用使用明文HTTP/WS流量,也就是默认不许你连ws://和http://,只允许wss://和https://。如果你的App在真机上连不上、模拟器上却正常,先查这个。解决办法是在AndroidManifest.xml里加android:usesCleartextTraffic="true",或者在network security config里单独允许某个域名走明文。
另一类是证书校验问题。App内的WebSocket客户端使用的TLS证书校验策略和浏览器不同,浏览器对证书的容忍度高一些,App端要求完整可信链,自签证书、证书过期、证书域名不匹配都会导致握手失败。真机上能连wifi、连不上蜂窝网络,也可能是运营商网络拦截了TLS握手,这时候加日志看底层错误码最直接。
6.3 鉴权信息怎么传递
WebSocket的API没法在连接建立后像HTTP那样随手加一个Authorization头,因为连接建立之后就是持续通信了。所以鉴权的通用做法是:在握手阶段,通过URL的query参数或者自定义Header把token传过去。
用query参数的话,Nginx配置里要注意透传参数,默认proxy_pass会把整个URI带过去,不需要额外处理。我用得比较多的是在Header里传:
proxy_set_header Authorization $http_authorization;后端拿这个头做鉴权,成功就返回101,失败就返回401或者403。但注意,虽然Nginx代理时会把Header透传,但很多浏览器端的WebSocket API不允许你自定义Header,这时候就只能退回到query参数方案。而query参数最大的风险是会留在Nginx的access.log里,token等于裸奔。我一般临时在access.log里去掉$request_uri,只保留路径,或者干脆对日志脱敏。
还有一种做法是先用HTTP接口做一次鉴权,换取一个短时效的随机token,再拿这个token去连WebSocket。这种方案跟前端框架无关,后端也好实现,生产环境比较推荐。
6.4 Nginx日志的正确打开方式
问题排查最后都落到日志上。Nginx的日志默认路径在不同系统里不一样,CentOS/RHEL一般在/var/log/nginx/下,Debian/Ubuntu也在/var/log/nginx/。access.log记录了WebSocket握手请求,但只要连接建立了,握手成功之后就不会再有新的access日志了——除非连接关闭时你把日志周期设成了always,所以我一般建议在log_format里加上$http_upgrade字段,能直观看到这个请求是不是WebSocket握手。
error.log对排查连接问题很有帮助。看不到日志不要慌,先执行:
nginx -t检查配置文件语法,然后再看/var/log/nginx/error.log,如果级别是warn,可以临时改成debug重启Nginx,能看到非常详细的转发过程。不过生产环境不要长期开debug,日志量太大,磁盘会被写满。
最后分享一个我自己的调试技巧:本地起一个WebSocket后端,用命令行工具websocat、wscat或curl直接测Nginx转发路径,不要一上来就调前端。命令行工具能精确控制请求头和连接行为,比浏览器调试器好用得多。比如这样测:
curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Host: ws.example.com" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \ -H "Sec-WebSocket-Version: 13" \ https://ws.example.com/ws如果返回101,说明Nginx这条路是通的,问题大概率出在业务代码或者客户端SDK;如果返回502或者504,那就是后端或者代理配置有问题,按上面的排查思路继续挖就好了。
Nginx做WebSocket负载均衡这事,配置本身不难,难的是理解长连接场景下各个超时参数的意义、粘性会话对整个架构的约束,以及客户端重连和心跳策略必须和网关参数配合。我踩过的最深的坑就是只配了Upgrade头忘了调超时,结果线上连接每小时断一次,用户疯狂投诉。希望你读完这篇之后,能少走我走过的这些弯路,一次就把长连接压测和上线之间的所有环节打通。