文章目录
- 一个说不通的现象
- 第一部分:单端口到底能接多少连接
-
- [65535 这个数字的真正含义](#65535 这个数字的真正含义)
- 反向代理:两条连接,两套四元组
- [算一下连接 B 的上限](#算一下连接 B 的上限)
- [那为什么观测到的是 4 万多,不是 2.8 万](#那为什么观测到的是 4 万多,不是 2.8 万)
- 一个容易误导人的巧合
- 第二部分:五个天花板
-
- [天花板一:回环端口耗尽(2.8 万)](#天花板一:回环端口耗尽(2.8 万))
- [天花板二:`use poll` ------ 最直接的性能杀手](#天花板二:
use poll—— 最直接的性能杀手) - [天花板三:`accept_mutex on` 导致负载严重倾斜](#天花板三:
accept_mutex on导致负载严重倾斜) - [天花板四:fd 上限被写死,且 stream 每会话吃两个](#天花板四:fd 上限被写死,且 stream 每会话吃两个)
- [天花板五:accept 队列只有 511,以及 conntrack](#天花板五:accept 队列只有 511,以及 conntrack)
- 第三部分:优化方案
-
- 改动一:事件模型与进程模型
- 改动二:突破回环端口上限
- 改动三:内核参数
- [改动四:fd 上限的三处同步](#改动四:fd 上限的三处同步)
- [改动五:两个容易漏掉的内存和 I/O 项](#改动五:两个容易漏掉的内存和 I/O 项)
- 第四部分:验证
- 第五部分:改完的容量账,以及一套可复用的方法
-
- 数字对比
- 一套可复用的容量核算方法
- 关于终端侧和后端侧
- [更彻底的方案:Unix domain socket](#更彻底的方案:Unix domain socket)
- 收尾

一个说不通的现象
某个现场的架构很简单:10 万台终端设备通过 nginx 的 15001 端口接入,完成注册激活后维持长连接跑业务。nginx 用 stream 模块做四层代理,把流量转发给本机 15002 端口上的一个 Java 服务。
上线后现场反馈:15001 端口的连接数涨到 4 万多就上不去了,新终端一律建链失败。已经连上的终端还活着,业务照跑,但新设备就是进不来。重启 nginx 之后能恢复一阵,涨到 4 万多又卡住。
这个现象有几个特征值得先记下来,它们后面都会派上用场:
- 有明确的数字边界。不是随机失败,是稳定卡在某个量级,说明撞的是硬限制,不是偶发故障。
- 老连接不受影响。排除了进程崩溃、内存耗尽、后端服务挂掉这类整体性故障。
- 重启能恢复。说明是某种可累积、可耗尽的资源,重启释放后重新累积。
- 4 万这个数字很"整"。既不是 65535,也不是 32768,不对应任何一个常见的默认值。
现场第一反应是"nginx 单端口最多只能接 6 万多个连接吧?4 万差不多到顶了"。这个判断听起来合理,实际上完全错误------而且错在一个非常普遍的认知误区上。把这个误区讲清楚,是解决问题的第一步。
先把现场配置摆出来。这是 nginx 主配置里跟本次问题相关的部分:
nginx
worker_processes 8;
events {
worker_connections 65535;
accept_mutex on;
use poll;
}
stream {
upstream socket_proxy {
hash $remote_addr consistent;
server 127.0.0.1:15002;
}
server {
listen 15001;
proxy_connect_timeout 600s;
proxy_timeout 600s;
proxy_pass socket_proxy;
proxy_protocol on;
}
log_format detailed '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time $upstream_addr ...';
access_log logs/stream-access-$logdate.log detailed;
}
配套的内核参数(由启动脚本在拉起 nginx 前统一 sysctl -w 设置):
bash
fs.file-max=999999
net.ipv4.tcp_tw_reuse=1
net.ipv4.tcp_fin_timeout=15
net.core.netdev_max_backlog=4096
net.core.somaxconn=40960
net.ipv4.tcp_max_syn_backlog=40960
net.ipv4.tcp_syncookies=1
net.ipv4.tcp_syn_retries=2
net.ipv4.tcp_synack_retries=2
启动脚本里还有一行:
bash
ulimit -n 65535
机器规格是 64 核 124GB 内存,资源上完全不该有压力。
这套配置看上去做过调优------worker_connections 拉到了 65535,somaxconn 和 tcp_max_syn_backlog 都调到了 40960,tcp_tw_reuse 开了,ulimit 也设了。但恰恰是这种"看起来调过"的配置最难排查,因为它会让人产生"该调的都调了"的错觉,从而把注意力转向别处。
实际上,这套配置里藏着五个独立的天花板,最低的那个在 2.8 万,而 4 万这个观测值刚好落在几个天花板叠加作用的区间里。
第一部分:单端口到底能接多少连接
65535 这个数字的真正含义
"一台服务器的单个端口最多接 65535 个连接"是流传最广的网络常识之一,也是错得最彻底的一个。
TCP 连接的唯一性由四元组决定:
(源IP, 源端口, 目的IP, 目的端口)
内核维护一张连接哈希表,判重的键是完整的四元组。只要四元组不完全相同,两条连接就可以同时存在。这里没有任何一条规则说"同一个本地端口只能有一条连接"。
对于监听端口,情况是这样的:
终端 A: 10.1.1.5:41230 → 服务器:15001
终端 B: 10.1.1.6:53102 → 服务器:15001
终端 C: 10.1.1.5:41231 → 服务器:15001 ← 同一终端的第二条连接
三条连接的目的地完全一样,但源 IP 和源端口不同,四元组各不相同,内核可以同时持有它们。监听套接字 0.0.0.0:15001 本身只是一个"接客的门",所有连接共享它,并不是每接一条连接就消耗掉一个本地端口。
所以 15001 端口上挂 10 万条、100 万条连接都不违反任何 TCP 规则。真正的限制来自三个别的地方:
- 文件描述符。每条连接是一个 socket,占一个 fd。
- 内存。内核侧的 socket 结构体 + 收发缓冲区,用户态侧的连接对象 + 应用缓冲区。
- 连接哈希表的容量。这个默认值通常很大,一般不是瓶颈。
65535 这个数字的适用场景恰好是反过来 的:当你的机器作为客户端主动发起连接、并且目标地址固定时,四元组里的目的 IP、目的端口都锁死了,源 IP 由路由决定也锁死了,唯一可变的就剩源端口------这时候才受端口数量约束。
一句话总结:接受连接不消耗端口,发起连接才消耗端口。
反向代理:两条连接,两套四元组
理解了上面这条,再看反向代理的结构就清楚了。nginx 做 TCP 代理时,一次"业务会话"实际上是两条完全独立的 TCP 连接:
终端 nginx Java 服务
│ │ │
│ 连接 A │ 连接 B │
├─────────────────────────►│───────────────────────────►│
│ │ │
连接 A: 终端IP:随机端口 → 服务器IP:15001
↑↑ 10万个不同 ↑↑ 各自随机 ↑ 固定 ↑ 固定
连接 B: 127.0.0.1:随机端口 → 127.0.0.1:15002
↑ 固定 ↑↑ 唯一变量 ↑ 固定 ↑ 固定
连接 B 是 nginx 作为客户端重新 connect() 出去的一条新连接。到了这一层,终端的 IP 和端口在 TCP 头里已经完全不存在了------nginx 不会、也无法把终端的源地址"带"到后端连接上去。
这一点在原配置里其实有明确的证据:proxy_protocol on。这行配置的作用是在应用数据前面插入一个文本头,把原始客户端 IP 用带内数据的方式告诉后端:
PROXY TCP4 10.1.1.5 127.0.0.1 41230 15002\r\n
<后面才是真正的业务数据>
如果终端的源 IP 能通过 TCP 层直达后端,这个协议根本没有存在的必要。PROXY protocol 的存在本身就说明了两条连接在地址层面是彻底割裂的。
现在把两条连接的自由度列出来对比:
| 源 IP | 源端口 | 目的 IP | 目的端口 | 可变自由度 | 理论上限 | |
|---|---|---|---|---|---|---|
| 连接 A | 10 万个终端 IP | 各自随机 | 固定 | 固定 15001 | 源IP × 源端口 | 天文数字 |
| 连接 B | 固定 127.0.0.1 | 仅此一项 | 固定 127.0.0.1 | 固定 15002 | 只有源端口 | = 端口区间大小 |
连接 A 不是瓶颈,连接 B 是。
算一下连接 B 的上限
现场的 ip_local_port_range 是内核默认值:
net.ipv4.ip_local_port_range = 32768 60999
可用端口数:60999 - 32768 + 1 = 28232。
也就是说,不管后端 Java 服务能扛多少,不管 nginx 配了多少 worker_connections,这套架构的并发会话上限就是 28232 。第 28233 个终端过来,nginx 的 connect() 会直接返回 EADDRNOTAVAIL(errno 99),日志里对应的是:
connect() to 127.0.0.1:15002 failed (99: Cannot assign requested address)
while connecting to upstream
这解释了现象里最关键的一条:15001 上的 accept 是好的,终端能连上 nginx,但立刻被断开。因为失败发生在后端连接阶段,前端连接已经建立完成了。终端侧看到的就是"连上又断",反复重试。
那为什么观测到的是 4 万多,不是 2.8 万
两个原因,任何一个都能解释这个差异。
第一,统计口径。 现场用的统计方式大概是这样:
bash
netstat -an | grep 15001 | wc -l
或者 ss -ant | grep :15001 | wc -l。这类命令统计的是所有状态 的连接,包括 ESTABLISHED、SYN_RECV、TIME_WAIT、CLOSE_WAIT、FIN_WAIT_2。在终端疯狂重连的场景下,非 ESTABLISHED 状态的条目会占相当大的比例。真实的 ESTABLISHED 可能只有 2.8 万,加上一万多个处于各种关闭中间态的连接,总数就是"4 万多"。
正确的统计方式应该按状态分开:
bash
ss -ant | awk '$4 ~ /:15001$/ {c[$1]++} END {for(s in c) print s, c[s]}'
输出会是类似:
ESTAB 28104
TIME-WAIT 11893
SYN-RECV 1247
CLOSE-WAIT 892
这种拆分对定位问题至关重要------ESTAB 卡在 28104 而 TIME-WAIT 有一万多,指向的原因和"ESTAB 有 4 万"是完全不同的两件事。
第二,现场可能已经改过端口区间。 "把 ip_local_port_range 调大"是运维调优里非常常见的一步。如果现场设成了 20000 65535,可用端口就是 45536------这正好就是"4 万多"。
不管是哪种情况,指向的都是同一个结论:回环上游的端口空间耗尽。
一个容易误导人的巧合
现场配置里有两个值刚好是 40960:
bash
net.core.somaxconn=40960
net.ipv4.tcp_max_syn_backlog=40960
"连接数到 4 万多就不行了"配上"配置里有个 40960",很容易让人认定就是这两个参数的问题,然后把它们往大调,发现没用,再去怀疑别的地方。
这两个参数限制的是未被应用 accept() 取走的连接的排队深度 ,不限制已建立连接的总数。somaxconn 是 accept 队列(全连接队列)的上限,tcp_max_syn_backlog 是 SYN 队列(半连接队列)的上限。一条连接被 accept() 之后就从队列里出去了,不再占用这两个额度。
所以 somaxconn=40960 的含义是"最多允许 40960 条连接排队等 nginx 来取",而不是"最多 40960 条连接"。数字上的巧合纯属偶然。
这类巧合在排查中很危险。判断一个参数是不是元凶,不能靠数字对得上,要靠机制上讲得通。
第二部分:五个天花板
回环端口耗尽是最硬的一个,但不是唯一的。把这套配置逐项过一遍,一共有五处会在 10 万量级之前先撞墙。按危害排序如下。
天花板一:回环端口耗尽(2.8 万)
原理上面已经讲透了,这里补充两个实践中一定会遇到的细节。
TIME_WAIT 也占端口,而且占 60 秒。
nginx 作为主动关闭方时,它那一侧的 ephemeral 端口会进入 TIME_WAIT,在这个状态下端口仍然被占用,不能分配给新的、目标相同的连接。
现场配置里有 net.ipv4.tcp_fin_timeout=15,看起来是想缩短这个时间。但这个参数管的是 FIN_WAIT_2 状态的超时,不是 TIME_WAIT。 Linux 的 TIME_WAIT 时长是内核里硬编码的常量:
c
/* include/net/tcp.h */
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
* state, about 60 seconds */
改不了,除非重新编译内核。把 tcp_fin_timeout 调到 15 完全不影响 TIME_WAIT,这是个流传很广的误解。
所以实际需要的端口数不只是并发数,还要加上 churn:
需要的端口数 ≈ 并发会话数 + 断连速率 × 60s
假设 10 万终端因为网络抖动在 5 分钟内全部重连一遍,断连速率约 333/s,那么额外需要 333 × 60 ≈ 2 万 个端口只是用来"晾着"TIME_WAIT。这在容量规划时必须算进去。
好消息是 tcp_tw_reuse=1 确实能缓解------它允许内核在满足两个条件时复用处于 TIME_WAIT 的 socket 来发起新连接:开启了 TCP timestamps,且该 socket 进入 TIME_WAIT 已超过 1 秒。这把 60 秒的等待压缩到了 1 秒,effectively 把 churn 的影响降低了 60 倍。这个参数配置对了。
顺便说一句:不要去找 tcp_tw_recycle。这个参数在 NAT 环境下会导致连接被静默丢弃,危害极大,已经在 Linux 4.12 中被彻底移除。现场是 4.19 内核,这个参数根本不存在。网上大量老文章还在推荐它,遇到就跳过。
为什么同一个源端口可以复用到不同目的地。
这一点是后面优化方案的理论基础,值得单独说明。
内核判重看的是完整四元组,不是"源端口是否被占用"。所以下面三条连接可以同时存在:
127.0.0.1:44210 → 127.0.0.1:15002 ✓
127.0.0.1:44210 → 127.0.0.2:15002 ✓ 四元组不同,合法
127.0.0.1:44210 → 127.0.0.3:15002 ✓
Linux 内核在 __inet_hash_connect() 里实现这个逻辑:遍历端口区间寻找可用端口时,对每个候选端口调用 __inet_check_established(),检查的是完整四元组是否已存在于连接哈希表中,而不是端口是否被占。更巧妙的是,扫描的起始偏移量由 (源IP, 目的IP, 目的端口) 哈希得出,不同目的地的扫描起点天然错开,进一步降低了冲突概率。
这意味着:每增加一个目的地址,整份端口空间就可以完整重用一遍。 这是后面"多回环 IP"方案能生效的根本原因,不是简单地"多堆几个 upstream 条目"。
天花板二:use poll ------ 最直接的性能杀手
配置里这一行是整套配置最严重的问题:
nginx
events {
use poll;
}
poll 是 O(n) 的 I/O 多路复用机制。nginx 的 poll 模块在初始化时按 worker_connections 分配 pollfd 数组:
c
/* ngx_poll_module.c */
event_list = ngx_alloc(sizeof(struct pollfd) * cycle->connection_n, cycle->log);
然后每一轮事件循环都要:
- 把整个 pollfd 数组(最多 65535 项)从用户态拷贝到内核态
- 内核线性遍历所有 fd,逐个检查状态
- 把结果数组整体拷回用户态
- nginx 再线性遍历一遍,找出就绪的那几个
在几百个连接时这毫无问题。在几万个连接、其中大部分是空闲长连接时,这套流程每轮要处理几万个 fd 才能找出可能只有几十个的就绪事件。CPU 全部烧在 poll() 系统调用的数据拷贝和遍历上,事件循环的单轮延迟从微秒级涨到毫秒甚至几十毫秒级。
后果是:新连接的 TCP 握手排在事件队列里等不到处理,超时失败。 而已建立的连接因为有数据要收发,在遍历中会被识别为就绪,反而还能工作。
这和现场现象完全对上:老连接活着,新连接进不来。
epoll 的复杂度是 O(就绪事件数),与总连接数无关:
- fd 通过
epoll_ctl()一次性注册到内核的红黑树,不需要每轮传递 - 内核在数据到达时通过回调把 fd 挂到就绪链表
epoll_wait()只返回就绪链表的内容
10 万空闲连接里有 100 个活跃,epoll_wait() 就返回 100 个,内核和用户态都不需要碰另外 99900 个。
这不是"优化一下更快"的量级差异,是"能不能用"的量级差异。10 万连接场景下 poll 和 epoll 之间是几个数量级的鸿沟。
一个有意思的旁证:同一台机器上另一个 nginx 实例(另一个业务的独立安装)配的是 use epoll; multi_accept on;。说明团队里是有人知道该怎么配的,只是这份配置没跟上。这种"同一环境下不同实例配置不一致"的情况在排查时是很有价值的线索------它往往意味着某份配置是从很老的模板抄来的,或者是在低负载环境下写完就没再动过。
天花板三:accept_mutex on 导致负载严重倾斜
nginx
events {
accept_mutex on;
}
nginx 从 1.11.3 开始,accept_mutex 的默认值就是 off。显式打开它,在长连接场景下会造成严重的 worker 负载不均。
accept_mutex 的设计初衷是解决"惊群"(thundering herd):多个 worker 同时 epoll_wait 在同一个监听套接字上,一个新连接到来会唤醒所有 worker,只有一个能 accept() 成功,其余白白唤醒。加一把互斥锁,同一时刻只让一个 worker 持有监听套接字,就避免了惊群。
问题在于让出这把锁的时机。nginx 的逻辑是:
c
/* ngx_event_accept.c */
ngx_accept_disabled = ngx_cycle->connection_n / 8 - ngx_cycle->free_connection_n;
c
/* ngx_event.c, ngx_process_events_and_timers() */
if (ngx_use_accept_mutex) {
if (ngx_accept_disabled > 0) {
ngx_accept_disabled--; /* 本轮放弃抢锁 */
} else {
if (ngx_trylock_accept_mutex(cycle) == NGX_ERROR) { return; }
...
}
}
也就是说,只有当一个 worker 的空闲连接数低于 worker_connections / 8 时,它才会主动放弃抢锁,把机会让给别人。
现场 worker_connections = 65535,阈值是 65535 / 8 = 8191。这意味着持锁的那个 worker 会一路吃到剩余空闲连接不足 8191 个才让位 ,也就是累积 65535 - 8191 = 57344 个连接槽。而 stream 代理每条会话占 2 个连接槽(前端 + 后端),换算下来是 28672 条会话。
结果就是:8 个 worker,一个吃掉近 2.9 万会话,其余 7 个基本空转。这个独吞的 worker 同时承受了:
poll的 O(n) 开销,而且 n 是最大的那个- 自己的 fd 上限压力
- 单核 CPU 的处理上限
在机器上用 ps 就能直接看到这个现象:
root 182625 nginx: worker process 00:00:00
root 182626 nginx: worker process 00:00:00
root 182627 nginx: worker process 00:01:49 ← 就这一个在干活
root 182628 nginx: worker process 00:00:00
root 182629 nginx: worker process 00:00:00
...
CPU 时间的极端不均是 accept_mutex 问题最好的诊断信号。 64 核的机器,实际只有一个核在处理连接。
现代内核有更好的解决方案:SO_REUSEPORT。每个 worker 拥有自己独立的监听套接字和独立的 accept 队列 ,内核按四元组哈希决定新连接进哪个队列。既没有惊群,也没有锁竞争,分配还天然均衡。nginx 里通过 listen ... reuseport 启用。
天花板四:fd 上限被写死,且 stream 每会话吃两个
这一项有三处相互制约的限制,少改一处就顶不上去。
第一处:配置里完全没有 worker_rlimit_nofile。
这个指令的作用是让 master 进程在 fork worker 时,用 setrlimit(RLIMIT_NOFILE) 设置 worker 的 fd 上限。没有这一行,worker 只能继承 master 启动时的 ulimit -n。
第二处:启动脚本里写死了 ulimit -n 65535。
第三处:/etc/security/limits.conf 里 hard limit 也只有 65536。
root soft nofile 65536
root hard nofile 65536
三者叠加的结果:worker 的 fd 上限就是 65535,雷打不动。实测确认:
bash
$ grep "Max open files" /proc/<worker_pid>/limits
Max open files 65535 65535 files
关键在于 stream 代理的每条会话消耗两个 fd:客户端 socket 一个,上游 socket 一个。同时占用两个 nginx 连接槽。
| 限制项 | 配置值 | 折算会话数 |
|---|---|---|
| fd 上限 | 65535 | 32767 |
worker_connections |
65535 | 32767 |
这个"除以 2"是 HTTP 场景下的经验容易失效的地方。做 HTTP 静态服务时,一个连接就是一个 fd,worker_connections 65535 就是 6.5 万并发。但只要涉及代理(HTTP 反代也一样),就要按每会话 2 个 fd、2 个连接槽来算。做容量规划时把系数搞错,算出来的数字会差一倍。
10 万终端需要 20 万 fd 和 20 万连接槽,当前配置差了一个数量级。
天花板五:accept 队列只有 511,以及 conntrack
listen 15001 没有指定 backlog。
nginx 在 Linux 上的默认 backlog 是 511(NGX_LISTEN_BACKLOG)。实测验证:
bash
$ ss -lnt | grep 15001
LISTEN 0 511 0.0.0.0:15001 0.0.0.0:*
↑ accept 队列上限
对比一下后端 Java 服务:
bash
$ ss -lnt | grep 15002
LISTEN 0 2048 *:15002 *:*
↑ Netty 默认 SO_BACKLOG
nginx 侧比后端还小 4 倍。
这里有个层次关系必须搞清楚:listen() 系统调用的 backlog 参数和 net.core.somaxconn 是取小的关系:
c
/* net/socket.c, __sys_listen() */
somaxconn = READ_ONCE(sock_net(sock->sk)->core.sysctl_somaxconn);
if ((unsigned int)backlog > somaxconn)
backlog = somaxconn;
现场把 somaxconn 调到 40960,但 nginx 传进去的是 511,取小之后实际队列深度就是 511 。somaxconn 白调了。
这个 511 在稳态运行时看不出问题------连接是缓慢累积的,队列不会堆积。但在两种场景下会致命:
- 10 万终端集中上线(比如机房断电恢复、版本升级后统一重启)
- 网络抖动后集体重连
这两种场景下瞬时新连接速率可能达到几千每秒,511 的队列瞬间溢出。溢出后配合 tcp_syncookies=1,行为会变得很诡异:内核用 SYN cookie 完成握手,但由于 accept 队列满,连接无法交付给应用,终端侧表现为"三次握手成功了但没有任何响应",然后超时重试------制造出更多的连接请求,形成正反馈的重连风暴。
溢出可以直接观测:
bash
$ netstat -s | grep -i listen
12847 times the listen queue of a socket overflowed
12847 SYNs to LISTEN sockets dropped
这两个计数器在压测时必须盯着。
net.core.netdev_max_backlog = 4096 也偏小。 这是网卡收包后、协议栈处理前的软中断队列长度。10 万终端集中上线时,瞬时包速率很高,4096 容易溢出丢包。
conntrack 表容量:
bash
$ sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 262144
10 万前端连接 + 10 万回环上游连接 = 20 万+ 条 conntrack 表项,已经贴着 262144 这个上限了。
需要注意:回环流量在加载了 conntrack 模块的情况下同样会被跟踪。这台机器上跑着 Docker(能看到 containerd 的 socket),iptables 的 nat 表必然存在,conntrack 模块必然加载并生效,回环连接会正常占用表项。
conntrack 表满的后果是静默丢包,这是所有故障模式里最难排查的一种:没有应用层日志,没有连接错误,包就是没了。唯一的痕迹在内核日志:
bash
$ dmesg -T | grep -i conntrack
[Fri Aug 22 14:03:17 2026] nf_conntrack: nf_conntrack: table full, dropping packet
第三部分:优化方案
五个天花板里,前四个都能通过调参解决。只有第一个------回环端口------是物理上限,必须改架构。
改动一:事件模型与进程模型
nginx
user root;
worker_processes 16;
worker_rlimit_nofile 1048576;
events {
worker_connections 65535;
accept_mutex off;
multi_accept on;
use epoll;
}
逐项说明:
use epoll ------ 前面已经论证过,这是最重要的一行改动,单独改这一行就能带来数量级的改善。
accept_mutex off ------ 恢复 nginx 1.11.3 之后的默认行为,配合下面的 reuseport 由内核做负载均衡。
multi_accept on ------ 一次事件循环中把 accept 队列里的连接全部取走,而不是每轮只取一个。在重连风暴场景下,这直接决定了队列能否及时排空。默认 off 的考虑是避免单个 worker 过度抢占连接,但配合 reuseport 之后每个 worker 有独立队列,这个顾虑不存在了。
worker_processes 8 → 16 ------ 64 核只用 8 个太浪费。这里有意不用 auto (会展开成 64):nginx 的连接槽是启动时预分配的,不是按需增长:
c
/* ngx_event.c, ngx_event_process_init() */
cycle->connections = ngx_alloc(sizeof(ngx_connection_t) * cycle->connection_n, ...);
cycle->read_events = ngx_alloc(sizeof(ngx_event_t) * cycle->connection_n, ...);
cycle->write_events = ngx_alloc(sizeof(ngx_event_t) * cycle->connection_n, ...);
单个连接槽约 450 字节(ngx_connection_t ~256B + 两个 ngx_event_t 各 ~96B)。算一下:
| worker 数 | 预分配内存 |
|---|---|
| 8 | 8 × 65535 × 450B ≈ 236 MB |
| 16 | 16 × 65535 × 450B ≈ 472 MB |
| 64 | 64 × 65535 × 450B ≈ 1.9 GB |
16 个 worker 提供 16 × 32767 ≈ 52 万会话容量,是 10 万需求的 5 倍余量,472MB 内存代价在 124GB 机器上可以忽略。开到 64 个多花 1.4GB 换不来任何实际收益,还增加上下文切换和内存局部性开销。另外这台机器上还跑着 Java 服务等其他进程,把 CPU 全部划给 nginx 也不合理。
worker_rlimit_nofile 1048576 ------ 补上原来完全缺失的这一行。注意 master 进程以 root 运行,具备 CAP_SYS_RESOURCE,可以把 worker 的 rlimit 提升到超过自己继承值的水平,上限由 fs.nr_open 决定(当前是 1073741816,不构成限制)。
改动二:突破回环端口上限
这是核心改动。前提条件必须先确认------后端服务监听的是通配地址而非仅 127.0.0.1:
bash
$ ss -lntp | grep 15002
LISTEN 0 2048 *:15002 *:* users:(("java",pid=738110,fd=319))
↑ 通配地址,127.0.0.x 全部可达
*:15002 意味着 IPv6 通配监听(Java 默认行为,同时接受 IPv4 映射连接),127.0.0.2 到 127.0.0.254 都能连上。如果这里显示的是 127.0.0.1:15002,那么下面的方案不成立,必须改后端的绑定地址。
Linux 的 lo 接口默认配置的是 127.0.0.1/8,整个 127.0.0.0/8 网段(1600 多万个地址)全部本地可用,不需要任何额外配置:
bash
$ ip addr show lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
inet 127.0.0.1/8 scope host lo
↑ 整个 /8 段都归本机
配置改为:
nginx
stream {
upstream socket_proxy {
least_conn;
# 4 个回环 IP = 4 份独立的端口空间。
# 后端是同一个 java 进程(15002),因此 max_fails=0 关闭被动健康检查,
# 否则某个别名被误标 down 会平白损失 1/4 的端口空间。
server 127.0.0.1:15002 max_fails=0;
server 127.0.0.2:15002 max_fails=0;
server 127.0.0.3:15002 max_fails=0;
server 127.0.0.4:15002 max_fails=0;
}
server {
listen 15001 reuseport backlog=65535;
proxy_connect_timeout 10s;
proxy_timeout 600s;
proxy_socket_keepalive on;
proxy_pass socket_proxy;
proxy_protocol on;
}
}
这里有四个决策需要解释,每一个都不是随手写的。
为什么删掉 hash $remote_addr consistent
原配置里有这一行,但 upstream 只有一个 server。单 server 的哈希没有任何意义------所有请求都只有一个去处。
改成 4 个条目之后,哈希反而变成了有害的 。这 4 个条目指向的是同一个 Java 进程 ,不存在任何会话保持的需求(连到 127.0.0.1 和连到 127.0.0.4 落到的是同一个进程、同一份内存)。而 hash $remote_addr consistent 会让同一个源 IP 恒定落到同一个回环 IP 上------终端如果大量位于 NAT 之后共享出口 IP,就会全部压到同一个 127.0.0.x,把那一份端口空间打满,而另外三份闲着。
为什么用 least_conn 而不是默认轮询
长连接场景下轮询会出问题:轮询按"新连接分配次数"均分,不考虑连接的存活时长。当连接的生命周期差异很大时(有的终端连上就断,有的挂几个小时),分配次数均等不等于活跃连接数均等,长时间运行后会逐渐倾斜。
least_conn 直接按当前活跃连接数最少的目标分配,能持续保持 4 份端口空间的均衡。这正是长连接代理该用的策略。
为什么必须加 max_fails=0
nginx 的被动健康检查默认是 max_fails=1 fail_timeout=10s:一次连接失败就把该 server 标记为不可用,10 秒内不再往它分配流量。
在这个架构下这个机制是有害的。4 个条目是同一个后端进程的 4 个别名,不存在"某一个坏了"的情况------要么全好,要么全坏。但偶发的网络抖动、瞬时的端口冲突都可能让某个别名触发失败计数,于是 nginx 把 127.0.0.2:15002 标记为 down,平白损失 1/4 的端口空间 10 秒。在端口本来就紧张的情况下,这会引发连锁反应:剩下 3 份空间压力更大 → 更容易失败 → 更多别名被标 down。
max_fails=0 关闭被动健康检查。这不会降低可用性:如果后端真的挂了,4 个别名同时不可用,标记与不标记的结果一样。
为什么 proxy_connect_timeout 从 600s 改到 10s
这是原配置里被严重低估的一个风险点。
回环连接的建立是亚毫秒级 的,不经过网卡,不经过任何物理链路。给它 600 秒的超时时间在正常情况下毫无意义,在故障情况下则是灾难性的故障放大器:
端口耗尽后,每一次 connect() 失败之前,都会有一条会话在这里挂着整整 10 分钟。这 10 分钟里它占着:
- 一个前端连接的 fd 和连接槽
- 一个上游连接槽
- 终端侧一个等待中的连接
于是故障从"新终端建不上链"迅速恶化成"资源被失败会话锁死,即使端口释放了也没有槽位可用",而且恢复时间被拉长到 10 分钟以上。现场"重启才能恢复"的现象里,这个参数是重要推手------不重启的话,那些挂着的会话会持续占用资源。
10 秒对回环连接来说已经是极度宽松的余量,超过 10 秒说明后端确实出了问题,应该快速失败让终端去重试,而不是挂着。
proxy_socket_keepalive on ------ 在上游 socket 上启用 TCP keepalive,让内核主动探测并回收对端已死但没发 FIN 的连接。10 万连接规模下,半死连接的累积是必须处理的问题,否则连接数只涨不跌。
reuseport backlog=65535 ------ 每个 worker 独立的监听套接字,各自 65535 深度的 accept 队列。16 个 worker 合计 100 万队列深度,重连风暴时有充足缓冲。
有一个需要知道的副作用:SO_REUSEPORT 下执行 nginx -s reload 时,旧 worker 关闭其监听套接字的瞬间,已经进入它 accept 队列但还没被取走的连接会被丢弃。这是 SO_REUSEPORT 的已知行为,不是 nginx 的 bug。对于终端会自动重连的场景影响可以接受,但要知道 reload 不再是完全无损的。
如果配置里启用了 IPv6 适配(原配置里有 #ipv6_adapt#listen [::]:15001; 这样的占位注释),对应的行也要同步加上 reuseport backlog=65535。
改动三:内核参数
这里有个陷阱值得单独强调:产品的启动脚本会在每次拉起 nginx 前统一执行 sysctl -w 覆盖一批参数。 这意味着只修改 /etc/sysctl.conf 是无效的------下次重启时会被启动脚本里的值覆盖回去。内核参数必须改在启动脚本的那个参数列表里。
这类"应用自带调优钩子"在国产化交付的软件里很常见,排查时如果只看 /etc/sysctl.conf 会得出完全错误的结论。判断方法很简单:比对 sysctl -n <key> 的实际运行值和配置文件里的值,不一致就说明有别的地方在设置它。
参数列表改为:
bash
local params=(
"fs.file-max=2097152"
"net.ipv4.tcp_tw_reuse=1"
"net.ipv4.tcp_fin_timeout=15"
"net.core.netdev_max_backlog=32768"
"net.core.somaxconn=65535"
"net.ipv4.tcp_max_syn_backlog=65535"
"net.ipv4.tcp_syncookies=1"
"net.ipv4.tcp_syn_retries=2"
"net.ipv4.tcp_synack_retries=2"
"net.ipv4.ip_local_port_range=10000 65535"
"net.ipv4.ip_local_reserved_ports=7060,7999,8443,8887-8890,15001-15002,15736,26113,28443,29001-29002,38443,48443,58443"
"net.ipv4.tcp_max_tw_buckets=1048576"
"net.netfilter.nf_conntrack_max=1048576"
)
| 参数 | 变化 | 理由 |
|---|---|---|
ip_local_port_range |
新增 10000 65535 |
从默认的 28232 个提升到 55536 个,× 4 个回环 IP = 22 万 |
ip_local_reserved_ports |
新增 | 见下方说明,这一行不能省 |
nf_conntrack_max |
262144 → 1048576 | 20 万+ 表项已贴上限,满了是静默丢包 |
somaxconn / tcp_max_syn_backlog |
40960 → 65535 | 配合 nginx 的 backlog=65535,取小之后才是真的 65535 |
netdev_max_backlog |
4096 → 32768 | 集中上线时的瞬时包速率缓冲 |
tcp_max_tw_buckets |
新增 1048576 | 默认值可能低于 TIME_WAIT 的峰值需求,超出后内核会直接 RST 而非正常 TIME_WAIT |
fs.file-max |
999999 → 2097152 | 20 万 fd 加系统其余占用 |
关于 ip_local_reserved_ports------这是最容易被忽略、后果最隐蔽的一项。
把 ip_local_port_range 的下界从 32768 下探到 10000 之后,临时端口池就和这套系统自己的业务端口重叠了。看一眼这个系统占用的端口:7060、7999、8443、8887、8888、8889、8890、15001、15002、15736、26113、28443、29001、29002、38443、48443、58443------全部落在 10000-65535 或与之相邻的范围内。
冲突场景是这样的:nginx 运行期间,内核给某条上游连接分配了临时端口 29001。此时 epcbus 服务因为某种原因重启,它要 bind(0.0.0.0:29001),但这个端口已经被那条连接占用了,bind() 返回 EADDRINUSE,服务启动失败。
这类故障的特征是:低概率、随机、只在服务重启时出现、重试一次可能又好了。极难复现,极难定位,而且往往在压测阶段不出现,上线跑一段时间后才偶发。
ip_local_reserved_ports 把指定端口从临时端口池里排除,内核永远不会把它们分配给 connect(),但显式 bind() 仍然可用。任何时候调宽 ip_local_port_range,都应该同时设置这个参数,把本机所有服务的监听端口列进去。这是一条应该写进规范的配对操作。
nf_conntrack_max 调大后需要同步哈希表大小。 conntrack 用哈希表索引表项,表项数涨了 4 倍而桶数不变,冲突链会变长,查表从 O(1) 退化。经验值是 hashsize = max / 4:
bash
[ -w /sys/module/nf_conntrack/parameters/hashsize ] && \
echo 262144 > /sys/module/nf_conntrack/parameters/hashsize
内存代价:每个 conntrack 表项约 300 字节,1048576 × 300B ≈ 300MB,加上哈希桶数组。可以接受。
如果不想给回环流量做连接跟踪(它确实没有跟踪的必要),更彻底的做法是在 raw 表加 NOTRACK 规则:
bash
iptables -t raw -A OUTPUT -o lo -p tcp --dport 15002 -j NOTRACK
iptables -t raw -A PREROUTING -i lo -p tcp --dport 15002 -j NOTRACK
这样能省掉一半的表项。但要注意这会影响依赖 conntrack 的其他规则(比如 -m state --state ESTABLISHED 的放行规则),环境里有复杂 iptables 规则时需要谨慎评估,直接调大 nf_conntrack_max 是更稳妥的选择。
改动四:fd 上限的三处同步
这三处是 AND 关系,少改一处整条链路就断了。
启动脚本:
bash
ulimit -Hn 1048576 2>/dev/null || true
ulimit -n 1048576
先抬 hard limit 再设 soft limit。脚本以 root 运行,具备 CAP_SYS_RESOURCE,可以提升自己的 hard limit。加 || true 是防止在某些受限环境下(容器内没有该 capability)整个启动脚本因为这一行失败而中断。
/etc/security/limits.conf:
* soft nofile 1048576
* hard nofile 1048576
root soft nofile 1048576
root hard nofile 1048576
原有的 65536 那几行要删掉。limits.conf 里同一个键的多条记录,生效规则是先匹配者优先,把新值加在文件末尾而不删旧行,大概率不生效。这是个很常见的坑。
/etc/sysctl.conf:
原配置里这个文件写的是 fs.file-max=65535,而启动脚本设的是 999999。两者矛盾,启动脚本在启动时会覆盖它。但存在一个危险的窗口:纯净重启后、itxnginx 启动前 ,系统的 fs.file-max 是 65535------整个系统所有进程加起来最多 65535 个 fd。这个数字对一台跑着 Java 服务、Docker、若干 nginx 实例的 124GB 服务器来说是灾难性的,重启后其他服务可能因为 fd 耗尽起不来。
fs.file-max=2097152
vm.max_map_count = 262144
这种"同一参数在多处被设置成不同值"的情况,本身就是配置管理的隐患。理想做法是统一到一处,但在无法改动产品启动脚本设计的前提下,至少要保证多处的值不冲突,并且以最大值为准。
改动五:两个容易漏掉的内存和 I/O 项
stream 代理的缓冲区内存。
ngx_stream_proxy_module 为每条会话分配两个 缓冲区,分别用于上下行,大小由 proxy_buffer_size 控制,默认 16k:
c
/* ngx_stream_proxy_module.c, ngx_stream_proxy_process_connection() */
/* u->downstream_buf 和 u->upstream_buf 各占 pscf->buffer_size */
10 万会话 × 2 × 16KB = 3.2 GB。
在 124GB 机器上撑得住,但这是纯浪费------终端注册激活和心跳这类信令流量,单条消息通常只有几十到几百字节,16KB 的缓冲区完全用不上。改小能显著降低内存占用:
nginx
proxy_buffer_size 4k;
10 万会话 × 2 × 4KB = 800 MB,省下 2.4GB。
要不要改取决于业务流量特征:如果 15001 上除了信令还要传文件、传固件升级包,缓冲区改小会增加系统调用次数,反而降低吞吐。这个参数应该按实测的消息大小分布来定,不能一概而论。至少要知道它的存在,以及它是按会话数线性增长的。
日志的同步写入。
nginx
access_log logs/stream-access-$logdate.log detailed;
stream 的 access_log 在每条会话结束时写一条记录,默认无缓冲,是同步 write。稳态下没问题(会话结束是分散的),但重连风暴时几千每秒的会话结束会产生几千次同步磁盘写,成为实打实的 I/O 热点,而且这个热点出现的时机恰好是系统最需要 CPU 处理连接的时候。
nginx
access_log logs/stream-access-$logdate.log detailed buffer=64k flush=5s;
攒满 64KB 或者过了 5 秒才落盘,把随机小写合并成顺序大写。代价是崩溃时最多丢 5 秒日志,对访问日志而言完全可以接受。
第四部分:验证
改完必须验证,而且要验证到点子上。改了一堆参数但某一处没生效,是这类优化最常见的失败模式------尤其是 fd 上限,三处里漏一处就白改,而且不验证根本发现不了。
配置语法检查
这套产品的 nginx 二进制链接了 LuaJIT,直接执行会报共享库找不到:
bash
$ /test/opt/itxnginx/bin/nginx -V
/test/opt/itxnginx/bin/nginx: error while loading shared libraries:
libluajit-5.1.so.2: cannot open shared object file: No such file or directory
需要显式指定库路径:
bash
LD_LIBRARY_PATH=/test/opt/itxnginx/bin/lib \
/test/opt/itxnginx/bin/nginx -p /test/opt/itxnginx \
-c /test/opt/itxnginx/conf/nginx.conf -t
这个细节值得记一下:很多产品化的 nginx 都会把依赖库放在自己的目录里,通过启动脚本设置 LD_LIBRARY_PATH。手工执行时容易忽略这一点,然后误以为二进制损坏。
生效确认清单
1. worker 的 fd 上限是否真的抬上去了
这是最容易漏、也最容易验证的一项:
bash
$ grep "Max open files" /proc/$(pgrep -f "itxnginx/bin/nginx: master")/limits
Max open files 1048576 1048576 files
master 显示对了还要看 worker:
bash
for p in $(pgrep -f "itxnginx.*nginx: worker"); do
printf "%s: %s\n" "$p" "$(awk '/Max open files/{print $4}' /proc/$p/limits)"
done
如果 master 是 1048576 而 worker 还是 65535,说明 worker_rlimit_nofile 没写对或者没 reload。
2. 监听队列深度和 reuseport 是否生效
bash
$ ss -lnt | grep 15001
LISTEN 0 65535 0.0.0.0:15001 0.0.0.0:*
LISTEN 0 65535 0.0.0.0:15001 0.0.0.0:*
LISTEN 0 65535 0.0.0.0:15001 0.0.0.0:*
... (16 行,每个 worker 一个)
reuseport 生效的标志是同一个地址端口出现多行 LISTEN ,行数等于 worker 数。如果只有一行,说明 reuseport 没生效(可能是 reload 没有重建监听套接字,需要完整重启)。
3. 端口耗尽是否消除
bash
$ grep -c "Cannot assign requested address" /test/opt/itxnginx/logs/error*.log
0
这个数字必须恒为 0。任何非零值都说明端口空间还是不够,需要继续加回环 IP 或者调宽端口区间。
4. 四份端口空间是否均衡使用
bash
$ ss -ant | awk '$5 ~ /:15002$/ {split($5,a,":"); c[a[1]]++} END {for(i in c) print i, c[i]}'
127.0.0.1 25103
127.0.0.2 24897
127.0.0.3 25201
127.0.0.4 24799
四个数字应该接近。如果严重倾斜(比如 127.0.0.1 有 8 万而其他几百),说明 hash 没删干净或者负载均衡策略配错了。
5. worker 负载是否均衡
bash
$ ps -o pid,time,args -p $(pgrep -d, -f "itxnginx.*nginx: worker")
16 个 worker 的 CPU 时间应该在同一量级。如果还是一个高其余为零,说明 accept_mutex off 或 reuseport 有一处没生效。这个检查非常直观,应该作为压测时的常规观察项。
压测时的监控面板
把关键指标放在一个 watch 里持续观察:
bash
watch -n2 '
echo "--- socket 总览 ---"; ss -s | head -4
echo "--- 15001 状态分布 ---"
ss -ant | awk "\$4 ~ /:15001$/ {c[\$1]++} END {for(s in c) print s, c[s]}"
echo "--- 上游端口用量 ---"
ss -ant | awk "\$5 ~ /:15002$/ {c[\$1]++} END {for(s in c) print s, c[s]}"
echo "--- conntrack ---"
cat /proc/sys/net/netfilter/nf_conntrack_count
echo "--- accept 队列溢出(应该不增长) ---"
netstat -s | grep -iE "listen queue|SYNs to LISTEN"
echo "--- 内核告警 ---"
dmesg -T | tail -3
'
其中三项是红线,任何一项开始增长就说明还有天花板没拆:
Cannot assign requested address计数times the listen queue of a socket overfloweddmesg里的nf_conntrack: table full
这三个指标的共同特点是:它们对应的故障在应用层几乎不可见。端口耗尽会被应用日志记录,但队列溢出和 conntrack 满都是内核层的静默丢弃,应用侧只能看到"连接超时"这种毫无信息量的表象。压测时不主动看这三个计数器,基本等于蒙着眼睛调优。
改动的分期执行
不建议一次全改。按风险和收益分两期:
第一期:reload 即可生效的低风险改动
use epollaccept_mutex offmulti_accept onworker_rlimit_nofileproxy_connect_timeout600s → 10s
bash
LD_LIBRARY_PATH=/test/opt/itxnginx/bin/lib \
/test/opt/itxnginx/bin/nginx -p /test/opt/itxnginx -c ... -t && \
LD_LIBRARY_PATH=/test/opt/itxnginx/bin/lib \
/test/opt/itxnginx/bin/nginx -p /test/opt/itxnginx -c ... -s reload
光 use epoll 和 accept_mutex off 这两行,预期就能把上限从 4 万推到 6 万以上(推到端口耗尽的真实边界为止)。这一期的价值在于快速验证诊断方向:如果改完毫无变化,说明前面的分析有问题,应该停下来重新定位,而不是继续往下改。
先做低风险、能快速验证的改动,再做需要停机的改动,这个顺序在生产环境排障中比"一次改到位"务实得多。
第二期:需要完整重启的改动
listen ... reuseport backlog=65535(需要重建监听套接字)- 多回环 IP upstream
- 内核参数(启动脚本里的
set_network_param) ulimit(只在进程启动时生效,reload 无效)limits.conf/sysctl.conf
需要停机窗口。执行前备份所有要改的文件。
第五部分:改完的容量账,以及一套可复用的方法
数字对比
| 资源 | 10 万终端需求 | 改前 | 改后 |
|---|---|---|---|
| 回环上游端口 | 10 万 | 28,232 ✗ | 55,536 × 4 = 222,144 ✓ |
| worker fd 上限 | 20 万 | 65,535 ✗ | 1,048,576 ✓ |
| 连接槽总量 | 20 万 | 8 × 65,535 | 16 × 65,535 = 104 万 ✓ |
| accept 队列 | ≥ 6 万 | 511 ✗ | 65,535 × 16 ✓ |
| conntrack 表项 | 20 万+ | 262,144 ⚠ | 1,048,576 ✓ |
| 事件模型 | epoll | poll ✗ | epoll ✓ |
| 缓冲区内存 | --- | 3.2 GB | 800 MB(可选) |
一套可复用的容量核算方法
这个案例最值得沉淀的不是具体的参数值,而是核算方法。四层代理场景下,每条业务会话的资源消耗系数是固定的:
| 资源 | 每会话消耗 | 10 万会话 | 对应限制项 |
|---|---|---|---|
| 文件描述符 | 2 | 20 万 | worker_rlimit_nofile、ulimit -n、limits.conf、fs.file-max |
| nginx 连接槽 | 2 | 20 万 | worker_connections × worker_processes |
| 上游临时端口 | 1 | 10 万 | ip_local_port_range × 目的地址数量 |
| conntrack 表项 | 2 | 20 万 | nf_conntrack_max |
| 应用缓冲区 | 2 × proxy_buffer_size |
3.2 GB | proxy_buffer_size |
| 连接槽预分配 | ~450 B × 2 | 90 MB | 启动即占用,与实际连接数无关 |
拿到一个"N 个客户端接入"的需求,把 N 代进这张表,逐项和当前配置比对,天花板就全部暴露了。这个动作应该在上线前做,而不是出故障后做。
几条从这个案例里提炼出来的经验:
第一,"配置看起来调过"不等于"调对了"。 现场配置里 worker_connections 65535、somaxconn 40960、tcp_tw_reuse 1 都是调过的,但 use poll 让前面所有努力归零,backlog 511 让 somaxconn 40960 变成摆设,ip_local_port_range 保持默认值成了最硬的天花板。一条链路的容量等于最短那一环,调优必须成体系,单点优化经常是零收益甚至负收益。
第二,不要用数字巧合做判断。 somaxconn = 40960 和"4 万多连接"数字上完美吻合,但机制上完全无关。判断一个参数是不是元凶,唯一可靠的方法是讲清它的作用机制,并推导出与观测现象一致的因果链。数字对上了只是提示,不是证据。
第三,统计口径决定结论。 "4 万多连接"这个观测混合了 ESTABLISHED、TIME_WAIT、SYN_RECV 等多个状态,和"2.8 万 ESTABLISHED + 1.2 万 TIME_WAIT"指向的原因完全不同。排查网络问题时,连接数一定要按状态拆开看。
第四,注意隐藏的配置覆盖层。 产品的启动脚本会 sysctl -w 覆盖内核参数,只改 /etc/sysctl.conf 会在下次重启失效。同样,limits.conf 的多条同键记录以先匹配者为准,追加新值不删旧值大概率不生效。判断方法:比对运行时实际值和配置文件值,不一致就说明有别的地方在设置它。
第五,超时参数是故障放大器。 proxy_connect_timeout 600s 在正常情况下毫无影响,在故障情况下把"部分请求失败"放大成"资源被锁死、十分钟不恢复"。超时值应该按操作的正常耗时量级来设------回环连接是亚毫秒级,给 10 秒已是百倍余量,给 600 秒纯属埋雷。同类的还有重试次数、健康检查阈值,这些参数在稳态下完全看不出问题,只在故障时决定故障的严重程度和恢复速度。
第六,同环境的配置差异是免费线索。 这台机器上另一个 nginx 实例配的是 use epoll,而出问题的实例配的是 use poll。这种同环境下的配置不一致往往意味着某份配置是从老模板抄来的,或者写完后再没在真实负载下验证过。排查时先横向对比同类组件的配置,成本极低,收益经常很高。
关于终端侧和后端侧
nginx 调好了,还有两个方向需要一并核对,否则瓶颈只是换了个位置。
后端 Netty 的 accept 队列是 2048。 nginx 侧改成 65535 之后,压力会前推到后端。压测时看:
bash
netstat -s | grep -i "listen queue"
如果这个计数器增长,需要在 Java 启动参数里调 SO_BACKLOG。后端的 fd 上限倒是没问题(实测 999999),这一项不用动------但这是运气好,换个环境就该先查。
proxy_timeout 600s 必须和终端心跳周期核对。 这个参数的含义是"上下行双向都没有数据传输超过 600 秒就断开连接"。如果终端的心跳间隔大于 600 秒(比如某些低功耗设备设成 15 分钟),nginx 会主动切链,终端侧表现为"莫名掉线后重连"------而这些重连会自己制造出重连风暴,恰好是我们花了大力气去应对的那种流量。
这类"超时配置与业务节奏不匹配导致的自激振荡"在长连接系统里很常见,而且极难和真正的网络问题区分开。长连接代理的超时值必须严格大于业务心跳周期,通常留 2-3 倍余量。
更彻底的方案:Unix domain socket
多回环 IP 是个有效且低成本的方案,但它本质上是在一个有上限的空间里做扩容------4 个 IP 给 22 万,如果哪天要支撑 50 万终端,还得继续加 IP。
更彻底的做法是让 nginx 和后端之间走 Unix domain socket:
nginx
upstream socket_proxy {
server unix:/var/run/itx15002.sock;
}
UDS 完全不经过 TCP/IP 协议栈:
- 没有四元组的概念,不消耗任何端口,并发上限只受 fd 限制
- 不占用 conntrack 表项,直接省掉一半表项压力
- 不走协议栈,没有 TCP 头处理、没有校验和计算、没有拥塞控制状态机,同机通信的开销明显低于回环 TCP
- 没有 TIME_WAIT,连接关闭即释放,重连风暴时不再有 60 秒的资源尾巴
代价是需要后端支持监听 UDS。Netty 通过原生 epoll 传输层的 EpollServerDomainSocketChannel 支持这一点(需要引入 netty-transport-native-epoll 依赖),PROXY protocol 在 UDS 上同样正常工作,所以客户端 IP 透传不受影响。改动量在 Java 侧,通常是几十行配置级别的工作。
对于"nginx 和后端确定同机部署"的架构,UDS 是明显更优的选择。回环 TCP 在这种场景下唯一的优势是不需要改后端代码------而这个优势的代价,就是本文前面分析的那个 28232 的硬天花板。
收尾
这个案例的有趣之处在于,现象("4 万连接就建不上链")和真凶("回环上游临时端口耗尽 + poll 事件模型")之间隔了好几层间接关系,而中间还散落着 somaxconn=40960 这样极具误导性的数字巧合。
如果一开始就接受"nginx 单端口最多 6 万连接"这个错误前提,整个排查方向会完全偏掉------可能会去加机器、加 LVS、拆端口,花掉大量成本,而真正的问题只需要改几行配置。
四元组这个概念是整个分析的支点。想清楚"哪一条连接的哪几位是可变的",天花板的位置就自然浮现了。而顺着这个思路往下,"为什么多加几个回环 IP 就能成倍扩容"也就成了显而易见的推论,而不是需要背下来的技巧。
这大概是网络问题排查最值得投入的地方:把机制搞清楚,而不是把参数背下来。 参数会随版本、内核、场景变化,机制不会。
