突发流量那十分钟:进程池、连接数与降级次序的峰值预案

个人站最刺激的时刻,是突然被一个大号转发、上了某个信息流的首页,或者一篇文章在群里被转了几百次。流量进来的时候你会发现,平常那套「能跑就行」的配置,在十分钟之内就能把一个 2 核 4G 的服务器打到 502。这篇文章讲的是我在几次这样的突发流量里总结出来的东西:提前要准备什么,峰值来的时候先看什么、先关什么,以及流量过去之后必须回头看哪几个数字。

先说一个前提判断,这个判断决定了后面所有的动作:突发流量打挂站点,八成不是因为 CPU 不够,而是因为某一层的并发上限被撞到了。PHP-FPM 的进程数、MySQL 的连接数、Nginx 的 worker_connections、系统的文件描述符、TCP 的 backlog,这五个数任何一个撞顶,表现都是「CPU 还有 70% 空闲,但页面全部超时」。所以准备工作的核心不是「加大配置」,而是「把这几个上限算清楚,并且让超出部分以可控的方式失败」。

一、先算出你的真实天花板,而不是拍脑袋买机器

峰值准备的第一步是量化。你需要的不是「能扛多少 PV」,而是「单请求占多少 CPU 时间和多少进程时间」。我常用的算法是从日 PV 倒推:

峰值 QPS ≈ 日 PV × 峰值系数 ÷ 86400

# 峰值系数按站点类型取:
# 资讯/教程站:3~5(白天平稳,晚上有一个小高峰)
# 突然被转发的单篇文章:10~20(十分钟内集中涌入)
# 有定时推送/邮件的站:20~50(推送后 3 分钟内)

举例:日 PV 3000 的站,峰值系数取 5,那么峰值 QPS 大约是 0.17。听起来小得可笑,但要注意突发流量的形态是「十分钟内集中」。如果一篇文章被大号转发,十分钟内进来 6000 个访客,每人看 2.5 页,就是 15000 次请求,摊到 600 秒上是 25 QPS。这 25 QPS 是最关键的瞬间,而你平常只跑 0.2 QPS,也就是说平常的占用完全不能作为容量参考。

然后是单请求的进程占用时间。strace -c 或者 PHP-FPM 的 slowlog 都能给出,但最实用的算法是:让机器在中等压力下跑一分钟,看 pm.max_children 有多少个进程处于 active 状态。计算公式是:

需要的 FPM 进程数 ≈ 峰值 QPS × 平均请求处理时间(秒)× 安全系数 1.5

# 25 QPS × 0.15 秒 × 1.5 ≈ 6 个进程
# 但如果你的页面里有 3 个外部 API 调用、平均 0.8 秒,就是 30 个进程

这就是为什么同样 25 QPS,有的站 4 个 FPM 进程就够,有的站要 40 个。我先量单请求时间,再决定要不要优化那 0.8 秒,最后才决定加进程。顺序反了就是白花钱。

二、PHP-FPM 的进程池:max_children 不是越大越好

突发流量下最典型的死法是 FPM 打满,请求在 listen backlog 里排队,最后 request_terminate_timeout 到点被砍,用户看到 502。这时候很多人第一反应是把 pm.max_children 从 10 调到 100,结果内存爆了,MySQL 连接数也跟着爆,站点更惨。

; 合理的进程池配置(2 核 4G,单进程常驻约 45MB)
pm = dynamic
pm.max_children = 24          ; 24 × 45MB ≈ 1.1GB,留足余量
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 10
pm.max_requests = 500         ; 防止内存碎片累积,也让进程定期重生
request_terminate_timeout = 30s
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/slow.log

这里有个容易忽略的点:pm.max_children 要和 MySQL 的 max_connections 一起算。如果每个 PHP 请求持有一个 MySQL 连接(最常见的情况),24 个 FPM 进程最多就是 24 个并发连接,加上后台任务、定时任务、你自己的客户端,max_connections 设 60 就很够。把 FPM 调到 100 而 MySQL 只开到 50,结果就是「Too many connections」,比 502 还难排查,因为它会出现在 php 的 error log 里而不是 Nginx 的日志里。

还有一个只在峰值时才暴露的配置:listen.backlog。它决定了有多少个已经完成三次握手、但还没被 FPM 接走的连接可以排队。默认值 511 在 25 QPS 的冲击下够用,但如果你的请求处理时间突然变成 2 秒(比如数据库慢了),队列就会秒满,之后进来的连接直接被拒绝,表现为「连接被重置」。临时可以调高到 2048,但要注意它和内核的 net.core.somaxconn 是取小值的关系,两个都要调。

三、MySQL:连接数、慢查询与那个致命的 COUNT

流量上来之后 MySQL 上发生的三件事,按概率排序:连接数打满、某个 SQL 突然变慢、表锁等待。第一件已经在上一节说过,这里说第二、第三件。

同一类 SQL 在低流量时 20 毫秒,高流量时 800 毫秒,这不是玄学,而是几个请求同时扫同一张表导致的 IO 竞争,加上 InnoDB 的 buffer pool 被挤占。峰值期间最有效的动作不是加索引(来不及),而是直接关掉那些「可选」的重查询。我在自己的站点上有一份峰值开关清单:

// config/peak.php —— 峰值期间用一个标记关闭重功能
return [
    'enable_related_posts' => false,   // 相关文章推荐(全表 LIKE 匹配标签)
    'enable_hot_list'      => false,   // 全站热门榜(ORDER BY views DESC)
    'enable_tag_cloud'     => false,   // 标签云(GROUP BY 聚合)
    'enable_archive_count' => false,   // 归档计数(每月一次 COUNT)
    'page_size'            => 20,      // 列表每页条数从 30 降到 20
];

相关文章推荐是最大的性能杀手,因为它通常写成「按标签匹配、按时间排序、LIMIT 5」,看起来很快,实际上在标签交叉的情况下要走一遍大范围扫描。热门榜同理,ORDER BY views DESC LIMIT 10 如果没有 views 索引就是全表排序。

第三个问题是表锁。MySQL 5.7 之后的 InnoDB 行锁已经好很多,但有两种情况会升级成更重的锁:一是 DDL(哪怕是加个索引,MySQL 5.6 之后的 online DDL 也有瞬间的 metadata lock),二是没有索引的 UPDATE/DELETE 会锁全表。峰值期间绝对不能做任何 DDL,也不要跑批量 UPDATE。如果要改表,等流量过去,或者用 pt-online-schema-change,但后者本身也会带来额外负载,峰值期间一样不要碰。

四、Nginx 侧:把不该给静态资源的请求拦掉

峰值时 Nginx 能替你挡掉很大一部分压力,前提是配置到位。我常用的四个动作:

# 1) 静态资源单独 location,关掉日志,开长缓存
location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|woff2)$ {
    expires 30d;
    add_header Cache-Control "public, immutable";
    access_log off;
}

# 2) 用 limit_req 给「贵」的路径加缓冲,而不是给全站加
limit_req_zone $binary_remote_addr zone=heavy:10m rate=5r/s;
location = /search {
    limit_req zone=heavy burst=10 nodelay;
    limit_req_status 429;
    # ...
}

# 3) 峰值期间临时开启的「维护提示」,只针对动态页
#    (用 map 控制,改一行 reload 就生效)
map $uri $peak_mode {
    default          0;
    ~^/category/     1;
    ~^/tag/          1;
}

第 2 条是关键:限流要精准,不要全站限流。全站限流会把正常访客也一起限掉,而且很容易误伤爬虫,导致收录受影响。只对搜索、表单提交、导出这类「单次请求很贵」的路径限流,其余路径放行。

另外要提前检查 worker_connections。它是每个 worker 能同时处理的最大连接数(注意 HTTP/1.1 下一个访客可能占 2 个连接:浏览器和 keepalive),2 个 worker × 1024 就是 2048 个并发,看起来够,但如果某个上游(PHP)慢下来,连接会堆积在 Nginx 侧不释放,很快就撞顶。峰值前把 worker_connections 提到 4096、worker_rlimit_nofile 提到 65535,是最低成本的保险。

五、缓存:峰值期间唯一「加一点就见效」的手段

如果只能做一件事来应对突发流量,那就是加缓存,而且要加在「最靠近用户」的那一层。顺序应该是:浏览器缓存 → CDN → 反向代理整页缓存 → 应用层对象缓存 → 数据库。峰值期间能立刻改的只有前三个。

最简单有效的做法是给文章页加 60 秒的 Nginx 整页缓存,条件是「未登录、GET、没有查询参数」:

proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=page:100m
                 max_size=2g inactive=10m use_temp_path=off;

location ~ ^/[0-9]+\.html$ {
    proxy_cache page;
    proxy_cache_valid 200 60s;
    proxy_cache_bypass $http_cookie;      # 有 cookie 就绕过(登录用户)
    proxy_cache_key "$scheme$request_method$host$request_uri";
    add_header X-Cache-Status $upstream_cache_status;
}

60 秒的缓存意味着:一篇被转发的文章,第一分钟内可能是 500 个请求打不到后端,之后每个请求都直接命中,后端压力降到接近零。这个改动不需要改代码,nginx -s reload 就生效,是峰值期间最值得做的动作。

但要小心两个坑。第一,proxy_cache_bypass $http_cookie 对于「带 cookie 的访客」等于完全不缓存,如果你的站会给所有访客种 cookie(很多统计脚本会这么做),那缓存就永远不会命中,可以用 proxy_cache_bypass $cookie_logged_in 只针对登录态绕过。第二,如果页面里有「当前时间」「随机推荐文章」这类内容,缓存 60 秒会让访客看到不一致的信息,这种页面就不要加缓存。

六、降级的正确顺序:先牺牲什么

流量超过天花板的时候,必然要有东西被牺牲。我的顺序是这样的,从上到下依次关掉:

  1. 统计脚本与第三方 widget(评论系统、分享按钮、客服悬浮窗)。这些东西不在你的服务器上,但会拖慢页面,而且会影响 Nginx 的连接数。
  2. 相关文章、热门榜、标签云。见第三节,这几个都是重查询。
  3. 搜索结果与筛选参数。这两个是「无限 URL」的来源,峰值期间可以直接返回一个静态提示页。
  4. 图片与附件。如果附件在源站上,峰值期间是最容易被打满带宽的;提前搬到对象存储 + CDN,峰值时才有退路。
  5. 页面数。列表每页从 30 条降到 10 条,把这个页面的 SQL 成本降到三分之一。
  6. 最后才是「返回一个简单页面」。静态 HTML 的维护页 + 503,比「全部超时」体验好得多,而且不会在搜索引擎那里留下大量超时记录。

第 6 条要提前准备好。我习惯放一个 /maintenance.html,Nginx 里用 error_page 502 503 504 = @maint; 指过去。注意这个页面要返回 503 而不是 200,并且带 Retry-After 头,否则搜索引擎会把它当成正常页面收录。

location @maint {
    add_header Retry-After 300;
    return 503;
}
error_page 502 503 504 = @maint;

七、峰值期间只盯四个数字

峰值来了的时候,仪表盘上几十个指标会让人慌张。这时候只看四个数就够了:

  • FPM 的 active processes 与 max_children 的比值。超过 0.9 说明进程池快满。
  • MySQL 的 Threads_running。正常个位数,超过 20 说明有 SQL 在排队。
  • Nginx 的 5xx 比例。这是最终的用户感知指标,1% 以内可以接受。
  • 系统负载(1 分钟)与 CPU。如果负载高但 CPU 不高,那是 IO 或锁等待;如果两个都高,就是真的要加机器了。
# 一条命令看这四个数
watch -n 2 'echo "== FPM ==";  curl -s localhost/fpm-status?full | \
  awk "/active processes/{a=\$3} /total processes/{t=\$3} END{print a\"/\"t}";
  echo "== MySQL =="; mysql -e "SHOW GLOBAL STATUS LIKE \"Threads_running\"";
  echo "== Nginx 5xx =="; tail -n 2000 /var/log/nginx/access.log | awk "\$9~/^5/{n++} END{print n+0}";
  echo "== Load =="; uptime'

再加一个临时动作:把 Nginx 的 access log 格式临时加上 upstream_response_time 和 request_time(如果还没有的话),这样流量过去之后你能知道到底是哪一层慢,而不是只看到一个总时长。

八、流量过去之后:必须回头看的三件事

突发流量是一次免费的压测,但如果事后复盘不做,下次还会一样。我每次都看三件事:

第一,峰值期间的慢请求清单。从日志里按 request_time 排序,取前 200 条,看它们的 URI 有没有共同模式。我的经验是,80% 的慢请求集中在 5 个 URI 上,改这 5 个地方就够了。

# 按 URI 聚合,看峰值期间平均耗时最高的路径
awk '$0 ~ /\[1[0-9]:/ {print $7, $NF}' /var/log/nginx/access.log \
  | awk '{a[$1]+=$2; n[$1]++} END{for(k in a) if(n[k]>50) printf "%.0f ms  %s (%d)\n", a[k]/n[k]*1000, k, n[k]}' \
  | sort -rn | head -20

第二,哪一层的限制最先被撞到。看 FPM 的 max children reached 计数、MySQL 的 Max_used_connections、Nginx 的 worker_connections are not enough 告警、内核的 nstat -az TcpExtListenOverflows。哪一个先动,就是下一次要提前处理的那一个。

第三,有没有对搜索引擎造成损伤。如果峰值期间你的站返回了大量 5xx,爬虫会降低抓取频率,之后几天收录会变慢。这时候要在收录管理里看「抓取错误」有没有上升,如果有,处理完就等着,不要急着改站点结构,那会让恢复更慢。

九、提前一周能做的六件事

如果知道要上热门(比如自己要做一次推送),提前一周应该做完这些:

  • 给文章页和列表页加 60 秒整页缓存,验证命中率能到 60% 以上。
  • 把图片和附件搬到对象存储 + CDN,源站只留 HTML 和少量 CSS/JS。
  • 把「相关文章、热门榜、标签云」做成可开关,并在测试环境验证关掉之后页面不报错。
  • 准备一个静态维护页 + 503 兜底,验证 error_page 生效。
  • 把 FPM、MySQL、Nginx 的四个限制数字算一遍,写在一张纸上(这是我的习惯,峰值时不用翻文档)。
  • 做一次小规模的压测,用 wrk -c 50 -d 30s 打到目标 QPS 的 1.5 倍,看哪一层先响。

最后说一个心态问题。峰值期间不要一边看仪表盘一边改配置。我吃过这个亏:在 502 的时候去调 pm.max_children,reload 的瞬间 FPM 重启,所有正在处理的请求全部中断,502 从 20% 变成 100%。峰值期间只做「不需要重启进程」的改动,其余记下来,等流量过去。

Last modification:November 12th, 2026 at 01:23 pm

Leave a Comment