去年冬天我给一台 4 核 8G 的云主机做压测,本来只想看看能不能扛住 3000 并发连接。测试刚跑到一半,机器没有挂,但所有新连接都被拒绝了,连 SSH 都进不去。从跳板机登录上去一看,CPU 空着、内存还剩一半、磁盘 IO 也不忙,ss -s 输出的 established 数量停在 1000 出头就再也不涨了。
这就是典型的 C10K 问题:不是算力不够,而是服务器处理并发连接的模型在某个数量级上撞了墙。这道墙在十几年前主要卡在操作系统内核,今天更多是默认参数、进程模型和代码写法共同造成的。这篇文章把我踩过的路和最终的解法完整写一遍,希望能帮到同样卡在某个连接数上的站长。
先搞清楚「一万连接」到底是什么量级
很多人对并发的直觉是错的。一台日 PV 两万的网站,平均每秒只有不到 5 个页面请求,但它同时在线的人可能有几百个,每个浏览器开着 4~6 个长连接(keepalive),再加上图片、CSS、JS 的请求复用,一个安静的下午 ss -s 里就能看到 800~1500 个 established。这不是压力,这是常态。
真正的一万连接意味着什么?按每个 TCP 连接在内核里占 2~3KB 的 socket 结构、每端各 32KB~256KB 的收发缓冲区算,如果应用真的把缓冲区读满,一万连接光内核缓冲区就要吃掉 1~2GB 内存。所以「能不能扛一万连接」这个问题,本质上是三件事的组合:
- 内核能不能同时维护一万个 socket 对象(文件描述符上限、内存、端口范围)
- 进程模型能不能在一万连接上做调度而不把 CPU 烧在上下文切换上
- 每个连接占用的用户态内存乘一万之后,你的机器是否还剩下给数据库的空间
这三件事里,第一件在今天的 Debian 12 上基本不是问题,第二件取决于你用的是 Nginx 还是自写的服务,第三件才是真正的死穴——一万个 PHP-FPM 进程是绝对不可能的,每个进程 40MB 起步,一万个就是 400GB。
第一层:先把内核的墙拆掉
我的压测卡在 1000 出头,第一个要查的就是文件描述符。这里的墙有三道,很多人只改了第一道就以为解决了。
三道 fd 上限
第一道是 /etc/security/limits.conf 里的 nofile,但它只对通过 PAM 登录的会话生效。也就是说你用 SSH 登录进去敲 ulimit -n 看到 65535,并不代表 systemd 拉起来的 nginx 也是 65535。systemd 服务的 fd 上限由 unit 里的 LimitNOFILE 决定,没写就用 systemd 自己的默认值(Debian 12 上是 1024)。
第二道是 systemd 的全局默认值,在 /etc/systemd/system.conf 和 /etc/systemd/user.conf 里的 DefaultLimitNOFILE。
第三道是内核的 fs.file-max 和 fs.nr_open,前者是系统级总数,后者是单个进程能设置的上限(ulimit -n 不能超过 fs.nr_open)。
判断一个进程真实生效的上限,唯一可信的地方是:
cat /proc/$(pidof nginx | awk '{print $1}')/limits | grep 'open files'
cat /proc/$(pidof php-fpm8.2 | awk '{print $1}')/limits | grep 'open files'输出里 Soft Limit 和 Hard Limit 都是 1024 的话,你改 limits.conf 改到天亮也没用。正确的做法是给服务写 drop-in:
mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/limits.conf <<'CONF'
[Service]
LimitNOFILE=65535
CONF
systemctl daemon-reload
systemctl restart nginx这里有个坑我吃过一次:daemon-reload 是必须的,只 restart 的话新配置不会被读进来。而且 Nginx 自己的 worker_rlimit_nofile 如果写得比 LimitNOFILE 大,Nginx 会静默地按小的那个算,日志里不会有任何提示。
端口范围和 TIME_WAIT
一万连接如果都来自同一台机器(比如从另一台服务器反代过来),那就要看 net.ipv4.ip_local_port_range。默认 32768~60999 只有两万八千个端口,扣掉保留端口,再撞上 TIME_WAIT 占用 60 秒,能建立的连接数会被压到几千。
但这里要提醒一句:如果你的连接是由别人主动连进来的(Nginx 作为服务端),端口范围跟你无关,那是对方的限制。我那次压测是测试工具在同一台机器上跑,直接被自己的端口范围卡死了,白折腾半天。
backlog 三个数必须一起看
net.core.somaxconn 是内核允许的 listen 队列上限,Nginx 的 listen 80 backlog=511 是单个 socket 想申请的值,实际生效的是两者取小。PHP-FPM 也有自己的 listen.backlog。
队列满了会怎样?内核的行为取决于 net.ipv4.tcp_abort_on_overflow。默认 0 的时候是静默丢弃那个 SYN-ACK,客户端超时重传,表现出来就是「偶尔很慢」而不是「连不上」,非常难查。netstat -s | grep -i listen 里的 times the listen queue of a socket overflowed 才是证据。
第二层:进程模型决定天花板
内核拆完墙之后,第二个问题就来了:一万个连接交给谁来处理?
一连接一进程为什么必然失败
最朴素的服务器写法是 accept() 之后 fork() 一个子进程专门处理这个连接。这个模型在 Apache prefork 时代很常见,优点是编程简单,缺点是内存和上下文切换的成本随连接数线性增长。
每个进程至少要一份独立的内核页表、一份栈空间、一份解释器运行时。哪怕一个进程只占 10MB RSS,一万个就是 100GB。同时 CPU 要在上万个可调度实体之间切换,光上下文切换就能把一核跑满——这时候 vmstat 1 里 cs 列会是几十万,而 us 列很低,这是最典型的症状。
IO 多路复用:一次等待多个连接
C10K 问题的正解是 IO 多路复用:一个进程一次性等着一万个 fd,哪个有数据就处理哪个,不需要为每个连接开进程或线程。
select 和 poll 的毛病是每次调用都要把整个 fd 集合从用户态拷进内核态,内核再线性扫一遍,一万个 fd 的复杂度是 O(n),而且 select 的 FD_SETSIZE 默认只有 1024,这就是为什么我的压测正好卡在 1000 出头。
epoll 的做法是把 fd 集合常驻在内核里(epoll_ctl 注册一次),然后 epoll_wait 只返回真正就绪的那几个。复杂度变成 O(就绪数),跟总连接数无关。这是我那次压测真正需要的东西。
水平触发和边缘触发
用 epoll 一定要理解 LT 和 ET 的区别,这是最容易写错的地方:
- 水平触发(LT):只要缓冲区里还有数据没读完,下次
epoll_wait还会通知你。写起来安全,忘了循环读也只是效率低。 - 边缘触发(ET):只在状态从「没数据」变成「有数据」那一刻通知一次。如果你这次没有循环读到
EAGAIN,剩下的数据就永远不会再通知,连接会挂在那里不动。
另外 fd 必须设成非阻塞(O_NONBLOCK),否则一次 write 在发送缓冲区满的时候会把整个事件循环阻塞住,一万个连接全部卡死。
PHP-FPM 的定位要说清楚
这里必须澄清一个常见误解:PHP-FPM 用的是进程池,它不能承载一万连接。但这不是问题,因为 FPM 前面永远有 Nginx。Nginx 用 epoll 接住这一万个空闲的 keepalive 连接,只有真正要执行 PHP 的时候才从 FPM 池里借一个进程,处理完立刻还回去。
所以一套典型架构的连接分布是这样的:内核里有一万个 established,其中可能只有几十个同时处在「FPM 正在处理」的状态,其余都在 Nginx 的 keepalive 上等着。真正要担心的不是一万个连接,而是一万个人同时点了一个慢查询——那时候排队的就是 FPM 的 backlog 了。
第三层:参数配完还得调优
内核墙拆了、模型对了,接下来是细节参数。这些参数单个看起来都无害,配错了却能把一万连接变成一万次内存分配。
每个连接的内存账
Linux 给每个 socket 分配发送和接收两个缓冲区,大小由 net.ipv4.tcp_rmem 和 net.ipv4.tcp_wmem 的三个数字(最小、默认、最大)控制。默认最小值是 4096,看起来很小,但它会随着连接活跃自动增长到最大值。
一万个连接如果在传大文件,每个连接都涨到 4MB,那就是 40GB。所以要限制最大值。但也不能限制太死,太小会导致吞吐下降,接收窗口小,TCP 被卡在慢启动里。我一般在有大流量的机器上设成 4096 87380 6291456,也就是最大 6MB,配合 tcp_moderate_rcvbuf=1 让内核自动调节。
keepalive 的两套语义别搞混
提到 keepalive 有两套完全不同的东西,这是最容易混的地方:
一套是 HTTP keepalive,也就是 keepalive_timeout 65s、keepalive_requests 1000。它决定一个浏览器连接能复用多少次请求,以及空闲多久断开。如果 keepalive_requests 设得太小(默认 100),长连接的收益很有限;设太大,单个连接上堆积的请求归属会变复杂,日志分析时会看到一些奇怪的关联。
另一套是 TCP keepalive,即 net.ipv4.tcp_keepalive_time、intvl、probes。它决定一个空闲连接在多久之后由内核发出探测包,判断对端是不是已经死了。
关键在于:如果 HTTP keepalive 的超时(65 秒)比 TCP keepalive 的探测周期(默认 7200 秒)短得多,那么 HTTP 层会先关闭连接,TCP 层根本没机会探测——这其实是好事。反过来如果你的应用是自写的长连接服务,就必须把 TCP keepalive 调短,否则客户端断电之后的服务端连接会一直挂着,直到两小时后才被回收。
内核参数的四个无效动作
网上一堆「高并发优化脚本」其实有一半是无效甚至有害的,我列四个常见的:
- 关掉 IPv6:跟并发毫无关系,反而可能让某些 CDN 节点回源变慢。
- 把
net.core.somaxconn调到 65535 就完事:如果 Nginx 的 backlog 没跟上,实际生效的还是那个小的。 net.ipv4.tcp_tw_recycle=1:这个参数在 4.12 内核就被移除了,在 NAT 环境下还会导致客户端连不上,现在写进配置只会报错或者被忽略。- 把
net.ipv4.tcp_max_syn_backlog调大但不看tcp_syncookies:SYN 队列满的时候如果 syncookies 关着,新连接会被直接拒绝。
一条完整的压测验证路径
参数改完不做压测等于没改。我这套流程每次上生产前都会跑一遍:
# 1. 先在压测机上把端口范围放开,避免测试工具自己卡住
sysctl -w net.ipv4.ip_local_port_range="1024 65535"
# 2. 确认目标机的 fd 上限已经是 65535
cat /proc/$(pidof nginx | awk '{print $1}')/limits | grep 'open files'
# 3. 跑 5000 并发、持续 60 秒
ab -k -c 5000 -n 200000 http://127.0.0.1/index.html
# 4. 压测过程中另开一个窗口采样
ss -s
vmstat 1 10
cat /var/log/nginx/error.log | tail -20判断结果要看三组数字交叉:ss -s 里的 established 是不是真的到了 5000、vmstat 的 cs 是不是在合理范围(正常几千,上十万就是模型有问题)、error.log 里有没有 worker_connections are not enough。最后这条最常见——指的是 Nginx 的 events { worker_connections 1024; },默认值其实很小,一万连接需要把它提到 8192 甚至 16384,并且乘上 worker 数不能超过 fd 上限。
什么时候根本不需要一万连接
讲到这里我想说一句可能不太好听的话:大部分个人站根本不需要考虑 C10K。
一个日 PV 五万的站点,峰值并发也就二三百,Nginx 默认配置就能扛。真正需要担心一万连接的场景只有三类:做 API 服务给大量客户端的长连接、做即时通讯、或者做代理/隧道类服务。博客、内容站、企业官网,先把数据库慢查询和图片体积优化好,比调内核参数收益大十倍。
所以我自己的判断标准是:当 ss -s 的 established 常年在 800 以上、并且 netstat -s 里开始出现 listen queue overflow 的时候,才值得花时间做这一整套优化。在那之前,把每一次请求处理快 50 毫秒,比接住多一千个连接对你更有用。