Nginx - 10 万终端接入卡在 4 万:一次 nginx TCP 代理容量天花板的完整拆解

文章目录

一个说不通的现象

某个现场的架构很简单: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,somaxconntcp_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 规则。真正的限制来自三个别的地方:

  1. 文件描述符。每条连接是一个 socket,占一个 fd。
  2. 内存。内核侧的 socket 结构体 + 收发缓冲区,用户态侧的连接对象 + 应用缓冲区。
  3. 连接哈希表的容量。这个默认值通常很大,一般不是瓶颈。

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。这类命令统计的是所有状态 的连接,包括 ESTABLISHEDSYN_RECVTIME_WAITCLOSE_WAITFIN_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);

然后每一轮事件循环都要:

  1. 把整个 pollfd 数组(最多 65535 项)从用户态拷贝到内核态
  2. 内核线性遍历所有 fd,逐个检查状态
  3. 把结果数组整体拷回用户态
  4. nginx 再线性遍历一遍,找出就绪的那几个

在几百个连接时这毫无问题。在几万个连接、其中大部分是空闲长连接时,这套流程每轮要处理几万个 fd 才能找出可能只有几十个的就绪事件。CPU 全部烧在 poll() 系统调用的数据拷贝和遍历上,事件循环的单轮延迟从微秒级涨到毫秒甚至几十毫秒级。

后果是:新连接的 TCP 握手排在事件队列里等不到处理,超时失败。 而已建立的连接因为有数据要收发,在遍历中会被识别为就绪,反而还能工作。

这和现场现象完全对上:老连接活着,新连接进不来。

epoll 的复杂度是 O(就绪事件数),与总连接数无关:

  • fd 通过 epoll_ctl() 一次性注册到内核的红黑树,不需要每轮传递
  • 内核在数据到达时通过回调把 fd 挂到就绪链表
  • epoll_wait() 只返回就绪链表的内容

10 万空闲连接里有 100 个活跃,epoll_wait() 就返回 100 个,内核和用户态都不需要碰另外 99900 个。

这不是"优化一下更快"的量级差异,是"能不能用"的量级差异。10 万连接场景下 pollepoll 之间是几个数量级的鸿沟。

一个有意思的旁证:同一台机器上另一个 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,取小之后实际队列深度就是 511somaxconn 白调了。

这个 511 在稳态运行时看不出问题------连接是缓慢累积的,队列不会堆积。但在两种场景下会致命:

  1. 10 万终端集中上线(比如机房断电恢复、版本升级后统一重启)
  2. 网络抖动后集体重连

这两种场景下瞬时新连接速率可能达到几千每秒,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.2127.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 offreuseport 有一处没生效。这个检查非常直观,应该作为压测时的常规观察项。

压测时的监控面板

把关键指标放在一个 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 overflowed
  • dmesg 里的 nf_conntrack: table full

这三个指标的共同特点是:它们对应的故障在应用层几乎不可见。端口耗尽会被应用日志记录,但队列溢出和 conntrack 满都是内核层的静默丢弃,应用侧只能看到"连接超时"这种毫无信息量的表象。压测时不主动看这三个计数器,基本等于蒙着眼睛调优。

改动的分期执行

不建议一次全改。按风险和收益分两期:

第一期:reload 即可生效的低风险改动

  • use epoll
  • accept_mutex off
  • multi_accept on
  • worker_rlimit_nofile
  • proxy_connect_timeout 600s → 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 epollaccept_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_nofileulimit -nlimits.conffs.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 65535somaxconn 40960tcp_tw_reuse 1 都是调过的,但 use poll 让前面所有努力归零,backlog 511somaxconn 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 就能成倍扩容"也就成了显而易见的推论,而不是需要背下来的技巧。

这大概是网络问题排查最值得投入的地方:把机制搞清楚,而不是把参数背下来。 参数会随版本、内核、场景变化,机制不会。

相关推荐
非凡大爹1 小时前
IPv6扩展首部:为什么IPv6要把功能从IP首部分离出来?IPv6扩展首部解析
网络协议·tcp/ip·计算机网络
hzxpaipai1 小时前
杭州网站运维|企业官网上线前技术检查清单:SEO、GEO、安全、服务器与备份
运维·服务器·nginx·安全
Logintern0910 小时前
网络通讯协议—TCP/IP协议
网络·网络协议·tcp/ip
熊IT12 小时前
什么是原生住宅 IP?如何判断一个 IP 是否真正“原生”?
服务器·网络·tcp/ip
虎王物联13 小时前
Docker Compose编排物联网平台后端:Nginx+PHP-FPM+EMQX+InfluxDB一键部署实战
物联网·nginx·docker
晓晓_za89866815 小时前
Geo 优化服务 CI/CD 流水线搭建:源码自动构建、测试与灰度发布
运维·服务器·tcp/ip·spring·缓存·ci/cd
好评12416 小时前
【Linux】Socket编程TCP
linux·网络·tcp/ip
浪兎兎16 小时前
Nginx 笔记
运维·笔记·nginx
因_崔斯汀17 小时前
Nginx 开启 Gzip 完整配置
nginx