2024 年,我们一个新服务要上线,上线前做压测。这台机器配置很好,8 核 16G,服务本身也优化过,我满心以为压测会很轻松。结果一上压力,QPS 刚冲到几千就卡住了,再怎么加压都上不去,客户端那边开始零星地报 connection timeout 和 connection reset。我第一反应是服务代码不行,扒着应用日志看了半天——应用侧风平浪静,CPU、内存都还有大把余量,线程池也没满,服务自己一点没觉得"忙"。一边是客户端实实在在地连不上,一边是服务器悠闲地宣称"我不累",这两件事凑在一起,把我整懵了。后来才发现,瓶颈根本不在我的服务里,而在它脚下那个我从来没正眼看过的东西——Linux 内核。这件事逼着我把 Linux 的内核参数、sysctl、TCP 连接队列这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,8 核 16G,一个高并发 HTTP 服务
事故现象:
- 压测 QPS 冲到几千就上不去了
- 客户端零星报 connection timeout / connection reset
- 应用侧 CPU/内存/线程池都有余量,服务"不觉得忙"
现场排查:
# 1. 服务在监听,看着没问题
$ ss -lntp | grep 8080
LISTEN 0 128 0.0.0.0:8080 ... # ★ 注意这个 128
# 2. ★ 关键命令:看监听 socket 的队列
$ ss -lnt | grep 8080
State Recv-Q Send-Q Local Address:Port
LISTEN 129 128 0.0.0.0:8080
# ^^^^^^ ^^^^^^
# Recv-Q Send-Q
# ★ LISTEN 状态下:Send-Q=全连接队列容量(128)
# Recv-Q=当前排队等 accept 的连接数(129!满了)
# 3. ★ 看内核统计:连接队列溢出了多少次
$ nstat -az | grep -i listen
TcpExtListenOverflows 28451 0 # ★ 溢出 2 万多次!
TcpExtListenDrops 28451 0 # ★ 因此被丢弃的连接
# 4. 看相关内核参数,默认值小得可怜
$ sysctl net.core.somaxconn
net.core.somaxconn = 128 # ★ 全连接队列上限,才 128
根因(后来想清楚的):
1. ★ 客户端和服务端建立 TCP 连接,内核侧有【两个队列】:
半连接队列(收到 SYN 还没完成握手)、
全连接队列(握手完成、等应用 accept 取走)。
2. 全连接队列的容量,取 min(应用传的 backlog,
内核的 net.core.somaxconn)。
3. ★ somaxconn 默认才 128。高并发下,握手完成的连接
像潮水涌进全连接队列,而应用 accept 取走的速度跟不上
-> 队列瞬间被塞满。
4. 队列一满,后来握手完成的连接【被内核直接丢弃】
-> 客户端 timeout / reset。QPS 就卡在这个天花板上。
5. ★ 服务"不觉得忙",因为瓶颈根本不在应用代码,
在它下面那层内核参数 —— 默认值是为通用场景定的,
扛不住高并发。
压测压不上去,先别怪代码,看看内核参数。
修复 1:sysctl 是什么——内核的运行时旋钮
# === ★ 先认识 sysctl:它是调节【内核运行时行为】的接口 ===
# Linux 内核有成百上千个可调参数 —— 网络缓冲区多大、
# 连接队列多长、内存怎么回收…… 这些就是"内核参数"。
# sysctl,就是【读写这些参数】的命令。
# === 读:看某个参数当前的值 ===
$ sysctl net.core.somaxconn
net.core.somaxconn = 128
$ sysctl -a | grep somaxconn # 模糊找参数
$ sysctl -a # 列出全部(几百上千个)
# === ★ 参数其实就是 /proc/sys 下的文件 ===
$ cat /proc/sys/net/core/somaxconn
128
# 参数名里的"."对应目录的"/":
# net.core.somaxconn <=> /proc/sys/net/core/somaxconn
# sysctl 只是读写这些文件的一个方便壳子。
# === 写:临时改一个参数(★ 立刻生效,但重启就丢)===
$ sysctl -w net.core.somaxconn=1024
# 等价于:echo 1024 > /proc/sys/net/core/somaxconn
# ★ 这种改法,只活在【当前这次开机】,机器一重启就还原。
# === ★ 永久改:写进配置文件 ===
$ vi /etc/sysctl.conf # 或 /etc/sysctl.d/xxx.conf
net.core.somaxconn = 1024
$ sysctl -p # ★ 让配置文件立刻生效
# /etc/sysctl.conf 会在【开机时】被读取并应用。
# sysctl -p 是手动触发一次加载,不用等重启。
# === ★ 这就是 sysctl 第一个坑:临时改和永久改是两回事 ===
# sysctl -w -> 改运行时,立刻生效,重启丢
# 写 sysctl.conf -> 改配置,重启也在,但要 sysctl -p 才立刻生效
# 正确姿势:两个都做 —— sysctl.conf 写一份 + sysctl -p 应用。
修复 2:TCP 连接建立的两个队列——这次的核心
# === ★ 要理解这次的瓶颈,必须搞懂 TCP 握手时的两个队列 ===
# 客户端连服务端,要走三次握手。服务端内核侧,排着两个队:
# === 队列 1:半连接队列(SYN queue)===
# 服务端收到客户端的 SYN、回了 SYN+ACK、但还没收到最后那个 ACK
# —— 这种"握手进行中"的连接,放在【半连接队列】。
# 相关参数:net.ipv4.tcp_max_syn_backlog
# === 队列 2:★ 全连接队列(accept queue)===
# 三次握手【全部完成】、连接已经建好,但应用还没调 accept()
# 把它取走 —— 这种连接,放在【全连接队列】里排队。
# ★ 这次塞满的,就是这个队列。
# === ★ 全连接队列的容量,由两个值取小 ===
# 容量 = min( 应用 listen() 时传的 backlog , net.core.somaxconn )
# - 应用代码里:listen(fd, backlog) 的那个 backlog
# - 内核侧:net.core.somaxconn
# ★ 关键:就算应用传了 backlog=4096,只要 somaxconn 是 128,
# 实际容量也只有 128 —— 内核的 somaxconn 是个"封顶值"。
# 这正是我的坑:somaxconn 默认 128,把队列死死压在了 128。
# === 队列满了会发生什么 ===
# 全连接队列满 -> 新完成握手的连接【进不来】:
# - 默认行为(tcp_abort_on_overflow=0):内核【丢弃】那个 ACK,
# 装作没收到 -> 客户端以为握手没完成,重传 -> 表现为超时/变慢
# - 若 tcp_abort_on_overflow=1:直接回 RST -> 客户端 connection reset
# === ★ 对症下药:把队列开大 ===
$ vi /etc/sysctl.conf
net.core.somaxconn = 1024 # 全连接队列封顶值,调大
net.ipv4.tcp_max_syn_backlog = 2048 # 半连接队列,也调大
$ sysctl -p
# ★ 注意:光改 somaxconn 还不够 —— 应用 listen() 的 backlog
# 也得够大(很多框架有自己的默认值,要去配)。两边都到位才行。
修复 3:全连接队列溢出——怎么确诊
# === ★ 怎么【确诊】就是全连接队列溢出,而不是瞎猜 ===
# === 证据 1:ss -lnt 看 LISTEN socket 的两个 Q ===
$ ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 129 128 0.0.0.0:8080
# ★ 对【LISTEN 状态】的 socket,这两列的含义很特殊:
# Send-Q = 全连接队列的【最大容量】(这里 128)
# Recv-Q = 全连接队列【当前的排队数】
# ★ Recv-Q 持续接近甚至超过 Send-Q -> 队列在溢出,实锤。
# === 证据 2:nstat / netstat 看溢出计数 ===
$ nstat -az | grep -iE 'listen|overflow'
TcpExtListenOverflows 28451 # ★ 全连接队列溢出的【累计次数】
TcpExtListenDrops 28451 # 因溢出等原因丢弃的连接数
# 或老命令:
$ netstat -s | grep -iE 'listen|overflow'
28451 times the listen queue of a socket overflowed
# ★ 这个数字【一直在涨】= 队列正在持续溢出。
# 隔几秒看一次,涨得快不快,一目了然。
# === 证据 3:半连接队列也可能出问题 ===
$ netstat -s | grep -i 'SYN'
xxx SYNs to LISTEN sockets dropped
# 这一行在涨 -> 半连接队列(syn backlog)也不够。
# === ★ 一个完整的确诊动作 ===
$ watch -n1 'ss -lnt | grep 8080; nstat -az | grep ListenOverflows'
# 一边压测,一边盯:Recv-Q 顶满 + ListenOverflows 飞涨
# = 全连接队列溢出,板上钉钉。
# === 排查思路:队列满,要么开大队列,要么让应用取得更快 ===
# - 开大队列:somaxconn + 应用 backlog(修复 2)
# - 取得更快:应用 accept 太慢 —— 检查 accept 线程、
# worker 数量、是不是被某个慢操作堵在了 accept 之后。
# ★ 单纯开大队列是"扩容缓冲",治标;
# 应用消费跟不上才是根,严重时两手都要抓。
修复 4:TIME_WAIT 堆积——高并发的另一个常见坑
# === ★ 高并发还常遇到另一个现象:海量 TIME_WAIT ===
$ ss -ant | awk '{print $1}' | sort | uniq -c | sort -rn
48000 TIME-WAIT # ★ 几万个 TIME_WAIT
2000 ESTAB
# 看各种 TCP 状态的连接各有多少。
# === TIME_WAIT 是什么、为什么存在 ===
# ★ 主动关闭连接的一方,关闭后会进入 TIME_WAIT 状态,
# 并停留 2*MSL(通常 60 秒)才彻底释放。
# 它【不是 bug】,是 TCP 协议特意设计的:
# - 确保最后那个 ACK 对方能收到
# - 让本次连接的"迷途旧包"在网络中自然消亡,
# 不会串到下一个复用了相同端口的新连接里。
# === TIME_WAIT 太多的危害 ===
# 每个 TIME_WAIT 占着一个【本地端口】不放(60 秒)。
# 一台机器作为【客户端】频繁发起短连接时,
# 本地端口会被 TIME_WAIT 大量占用 -> 端口耗尽 -> 连不出去。
# === ★ 调优参数(注意区分版本和适用方向)===
$ vi /etc/sysctl.conf
net.ipv4.tcp_tw_reuse = 1 # ★ 允许把 TIME_WAIT 的端口
# 【复用】给新的【对外】连接,安全
net.ipv4.ip_local_port_range = 1024 65535 # 放宽本地端口范围
net.ipv4.tcp_fin_timeout = 30 # FIN_WAIT2 的超时,可适当调小
$ sysctl -p
# === ★ 一个臭名昭著的坑:tcp_tw_recycle ===
# net.ipv4.tcp_tw_recycle —— ★ 千万别开!
# 它在 NAT 环境下会导致【同一 NAT 后的不同客户端连接被错误丢弃】,
# 引发诡异的"部分用户连不上"。
# 这个参数在新内核(4.12+)已被【彻底删除】。
# ★ 记住:要复用,用 tcp_tw_reuse;tcp_tw_recycle 永远不要碰。
# === 更治本的思路:别用短连接 ===
# TIME_WAIT 多,根子常常是"大量短连接"。
# 用【长连接 / 连接池】,连接复用,TIME_WAIT 自然就少了 ——
# 这比调内核参数更根本。
修复 5:sysctl 改了不生效 / 不持久的坑
# === ★ sysctl 调优,栽在"改了没生效"上的人,不在少数 ===
# === 坑 1:sysctl -w 改了,重启就没了 ===
$ sysctl -w net.core.somaxconn=1024 # 只改运行时
# 重启后打回 128。★ 必须同时写进 /etc/sysctl.conf。
# === 坑 2:写了 sysctl.conf,但没 sysctl -p,当前没生效 ===
$ vi /etc/sysctl.conf # 写好了
$ sysctl -p # ★ 别忘这一步!
# 不执行 sysctl -p,要等下次重启才生效。
$ sysctl -p /etc/sysctl.d/99-tune.conf # 加载指定文件
# === ★ 坑 3:改了内核参数,但应用【没重启】,没吃到新值 ===
# 这是最隐蔽的一个。以 somaxconn 为例:
# 应用是在【调 listen() 那一刻】,把当时的 somaxconn
# 当作上限"定下来"的。
# ★ 你 sysctl 改大了 somaxconn,但应用进程还是老的 ——
# 它的监听 socket 还用着启动时那个旧上限。
# 必须【重启应用】,让它重新 listen 一次,才会用上新值。
$ ss -lnt | grep 端口 # 改完重启应用后,看 Send-Q
# Send-Q 变成新值了 -> 才算真正生效。
# === ★ 坑 4:被 sysctl.d 里的其他文件覆盖 ===
# 系统会按顺序加载 /etc/sysctl.d/、/usr/lib/sysctl.d/ 里
# 一堆 .conf,后加载的覆盖先加载的。
$ sysctl --system # 按完整顺序重新加载全部
# 你在 sysctl.conf 写的,可能被 /etc/sysctl.d/99-xxx.conf 覆盖。
# 拿不准,就 sysctl 直接读【最终值】为准:
$ sysctl net.core.somaxconn # 这个才是真正生效的值
# === ★ 调优纪律:小步、可量化、留记录 ===
# - 一次只改一两个参数,改完压测,看效果再决定下一步
# - 别照搬网上的"优化大全"——很多参数互相影响、且看场景
# - 每个改动写注释:为什么改、改前多少、改后多少
# - 改之前 sysctl -a > /root/sysctl.before 存一份底,好回滚
修复 6:内核参数调优排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 压测压不上去、服务却"不忙",怀疑内核参数 ===
# 应用 CPU/内存/线程池都有余量,瓶颈八成在它下面那层。
# === 2. ★ 连接建立慢/失败,先看全连接队列 ===
$ ss -lnt | grep 端口 # LISTEN 行:Recv-Q 顶满 Send-Q = 溢出
$ nstat -az | grep ListenOverflows # 这个计数在涨 = 实锤
# === 3. 全连接队列容量 = min(应用 backlog, somaxconn) ===
# 两个都要够大,只改一个没用。改完要【重启应用】才吃到。
# === 4. TIME_WAIT 多用 tcp_tw_reuse,绝不碰 tcp_tw_recycle ===
# recycle 在 NAT 下会丢连接,新内核已删除。
# === 5. ★ sysctl 改了要持久 + 要生效 ===
$ vi /etc/sysctl.conf # 写进去(持久)
$ sysctl -p # 应用(立刻生效)
# 还要重启用到该参数的应用进程。
# === 6. 改之前先存底,一次只改一两个,改完压测验证 ===
$ sysctl -a > /root/sysctl.before
# 别照搬网上"优化大全",参数看场景、会互相影响。
# === 7. 排查内核参数问题的命令链 ===
$ ss -lnt | grep 端口 # ① 监听 socket 队列满没
$ nstat -az | grep -i listen # ② 溢出/丢弃计数在不在涨
$ ss -ant | awk '{print $1}' | sort | uniq -c # ③ 各状态连接数
$ sysctl 参数名 # ④ 看参数【当前生效】的值
$ netstat -s # ⑤ 协议栈各种异常计数
# 按这个顺序,内核参数相关问题基本能定位。
命令速查
需求 命令
=============================================================
看某内核参数当前值 sysctl 参数名
模糊查参数 sysctl -a | grep 关键字
临时改参数(重启丢) sysctl -w 参数=值
让 sysctl.conf 立刻生效 sysctl -p
按完整顺序重载全部 sysctl --system
看 LISTEN socket 队列 ss -lnt | grep 端口
看全连接队列溢出计数 nstat -az | grep ListenOverflows
看各 TCP 状态连接数 ss -ant | awk '{print $1}' | sort | uniq -c
看协议栈异常统计 netstat -s
改之前存底 sysctl -a > /root/sysctl.before
口诀:压测上不去服务又不忙 -> 查内核参数和连接队列
somaxconn 改完要 sysctl -p 还要重启应用才吃到
避坑清单
- sysctl 参数对应 /proc/sys 下的文件,sysctl 只是读写它们的方便壳子
- sysctl -w 只改运行时重启就丢,要持久必须写 /etc/sysctl.conf
- 写了 sysctl.conf 要执行 sysctl -p 才立刻生效,否则等重启
- TCP 建连有半连接和全连接两个队列,高并发下全连接队列最易满
- 全连接队列容量是 min(应用 backlog, somaxconn),两个都要够大
- somaxconn 改大后应用没重启不生效,应用 listen 时才定上限
- ss -lnt 对 LISTEN socket:Send-Q 是队列容量,Recv-Q 是当前排队数
- nstat 的 ListenOverflows 计数持续上涨就是全连接队列在溢出
- TIME_WAIT 多用 tcp_tw_reuse,绝不要开 tcp_tw_recycle,NAT 下会丢连接
- 调优一次只改一两个参数,改前存底,改后压测验证,别照搬优化大全
总结
这次"压测 QPS 卡死、服务却不觉得忙"的事故,纠正了我一个长期存在的、关于"我的程序到底跑在什么之上"的认知盲区。在这次之前,我心里有一条清晰的责任边界:一个网络服务的性能,是由"我的代码"和"这台机器的硬件"共同决定的——代码写得好不好,CPU、内存、网卡够不够强。在这个二元的世界观里,排查性能问题,我的目光只会在这两处之间来回扫描:要么是代码有瓶颈,要么是硬件不够用。所以当压测卡在几千 QPS、客户端明明在大量报连接失败,而我的服务器却 CPU 充裕、内存富余、线程池也没满、一副"我很闲"的样子时,我陷入了彻底的困惑——代码不忙,硬件不缺,那这道连接不上去的天花板,究竟是谁砌的?复盘到根上,我才真正看见了一直被我忽略的、夹在"我的代码"和"硬件"之间的那一层——操作系统内核。我的服务,从来不是直接跑在硬件上的;它和硬件之间,隔着一整层内核。而我那些 accept 来的 TCP 连接,在抵达我的应用代码之前,要先经过内核 TCP 协议栈的处理——具体到这次,是要先在内核维护的【全连接队列】里排队。这个队列我以前根本不知道它的存在:当客户端和服务端完成了三次握手、连接已经实实在在建好,但我的应用还没来得及调 accept() 把它取走时,这个"建好了、但还没被领走"的连接,就待在内核的全连接队列里等着。而这个队列的容量,有一个我做梦也没想到的上限——它等于"我的应用 listen() 时传的 backlog"和"内核参数 net.core.somaxconn"这两者中的较小值。问题就出在 somaxconn 上:它的默认值,只有可怜的 128。这个默认值,是内核为"通用场景"准备的一个保守设定,它从没打算去伺候一个高并发服务。于是这次的真相清晰了:压测时,完成握手的连接像潮水一样涌进全连接队列,而我的应用 accept 取走它们的速度跟不上这股潮水,队列在 128 这个位置被瞬间塞满;之后每一个新完成握手的连接,都因为队列满了而被内核直接丢弃——客户端那头,就表现为 timeout 和 reset。我的服务"不觉得忙",恰恰因为这些连接根本没能走到我的代码面前,它们在内核那一层就被默默挡掉了——瓶颈不在我的代码里,而在我代码脚下那层我从未正眼看过的内核参数里。这就引出了 sysctl:它是读写这些内核运行时参数的接口,而 net.core.somaxconn 这道把我的 QPS 死死压在天花板上的限制,正是 sysctl 可以调节的一个旋钮。把它从 128 调大,连同应用 listen() 的 backlog 一起调大,再重启应用让新值生效,那道天花板就被顶开了。这次事故还顺带教会了我 sysctl 调优的几个反复让人栽跟头的坑:sysctl -w 改的是运行时,重启就丢,必须同时写进 /etc/sysctl.conf 才持久;写了配置文件还得 sysctl -p 才立刻生效;而最隐蔽的是,像 somaxconn 这种参数,即便内核侧改大了,我那个还没重启的应用,用的依然是它启动时 listen() 那一刻"定格"下来的旧上限——不重启应用,内核参数改了也是白改。这次从一个"服务器宣称自己不累、客户端却连不上"的矛盾出发,我最大的收获,是在脑子里"代码"和"硬件"之间,郑重地补上了"内核"这一层。一个服务的性能上限,从来不只由代码和硬件决定;它还被脚下那层操作系统内核的、一系列为通用场景而生的保守默认参数,悄悄地框定着。当代码不忙、硬件有余,性能却莫名其妙地撞上一堵墙时,该低头看看的,正是那层一直沉默地承载着这一切、却从不主动诉说自己也有极限的内核。
—— 别看了 · 2026