ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发场景Linux内核网络参数调优:从连接队列到TIME_WAIT

高并发场景Linux内核网络参数调优:从连接队列到TIME_WAIT

1. 高并发场景的网络瓶颈,到底卡在哪

先讲个我自己的案例。某天晚上我们一个模拟的统计查询系统突然报警,线上Nginx的活跃连接数直接顶到几万,但业务其实远没到这个量级,大量连接堆在SYN_RECV和TIME_WAIT状态,后端接口响应倒是不慢,但客户端就是觉得“卡”。当时第一反应是加机器,后来冷静下来查了一圈,发现问题不在应用代码,而在操作系统网络栈的默认参数上。

这个事给我的触动很大。很多团队一提到高并发,优先想到的是扩容、负载均衡、微服务拆分,却忽略了一个基本事实:你所有请求最终都要穿过Linux内核的网络协议栈。即使你把应用层做得再高效,如果内核默认参数不支持那么大的并发量,连接队列、文件句柄、TIME_WAIT、缓冲区这些环节早晚会变成瓶径。

这篇文章不是教科书式地罗列参数,而是从我实际调优的经验出发,把高并发场景下真正管用的Linux网络内核参数掰开揉碎讲一遍。

调优方向涉及的参数解决的核心问题
文件句柄上限ulimit -n、fs.file-max进程能打开多少socket
连接队列somaxconn、tcp_max_syn_backlog握手队列能积压多少新连接
TIME_WAIT管理tcp_tw_reuse、tcp_fin_timeout四挥后端口和连接资源释放速度
Socket缓冲区rmem、wmem单条连接收发数据的瞬时吞吐
通用连接优化tcp_syncookies、tcp_keepalive_time半连接和存量连接的心跳管理

能不能扛住突发流量,通常就是靠这几个模块的配合,下面一节一节点拆开聊。

2. 文件句柄与最大连接数,地基没打牢谈何并发

2.1 别小看“能打开多少文件”这件事

Linux下一切皆文件,socket也不例外。每建立一个TCP连接,就占用一个文件描述符(fd)。你在高并发接入层看到的连接打满,很多时候根本不是Nginx、Redis或者Java进程的线程不够,而是进程能使用的文件描述符上限太小。

我见过一个真实的例子,某内部管理平台上线第一天,压测到5000并发就报错,错误信息是“Too many open files”。运维以为是进程配置问题,翻了半天systemd配置,最后发现是shell启动方式没有加载limits.conf,进程fd上限只有1024。高并发应用默认单进程1024个fd,这个数字注定了你连做个小规模压测都不够。

所以调优的第一步是先看基础配额。确认ulimit的时候要注意,soft limit和hard limit都要看,只改soft不改hard,进程在运行中可能还是会受限。查看方式:

ulimit -n ulimit -Hn

这两个值默认情况下通常是1024,有些发行版是4096。

2.2 永久调优的正确姿势:不只改limits.conf

修改文件句柄上限,最常见的做法是改/etc/security/limits.conf:

* soft nofile 1048576 * hard nofile 1048576

但这里有个容易被坑的地方:这个文件只对通过PAM登录的会话生效。如果你用systemd服务方式启动Java进程或Docker容器,systemd会给服务单独设一个进程级限制,你得在service文件里显式设置:

LimitNOFILE=1048576

如果应用是跑在systemd环境里,光改limits.conf是没用。这个坑我踩过不止一次,某次压测Redis集群,明明limits.conf已经改成百万级了,重启完服务发现fd上限还是4096,查了半天才发现是docker容器里的进程默认继承的是容器自身的限制,后来在容器编排文件的ulimits段里显式指定才生效。

还有两个全局参数需要配合调整。一个是fs.file-max,这是内核级别的全局fd上限,另一个是fs.file-nr,它是在动态反馈当前系统fd使用情况:

sysctl -w fs.file-max = 1000000 sysctl fs.file-nr

file-nr输出的三个数字分别是:已分配fd数、未使用fd数、总上限。如果第一个数字长期接近第三个数字,说明你的全局fd已经告急,光调进程级限制已经不够了。

3. TCP握手与队列参数,高并发下最先撞上的墙

3.1 半连接队列和全连接队列,各管一段

TCP建立连接要走三次握手,内核在这期间维护两个队列:一个是半连接队列(SYN队列),存放还没完成握手的连接;一个是全连接队列(accept队列),存放已经完成握手、等待应用调用accept()的连接。

和这两个队列相关的核心参数是:

  • net.core.somaxconn:全连接队列长度上限,默认一般是4096(部分内核是128)
  • net.ipv4.tcp_max_syn_backlog:半连接队列长度,默认一般是1024或2048

高并发场景下,这两个默认值基本不够用。尤其是somaxconn,很多应用(比如Nginx和Redis)在listen监听时还会传入一个backlog值,最终生效的实际长度是应用传入值和内核somaxconn值之间较小的那个。换句话说,即使你的应用设置了backlog=1024,如果内核somaxconn只有128,实际全连接队列就被限制在128。

我实际调优时一般把这个参数设为65535:

sysctl -w net.core.somaxconn=65535 sysctl -w net.ipv4.tcp_max_syn_backlog=65535

关于他们做“说了什么”。linux默认1024“全连接队列”的上限是因为这两个队列的长度需要消耗内存。连接排队的本质是让内核帮你保住这些socket。排队长了如果应用处理得不快,内存消耗就会上升。所以调优的时候不要只调队列,还要观察应用的accept速度。如果队列满了,应用侧日志或ss -lnt输出里能看到全连接队列溢出,这个后面讲排查时会细说。

3.2 防御SYN洪水:syncookies是保险丝不是万能药

net.ipv4.tcp_syncookies这个参数争议不大,一般建议直接设为1开启。它开启后,当半连接队列满时,内核不再丢弃SYN包,而是通过一种特殊的cookie机制完成握手校验,从而让服务在遭受大量SYN攻击时仍然能处理合法连接。

但要提醒一句:syncookies是一根保险丝,不能完全依赖它。内核文档明确说过,syncookies会破坏TCP的一些扩展特性(比如时间戳选项的部分功能),而且它本身也只能缓解SYN Flood,不能替代对半连接队列长度的合理设置。而且开启syncookies时,如果还希望TCP时间戳正常工作,需要配合tcp_timestamps参数一起考虑。

我自己生产环境一般这样配:

net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_timestamps = 1

3.3 全连接队列监控:用ss看溢出比看日志快得多

排查队列溢出,最快的工具是ss。看某个端口当前的全连接队列使用情况:

ss -lnt 'sport = :8080'

输出里的Send-Q字段在LISTEN状态下显示的就是全连接队列的最大长度,而Recv-Q显示当前等待accept的连接数。当Recv-Q接近Send-Q甚至等于Send-Q时,说明应用层消费连接的速度跟不上了。

如果出现大量溢出,ss还能看到drop计数。需要说明的是,不同内核版本输出格式可能有差异,但Recv-Q和Send-Q两列的含义大体一致。总原则是:Recv-Q长期接近Send-Q,就要排查应用accept线程模型或处理逻辑是否需要优化。

4. 四次挥手与TIME_WAIT,高并发短连接的隐形杀手

4.1 TIME_WAIT为什么那么多,以及为什么不能直接清零

客户端主动关闭连接后,服务端会进入TIME_WAIT状态,等待2MSL(Maximum Segment Lifetime)之后才彻底释放。其实TIME_WAIT本身是TCP设计里的一个安全机制,防止上一个连接的延迟报文干扰新连接。

它的问题在于:高并发短连接场景下,TIME_WAIT连接会大量堆积。每个TIME_WAIT连接要占用一个五元组(源IP、源端口、目的IP、目的端口、协议),如果源端口耗尽,新连接就会建立失败。

net.ipv4.tcp_fin_timeout可以控制TIME_WAIT的持续时间,默认60秒。如果线上短连接多,可以适当调小:

net.ipv4.tcp_fin_timeout = 30

4.2 开启timewait复用,但要知道它复用的是哪类连接

net.ipv4.tcp_tw_reuse是优化TIME_WAIT的重头戏,它允许内核把处于TIME_WAIT状态的连接端口重新分配给新的连接。但它有一个严格的限制条件:只有同时开启TCP时间戳(tcp_timestamps),且新连接的时间戳比TIME_WAIT记录的时间戳更新时,才会复用。

所以合理的做法是同时开启:

net.ipv4.tcp_timestamps = 1 net.ipv4.tcp_tw_reuse = 1

注意,tcp_tw_reuse和tcp_tw_recycle是两个不同的参数。tcp_tw_recycle在NAT环境下会有严重的坑,它开启后会把同一源IP的报文时间戳做递增校验,但由于不同设备通过同一个出口访问,时间戳未必递增,很可能导致丢包和连接异常。除非业务完全可控且没有NAT,否则我不建议开启tcp_tw_recycle。新版内核里这个参数也已经被移除。

4.3 终极方案:用连接复用替代TIME_WAIT产生

上面这些调优都在做“事后处理”,更高级的做法是从根源上减少TIME_WAIT的产生。对于HTTP短连接场景,尽量使用HTTP Keep-Alive,让客户端复用同一个TCP连接,避免频繁建连、断连。只要连接不频繁重建,TIME_WAIT的数量自然就下来了。

另外,net.ipv4.tcp_max_tw_buckets这个参数可以限制系统内TIME_WAIT连接的最大数量,超出后内核会直接销毁多余的TIME_WAIT连接。很多人觉得这个参数能“清理”TIME_WAIT,但需要注意:销毁TIME_WAIT连接打破的是TCP的可靠交付保障,可能在极低概率下导致旧报文串扰到新连接。

所以我一般不靠它来控制TIME_WAIT,只把它当作一个兜底防内存泄漏的手段,比如设成50000,防止异常情况下TIME_WAIT无限膨胀。

5. 缓冲区与窗口,决定单连接吞吐量的关键

5.1 Socket收发缓冲区,别一味求大

TCP的收发缓冲区,决定了单条连接能够缓冲多少数据。在自研网关或文件传输场景里,如果发送方的wmem太小,数据来不及发送就会被丢弃;接收方的rmem太小,则可能频繁触发零窗口通告,严重影响吞吐量。

查看默认值:

sysctl net.ipv4.tcp_rmem sysctl net.ipv4.tcp_wmem

输出一般是三个数字:最小值、默认值、最大值。比如4096 87380 6291456表示最小4KB、默认约87KB、最大6MB。

在高带宽、长肥管道(高带宽延迟积)场景下,默认的缓冲区和窗口可能不够用。修改为:

net.ipv4.tcp_rmem = 4096 87380 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216

这里有一个常见误解:把最大缓冲区调大就能提升吞吐?不是的。内核只有在内存压力不大的情况下,才允许缓冲区动态涨到最大值。如果系统内存紧张,tcp_rmem再大也不会真正涨上去。所以调buffer之前,先确认系统内存余量充足。

5.2 如何估算合理的缓冲区大小:带宽延迟积

缓冲区调多大合适?一个简单的估算公式是:带宽延迟积(BDP)= 带宽 × RTT。它表示一个RTT周期内在途数据量,本质上就是TCP滑动窗口的理想大小。

比如服务器出口带宽1Gbps、客户端RTT约20ms,那么:

1Gbps ≈ 125MB/s,乘以0.02秒 ≈ 2.5MB。也就是说,缓冲区至少需要2.5MB才能让一条连接跑满1Gbps带宽。超过这个值意义不大,因为TCP窗口本身受限于通告窗口和拥塞窗口,buffer再大也就那样。

当然,正常业务下高并发系统追求的是同时服务大量连接,而不是单条连接占满带宽,所以缓冲区设计需要结合场景权衡。如果你做的是网关或文件传输类高并发服务,每连接的内存占用 = rmem + wmem,假设同时有5万连接,每连接平均占用64KB,那就是3.2GB内存。这个账一定要算清楚,否则调优调优,调出内存爆掉。

5.3 拥塞控制算法,别乱换,但可以试

net.ipv4.tcp_congestion_control控制的是拥塞控制算法。Linux默认一般是cubic,在长距离高带宽场景下,可以尝试bbr。BBR的优势在于它不依赖丢包来判断拥塞,而是基于实时带宽和延迟的测量,在有一定丢包率的链路上吞吐表现往往更好。

切换方式:

sysctl -w net.ipv4.tcp_congestion_control=bbr

需要确认内核里是否加载了bbr模块:

sysctl net.ipv4.tcp_available_congestion_control

如果没有bbr,先执行:

modprobe tcp_bbr

BBR并不是万能的,在丢包率低、延迟极其稳定的内网环境里,bbr相对cubic的优势并不明显。所以我的建议是:接入层出口、跨地域长链路用bbr,数据中心内部短链路保持cubic问题不大。

6. 其他影响高并发的重要参数与整体配置建议

6.1 本地端口范围:连接发起方的弹药库

net.ipv4.ip_local_port_range决定了本机作为客户端(主动向外发起连接)时可用的源端口范围。默认通常是32768到60999,也就是大约28000多个端口。如果这台机器要同时向外发起几十万条连接(比如在高并发代理、爬虫采集、或者大量回调请求场景),端口很快就用完了。

调优:

net.ipv4.ip_local_port_range = 1024 65535

改到1024起始,可用端口从28000多扩到64000多,差不多翻了一倍多。注意端口耗尽会出现什么现象?程序一直报“Cannot assign requested address”,但你的fd和内存都还充裕。

6.2 长连接探测与存活管理

tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes这三个参数控制空闲TCP连接的存活探测机制。默认keepalive_time是7200秒(2小时),也就是一条空闲连接要等2小时才开始探测。对于高并发的长连接网关,默认值偏长,如果对端已经崩溃,这条连接会一直占着资源很久才被清理。

可以适当缩短:

net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3

意思是空闲600秒后开始发送探测包,每30秒发一次,连续3次没有响应就判定对端不可达并断开连接。这套配置对大并发长连接场景能明显减少“僵尸连接”挤占fd的情况。

6.3 一键配置参考:生产环境常用sysctl组合

下面是我在高并发网关场景常用的一套内核网络参数,各位可以按需加减:

fs.file-max = 1000000 net.core.somaxconn = 65535 net.core.netdev_max_backlog = 65535 net.ipv4.tcp_max_syn_backlog = 65535 net.ipv4.ip_local_port_range = 1024 65535 net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_timestamps = 1 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_fin_timeout = 30 net.ipv4.tcp_max_tw_buckets = 50000 net.ipv4.tcp_rmem = 4096 87380 16777216 net.ipv4.tcp_wmem = 4096 65536 16777216 net.core.rmem_max = 16777216 net.core.wmem_max = 16777216 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_keepalive_intvl = 30 net.ipv4.tcp_keepalive_probes = 3

注意,我明确没有写tcp_tw_recycle,原因前面已经提过。netdev_max_backlog控制的是网卡接收队列的长度,高并发收包场景下可以相应调大,它能缓解短时突发流量导致的丢包。

7. 常见问题与排查技巧,这些坑我替你踩过了

7.1 连接数上去了,内存却爆了

调优后并发连接从5万升到20万,看起来是好事,但如果你没有同步关注内存,服务器很可能先挂掉。每一条TCP连接在内核里都有对应的socket结构体,即使没有任何数据传输,也要占几KB内存。20万连接就意味着几百MB,甚至上GB的内核内存开销。再加上业务缓冲区、日志、线程栈,内存很容易吃紧。

排查时可以看:

ss -s

它会汇总当前socket的使用情况,能直观看到TCP连接总数以及各状态连接数量。如果发现total值异常高,结合free -h看内存,优先排查是不是连接回收过慢。

7.2 调了参数不起作用,先检查这些原因

提示:调优参数不生效是我被问得最多的问题,这个优先级极其重要。

三条常见原因供排查:

  1. 没有用sysctl -p或重启网络服务加载配置。改完/etc/sysctl.conf后不重载不会生效。执行sysctl -p才是真正加载。
  2. 应用覆盖了内核参数。比如Nginx的worker_rlimit_nofile、Java的-XX:MaxOpenFiles、或者systemd服务里的LimitNOFILE,这些应用层配置优先级高于全局limits,改了系统参数仍然受限。
  3. 容器环境里的参数是宿主机的。容器内的/etc/sysctl.conf通常不能修改内核参数,即使修改也起不了作用。要么在宿主机调优并注意容器共享内核,要么在容器编排层面设置ulimits。

7.3 用tcpdump确认丢包发生在哪一层

很多人对“丢包”判断很粗糙,直接就说“网络有问题”。但丢包可能发生在网卡队列、协议栈缓冲区、应用socket缓冲区等多个层面。排查顺序建议:

  1. 看/proc/net/netstat里的ListenOverflows和ListenDrops,对应全连接队列溢出次数。
  2. 用netstat -s查看TCP层的重传、丢包计数。
  3. 用tcpdump -i eth0 -nn port 8080抓包确认是否有SYN重传、乱序等。

举个例子,之前排查一个网关抖动问题,ListenOverflows持续增长,看起来是全连接队列满了,但应用处理线程一点都不忙。后来才发现是应用把accept改成非阻塞之后,代码里忘记了循环accept直到EAGAIN,导致accept事件处理不彻底,系统队列越积越满。这是一个典型的“不是调优能解决,而是代码要修”的案例。内核参数只是给应用一个更大的缓冲池,但它治不了应用层的效率问题。

7.4 调优后还是要压测验证,别凭感觉调

每次改完参数,我都建议用压测工具跑一轮对比。不要求压测多么专业,但至少要做“改前/改后”对照。我习惯用wrk或ab分别测三个指标:最大并发连接数、RPS(每秒请求数)、P99延迟。

压测时有一个容易被忽视的坑:压测机本身的网络参数也要同步调优。如果服务端调好了,压测机的端口范围没调大,客户端反而先成为瓶颈,结果就是服务端参数看起来“没效果”。调优时两边都检查,最忌讳只看服务端。

8. 最后的实操心得,关于调优这件事本身

再分享一个我判断调优是否到位的思路。调优并不是“参数越大越好”,而是在资源约束下找到一个稳定的平衡点。比如把tcp_fin_timeout调得太小,虽然TIME_WAIT少了,但极端情况下可能影响到TCP的可靠传输;把缓冲区调得过大,又有内存风险。每个参数都有其适用的场景和副作用,懂原理比抄配置重要得多。

我个人的习惯是:每次调优只动一个变量,压测验证后再动下一个。这样即使出问题,也知道回退到哪一个步骤。切忌一次改几十个参数然后“看运气”,这种调优方式在生产环境里非常危险。

最后说一个小技巧。如果你不想在业务高峰期手动执行sysctl命令,可以把这套参数写进系统启动脚本或者交付给配置管理工具统一分发。但无论怎么分发,都建议先在预发环境跑一轮压测验证。毕竟内核参数这个东西,部署错了不像普通代码一样能看到报错,它是在你遇到高并发的那一瞬间才暴露出来,到那时候再排查就有点晚了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进