冷启动那两百毫秒:PHP-FPM重启后的预热链路

上周帮朋友看一个站,他发来一条报错:Cannot access database: SQLSTATE[HY000] [2002] Connection refused,紧接着是「PHP-FPM 重启后第一屏全部 500,过 20 秒自己又好了」。这台机器 CPU 4 核、内存 4G,白天 QPS 不到 30,按理说连压力都算不上,但每天第一次有人访问的早上七点、每次发版重启之后、每次 cron 备份跑完,必崩一次。

这类现象的共性很清楚:冷启动阶段的资源是空的,第一批请求要同时干三件重活——把字节码编译进 OPcache、把连接建到 MySQL、把模板渲染结果塞进缓存。而这三件事的发生时刻恰好重叠,于是「开机后第一个请求」比稳态请求慢 20 到 50 倍,如果并发第一波来了三五个请求,它们会一起去建连接、一起去编译,队列一挤就撞上连接上限或者超时。

本文不讲「怎么调大 max_children」这种止痛药,而是把冷启动这件事拆成一条可以量化的链路:先测出冷启动比稳态慢多少,再逐层把冷的部分预热掉,最后用发布脚本把预热固化成流程的一部分。

一、先量出来:冷启动到底慢在哪里

没数据就不要动手。三个探针,十分钟能把范围缩到一层。

第一个探针是后端真实耗时,绕过 Nginx 和 CDN,直接打本机回环:

for i in $(seq 1 5); do
  curl -s -o /dev/null -w '%{time_starttransfer} %{time_total}\n' \
    -H 'Host: www.example.com' http://127.0.0.1/index.php
done

重启 FPM 之后立刻跑,再隔 30 秒跑一次。如果第一轮 800ms、第二轮 90ms,说明慢在 PHP 侧;如果两轮都是 90ms,问题在 Nginx 到 FPM 的连接建立或者上游 DNS 解析上,PHP 是背锅的。

第二个探针看 OPcache 的冷热状态。很多人以为「开了 OPcache 就快了」,其实 OPcache 的命中率在进程刚起来时接近 0,缓存是随访问一页一页填进去的:

php-fpm -i | grep -i opcache
# 或运行时看真实状态
php -r 'print_r(opcache_get_status(false)["opcache_statistics"]);'

重点看 num_cached_scripts 和 hits、misses。重启后 num_cached_scripts 如果是 5 而不是全站的两三百个文件,那第一批用户就是在替你做编译。一个中型 Typecho/WordPress 站,每个请求要 include 上百个文件,编译开销合计几百毫秒很正常。

第三个探针看数据库连接是不是每次重建:

mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Threads_connected';"
mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Connections';"

连着查两次,Connections 每次访问都涨,说明没有连接复用。PHP 的 pconnect 默认关闭(这是对的,长连接在 FPM 下容易把 MySQL 的 max_connections 占满),但「关闭长连接」不等于「每次都要重新 TCP 握手 + 认证」,如果 MySQL 不在本机而是内网另一台,TCP 握手 + 认证在跨机房高延迟链路上能吃掉 10 到 30ms,冷启动时叠加起来非常明显。

二、先区分「冷启动慢」和「容量不够」

这两个问题表现一样,处理方式完全相反。判据只有一条:稳态能不能扛住并发。

用 10 个并发打 100 个请求,看 p95:

ab -n 100 -c 10 -H 'Host: www.example.com' http://127.0.0.1/index.php

稳态 p95 如果在 200ms 以内、QPS 能到 20 以上,那么你的问题是冷启动——机器有富余,只是开头那一段没准备好。稳态 p95 已经超过 800ms,那不管冷不冷都得先做容量规划:FPM 进程数按 PSS 内存算、request_terminate_timeout 让最坏耗时可控,这些属于另一篇文章的范围,别在冷启动上找病因。

我见过最典型的误判:站长看到「重启后第一分钟 502」,直接把 max_children 从 10 加到 30,结果 4G 内存的机器被 30 个 PHP 进程吃满 swap,冷启动确实不 502 了,变成全天间歇性卡顿,比原来更糟。冷启动要的是预热,不是扩容。

三、OPcache 预热:把编译搬到发布时

最直接的做法是在发布完成后,由脚本主动把全站 PHP 文件跑一遍,让 OPcache 提前装满。用 CLI 有个致命陷阱:命令行 php 和 FPM 是两套独立的共享内存,你在 CLI 里跑 opcache_compile_file(),装满的是 CLI 自己的缓存,FPM 一个字节都拿不到。所以预热必须走 FPM。

最省事的办法是让 FPM 自己编译:通过 HTTP 请求把所有路由打一遍。对内容站来说,需要预热的是首页、前若干页列表、所有分类页、标签页和最近的文章页,数量有限,可以用脚本从 sitemap 里取:

#!/bin/bash
# /usr/local/bin/warmup.sh
BASE="http://127.0.0.1"
set -u
for u in / /page/2/ /page/3/ /tech/ /webmaster/ /seo-tutorial/ \
         /performance/ /sitemap.xml /feed/; do
  code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: www.example.com' "$BASE$u")
  echo "$code $u"
done
# sitemap 里最近的 30 篇文章
curl -s -H 'Host: www.example.com' "$BASE/sitemap.xml" \
  | grep -oE 'https://www[.]example[.]com/[0-9]+[.]html' \
  | head -30 \
  | while read -r u; do
      path=${u#https://www.example.com}
      code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Host: www.example.com' "$BASE$path")
      echo "$code $path"
    done
echo "warmup done"

几个细节踩过一次就记住了:

一定走 127.0.0.1 并带 Host 头,不要打公网域名。打公网会穿透 CDN,你预热的是 CDN 边缘节点而不是源站 FPM,而且会把 CDN 的缓存刷新成本也算一遍。用 -o /dev/null 丢掉响应体,省带宽。

顺序有讲究:先打真正会被第一批用户访问的页面(首页、列表页),再打文章页。max_execution_time 在 curl 层面不生效,但脚本本身要防止某一个 URL 卡死拖住整个流程,所以每次 curl 都加 --max-time 8。

预热完要验证真的进了缓存:

php-fpm -i | grep -E 'opcache.max_accelerated_files|opcache.memory_consumption'

如果 max_accelerated_files 设成默认的 10000 而文件数只有几百,够用;但如果站点装了插件、用了 Composer 依赖,实际要编译的文件可能超过这个上限,超出部分会一直缺席,表现为「热了还是有 300ms」。这个值建议按实际文件数向上取整再乘以 1.5,设 20000 或 30000,内存 memory_consumption 相应给到 256M 到 512M——注意这是按峰值算的,设小了会频繁触发缓存清理,反而更慢。

四、数据库与模板层的预热

PHP 字节码预热完,第二批冷的东西在数据库。InnoDB 的 buffer pool 在 MySQL 重启后是空的,第一次查询要把数据页从磁盘读进内存。云盘上的随机读不便宜,冷 buffer pool 上跑一遍首页的十条查询,可能要 200 到 500ms。

MySQL 8.0 提供了 innodb_buffer_pool_dump_at_shutdown 和 innodb_buffer_pool_load_at_startup,默认打开。它会在正常关库时把内存里的热页清单写到 ib_buffer_pool,启动时预读回来。这个机制只覆盖「正常关闭」的场景,被 OOM Killer 杀掉或者机器断电就没有清单。所以还可以加一个主动预热:

SELECT COUNT(*) FROM typecho_contents WHERE type = 'post' AND status = 'publish';
SELECT * FROM typecho_contents WHERE type = 'post' ORDER BY created DESC LIMIT 20;

执行顺序按「日常访问频率从高到低」走:先跑首页和列表页用到的那几条查询,再跑文章详情。放在 warmup.sh 的第 0 步,用只读账号跑,别用 root。

模板层如果用了文件缓存,或者应用层有 APCu 缓存,同理——重启后都是空的。这类缓存的预热不需要专门写代码,warmup.sh 打一遍页面就把它们填上了。真正要注意的是别让预热本身把缓存写入打爆:如果缓存写入是「先删后写」,预热期间第一个请求删了、还没写,第二个请求穿透到数据库,这时你等于把并发放大了一倍。用 cache_lock 或「写新键再切指针」的方式避免。

五、连接层:把建连成本从请求里挪走

如果 MySQL 不在本机,冷启动时最贵的一步其实是 TCP 三次握手加 MySQL 认证。FPM 进程起来后的第一个请求要付这笔钱,之后每个进程第一次访问数据库都要付一次。pm.start_servers 设成 10,就意味着重启后头 10 个请求各付一次。

三个处理方式,按侵入性从低到高:

一是把 pm.start_servers 设成 pm.max_children 的一半左右,让进程在启动时就创建好,而非 ondemand 模式(pm = ondemand 是冷启动问题的放大器,不推荐给有小流量长尾的站)。

二是在 warmup.sh 之后加一步「连接预建」:并发打 5 个不同的页面,让 5 个 worker 各建立一条连接并保持到 MySQL 的 wait_timeout。注意默认 wait_timeout 是 28800 秒,但很多主机商会调到 60 或 120,连接空闲两分钟就被服务端踢掉,客户端还以为是好的,下次用的时候报 MySQL server has gone away。所以要么把 wait_timeout 设得比 FPM 的 pm.max_requests 生命周期长,要么在应用层做好重连。

三是用连接池。对个人站来说这不是必需品,一台机器一个 FPM,连接数不超过 max_children,用不上 ProxySQL 这一层。别为了炫技引入一个新组件,它自己也会冷启动。

六、把预热接进发布流程

预热脚本写完,如果靠人手动执行,那它迟早会被忘掉。合理的接入点是在发布脚本的最后一步,并且要满足两个条件:失败不影响发布、有输出可追溯。

#!/bin/bash
set -uo pipefail
# deploy.sh 尾部
systemctl reload php8.2-fpm || { echo "fpm reload 失败"; exit 1; }

# 等 FPM 真正起来,reload 是异步的
for i in $(seq 1 20); do
  if curl -s -o /dev/null --max-time 2 -H 'Host: www.example.com' http://127.0.0.1/ping; then
    break
  fi
  sleep 0.5
done

# 预热不阻塞发布,但结果要记账
if ! /usr/local/bin/warmup.sh > /var/log/warmup.log 2>&1; then
  echo "WARN: 预热有异常,详见 /var/log/warmup.log"
fi
grep -c '^200 ' /var/log/warmup.log

最后那行 grep -c '^200 ' 是关键:预热脚本跑完一定会「成功」(脚本退出码 0),但里面可能有一半 URL 返回 404 或 502。查一下 200 的条数,与预期数量对不上就说明有路由挂了,而这个问题在用户发现之前就能暴露出来。

还有一个容易被忽略的时刻:每天的 cron 任务。如果半夜跑一次全量备份或者日志切割,把 buffer pool 和文件缓存挤掉了,早上七点的第一次访问还是会慢。这时候要么把备份的 ionice 调到最低、--bwlimit 限速,要么在备份脚本末尾再挂一次轻量预热(只打首页和列表页)。

七、三个不要做

不要把 opcache.validate_timestamps 关闭当成预热。关掉时间戳校验确实能省一点点 stat 开销,代价是改代码后必须重启 FPM 才生效,忘记重启就会出现「改了没生效」的经典事故。这个开关属于「部署流程已经很严格」的团队,个人站开着更安全。

不要用「访问一次首页」当作预热完成。首页只能覆盖到 5% 的文件,真正的长尾在文章页里。至少按 sitemap 打最近 30 篇,覆盖到主要模板分支。

不要为了让冷启动好看而无限提高超时。把 fastcgi_read_timeout 从 60 秒加到 300 秒,只是把「502」变成「用户白屏五分钟后看到页面」,排障难度还更高。冷启动是准备问题,不是等待问题。

八、收尾检查单

上线前过一遍这五条,基本不会再被「重启后第一分钟」咬到:

一,warmup.sh 已在发布脚本尾部执行,日志里 200 的条数符合预期;二,pm.start_servers 不低于 max_children 的一半,且不是 ondemand 模式;三,opcache.max_accelerated_files 与 memory_consumption 按实际文件数核算过,没有频繁清理;四,MySQL 的 buffer pool dump/load 打开,且 wait_timeout 与应用的重连逻辑不冲突;五,凌晨 cron 任务跑完后有一次轻量预热。

冷启动优化做完之后,收益是能用数字说话的:重启后第一个请求从 1.2 秒降到 180ms,第一批并发不再出现 502,工程师不用在发版时死盯监控。这才是「性能调优」里性价比最高的一类工作——它不需要更贵的机器,只需要把已经存在的空闲资源提前用起来。

Last modification:October 26th, 2026 at 08:26 pm

Leave a Comment