短连接代理的配置经验几乎无法直接迁移到长连接上:一个保持数小时不发送数据的 WebSocket 连接,在 Nginx 默认超时下会被静默切断;一个持续推送事件的 SSE 响应,会因为响应缓冲而永远不刷给客户端。这两类问题的共同点是「连接活着但 Nginx 认为它有问题」,而排查时又往往看不到明显错误。本文先讲清 Upgrade 协商的机制,再分别处理 WebSocket 与 SSE 的超时、缓冲和资源控制。
一句话总结: 长连接代理的核心是三件事——正确透传 Upgrade 相关头部、把超时调到与应用心跳匹配的量级、关闭一切会缓存响应的缓冲。
1. 长连接代理与短连接的本质差异
一句话总结: 短连接代理关心的是吞吐与首字节延迟,长连接代理关心的是连接存活时间与空闲期间的资源占用,两者的调优方向几乎相反。
短连接请求的典型生命周期是毫秒级:连接建立、收发数据、关闭。而 WebSocket 与 SSE 的生命周期是分钟到小时级,期间可能长时间没有任何数据流动。这带来三个直接后果:
超时语义变化
短连接:proxy_read_timeout 用于兜住「后端卡死」
长连接:空闲是正常状态,超时必须放大或由心跳维持
缓冲语义变化
短连接:proxy_buffering on 能提升吞吐
长连接:必须 off,否则数据积在 Nginx 侧不下发
资源语义变化
短连接:worker_connections 只影响并发峰值
长连接:每个空闲连接长期占用 fd 与内存,容量规划完全不同
默认配置下,proxy_read_timeout 是 60 秒,意味着一个 60 秒没收到后端数据的 WebSocket 连接会被关闭。同样地,proxy_buffering 默认开启,SSE 的事件会被 Nginx 攒在缓冲区里,直到缓冲区满或连接结束才下发——从客户端看就是「一直没有消息」。
2. WebSocket 的 Upgrade 协商
一句话总结: WebSocket 握手本质是一个带 Upgrade 头的 HTTP 请求,Nginx 必须把 Upgrade 与 Connection 两个头部原样透传给后端,同时用 HTTP/1.1 发起上游请求。
2.1 握手过程与必需的头部透传
一句话总结: 客户端发 Upgrade: websocket,Nginx 透传给后端,后端回 101 Switching Protocols,之后这条连接就变成全双工的裸 TCP 隧道。
客户端 Nginx 后端
| GET /ws HTTP/1.1 | |
| Upgrade: websocket | 同左(必须透传) |
| Connection: Upgrade | |
|------------------------->|------------------------->|
| | 101 Switching Protocols |
|<-------------------------|<-------------------------|
| <= 双向帧传输,Nginx 只做字节转发 => |
对应的 Nginx 配置:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name chat.example.com;
location /ws/ {
proxy_pass http://ws_backend;
# 关键一:用 HTTP/1.1 发起上游请求,1.0 不支持 Upgrade
proxy_http_version 1.1;
# 关键二:透传 Upgrade 与 Connection
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 关键三:长连接必须关闭缓冲
proxy_buffering off;
# 关键四:放大读超时,或配合心跳维持
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
map 块的作用是避免「无条件写 Connection: upgrade」带来的副作用:普通 HTTP 请求并不需要 Upgrade,若强行带上,某些后端会误判为升级请求。$connection_upgrade 只有在客户端真的带了 Upgrade 头时才输出 upgrade,否则输出 close。
2.2 Connection 头部的坑
一句话总结: 直接写死 Connection: upgrade 会破坏 keepalive 语义,用 map 按需生成才是正确做法。
另一个常见问题是 proxy_set_header Connection ""。这个写法在配置 upstream keepalive 时用于清除逐跳头部,但用在 WebSocket 场景下会把客户端的 Upgrade 语义一并抹掉,握手直接失败。两者的区别是:
# 用于普通 HTTP + upstream keepalive 复用连接池
proxy_set_header Connection "";
# 用于 WebSocket,按客户端头部动态决定
proxy_set_header Connection $connection_upgrade;
同一个 server 中若既有普通接口又有 WebSocket 端点,应把它们拆到不同的 location,各用各的写法,不要试图用一条配置兼顾。
3. 超时与心跳
一句话总结: 超时值与心跳周期必须协同设计——心跳周期要显著小于超时值,否则连接会在心跳到来前被 Nginx 或中间设备回收。
3.1 proxy_read_timeout 与空闲断开
一句话总结: proxy_read_timeout 是「两次读操作之间的最大间隔」,不是连接总时长,因此只要数据持续流动就不会触发。
这个语义容易被误解。它约束的是空闲间隔:后端在 60 秒内没有任何字节发给 Nginx,连接就被关闭;如果每 30 秒发一个心跳帧,即使连接保持 10 小时也不会超时。据此可以有两种策略:
# 策略 A:把超时放大到业务最长空闲时间之上(简单但有风险)
proxy_read_timeout 3600s;
# 策略 B:应用层心跳 + 较小的超时(推荐,能及时发现半开连接)
proxy_read_timeout 90s;
策略 B 更健壮的原因在于它能识别「假活」连接:网络中断后 TCP 连接可能长时间处于半开状态,双方都不知道对端已消失,此时只有靠心跳超时才能清理。推荐的组合是心跳 30 秒、proxy_read_timeout 90 秒(三倍心跳),这样偶发丢一个心跳也不会误断。
3.2 应用层心跳与 TCP keepalive
一句话总结: 应用层心跳负责穿越代理与负载均衡,TCP keepalive 负责探测死连接,两者互补且不能互相替代。
server {
listen 80;
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_buffering off;
# 上游方向:探测与后端的死连接
proxy_socket_keepalive on;
proxy_read_timeout 90s;
# 下游方向:探测与客户端之间的死连接
keepalive_timeout 90s;
}
}
上游方向用 proxy_socket_keepalive on 打开 SO_KEEPALIVE,让内核周期性探测;下游方向由 keepalive_timeout 控制客户端侧的空闲连接回收。但这两者都工作在 TCP 层,探测周期通常是小时级(受 net.ipv4.tcp_keepalive_time 控制),无法满足业务级的心跳需求。真正保证链路活跃的仍然是应用层协议帧(WebSocket 的 ping/pong 或自定义 JSON 心跳),代理层只能保证这些帧不被缓冲、不被超时切断。
4. SSE 代理要点
一句话总结: SSE 走的是普通 HTTP 响应流,不需要 Upgrade,但必须关闭响应缓冲、禁用压缩并保证 Content-Type 正确。
4.1 缓冲与分块传输
一句话总结: proxy_buffering off 是 SSE 能否实时推送的分水岭,开启时事件会积压到缓冲区满才下发。
server {
listen 80;
location /events/ {
proxy_pass http://sse_backend;
# 关闭响应缓冲,事件产生即下发
proxy_buffering off;
proxy_cache off;
# 关闭请求侧缓冲,避免影响流式请求体
proxy_request_buffering off;
# 禁用分块编码的合并,保持逐块下发
chunked_transfer_encoding on;
# SSE 是长时间无数据的流,读超时按业务心跳设置
proxy_read_timeout 1800s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
}
}
proxy_cache off 在部分版本中需要显式写出:如果 http 层开启了 proxy_cache,SSE 响应可能被缓存逻辑拦截。同理 gzip 会破坏 SSE 的逐事件刷新——压缩算法需要攒够数据才能输出有意义的块,必须对 SSE 路径禁用:
location /events/ {
gzip off;
proxy_pass http://sse_backend;
proxy_buffering off;
add_header X-Accel-Buffering no;
}
4.2 头部与压缩
一句话总结: SSE 需要 Content-Type: text/event-stream,且必须避免任何中间层缓存或压缩该响应。
location /events/ {
proxy_pass http://sse_backend;
proxy_buffering off;
# 覆盖后端头部,确保类型正确
proxy_hide_header X-Accel-Buffering;
add_header X-Accel-Buffering no always;
add_header Cache-Control "no-cache" always;
# 若后端未设置正确类型,可在代理层补上
proxy_set_header Accept text/event-stream;
}
X-Accel-Buffering: no 是一个约定头部,部分支持它的上游与代理会据此关闭缓冲。显式在 Nginx 侧 add_header 可以兜底,但真正起作用的是 proxy_buffering off 指令本身。
5. 连接数与资源控制
一句话总结: 长连接的容量瓶颈在 fd 与内存而非 CPU,需要同时调整 worker_connections、worker_rlimit_nofile 与系统级 ulimit。
worker_processes auto;
worker_rlimit_nofile 65535;
events {
worker_connections 32768; # 每个 worker 可处理的连接数
multi_accept on;
}
http {
# 限制单 IP 的并发长连接数,防止单客户端占满连接池
limit_conn_zone $binary_remote_addr zone=wsconn:10m;
server {
location /ws/ {
limit_conn wsconn 50; # 单 IP 最多 50 条并发长连接
limit_conn_status 429;
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 90s;
}
}
}
关键点在于 worker_connections 只是上限的一半:每个 WebSocket 连接在 Nginx 侧会占用两个连接槽(客户端侧 + 上游侧),因此实际可承载的 WebSocket 数约为 worker_connections / 2。系统层面还需要同步调高 nofile,否则 Nginx 会报 worker_connections are not enough 或 too many open files。内存方面,每条空闲连接约占用几 KB 到十几 KB,按 10 万连接估算需要预留 1~2 GB。
6. 负载均衡与会话保持
一句话总结: WebSocket 是有状态长连接,同一连接内的所有帧必须落在同一后端,因此需要 ip_hash 或一致性哈希,且健康检查要覆盖已建立的连接。
upstream ws_backend {
# 同一客户端 IP 固定到同一后端,保证重连后仍命中同一实例
ip_hash;
server 10.0.3.11:8080 max_fails=3 fail_timeout=30s;
server 10.0.3.12:8080 max_fails=3 fail_timeout=30s;
server 10.0.3.13:8080 backup;
# 与后端保持长连接,减少握手开销
keepalive 64;
keepalive_timeout 60s;
}
ip_hash 的代价是负载可能不均(同一出口 NAT 下的大量用户会集中到一台),更均衡的方案是一致性哈希(需第三方模块)或让应用层把会话状态外置到 Redis,从而允许任意后端处理任意连接。无论选哪种,backup 节点在扩容缩容时都会引发大量重连,需要应用层实现自动重连与状态恢复。
7. 排错与验证
一句话总结: 长连接问题大多表现一致——握手失败看 101 与头部透传,连接被断看超时与心跳,消息延迟看缓冲开关。
# 验证握手:观察是否返回 101 与正确的 Upgrade 头部
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/
# 验证 SSE:用 -N 关闭 curl 自身缓冲,观察事件是否逐个到达
curl -N -H 'Accept: text/event-stream' http://127.0.0.1/events/
# 查看当前连接数与状态分布
ss -tn state established '( sport = :80 )' | wc -l
排查顺序建议固定为:先确认握手阶段(101 是否返回、Upgrade 是否透传),再确认连接存活(是否在固定时间后被断、时间是否等于某个超时值),最后确认消息延迟(是否等缓冲区满才到)。error_log ... info 级别下,Nginx 会记录连接的上游选择与关闭原因,配合 $upstream_addr、$upstream_status 两个变量写入日志,可以快速定位是 Nginx 主动断开还是后端主动关闭。
8. 总结
| 环节 | 要点 |
|---|---|
| 与短连接差异 | 长连接关心存活时间与空闲资源,调优方向与吞吐相反 |
| Upgrade 协商 | 透传 Upgrade 与 Connection,上游用 HTTP/1.1 发起 |
| Connection 头部 | 用 map 按需生成,不要写死 upgrade 也不要清空 |
| 超时语义 | proxy_read_timeout 约束空闲间隔,不是连接总时长 |
| 心跳设计 | 心跳周期约为超时值的 1/3,应用层心跳不可省 |
| SSE 缓冲 | proxy_buffering off + gzip off + 正确的 Content-Type |
| 资源控制 | worker_connections 需按连接数翻倍估算,同步调高 nofile |
| 负载均衡 | 有状态连接需 ip_hash 或外置会话状态 |
长连接的配置项不多,但每一项都与默认值相悖:默认超时太短、默认缓冲太激进、默认连接池不适用。把这几项调整到位之后,剩下的是容量规划问题——长连接会把「并发峰值」变成「持续占用」,需要按业务同时在线数而非 QPS 来估算资源。配置一旦复杂起来,「改完能否安全上线」就成了新问题,这也是下一篇配置测试与 CI 流水线的出发点。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。