个人站最刺激的时刻,是突然被一个大号转发、上了某个信息流的首页,或者一篇文章在群里被转了几百次。流量进来的时候你会发现,平常那套「能跑就行」的配置,在十分钟之内就能把一个 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 秒会让访客看到不一致的信息,这种页面就不要加缓存。
六、降级的正确顺序:先牺牲什么
流量超过天花板的时候,必然要有东西被牺牲。我的顺序是这样的,从上到下依次关掉:
- 统计脚本与第三方 widget(评论系统、分享按钮、客服悬浮窗)。这些东西不在你的服务器上,但会拖慢页面,而且会影响 Nginx 的连接数。
- 相关文章、热门榜、标签云。见第三节,这几个都是重查询。
- 搜索结果与筛选参数。这两个是「无限 URL」的来源,峰值期间可以直接返回一个静态提示页。
- 图片与附件。如果附件在源站上,峰值期间是最容易被打满带宽的;提前搬到对象存储 + CDN,峰值时才有退路。
- 页面数。列表每页从 30 条降到 10 条,把这个页面的 SQL 成本降到三分之一。
- 最后才是「返回一个简单页面」。静态 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%。峰值期间只做「不需要重启进程」的改动,其余记下来,等流量过去。