从峰值倒推配置:四个数字算出单机到底能扛多少并发

一、先说结论:别急着加服务器

周三晚上九点,值班群里有人截图:首页白屏,后台 502。随手 top 一看,load average 已经到 38,但 top 里没有哪个进程的 CPU 占用超过 20%。这个现象很典型——平均值高、单进程低,说明瓶颈不在 CPU 计算上,而在排队。要么内存不够开始换页,要么磁盘 I/O 堵着,要么 PHP-FPM 的进程池满了在等。

很多人的第一反应是升级配置:2C4G 换 4C8G。但如果瓶颈是 MySQL 的一条慢查询,加 CPU 只是让队列排得更快、把数据库压得更死。我自己吃过这个亏,一年内升了两次配置,账单翻倍,峰值照样卡。

这篇文章把我用过的一套容量倒推方法写清楚:用几个真实数字算出现在到底能扛多少,找出第一个断点,再决定是加配置、改代码还是加缓存。全程不需要压测工具,靠现成的日志和系统命令就能算出来。

二、四个必须量出来的数字

容量规划脱离数据就是猜。不管站大站小,先把这四个数量出来,后面所有判断都建立在这上面。

1. 峰值 RPS,不是平均 PV。很多人报容量说「日 PV 一万」,这没用。打开访问日志,按分钟聚合,找出最高的那一分钟:

# 按分钟统计请求数,取前 10 高的分钟
awk '{print substr($4,2,17)}' /var/log/nginx/access.log \
  | sort | uniq -c | sort -rn | head -10
# 输出形如:  412 12/Nov/2026:21:03
# 412 就是那一分钟的请求数,除以 60 得到 RPS

站点只要做过一次活动、被一个大号转发过、或者被爬虫集中抓了一轮,峰值就会比平均值高一个数量级。容量按平均值配,必然在峰值崩。

2. 单请求平均耗时。Nginx 的 $request_time 是最可靠的来源,它统计的是从收到第一个字节到发出最后一个字节的时间。也可以直接用 %D 微秒变量:

# 平均耗时(毫秒)
awk '{sum += $NF; n++} END {print sum/n/1000, "ms", n, "req"}' \
  /var/log/nginx/timing.log
# 只看 /article/ 这类动态页
grep -c '/article/' /var/log/nginx/access.log

3. 并发数。这是被误解最多的指标。并发不是「同时在线人数」,而是「同一瞬间还没返回的请求数」。按利特尔法则,它约等于 RPS × 平均耗时(秒)。每秒 200 个请求、每个 0.2 秒,平均并发就是 40。峰值那一刻的并发需要用 ss 现场抓:

# 当前处于 ESTABLISHED 且连的是 80 端口的连接数
ss -tan state established '( sport = :80 )' | wc -l
# 只看 443
ss -tan state established '( sport = :443 )' | wc -l

4. 内存余量。注意是「可用」,不是 free 那一列。Linux 会拿空闲内存做文件缓存,available 才是真正还能分给新进程的量:

free -m
#               total   used   free   shared  buff/cache  available
# Mem:           3936   2912    120        8        904        712
# available 712MB,这才是余量。free 的 120MB 会让人误判。

四个数字到手,就能算出现在这台机器的工作点和离崩溃有多远。

三、把峰值换算成资源需求

以我手上一个实际问题为例:峰值那一分钟 480 个请求,平均耗时 0.45 秒,动态页占七成。

峰值 RPS = 480 / 60 = 8。这个数字看着小,但站点在活动开始那五分钟会冲到 40 RPS 左右,所以按 3 倍峰值规划比较稳妥,目标按 120 RPS 算。

并发需求 = 120 × 0.45 = 54。如果每个 PHP-FPM worker 占 40MB 常驻内存,54 个 worker 需要 2.1GB。加上 MySQL 的 innodb_buffer_pool_size 和 Nginx 本身,4G 机器刚好压线,没有任何余量给文件缓存——这就解释了为什么一冲高就开始换页。

这里有个容易被忽略的细节:PHP-FPM worker 的真实占用远高于 pm.max_children 配置里想象的数字。一个只做简单查询的页面可能 25MB,但一旦加载了完整的框架、处理大图、内存里拼接 PDF,60MB 也不稀奇。量法:

# 看正在跑的 fpm 进程的常驻内存分布
ps --no-headers -o rss,cmd -C php-fpm | \
  awk '{a[$2]+=$1; c[$2]++} END {
    for (k in a) printf "%-60s n=%d avg=%dMB\n", k, c[k], a[k]/c[k]/1024}'

如果平均 RSS 是 55MB,而你按 30MB 算容量,实际能开的 worker 只有计划的一半,这就是「配置看着够、实际打不满」的根源。

四、找第一个断点,而不是全部补齐

系统永远死在一个地方,补齐了这里,下一个地方会变成新的瓶颈。所以容量规划的正确目标是找到当前的第一个断点,并让它离峰值至少有一倍距离,而不是每项都配得很充裕。

按出现频率排,单机小站最常见的断点顺序是:

第一:PHP-FPM 进程池打满。特征很明显,Nginx 错误日志里出现 server reached pm.max_children, consider raising it,同时 502 或 504 集中出现。可以用 pm.status_path 直接看:

# php-fpm.conf 或 pool 配置里打开
pm.status_path = /fpm-status
# Nginx 侧只允许本机访问
location = /fpm-status {
    allow 127.0.0.1; deny all;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.1-fpm.sock;
}
# 然后
curl -s http://127.0.0.1/fpm-status | grep -E 'active|max children reached|listen queue'

listen queue 非零就说明有请求在 socket 上排队,max children reached 大于零就说明曾经顶到上限。这两个数字比 CPU 占用更能说明问题。

配置层面还有两个参数要一起看:pm.max_requests 和 request_terminate_timeout。前者决定 worker 处理多少个请求后重启,设得太大会让内存泄漏慢慢累积,设得太小会让进程频繁重启、每次重启都要重新加载框架。我一般在 500 到 1000 之间取。后者是单个请求的硬超时,设成 300 秒意味着一个卡住的请求能把一个 worker 占满五分钟——峰值时只要几个这样的请求,进程池就见底了。把它调到 30 秒以内,配合 Nginx 的 fastcgi_read_timeout 略大一点,可以让超时先在应用层发生,不至于把 worker 拖死。

第二:MySQL 连接数或锁等待。查 Threads_connected 与 Max_used_connections,再看 Innodb_row_lock_waits 的增量。连接数接近 max_connections 时,应用侧的报错通常是「Too many connections」,而不是「慢」。

第三:磁盘 I/O。iostat -x 1 里看 %util 和 await。云盘的 %util 常年 90% 以上、await 超过 20ms,就说明 I/O 已经在排队。这时候 CPU 再高都是等出来的。

第四:网络带宽。ifstat 或 sar -n DEV 1 看出口是否贴着带宽上限。带宽打满时,页面加载慢但服务器负载可能很漂亮,这是最容易被误判成「前端问题」的一类。

第五:连接跟踪或端口。并发再高一点,会撞上 TIME_WAIT 堆积或者 conntrack 表打满。这个断点在小站不常见,但一旦遇到极难认,现象是随机丢包、部分用户连不上。

五、三条比加配置更划算的路

算出断点后,先别买配置。下面三件事的投入产出比通常高得多。

1. 把不需要动态的东西变成静态。文章页对所有人返回一样的内容,却每次都过 PHP 和 MySQL,这是最大的浪费。Nginx 的 fastcgi_cache 给文章页加 60 秒缓存,动态请求量能掉八成。关键是要配好 fastcgi_cache_key,把登录态和移动端分开,否则会出现登录用户看到别人页面的串号事故。

fastcgi_cache_path /var/cache/nginx/article levels=1:2
    keys_zone=ART:100m inactive=10m max_size=2g;
fastcgi_cache_key "$scheme$request_method$host$request_uri$http_cookie";
# 这一行很关键:cookie 里有登录标识的不要缓存
fastcgi_no_cache $cookie_logged_in;

2. 把慢查询消灭在源头。一条没走索引的 SQL 可以把单请求从 30ms 拉到 800ms,效果相当于把服务器砍掉九成。查法是开启慢日志,按 Rows_examined / Rows_sent 比值排序,比值大于 100 的优先看。

3. 给静态资源加长缓存。图片、CSS、JS 加 Cache-Control: max-age=31536000,文件名带指纹。这一个动作能把回源请求砍掉一大半,而且完全不影响动态容量。

六、什么时候才真的该加配置

三件事都做完,断点仍然在 CPU 或者内存上,而且峰值离断点不足一倍距离,那才轮到加配置。加的时候要按断点加,别一次全升:

如果断点是 iowait 高、await 大,加 CPU 完全无用,要换 SSD 云盘或者提高 IOPS 上限。如果断点是 FPM 打满但 CPU 还有余量,加内存最直接,因为内存决定了能开多少 worker。如果断点是 CPU 100% 且没有 I/O 等待,那才是加核心的时候,但先确认没有死循环或正则回溯炸弹——一个写得糟的正则能把单核吃满,加十个核也是十个核一起吃满。

还有一个隐形成本要算进去:内存型配置(比如 MySQL buffer pool)的增长不是线性的。buffer pool 从 1G 加到 2G,命中率可能从 92% 到 98%,收益显著;从 2G 加到 4G,可能只到 99%,边际收益骤降。所以先把 buffer pool 调到「热数据总量 × 1.2」,剩下的钱花在别处。

七、把容量写进一张表,每月看一次

还有一点值得单独说:峰值不是均匀的。一天里的峰值通常集中在晚上八点到十点,一周里的峰值在周日晚,一年里的峰值在节假日前后。这意味着容量余量必须按「一年内最坏的那一小时」来算,而不是按最近一周的平均峰值。我习惯把过去一整个月的日志按小时聚合,取出最高的二十个小时,看它们集中在什么时段、是什么内容带来的。如果峰值全部来自同一类页面(比如某个被大量转发的文章),那扩容不如给那几个页面单独做静态化。

另一个常被忽略的量是每请求的字节数。带宽成本在云上很贵,一个页面 2MB 和 400KB,同样的 RPS 下带宽差了五倍。用日志算:

# 每个请求平均响应字节数(body_bytes_sent 为 Nginx 默认字段)
awk '{s += $10; n++} END {printf "%.1f KB/req over %d req\n", s/n/1024, n}' \
  /var/log/nginx/access.log

如果这个数字超过 800KB,说明页面体积本身有优化空间,先做图片和资源的瘦身,比换带宽划算得多。

我把这套数字做成一张简单的表,每月 1 号填一次,五分钟搞定:

项目              当前值      断点值     余量      
峰值 RPS           38         120       3.1x     
平均并发            17          54       3.2x     
FPM worker 占用    52/60       60        1.15x  ← 断点
FPM 平均 RSS       58MB        —         —       
MySQL 连接         31/100      100       3.2x     
磁盘 await         4ms         20ms      5x       
出口带宽           6Mbps/30    30Mbps    5x       

一眼就能看出断点是 FPM,余量 1.15 倍,动作就是先降 worker 的 RSS(关掉不必要的扩展、检查是否有内存泄漏),或者加内存。表格的价值不在精度,而在于「每月都看」,因为流量在慢慢长,断点会从这一行漂到另一行,等你发现时通常已经在崩了。

八、小结

容量规划不是买更大的机器,而是把「现在能扛多少、第一个断点在哪、离它有多远」三个问题答清楚。四个数字(峰值 RPS、平均耗时、并发、可用内存)量出来,利特尔法则换算一下,判断顺序就清楚了:静态化 → 慢查询 → 长缓存 → 最后才是加配置。

一个人管站最怕的不是流量大,而是不知道自己在什么位置。有了这张表,流量涨了心里有数,老板问「要不要升配」时你能给出一个带数字的回答,而不是「感觉有点卡」。

Last modification:November 12th, 2026 at 08:24 pm

Leave a Comment