Nginx WebSocket生产级负载均衡:从配置到长连接稳定性实践

发布时间:2026/9/15 6:25:52
Nginx WebSocket生产级负载均衡:从配置到长连接稳定性实践 搞实时推送的谁没被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: websocketNginx转发的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秒也就是一小时然后结合客户端的应用层心跳机制保证这个时间窗口内至少有一次数据交互。如果你的业务允许长时间静默甚至可以设置成更大的值但别设成00表示完全不超时万一后端连接挂了却没人发现连接对象会一直堆积内存迟早爆掉。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_fails3 fail_timeout10s; server 10.0.1.12:8080 max_fails3 fail_timeout10s; 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_fails3 fail_timeout10s; server 10.0.1.12:8080 max_fails3 fail_timeout10s; server 10.0.1.13:8080 max_fails3 fail_timeout10s; } 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 interval5000 rise2 fall3 timeout3000 typetcp; }这段配置的意思是每5秒主动连接一次后端连续成功2次判定为健康连续失败3次判定为宕机。typetcp就是只检查端口通不通不检查业务状态。如果后端业务启动比较慢比如要加载几十MB的配置rise2可以适当调大避免Nginx把还在启动的后端误判为宕机。4.3 TLS终结与WSS的坑WebSocket走HTTPS之后就成了WSSws://变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:usesCleartextTraffictrue或者在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头忘了调超时结果线上连接每小时断一次用户疯狂投诉。希望你读完这篇之后能少走我走过的这些弯路一次就把长连接压测和上线之间的所有环节打通。