第一次把目标写成“百万并发”的时候,我心里其实一点底都没有。听起来就是跑个压力测试,把连接数怼到一百万,真正动手那天才发现,最先扛不住的不是业务代码,而是操作系统本身。这篇文章就记录我从零开始做一台百万并发服务器的压测经历:中间踩了哪些坑、改了多少内核参数、最后对 Linux 内核的调度、内存和网络栈有了什么新的理解。如果你也准备做类似的事情,或者正被高并发连接数和一堆 sysctl 参数搞到头大,这篇文章应该能帮你省下很多排查时间。
先提醒一句:这是一篇实操向的经验分享,不是理论综述。我会尽量把每个决策背后的原因说清楚,也会把哪些参数真的有用、哪些网上流传但已经失效甚至有害的做法一起点明白。
1. 先说个很多人忽略的前提:百万连接和百万 QPS 根本不是一回事
1.1 连接数、请求数、吞吐量,三者的量级完全不同
很多人一上来就说“我要做百万并发服务器”,但“百万并发”这个词在不同场景下含义完全不同。最常见的混淆是把“百万并发连接”当成“每秒百万请求”,这两个目标的难度差距,不是一倍两倍,是数量级的差距。
百万并发连接,通常指同一时刻有 100 万个 TCP 连接处在 ESTABLISHED 状态。这种场景常见于在线 IM、消息推送、物联网长连接这类业务。它的核心挑战是让系统“养得住”大量空闲连接,并且每条连接到来时能及时分发事件。连接本身不一定要有大量数据传输,很多连接可能一分钟只发一个心跳包。
百万 QPS 则是每秒要处理 100 万次完整的请求响应。算一笔账:假设每个请求的平均响应体是 1KB,那么光响应数据就是 8Gbps 的网络流量,单台服务器至少要万兆网卡才能扛住带宽。再加上 TCP 握手、HTTP 解析、业务逻辑、日志写入,单机做到百万 QPS 基本是极端优化后才能碰到的场景,通常需要多机集群加高性能网关一起扛。
所以做项目第一步不是急着调参数,而是先明确目标:
- 到底是要“同时在线连接数”高,还是“每秒新建连接数”高?
- 到底是长连接为主,还是短连接为主?
- 是每个连接的数据量小、频率低,还是数据包大、频率高?
这些不同目标的瓶颈点完全不同。长连接百万的目标在文件描述符、内存、连接管理;短连接百万的目标在握手速率、TIME_WAIT 回收、CPU 软中断;大流量场景的目标则直接变成网卡吞吐和数据拷贝。方向没搞清楚就调参,大概率是白忙。
1.2 压测工具和压测客户端往往先成为瓶颈
做百万并发压测时,很多人忽略了一个问题:服务端要撑住百万连接,压测客户端也得能建立并保持百万连接。
单个 TCP 客户端 IP 能建立的出站连接数是有限的。一个四元组是“源 IP + 源端口 + 目标 IP + 目标端口”,源端口只有 65535 个,去掉系统保留的端口段,实际上能用的也就两万多个。也就是说,单台压测机、单个网卡 IP,最多只能发起 5 万到 6 万个连接。
要压到百万连接,客户端必须准备多 IP。常见做法是在压测机上给网卡绑定多个 IP,或者用多张网卡分别绑定不同 IP。如果是在虚拟机里做测试,也可以用 loopback 上绑多个 IP 来模拟,但这种方式测出来的网络栈行为和真实网卡有差异,只能做功能验证,不能做性能基准。
压测工具方面,wrk、ab 这类工具适合测短连接和高吞吐请求,但不适合模拟百万长连接。因为这类工具通常追求单线程高吞吐,连接管理模型不是为百万数量级设计的。我自己最后是写了一个简单的压测客户端,每个线程绑定若干连接,每个连接独立 epoll 事件循环,用多进程分布在多个 CPU 核上,这样才能模拟出足够真实的连接压力。
1.3 一个可以直接照抄的目标定义清单
如果你也要做类似项目,我建议动手前把下面这几项写清楚:
- 目标连接数:100 万 ESTABLISHED
- 每秒新建连接上限:比如 1 万/秒、5 万/秒
- 单连接平均数据量:比如每 30 秒一个 1KB 心跳
- 压测机配置:绑定多少 IP、多少并发进程
- 服务端预估资源:CPU 核数、内存大小、网卡队列数
- 验收指标:连接建立成功率、事件分发延迟、CPU 峰值占用
把这些写下来之后,你再去看内核参数就知道每个参数到底在解决哪一环的问题。接下来我按自己踩坑的顺序,从文件描述符开始讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一道墙:文件描述符和连接生命周期
2.1 三个文件描述符限制,缺一个都会卡死
百万连接对服务端第一个直接冲击就是文件描述符数量。Linux 下每个 TCP 连接对应一个 socket 文件描述符,默认情况下进程的 nofile 限制只有 1024。不调这个参数,连接数连一万都到不了。
但文件描述符限制其实有三层,只改 ulimit 是不够的:
- 进程级限制:通过
ulimit -n查看和设置,持久化配置写在/etc/security/limits.conf - 系统级限制:
/proc/sys/fs/file-max,这是整个系统能打开的文件总数上限 - 系统单进程硬上限:
/proc/sys/fs/nr_open,这是单个进程能打开文件数的硬限制,ulimit 不能超过它
我当时的操作是先把系统级放开:
bash复制# /etc/sysctl.conf
fs.file-max = 2000000
fs.nr_open = 2000000
然后修改 limits.conf:
bash复制# /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
如果你的服务是通过 systemd 管理的,还要注意 service 文件里的 LimitNOFILE。这个字段默认不继承 limits.conf 的配置,必须显式设成 infinity 或者直接写 1048576,否则你改了半天 ulimit,systemd 服务起来之后还是受限。
我那次排查的就是这个问题:明明 limits.conf 都写好了,手动启动进程一看 ulimit -n 是正常的,但从 systemd 启动的进程死活只有 1024,最后才想起来 LimitNOFILE 这个坑。
2.2 一百万连接的隐性内存成本比你想的大
文件描述符只是数字,真正让很多机器提前“趴窝”的是内存。
每个 TCP 连接在内核里都需要维护一个 socket 结构、TCP 控制块、发送接收缓冲区。内核并不是等你有数据才分配缓冲区,很多结构在连接建立时就会初始化。我自己估算时一般按每条连接 5KB 到 10KB 的内存来预留,这还没有考虑业务层为每个连接分配的上下文对象。一百万连接,保守就是 5GB 到 10GB 的内核态内存开销。
如果业务层再为每个连接维护一个 session、buffer 之类的对象,内存开销直接翻倍。我见过不少服务挂着百万连接,机器内存报警,不是内核问题,是应用层给每个连接分配了太多东西。
所以在压测前一定要先做内存预算。top 里的 RES 不准确,要看业务进程实际占用的 PSS(比例集大小),以及通过 cat /proc/meminfo 观察 Slab 内存是否异常增长。如果 Slab 增长明显,说明内核对象的分配不太对劲,可以进一步查看 slabtop 确认是哪类对象在膨胀。
2.3 连接数上不去时怎么定位 fd 问题
如果服务运行一段时间后出现“连接数卡在一个固定值上下不去”,或者服务端日志偶尔报 Too many open files,先按下面的链路排查:
bash复制# 看进程当前打开的 fd 数量
ls /proc/<pid>/fd | wc -l
# 看系统当前文件句柄使用情况
cat /proc/sys/fs/file-nr
# 看系统网络连接统计
ss -s
file-nr 的三列分别是“已分配文件句柄数、已使用未释放句柄数、系统最大句柄数”。如果第二列长期很高且不降,说明可能有句柄泄漏,常见原因是应用层的连接对象没有被正确 close。
还有一种情况是 fd 数没到上限,但新连接 accept 不出来。这时候用 ss -lnt 看 listen 队列的情况:如果 Send-Q 很大且 Recv-Q 有积压,说明应用层 accept 的速度跟不上新连接建立的速度。这不是 fd 的锅,而是 epoll 事件处理效率或者单核软中断的问题,后面第四章会详细讲。
3. 网络栈调参:哪些参数真的有用,哪些是玄学
3.1 先分清楚三个队列:accept 队列、SYN 队列、网卡队列
Linux TCP 协议栈里处理连接建立时,有两个容易被混在一起的队列:SYN 队列(半连接队列)和 accept 队列(全连接队列)。
SYN 队列保存的是已经收到 SYN、还没完成三次握手的连接。它的长度由 net.ipv4.tcp_max_syn_backlog 控制,同时受 net.core.somaxconn 影响。accept 队列保存的是已经完成握手、等待应用层调用 accept() 取走的连接,长度主要由 net.core.somaxconn 控制。
高新建连接场景下,如果应用层 accept 不够快,accept 队列溢出,内核会丢弃已完成握手的连接,客户端表现就是连接偶尔失败。这时调大 somaxconn 有用,但不能从根本上解决问题,还是要优化应用层 accept 速度。
SYN 队列溢出时,内核会开启 SYN Cookie 机制,也就是不真正分配半连接结构,而是把连接信息编码进序列号。这种机制能扛洪水攻击,但正常业务下如果频繁触发,会引入额外开销,而且某些 TCP 选项会失效。我的建议是:压测前适当调大 tcp_max_syn_backlog,但也要观察 /proc/net/netstat 里的 ListenDrops 和 SYNChallenges 计数,光调大参数不看计数等于白调。
另外网卡本身也有一个接收队列,对应 net.core.netdev_max_backlog。这个参数影响的是网卡驱动收包后、协议栈处理前积压的数据包数量。如果软中断处理不过来,包就会在这里丢弃,表现为网卡收包计数正常但应用层看到的请求数不对。
一个观点先放在这里:调参不是把每个值调大就完事,而是要让每一层队列的“生产能力”和“消费能力”匹配。accept 队列再大,应用层 accep 慢也是白搭;SYN 队列再大,CPU 处理握手的能力跟不上也是积压。
3.2 我踩过的 tcp_tw_recycle 坑,以及 tcp_tw_reuse 的真正边界
短连接场景下,TIME_WAIT 状态经常成为焦点。经典问题:压测一跑,ss -ant state time-wait | wc -l 出来好几万个 TIME_WAIT,然后很多人就开始搜“怎么清理 TIME_WAIT”。
首先要明白 TIME_WAIT 是主动关闭连接的一方产生的,它的作用是保证最后一次 ACK 能可靠到达对方,同时防止旧连接的报文串到新连接。它不是错误状态,只是一个需要消耗表项的状态。
网上流传的方案主要有三个:tcp_tw_reuse、tcp_tw_recycle、调小 tcp_fin_timeout。我一个个说。
tcp_tw_recycle 这个参数在新内核里已经被移除了,4.12 以后的 Linux 内核再设置它根本没效果。而且它在旧内核上启用时存在一个非常阴的 bug:它依赖 TCP 时间戳来加速回收 TIME_WAIT,但当多台机器通过同一个 NAT 出口访问时,来自不同主机的连接时间戳可能不同步,内核会把时间戳“倒退”的报文当成旧包扔掉,导致大量连接异常。如果你在网上看到老文章让你开这个参数,直接跳过。
tcp_tw_reuse 比 tcp_tw_recycle 安全一些,但它有明确的前提:只对 connect() 发起的出站连接有效,而且要求连接双方都开启 TCP 时间戳。它的作用是让新的出站连接可以复用已经处于 TIME_WAIT 状态的旧连接端口,而不是清理掉 TIME_WAIT 本身。也就是说,如果服务端是主动关闭方,它的 TIME_WAIT 数量不会因为 tcp_tw_reuse 减少。
我调优时真正觉得有用的是这几个:
net.ipv4.ip_local_port_range:客户端发起连接时可用端口范围,默认从 32768 开始,只有两万多个端口。压测客户端需要大量出站连接时,可以改成1024 65535。net.ipv4.tcp_max_tw_buckets:控制 TIME_WAIT 表项总量,超过后内核会回收最老的表项。默认值一般是 18 万,对于百万连接级别的短连接场景可能不够,可以调大。net.ipv4.tcp_fin_timeout:控制 FIN_WAIT_2 状态超时,调小能加快连接回收,但别调到太小,会影响对端迟迟不关闭连接时的可靠性。
短连接压测时还有一个常见误区:通过修改 socket 选项强制服务端不主动关闭连接,或者客户端用 SO_LINGER 发送 RST 来绕过 TIME_WAIT。这些手段实测确实能让 TIME_WAIT 消失,但它们改变了真实的连接生命周期,测出来的结果不代表线上长连接场景的表现,只能算“压测取巧”。
3.3 缓冲区和保活参数:调大了不一定快
网络传输的另一个常见调参对象是 socket 缓冲区。相关参数有 net.core.rmem_max、net.core.wmem_max、net.ipv4.tcp_rmem、net.ipv4.tcp_wmem。
默认情况下,TCP 缓冲区是自动调节的,取决于 tcp_rmem 的三个值(最小值、默认值、最大值)。高吞吐大文件场景,把最大缓冲调大能提升吞吐;但长连接空闲场景,缓冲区调大反而浪费内存,因为每个连接都要预留内存。
我踩过的坑是:为了压测大包吞吐,把 rmem_max 和 wmem_max 都调到了 16MB。结果百万长连接场景下,内存直接爆表。后来想通了,缓冲区是“按需增长”的,绝大多数空闲连接的根本用不到大缓冲,把上限调高不等于每个连接立刻分配这么多内存。但真正的问题在于,如果某些连接短暂出现大流量,内存分配峰值会非常高,这需要结合压测场景来定,不是简单地“越大越好”。
Keepalive 参数也是长连接场景经常要动的。默认的 tcp_keepalive_time 是 7200 秒,也就是连接空闲两小时才发一次探测包,探测失败后才开始重试。对于物联网、IM 这类业务,半小时没心跳可能就要判定连接断开,这时候可以调小:
bash复制net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 3
但要注意,修改系统级 keepalive 参数影响的是所有不显式设置 SO_KEEPALIVE 选项的 socket。更精细的做法是业务代码里给每个连接单独设置 keepalive 参数,或者在应用层做心跳检测。系统级参数是兜底,不能完全替代业务层的心跳。
4. 惊群、SO_REUSEPORT 和软中断:CPU 为什么吃不满
4.1 accept 惊群与 EPOLLEXCLUSIVE:一个老问题的新解法
做到百万连接之后,另一个经常出现的问题是:业务进程有多个线程或进程同时监听同一个 listen socket,但压测时 CPU 利用率始终上不去,或者只有一个核在忙,其他核闲着。
这背后有个经典问题叫“惊群”。早期内核里,当一个新连接到来,内核会唤醒所有阻塞在 accept() 或 epoll_wait() 上的线程。但最终只有一个线程能拿到连接,其他线程被唤醒后只能再次睡眠。连接数很多的时候,这种无意义的唤醒会消耗大量 CPU 和调度开销。
Linux 2.6 之后内核把 accept 的唤醒改成了“只唤醒一个等待者”,但换成 epoll 后,问题重新变得复杂。因为 epoll 的等待队列可能有多个线程,内核的唤醒策略在不同版本里不一致。直到内核 4.5 引入了 EPOLLEXCLUSIVE 标志,才真正解决了 epoll 场景下多个等待者同时被唤醒的问题。
如果你的服务端代码是自己写的 epoll 多线程模型,用 epoll_ctl 注册事件时可以加上这个标志:
c复制event.events = EPOLLIN | EPOLLEXCLUSIVE;
但要注意,EPOLLEXCLUSIVE 只用于 listen socket 这个“事件源”,如果对普通 socket 也用,会影响事件分发的公平性。
我自己从内核演进里学到的经验是:这类问题先查内核版本再谈优化。同样是惊群,内核版本不同,原因和解法完全是两回事。不要盲目照搬老博客的“加锁接受”方案。
4.2 SO_REUSEPORT:把 listen 队列从单点变成并行
解决 accept 争抢的另一个更彻底的方法是 SO_REUSEPORT。它允许多个 socket 监听同一个端口,内核在收到新连接时,按照四元组哈希把连接分发到不同的 socket 上,相当于把 listen 层的负载均衡从应用层搬到了内核。
这个属性对 Nginx 这类多 worker 模型的收益非常明显。Nginx 从 1.9.1 开始支持在 listen 指令上启用 reuseport:
nginx复制listen 80 reuseport;
启用后,每个 worker 进程都有自己的 listen socket,不再需要共享同一个 socket 接受连接,也就绕开了 accept 锁和惊群的大部分开销。
不过 SO_REUSEPORT 不是没有代价。内核分发连接用的是哈希,极端情况下可能倾斜,导致某些 worker 连接特别多。而且如果 worker 进程频繁重启,已经建立的连接不会被迁移到新进程。所以它不是银弹,最好配合 worker 数量的稳定性和流量均衡手段一起用。
4.3 软中断集中在一个核上,连接再多也白搭
连接数涨到几十万之后,我最头疼的问题不是业务代码,而是 CPU 的软中断全部压在一个核上。
网络包从网卡进来后,前半段处理是在软中断里完成的,包括协议栈解析、TCP 处理、把数据挂到 socket 接收队列。如果网卡只有一个队列,或者中断只有一个 CPU 处理,那所有连接的收包都堆在一个核上。这个核的 si(软中断)占用会飙到 100%,但其他核很空闲。
排查方法很简单:
bash复制# 看每个 CPU 的软中断开销
mpstat -P ALL 1
如果看到 CPU0 的 si 特别高,其他 CPU 几乎为 0,基本就是软中断没分散。
这时候有几个层面可以处理:
- 网卡多队列:先看网卡支持几个队列,
ethtool -l eth0 - 开多队列:
ethtool -L eth0 combined 16,让网卡把不同连接的收包哈希到不同队列 - 配置 RPS(Receive Packet Steering):如果网卡队列数不够,可以用 RPS 把收包处理分散到多个 CPU,例如
echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus(这里的 f 表示 CPU 0-3) - 配置中断亲和性:把网卡中断绑定到多个 CPU,而不是都留在 CPU0
还有一个容易忽略的点:网卡多队列的哈希默认基于 IP 和端口,但如果所有压测连接都来自同一批客户端 IP,哈希结果会集中在少数几个队列上。压测时客户端 IP 数量少,服务端网卡多队列的效果会打折扣。这也是前面强调压测客户端要多 IP 的一个原因。
软中断分散开之后,我发现 CPU 利用率能一下子从单核 100% 变成多核均衡,连接处理能力提升非常明显。这一步往往比调各种 TCP 内核参数见效更快。
5. 跑完百万连接后,我对 Linux 内核的几个全新认识
5.1 内核本质上是一个资源匹配器,不是黑盒
做这个项目之前,我对 Linux 内核的态度基本是“能用就行”,出了问题就重启、就调参、就加机器。真正跑完百万连接压测后,我的理解变了一些:内核本质上是一个资源匹配器,它干的事情是把你请求的 fd、内存、CPU 时间、网络带宽,在冲突和竞争中进行分配和调度。
写业务代码时,你总觉得“这个请求要处理,那就处理”。但内核不是这样。每个系统调用背后都有权限检查、资源配额检查、锁竞争、队列排队。百万并发之所以难,不是业务逻辑变复杂了,而是每一个原本很便宜的“检查”和“锁”,在百万级数量下都被放大成了瓶颈。
比如 fd 分配,单看 open() 一次调用很快,但高频率创建连接时,fdtable 的锁会变成热点。再比如协议栈处理一个网络包,中间要经过路由查找、socket 锁、队列操作,每个环节都有锁。连接数一高,这些锁的开销会直接反应在 CPU 的 sys 占用上。
理解了这一点,再看调参就会更理性:调参不是“改个数字碰运气”,而是去调整某个资源池的容量,让它和上层的分配模式匹配。比如 somaxconn 是 accept 队列的容量上限,file-max 是 fd 池的上限,tcp_mem 是 TCP 内存池的上限。你调的每个参数,本质上都是在回答一个问题:内核为某类资源准备了多少余量。
5.2 透明大页 THP:高并发流量场景的隐性杀手
内存方面的坑,除了前面说的连接内存开销,还有一个很容易被忽略的:透明大页(Transparent Huge Pages,THP)。
THP 是内核自动把内存页合并成 2MB 大页的机制。对大数据量计算场景来说,减少页表项、降低 TLB miss 是好事。但对高并发网络转发场景,它反而可能带来问题:内存碎片化严重时,THP 触发内存规整(compaction)可能会造成比较长的延迟,而且大页的分配失败处理在某些版本下有 bug。
很多服务在性能调优手册里都有一条:关闭 THP。
bash复制echo never > /sys/kernel/mm/transparent_hugepage/enabled
我在压测时确实遇到过一个奇怪现象:连接数到某个值后,偶发性的延迟突然飙升,排查了很久,最后发现是 THP 在后台做内存规整,抢占了 CPU。关闭之后,延迟尖峰明显减少。如果你的服务是延迟敏感型,建议直接关掉 THP。如果是单纯追求吞吐且内存充足,THP 的影响没那么大,但也不建议在高并发网络场景默认开启。
另外还遇到过一次 OOM。连接数往上冲的时候,内存被 socket 缓冲区和业务缓存一起吃满,OOM Killer 直接挑了一个进程杀掉。dmesg | grep -i oom 能看到记录。那次之后我学到的经验是:压测前一定要给系统留足内存余量,并且用 cgroup 给业务进程设置内存上限,让它在内存不足时被卡住而不是被杀死。OOM Killer 的选择标准是内存占用和 oom_score,但业务进程往往不是那个“最该被杀”的,被误杀后恢复成本很高。
5.3 进程调度:连接数多不等于线程数多
百万连接的服务端,不太可能是百万线程。实际做法是少量线程加 epoll 事件驱动,一个线程管理几万个连接的事件。但这也带来了一个内核调度层面的问题:当一个线程管理的连接里,某个连接突发大量数据,这个线程的事件循环会被长时间占住,其他连接的事件响应延迟就会变大。
这时候你可能会想:“把业务处理拆到更多线程里不就行了吗?”但线程多了之后,上下文切换和锁竞争会开始消耗 CPU。我用 vmstat 1 观察过 cs(上下文切换)列,线程数翻倍之后,context switch 数量可能直接翻几倍,CPU 时间被调度本身吃掉。
比较合适的思路是:CPU 密集的逻辑用“核数 - 1”个 worker 线程,IO 密集依然靠 epoll 驱动。对延迟极敏感的关键路径,在条件允许的情况下可以绑定 CPU,把某个处理线程固定在某个核上,减少线程在不同核之间迁移带来的缓存丢失。
这其实和 CFS 调度器有关。CFS 追求的是“公平”,它会让所有可运行任务按权重分享 CPU。但在高并发场景里,你需要的往往不是“每个任务都公平”,而是“关键任务优先、批量任务靠边”。这时候可以通过 nice 值或者 cgroup 的 cpu 控制器来调整权重。我自己压测时会把压测客户端进程放到一个单独的 cgroup 里,限制它的 CPU 份额,避免压测进程和服务端抢 CPU,导致服务端测出来的性能数据不真实。
6. 再往上走一步:多队列、CPU 亲和性与用户态协议栈
6.1 网卡多队列和 RPS/RFS 的配合:让每个核都干活
前面的软中断问题提了网卡多队列,这里再展开讲一下完整链路。
网卡收包后,硬件会根据哈希决定放到哪个队列,每个队列对应一个中断,中断可以绑定到不同 CPU。这就是 RSS(Receive Side Scaling)。如果网卡队列数等于 CPU 核数,并且中断亲和性设置正确,收包基本能做到每个核各干各的。
但 RSS 的哈希只管到 IP 和四元组层面,如果某个连接的流量特别大,哈希到同一个队列,其他队列的核还是会闲着。RPS 可以在软件层做二次分发,把队列里的包再根据哈希分到其他 CPU 上处理。RFS 则更进一步,它会优先把包放到“正在处理这个连接的应用线程所在的那个 CPU”上,减少跨 CPU 的缓存一致性问题。
打开 RPS 的方式很简单,就是往 rps_cpus 里写掩码。实际设置时要注意:如果没做 CPU 隔离,RPS 把包分散到所有 CPU 上,业务线程也被调度到所有 CPU 上,可能会造成 CPU 乱跳。最好的情况是:RPS 用的 CPU 集合和业务线程绑定的 CPU 集合保持一致。这一点配置起来比较繁琐,但对性能提升可感知。
6.2 DPDK 和 XDP 不是必选项,但值得了解边界
单机真正要做到“每秒百万级数据包处理”,在内核协议栈里其实是非常吃力的。内核协议栈的好处是通用、稳定、安全,代价是每个包要经历中断、软中断、锁、内存拷贝等开销。
商用 C10M 场景通常会走向两条路:
- DPDK:完全绕过内核协议栈,用用户态驱动直接接管网卡,收包之后在用户态做轮询处理
- XDP/eBPF:在网卡驱动层挂 eBPF 程序,利用内核提供的 XDP hook 做快速转发,不用完全脱离内核
DPDK 确实能到极高的包处理速率,但它的问题也很明显:几乎要独占网卡和 CPU,和传统网络协议栈的兼容性差,开发调试复杂度高。XDP 相对轻一些,适合做 DDoS 过滤、简单转发,但也不是所有网卡驱动都支持。
我的建议是:如果你的业务场景需要解析 HTTP、跑业务逻辑、访问数据库,那高性能网关可以交给 DPDK 这类方案,但业务后端大概率仍然跑在内核协议栈上。先用内核协议栈把百万连接这件事打通,再考虑要不要为了更高的包处理速率引入 DPDK,是性价比更高的路径。
百万并发不是靠某一个“神级参数”就能拿下的,它是一连串资源限制的释放:文件描述符、连接队列、内存预算、软中断分散、多队列配合。这些环节你可以在网上查到无数份 sysctl 配置清单,但真正有价值的是搞清楚每一行配置背后对应哪一个资源池、哪一条处理链路。
对我来说,做完这个项目之后最大的感受是:内核调参救不了糟糕的应用层设计。如果业务代码里每个连接一到数据就要同步写日志、加全局锁,那你开再大的 fd 上限、调再多的 TCP 参数,系统照样会卡死。先保证应用层没有明显的锁竞争和过度内存分配,再去优化内核,这个顺序一定不要倒过来。
最后建议你在压测开始时就把计数型工具先架好:ss -s 看连接状态统计,netstat -s 看协议栈计数,mpstat -P ALL 看每核 CPU,/proc/net/softnet_stat 看收包丢包情况。有数据再调参,而不是凭感觉乱调,这才是把 Linux 内核用明白的正确姿势。
