Nginx搭配WebSocket,几乎是每个做实时业务的团队绕不开的组合。很多项目前期用开发服务器直连WebSocket一切正常,一旦上Nginx就出各种幺蛾子:连接建不上、连上就断、数据发不过来、发一个大包直接被吞。这些问题十有八九不是代码的锅,而是Nginx默认配置压根没为长连接和大数据量做适配。这篇文章把我这几年在生产环境里调Nginx WebSocket长连接和数据容量配置的完整经验整理出来,从Upgrade头传递、超时保活、Buffer容量到心跳联动和负载均衡,一次说清楚。
1. 为什么Nginx默认配置跑不了WebSocket
Nginx从1.3.13版本开始就支持WebSocket反向代理,但默认配置的很多参数是给普通HTTP短连接设计的。HTTP请求一来一回就断,连接生命周期短,Nginx的timeout、buffer、连接数这些默认值在短连接场景下没什么感知。可WebSocket是长连接,一条连接可能要挂几小时甚至几天,问题就全部暴露出来了。
1.1 WebSocket和普通HTTP请求的本质差异
普通HTTP请求是典型的"请求-响应"模型:客户端发一个请求,Nginx转发给后端,后端返回响应,连接随即关闭。哪怕开了keep-alive,也只是复用同一条TCP连接去做多次短请求,每次请求依然有明确的边界。
WebSocket则完全不同。客户端先通过HTTP Upgrade机制发起握手,一旦后端返回101 Switching Protocols,这条连接就升级为全双工长连接。升级成功之后,Nginx在这条连接上的角色从"请求转发"变成了"透明隧道"——它不再关心请求和响应的边界,只是把客户端和后端之间的字节流互相搬运。
这个模式下的关键区别在于:HTTP短连接下,长时间没数据是异常;WebSocket长连接下,长时间静默是常态。用户挂在页面上不操作,不代表连接应该被断开。如果Nginx还用处理短连接的逻辑来管这条长连接,必然出问题。
1.2 请求升级到数据透传的完整路径
要配置好,先得知道一条WebSocket请求在Nginx里到底怎么走的。简化流程如下:
客户端发起HTTP GET请求,携带Upgrade: websocket和Connection: Upgrade头,Nginx根据location匹配规则转发给后端,后端确认支持WebSocket后返回101 Switching Protocols,Nginx把这个101响应回给客户端,连接升级成功,进入双向数据转发模式。
这个流程里任何一个环节出问题,都会导致握手失败或连接异常。最常见的三个坑:
- Nginx默认不转发
Upgrade和Connection这两个头,后端根本不知道这是WebSocket握手请求 - Nginx默认用HTTP/1.0向后端发起请求,而HTTP/1.0不支持Upgrade机制
- 后端返回101后,Nginx如果按普通HTTP响应处理,可能会因为超时或缓冲问题掐断连接
把这个链路刻在脑子里,后面调参的时候才不会乱。我见过不少人配置WebSocket时东改一个参数西改一个参数,但根本不知道每个参数管的是哪个环节,出了问题只能瞎试。
2. 长连接配置:连接"建得上"和"不断开"
长连接配置是整套WebSocket反向代理的地基。地基不打牢,后面调什么都白搭。这一节把几个核心参数一个一个拆开讲明白。
2.1 Upgrade与Connection响应头的显式传递
这是新手遇到的第一道坎。Nginx转发请求时,默认会带上大部分原始请求头,但Upgrade和Connection这两个头属于逐跳(hop-by-hop)头,理论上不该被代理转发。Nginx遵循HTTP规范,默认不会把客户端的Connection头原样传给后端。
所以location里必须显式加上:
proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";第一行把客户端的Upgrade头值(websocket)取出来传给后端,第二行强制告诉后端"这条连接需要升级"。少了任何一个,后端收到的就是普通HTTP请求,WebSocket握手必然失败。
前端表现就是:WebSocket一直停在CONNECTING状态,几秒后报错,浏览器控制台提示WebSocket connection failed。
实际排查的时候我发现一个很容易被忽略的细节:Connection头的值必须是小写的upgrade。HTTP头名称不区分大小写,但部分后端的WebSocket实现会对值做大小写敏感判断,写成Upgrade或者UPGRADE都会导致握手失败。这个坑我踩过一次,报错日志里完全看不出来,最后是抓包才发现值的大小写不对。
2.2 超时参数:proxy_read_timeout与proxy_send_timeout
连接建立起来之后,最闹心的问题就是"莫名掉线"。Nginx默认的proxy_read_timeout是60秒,含义是:Nginx从后端读取数据时,如果60秒内没读到任何字节,就主动断开这条连接。
这个默认值在HTTP短连接下完全没问题——正常请求响应都在秒级完成。但WebSocket长连接下,用户挂在页面上不操作,一分钟内没有数据流动是再正常不过的事。结果就是:用户挂机超过60秒,连接被Nginx静默掐断,客户端那边还没反应过来,下次服务端推送数据时才发现连不上了。
生产环境我一般这样设置:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;1小时是起步值,有些业务场景(比如在线文档协同、IoT设备长连接上报)我会直接设成86400s,也就是24小时。
这里有个权衡:超时时间设太长,如果后端进程崩溃导致连接假死,Nginx不会主动回收,会一直占用连接资源;设太短,用户频繁掉线。我的经验是用心跳周期乘以3到5倍作为超时值,既留足网络抖动的余量,又不会让僵尸连接挂太久。心跳这块后面专门讲,这两个配置是联动的。
2.3 HTTP版本与后端子连接保活
proxy_http_version必须设为1.1,原因前面提到过:HTTP/1.0不支持Upgrade机制。不设置这一项,Nginx默认以HTTP/1.0向后端发请求,即使Upgrade和Connection头都传了,后端WebSocket服务也可能直接忽略。
proxy_http_version 1.1;除了HTTP版本,还有个容易被忽视的参数叫proxy_socket_keepalive。这个参数控制Nginx与后端TCP连接层面的keepalive探测:
proxy_socket_keepalive on;开启后,Nginx会定期向后端发送TCP keepalive探测包,用来发现半开连接(比如后端主机断电、网络设备静默丢包)。TCP keepalive默认探测间隔是2小时,虽然长了点,但作为兜底机制很有价值。WebSocket应用层的心跳负责业务保活,TCP层的keepalive负责物理链路探测,两者不冲突,建议都打开。
另外还有一个keepalive指令,配置的是Nginx与后端空闲连接的复用数量,通常是配合upstream用的:
upstream backend_ws { server 10.0.0.11:8080; keepalive 32; }这个参数在WebSocket场景下意义不大,因为WebSocket长连接本身就长期占用,谈不上"复用"。但在同一个upstream里如果还跑了普通HTTP接口,keepalive 32能减少大量短连接频繁建立TCP握手的开销,顺手写上没坏处。
2.4 可复用的长连接配置模板
把上面的参数整合起来,一个能直接抄作业的长连接location配置如下:
location /ws/ { proxy_pass http://backend_ws; 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_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_connect_timeout 10s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_socket_keepalive on; proxy_buffering off; }proxy_connect_timeout是Nginx与后端建立TCP连接的超时,设成10秒足够。proxy_buffering off建议在WebSocket场景下一律关闭,原因下一节细说,这里先记住结论。
Host、X-Real-IP、X-Forwarded-For这几个头是通用配置,转发真实客户端IP和域名信息,后端业务做日志审计、限流、地区判断都依赖它们,顺手都配上。
3. 数据容量配置:别让大帧数据在Nginx层被截断
长连接稳定了,下一个高频问题就是数据容量。WebSocket的数据帧可以承载各种业务数据——图片、日志、批量消息,单帧几MB甚至几十MB都不稀奇。Nginx默认的容量参数是给普通HTTP设计的,遇到大帧数据会出现各种诡异问题。
3.1 client_max_body_size与请求头大小限制
client_max_body_size很多人以为只管HTTP上传,实际上它在WebSocket握手阶段也有影响。握手请求虽然本身不含大体积body,但如果业务把一些参数放在请求头或者URL参数里传,头的大小就会受另一个参数约束。
Nginx默认的large_client_header_buffers是4个8KB,如果请求头总大小超过这个限制,Nginx会直接返回400错误。有些业务会在WebSocket握手时通过Cookie或者其他自定义头携带大量上下文信息(比如用户权限数据、埋点参数),头很容易撑爆默认值。
建议配置:
client_max_body_size 50m; large_client_header_buffers 4 16k;client_max_body_size 50m的意义在于:如果业务需要WebSocket握手时在body里带点数据,或者同一个server块里还有文件上传接口,这个值能避免上传大文件时被Nginx拒绝。注意,如果WebSocket升级成功后传输的数据走的是数据帧而不是HTTP body,这个参数就不再起作用了——真正管数据帧的是buffer系列参数。
3.2 proxy_buffer_size与proxy_buffers原理拆解
Nginx收到后端响应后,默认先把数据写进缓冲区。缓冲区不够了,要么写临时文件,要么直接转发到客户端。这个机制在HTTP短连接下是性能优化,在WebSocket下却可能成为瓶颈。
先解释三个参数各自管什么:
proxy_buffer_size:单个缓冲区的初始大小,用于读取后端响应的第一部分(通常包含响应头)proxy_buffers:指定缓冲区的数量和单个大小,用于缓冲后端响应的主体数据proxy_busy_buffers_size:Nginx向客户端发送数据时,可以同时使用的"忙碌"缓冲区上限
默认的proxy_buffer_size通常只有4k或8k,proxy_buffers通常是8个4k或8k。这在普通HTTP下够用,因为响应头一般就几百字节,body通过文件或流式转发处理。但WebSocket的数据帧是实时流式的,Nginx分不清"哪里是头哪里是body",它就是无脑往缓冲区里塞。
缓冲区太小会导致什么?我用压测环境实际测过,推送一个2MB的JSON数据帧:
- 默认buffer(4k/8k):连接直接异常,客户端收到的数据不完整,部分场景触发断连
- 改成
16k单个,8个buffer:数据完整到达,但推送耗时比直连增加约15% - 加大buffer并合理配置:数据完整,耗时接近直连
原因是buffer太小的时候,Nginx的转发逻辑会频繁触发"缓冲区已满→写临时文件→读回文件→转发"的路径,不仅性能差,数据边界也可能在拆分转发中丢失。
3.3 大小帧业务下的缓冲策略选择
不同业务对缓冲的需求是相反的,这块一定要根据实际场景来选,不能一套配置打天下。
高频小包业务(聊天消息、行情价格、实时位置更新),数据帧通常只有几百字节到几KB。这类业务追求的是低延迟,建议关掉缓冲:
proxy_buffering off;关闭后,Nginx收到数据就立刻转发给客户端,不攒不囤,延迟最低。这也是我在长连接配置模板里直接写proxy_buffering off的原因——大多数实时业务都是高频小包。
低频大包业务(批量数据同步、大图传输、日志批量上报),数据帧动辄几MB。这类业务追求的是完整性,关掉缓冲反而可能导致Nginx转发时拆包异常。建议开缓冲并加大容量:
proxy_buffering on; proxy_buffer_size 16k; proxy_buffers 16 32k; proxy_busy_buffers_size 64k;16个32KB的缓冲区,总共512KB。这个容量能扛住大多数业务场景的大帧推送。如果单帧超过10MB,再把proxy_buffers的数量往上加,但每增加一倍容量,每个连接就多占512KB内存,并发1000个连接就是500MB,内存规划要心里有数。
3.4 数据容量参数完整示例
一条同时兼顾握手和数据传输的容量配置长这样:
location /ws/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; client_max_body_size 50m; large_client_header_buffers 4 16k; proxy_buffer_size 16k; proxy_buffers 8 16k; proxy_busy_buffers_size 32k; proxy_temp_file_write_size 64k; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering on; }proxy_temp_file_write_size是缓冲区满后写入临时文件时的一次性写入大小,设成64k可以减少磁盘IO次数。这套配置适用大多数"可能有较大数据帧但不确定多大"的业务。如果确定是高频小包,把proxy_buffering改成off,其它保持不变。
4. 心跳机制:让长连接真正长久存活
前面反复提到心跳和超时是联动的,这一节专门把心跳机制讲透。很多人以为WebSocket长连接天然"长",其实不然——WebSocket协议本身没有内置心跳,连接能活多久,完全取决于两端和中间设备的耐心。
4.1 没有心跳的连接会怎样
TCP的keepalive默认是关闭的,即便开启,默认探测间隔是2小时,对应用层感知连接状态来说太迟钝。没有心跳的话,下面几种场景都会产生僵尸连接:
- 用户Wi-Fi断开又重连,IP变了,旧连接在网络设备上被清掉,但两端都不知道
- 后端服务发布重启,进程没了,客户端还傻傻等着推送
- 中间有NAT设备、负载均衡器,长期空闲的连接被它们强制回收
这些场景下,没有心跳的WebSocket连接就像断了线的风筝,表面上还挂着,实际上收发数据全失败。用户侧的表现是页面突然收不到消息,但没有任何报错,因为浏览器认为连接还活着。
4.2 心跳周期与Nginx超时时间的联动设计
心跳的本质是周期性发送小数据包,让链路保持活跃,同时让两端感知到对方的存在。WebSocket协议里对应的就是ping/pong帧。注意,Nginx对WebSocket帧的处理是透传的——心跳ping帧经过Nginx,Nginx能看到有数据流动,proxy_read_timeout就不会触发。
这里有个非常实用的配置联动规则:
心跳间隔建议设在30到60秒之间。Nginx的proxy_read_timeout设成心跳间隔的3倍以上。比如心跳30秒一次,read_timeout设90秒以上;心跳60秒一次,read_timeout设180秒以上。留出3倍余量是为了容忍偶发的网络抖动——某次心跳丢了,还有下一次心跳能续命。
后端业务如果有真实的业务消息在推送,心跳间隔可以适当放宽;如果业务本身是长时间静默的(比如IoT设备状态上报,几分钟才一条),心跳就必须勤快一些。
4.3 真实故障案例:被误删的心跳逻辑
分享一个真实的线上事故。某个在线协作项目,某次前端重构时把心跳逻辑当"无用代码"删掉了,上线后第一周一切正常,因为用户活跃度高,连接上总有数据流动。到了第二周,开始有用户反馈"挂机半小时再回来,页面就收不到实时更新了,必须刷新"。
排查过程:
- 先看Nginx error.log,没有报错
- 看access.log,发现这些用户连接的请求在某个时间点之后就没有任何记录了——连接被掐了
- 查看当时的
proxy_read_timeout配置,正好是600秒 - 推断链路是:用户挂机→心跳缺失→10分钟无数据→Nginx触发read_timeout断连
客户端没有自动重连逻辑,断了就断了,必须手动刷新。加上心跳和自动重连后,这个问题彻底消失。
我个人的建议是:无论前端还是后端,WebSocket应用必须配套"心跳+自动重连"双机制。心跳负责保活,重连负责恢复。只做心跳不做重连,遇到网络闪断还是会卡死;只做重连不做心跳,连接资源被僵尸连接白白占用。
5. 多节点部署与wss加密场景的进阶配置
单节点WebSocket配好之后,接下来就是上规模的问题。生产环境不可能只有一个后端节点,一旦涉及多节点,负载均衡和连接黏着这两个问题必须面对。
5.1 upstream负载均衡与连接黏着问题
upstream的基础配置很简单:
upstream backend_ws { server 10.0.0.11:8080; server 10.0.0.12:8080; server 10.0.0.13:8080; }但WebSocket和普通HTTP在负载均衡上有个本质区别。HTTP请求是无状态的,每个请求可以随机打到任意节点,响应结果一致。WebSocket连接一旦建立,同一条连接的所有数据帧必须打到同一个后端节点——如果客户端A的会话数据在节点11,下一条数据帧被轮询算法转发到节点12,业务状态就对不上了。
默认的轮询策略在WebSocket场景下是绝对不能用的。同一连接的不同数据帧被分散到不同节点,轻则业务数据错乱,重则后端直接抛异常断连。
5.2 ip_hash策略的使用与注意点
WebSocket最常用的负载均衡策略是ip_hash:
upstream backend_ws { ip_hash; server 10.0.0.11:8080; server 10.0.0.12:8080; server 10.0.0.13:8080; }ip_hash根据客户端IP计算hash值,把同一个IP的所有请求固定分配到同一个后端节点。这样WebSocket连接建立后,同一连接的数据帧都会打到同一个节点,业务状态不会乱。
但ip_hash有几个注意点:
第一,节点数量变化会导致hash结果变化。新增一个节点,部分老IP的hash结果会变,新连接可能被打到新的节点上。连接已经建立的不受影响(TCP连接是固定的),但新连接会重新分布,这可能让某个节点瞬时承接大量新连接。
第二,某个节点宕机,Nginx会把本来hash到该节点的请求转发给其他节点,这个没问题,但该节点上存活的WebSocket连接会全部中断。客户端必须有重连机制才能恢复。
第三,如果是大规模客户端(比如上万用户)且都从同一个出口IP访问(公司办公网、NAT网关),ip_hash会把这些用户全部打在同一个节点上,导致严重的负载倾斜。这种情况下更适合在上层再做一次分组,或者用sticky模块基于Cookie做黏着。
5.3 wss://场景下的SSL配置要点
HTTPS站点下,WebSocket地址必须用wss://。这意味着Nginx需要在SSL层终止加密连接,再把解密后的数据转发给后端。
SSL配置本身不复杂:
server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/example.com.pem; ssl_certificate_key /etc/nginx/ssl/example.com.key; location /ws/ { proxy_pass http://backend_ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } }这里有个关键点:Nginx与后端之间走的是http://而不是https://,因为SSL在Nginx这一层已经终结了,内部链路用明文HTTP更高效。如果Nginx和后端不在同一内网,或者内网链路本身不可信,才需要再走一层TLS,也就是proxy_pass https://backend_ws,但绝大多数场景不需要。
wss下容易踩的坑是证书链不完整。有些证书提供商给了多个证书文件,必须按"服务器证书+中间证书"的顺序合并成一个pem文件,合并顺序错了会导致部分客户端(尤其iOS设备)握手失败。排查方法是:
openssl s_client -connect example.com:443 -servername example.com -tlsextdebug看到verify return:1说明证书链正常,其他任何提示都要检查证书配置。
另外一个容易被忽略的参数是ssl_session_cache,建议开启会话缓存减少TLS握手开销:
ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m;10m共享缓存大约能存5万个会话凭据,对大多数站点足够。这能显著减少客户端频繁重连时的TLS握手延迟。
6. 网上实战踩坑记录与排查速查
最后整理一下我在实际运维中遇到的典型问题,每条都对应一个真实的排障过程。如果你照着前几节的配置做了还是有问题,来这里对照排查。
6.1 "浏览器一直CONNECTING"排查思路
症状:前端WebSocket停在CONNECTING状态,几秒后报错。
排查顺序:
- 确认location里
Upgrade、Connection、proxy_http_version 1.1三件套有没有配齐 - 绕过Nginx直连后端WebSocket服务,确认后端本身没问题
- 查看Nginx error.log,搜索"upgrade"关键字
- 确认
proxy_pass的URL路径和location匹配是否正确——location /ws/配上proxy_pass http://backend_ws/和proxy_pass http://backend_ws是有区别的,前者会带上/ws/前缀,后者不带,后端路由不匹配就会握手失败
最隐蔽的坑是proxy_pass末尾的斜杠。location /ws/+proxy_pass http://backend_ws;(无斜杠)会把完整的/ws/xxx路径传给后端;proxy_pass http://backend_ws/;(有斜杠)会把/ws/前缀替换成/。后端WebSocket路由通常是固定路径(比如/ws/chat),配错了直接404,前端表现就是连不上。
6.2 "连接几分钟就断开"的常见原因
症状:WebSocket能建立,但过一会儿就断,非常有规律。
几乎都是proxy_read_timeout太短。默认60秒,用户挂机超过60秒连接必断。把timeout调大之后,还要确认心跳有没有正常工作。如果后端有心跳,但Nginx还是断连,排查心跳数据是不是走了代理路径——有些业务把心跳请求单独发到另一个域名或端口,绕过了Nginx,导致Nginx看不到数据流动,照样掐连接。
另一个少见但真实的原因是TCP keepalive被网络设备拦截。云环境里的负载均衡器或者安全组可能会丢弃长时间空闲的TCP连接,即使Nginx的timeout设得再大也没用。这种情况只能靠应用层心跳兜底,缩短心跳间隔。
6.3 "小数据正常大数据丢失"的buffer问题
症状:普通消息收发正常,一旦推送大数据帧(比如超过1MB),客户端收到的数据不完整,或者连接直接断掉。
优先检查proxy_buffer_size和proxy_buffers。我遇到过一次推送3MB数据帧,Nginx只转发了一部分就断连,查了半天发现proxy_buffer_size还是默认的4k,数据还没转发完连接就异常了。
缓冲区调大之后如果还出问题,检查是否触发了临时文件路径。proxy_max_temp_file_size默认1MB,响应超过这个值会落盘写临时文件,如果临时文件目录权限不对或者磁盘满了,数据就会丢失。把这几个值统一调大,问题基本都能解决。
6.4 常用排查命令与验证方法
实战中最常用的排查手段:
浏览器开发者工具Network面板,直接查看WebSocket帧的收发记录,确认数据是否到达客户端。
# 看Nginx访问日志是否有101响应 tail -f /var/log/nginx/access.log | grep " 101 " # 看Nginx错误日志 tail -f /var/log/nginx/error.log # 查看当前WebSocket长连接数量 ss -tnp | grep :8080 | grep ESTAB | wc -l # 查看nginx worker进程的连接数 ss -tnp | grep nginx | wc -l手动验证Nginx是否成功代理WebSocket,可以用curl带Upgrade头做一次握手测试:
curl -i -N \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Version: 13" \ -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ http://your-server/ws/如果返回HTTP/1.1 101 Switching Protocols,说明Nginx这一层的握手转发没问题。后续的帧数据用curl看不出内容,但至少能确认代理链路是通的。
最后分享一个调参心得。Nginx的WebSocket配置没有一套万能模板,必须根据业务特征来定:实时消息追求低延迟就关缓冲,批量传输追求完整性就开缓冲加大容量;心跳间隔决定超时时间的下限,节点数决定连接策略的选择。我踩过最多的坑就是把HTTP短连接的思维带到WebSocket场景里,所有参数都按默认值走。把上面这套配置吃透,再结合自己的业务压一遍,WebSocket在Nginx后面基本不会再有脾气。