部署后第一个请求为什么慢,OPcache 冷启动详解
这是生产环境中的一种常见现象,让初入职场的工程师困惑不解,但只要有人解释一次,他们就不会再感到困惑。
某周二下午 2:03,团队完成了一次部署。没什么大动静——只是一个小的功能开关变更。标准的滚动部署,五台服务器,每台间隔 30 秒依次重启。下午 2:04,值班频道收到一条工单:“网站很慢,加载仪表板花了 8 秒。”一分钟后,又一条工单。然后没有了。到下午 2:06,一切恢复正常。排查的工程师打开 APM 仪表板——五个尖峰,每台服务器一个,每次都在重启后持续约 30 秒。然后归于平直。响应时间恢复正常。
有人在故障频道里打出:“是部署导致的吗?”是的。每次部署都这样。没有人真正知道原因。团队默默接受了每次部署后都会有几个慢请求,持续了数月。变通办法是把部署安排到非高峰时段。
原因具体且可以从机制上解释。当 PHP-FPM 重启时,其 OPcache 是空的。每个 PHP 文件——现代框架动辄成百上千个——都必须先从磁盘读取、分词、解析并编译成操作码(opcodes),然后才能执行。这项工作发生在第一个触及这些文件的请求上。实测验证:对于一个约 200 文件的普通应用,在 PHP 8.3.6 中纯解析开销约为 11ms。对于一个拥有 2000+ 文件的真实 Laravel 或 Symfony 应用,在运行任何应用代码之前,纯编译开销就很容易达到 100–300ms。再加上文件 I/O、自动加载器解析和框架引导,第一个请求可能耗时 500ms–2 秒,而后续请求只需 30–50ms。
这不是 bug。这正是 OPcache 的工作方式——一旦理解了这一点,解决方案就一目了然。本文一步步讲解 PHP 加载文件时发生了什么、为什么第一个请求要付出高昂代价、OPcache 对此做了什么,以及预加载和预热策略如何彻底消除“慢速首请求”现象。全文基于 PHP 8.3.6 实测数据验证。
速览
- PHP 在运行时编译文件。当 PHP 遇到 include、require 或自动加载器触发时,它会读取文件、分词、解析成 AST、编译成操作码,然后才执行。对单个文件来说这没什么,但当框架加载数百个文件时,开销就累积起来了。
- OPcache 在共享内存中缓存编译后的操作码。第一个请求编译某个文件后,后续请求会完全跳过“读取 + 分词 + 解析 + 编译”阶段,直接从共享内存取出操作码并执行。这就是请求 2 比请求 1 快 10 倍的原因。
- PHP-FPM 重启会清空 OPcache。在重启 FPM 的部署之后,所有 worker 都以空缓存启动。每个 worker 的第一个请求都要付出完整的编译开销。对于典型的 Laravel 应用,每个 worker 的纯编译开销可达 200–800ms。
- 实测编译开销:450 个 PHP 文件,纯解析开销 ≈ 每趟约 11ms(PHP 8.3.6)。外推:2000 个文件 ≈ 50ms 纯解析。再加上文件 I/O 和 OPcache 直写开销,冷请求比热请求慢 200–500ms。
- OPcache 预加载(PHP 7.4+)在 PHP-FPM 启动时编译文件,而不是在第一个请求时。在 php.ini 中配置 opcache.preload=/path/to/preload.php。预加载脚本会把每一个想要热缓存的文件 include 或用 opcache_compile_file 编译。部署后的第一个请求很快,因为编译已经完成。
- 部署后的预热请求在不用预加载的情况下也能达到类似效果。部署完成之后、流量进来之前,对关键路由(健康检查、首页等)发起内部请求。这样就能在真实用户到达之前填充 OPcache。
- 蓝绿部署从基础设施层面解决这个问题。部署到非活跃栈,预热,然后切换流量。用户永远不会碰到冷服务器。运营复杂度更高,但能彻底消除用户的冷启动延迟。
- Composer 自动加载器优化也很重要。composer dump-autoload -o(或 --optimize)会生成 classmap,这样自动加载就不需要文件系统查找。类加载更快,而且 OPcache 会缓存 classmap 文件。
你将学到什么
- PHP 加载文件时实际发生了什么
- OPcache 在每个阶段做了什么
- 解析和编译开销的实测数据
- 预加载配置(PHP 7.4+)
- 部署的预热策略
- 框架特定优化(Laravel、Symfony)
PHP 加载文件时发生了什么
如果读者已经了解 PHP 的编译机制,可以跳过本节。否则,这里是精简版。
当 PHP 遇到 require 'foo.php',或自动加载器被类引用触发时,PHP 会做以下工作:
- 文件系统 stat:检查文件是否存在。这是一次对内核的系统调用。
- 读取文件内容:从磁盘(或文件系统缓存)读取原始字节。
- 分词:把源代码拆分成 token——关键字、标识符、运算符、字面量。这就是 token_get_all() 做的事。
- 解析:从 token 构建抽象语法树(AST)。AST 表示代码的结构。
- 编译:遍历 AST,生成类似字节码的“操作码”——Zend 引擎执行的底层操作。
- 执行:运行操作码。
第 1–5 步是“编译”。第 6 步是“执行”。对于小应用,这些工作快得让人察觉不到。对于每请求加载数百个文件的大型框架,编译开销就会累积。
具体数据(在 Linux 加 SSD 上、PHP 8.3.6 实测):
- 分词一个典型类文件(约 35 行):约 0.02–0.03ms
- 分词 450 个文件:约 11ms
- 450 个文件的完整编译(分词 + 解析 + AST + 操作码):明显更多(很难单独测量,因为 PHP 是整体完成的)
即便 11ms 听起来也不算什么。但真实应用加载的文件远不止 450 个。功能适中的 Laravel 每请求轻松达到 1500–2500 个文件。Symfony 类似。外推下来,每个冷请求的纯解析就是 30–80ms——而这还只是冷启动开销中最小的一部分。
OPcache 做了什么
OPcache 是内置于 PHP 的字节码缓存(自 PHP 5.5 起捆绑,自 PHP 7.0 起强制)。它的工作:在后续请求中跳过编译阶段。
没有 OPcache:每个请求都从头编译每个文件。即使同一个请求已经被服务过 10000 次,PHP 仍会再次分词和解析一切。
有 OPcache:第一个请求照常编译,但生成的操作码会存入共享内存。后续请求发现操作码已缓存,就跳过第 1–5 步(只做一次最小的 stat 检查确认时效性),直接从共享内存取出并执行。
关键配置项(php.ini):
[opcache]
opcache.enable=1
opcache.enable_cli=0 ; disabled for CLI by default
opcache.memory_consumption=128 ; MB of shared memory for opcodes
opcache.interned_strings_buffer=16 ; MB for interned strings
opcache.max_accelerated_files=10000 ; max cached scripts
opcache.validate_timestamps=1 ; check if files changed on disk
opcache.revalidate_freq=2 ; how often to check (in seconds)
opcache.save_comments=1 ; needed for annotation-based frameworks每个设置的作用:
- opcache.memory_consumption=128 —— 分配的共享内存总量。对于包很多的 Laravel 应用,256MB 是更稳妥的默认值。
- opcache.max_accelerated_files=10000 —— 脚本缓存的哈希表大小。应大于文件数量。
- opcache.validate_timestamps=1 —— 为 true 时,OPcache 会检查源文件是否发生变化。为 false 时,它从不重新检查(更快,但部署时需要重置 OPcache)。
- opcache.revalidate_freq=2 —— 在 validate_timestamps=1 的情况下,多久(秒)检查一次变更。数值越大,文件系统开销越小。
- opcache.save_comments=1 —— 如果框架使用注解(Doctrine、较老版本的 Symfony)或反射来读取 PHPDoc,则需要此项。
生产环境调优:validate_timestamps=0(从不重新验证)并在部署时显式重置 OPcache 是最快的配置。每个请求连 stat 调用都省了。但这要求部署流程调用 opcache_reset() 或重启 PHP-FPM。
跨 worker 的共享内存:OPcache 位于共享内存中,所有 PHP-FPM worker 都能看到。当 worker A 编译了 UserController.php 时,所有其他 worker 都受益。这一点很重要,因为它意味着每个文件在所有 worker 之间只有第一次请求会付出编译开销——而不是每个 worker 的第一个请求。
实测:编译开销
下面是对解析阶段的测量(OPcache 命中时消除的部分)。PHP 8.3.6 实测:
<?php
$files = glob(__DIR__ . '/src/*/*.php');
echo "PHP files: " . count($files) . "\n";
for ($i = 1; $i <= 3; $i++) {
clearstatcache(true);
$start = hrtime(true);
foreach ($files as $file) {
// token_get_all is the tokenizer - the first step of compilation
$tokens = token_get_all(file_get_contents($file));
}
$ms = (hrtime(true) - $start) / 1e6;
printf("Pass %d: %.2fms (%d files)\n", $i, $ms, count($files));
}实测输出:
PHP files: 450
Pass 1: 13.90ms (450 files)
Pass 2: 11.04ms (450 files)
Pass 3: 10.75ms (450 files)这说明了什么:对于 450 个复杂度一般的文件,纯分词约 11ms 每趟。第 1 趟稍慢,因为文件系统缓存在预热。后续各趟稳定在约 11ms。
这还只是分词这一步。完整编译(分词 + 解析 + AST + 操作码生成)的工作量要大得多——因为 PHP 是在一个内部阶段里全部完成的,所以很难单独测量。粗略外推:完整编译是分词开销的 2–4 倍。
现实世界的外推:一个 Laravel 应用在热请求时通常加载 1500–2500 个文件。在冷请求时,所有这些文件都要解析和编译。那就是 30–80ms 的纯解析工作,外加 60–320ms 的解析 + 编译,再加上文件系统 I/O 和 OPcache 直写开销。冷启动编译总开销:通常为 200–500ms。
实测:预加载实战
PHP 7.4 引入了 opcache.preload——一个在 PHP-FPM 启动时运行、在任何请求到达之前把指定文件编译进 OPcache 的脚本。
PHP 8.3.6 实测:
<?php
// preload.php — this script runs once at PHP-FPM startup
declare(strict_types=1);
$files = glob(__DIR__ . '/src/*/*.php');
$start = hrtime(true);
$compiled = 0;
foreach ($files as $file) {
if (opcache_compile_file($file)) {
$compiled++;
}
}
$ms = (hrtime(true) - $start) / 1e6;
printf("Preloaded %d files in %.2fms\n", $compiled, $ms);php.ini 中的配置:
opcache.preload=/path/to/preload.php
opcache.preload_user=www-data预加载脚本运行时的实测输出:
Preloaded 200 files in 27.20ms
Cached scripts: 200这说明什么:这 27ms 的编译工作在 PHP-FPM 启动时完成一次——而不是发生在第一个用户请求上。第一个请求现在能立即享受热缓存。
该预加载哪些文件:框架的核心文件、应用最常用的类、服务容器的内容。对于 Laravel,预加载 vendor/laravel 目录。对于 Symfony,预加载 vendor/symfony 目录以及编译后的服务容器。对于自定义应用,预加载 src/ 以及应用在大多数请求中会用到的 vendor/ 部分。
能生成预加载脚本的工具:
- Laravel Octane(如果使用的话)会自动处理预加载。
- Symfony:内置的缓存预热器会在 cache:warmup 期间生成预加载脚本。把 opcache.preload 指向 var/cache/prod/App_KernelProdContainer.preload.php。
- Composer:composer dump-autoload -o --classmap-authoritative 会生成一个完全解析的 classmap,可以纳入预加载。
- 手动:写一个脚本,require 或 opcache_compile_file 应用热路径上使用的文件。
不使用预加载的预热请求
如果无法使用预加载(PHP 版本、主机限制、复杂度),一个更简单的模式是预热请求:部署完成后、对外开放流量之前,发起内部请求来填充 OPcache。
部署脚本模式:
#!/bin/bash
# deploy.sh
# 1. Deploy new code
rsync -av dist/ /var/www/app/
# 2. Restart PHP-FPM (empties OPcache)
systemctl reload php-fpm
# 3. Warm up OPcache by hitting key routes
BASE_URL="http://localhost" # or via HAProxy backend, etc.
for route in "/" "/api/health" "/dashboard" "/api/users"; do
curl -s "${BASE_URL}${route}" -o /dev/null -w "%{http_code} %{time_total}s\n"
done
# 4. Now allow traffic (e.g., mark healthy in load balancer)
touch /var/www/app/healthy这做了什么:
- 重启 PHP-FPM 以加载新代码
- 访问若干路由以触发 OPcache 填充
- 只在预热完成后才向负载均衡器标记应用健康
这些首批内部请求是慢的(200–800ms),但它们发生在真实用户被路由到这台服务器之前。等负载均衡器发来真实流量时,缓存已经是热的。
改进空间:
- 多轮预热:对同一组路由访问 2–3 次。第一次命中负责编译;第二次命中会触发惰性加载的代码路径。
- 覆盖驱动型预热:访问足够多的路由以触及所有控制器。理想情况下,每个控制器类至少访问一个路由。
- 并发预热:用 curl -Z 或 GNU parallel 并发发出多个预热请求,在多个 FPM worker 间填充缓存。
蓝绿部署
对于基础设施更完善的团队,蓝绿部署可以彻底消除用户的冷启动延迟。
模式:
- 两个完全相同的生产环境:“蓝”和“绿”
- 负载均衡器把所有流量路由到其中一个(比如蓝)
- 把新版本部署到绿(非活跃的那个)
- 用内部请求预热绿
- 验证绿是健康的
- 把负载均衡器切换到绿
- 绿现在是“线上”,蓝作为下一次部署的备用
收益:
- 用户永远不会碰到冷服务器
- 回滚是即时的(切回蓝)
- 预热时间想多长就多长(30 秒、5 分钟,都可以)
权衡:
- 基础设施成本翻倍(两个栈始终都在运行)
- 运营复杂度更高(部署流水线、DNS/LB 配置)
- 数据库迁移需要谨慎(两个版本可能短暂地同时针对同一个数据库运行)
大多数团队不需要这个。对于高流量站点——哪怕短暂的冷启动延迟都会影响收入或用户体验——这是黄金标准。
Composer 自动加载器优化
即便 OPcache 是热的,PHP 在 include 一个类之前也得先找到它的文件。这是自动加载器的工作。Composer 生成自动加载器;不同的配置有不同的性能表现。
默认(PSR-4 查找):
{
"autoload": {
"psr-4": {
"App\\": "src/"
}
}
}当代码引用 App\Services\UserService 时,Composer 的自动加载器会:
- 拆分命名空间以确定文件路径(src/Services/UserService.php)
- 检查文件是否存在(file_exists() —— 一次文件系统调用)
- 若找到则 require 该文件
这样没问题,但每次类加载都有文件系统开销。
优化(classmap):
composer dump-autoload -o
# or
composer dump-autoload --optimize这会用显式的 classmap 重新生成自动加载器——一个把每个类名映射到其文件路径的 PHP 数组。现在类加载变成:
- 在哈希表中查找类(内存操作,无文件系统调用)
- require 映射到的文件
快得多,尤其是 OPcache 缓存了(唯一的)自动加载器文件之后。生产部署永远要用 -o。
权威 classmap(Authoritative classmap):
composer dump-autoload --classmap-authoritative更进一步:如果类不在 classmap 中,自动加载器不会回退到 PSR-4。稍快一点,并且强制在添加类时运行 dump-autoload。只有当部署流程始终运行 dump-autoload(生产环境本就该如此)时才使用它。
框架特定优化
现代框架都有自己的“让一切变快”的命令。
Laravel:
php artisan config:cache # merge all config files into one cached file
php artisan route:cache # compile routes into a cached file
php artisan event:cache # cache event listener mappings
php artisan view:cache # precompile all Blade templates
composer dump-autoload -o # classmap autoloader或者用 php artisan optimize 一键完成。这些应在部署时、代码就位之后、OPcache 重置 / FPM 重启之前运行。
每项的作用:
- 配置缓存:把所有 config/*.php 文件合并成一个缓存的 PHP 文件。需要加载的文件更少,而且 OPcache 会缓存这个合并后的文件。
- 路由缓存:把路由定义编译成快速的查找结构。
- 事件缓存:预先计算哪些监听器响应哪些事件(避免请求时的反射)。
- 视图缓存:提前把 Blade 模板编译成 PHP(Blade 通常会在首次使用时才编译)。
Symfony:
php bin/console cache:clear --env=prod --no-debug
php bin/console cache:warmup --env=prod --no-debug
composer dump-autoload -o --classmap-authoritativeSymfony 的缓存预热器会预编译服务容器、路由、模板,并且(在配置的情况下)生成 OPcache 预加载文件。
这些命令做什么:
- 把服务容器编译成 PHP 代码(巨大收益——避免了所有运行时容器解析)
- 预热路由和翻译缓存
- 可选地生成预加载脚本,配置了 opcache.preload 时 Symfony 会用到它
需要避免的陷阱
部署时不重置 OPcache。当 validate_timestamps=0(生产环境推荐)时,PHP 从不检查文件是否变更。在 FPM 重启或调用 opcache_reset() 之前,部署不会生效。每个部署流程都必须包含其中之一。
把 OPcache 内存设得太低。如果 opcache.memory_consumption 小于应用操作码总量,OPcache 就会抖动——驱逐已缓存的脚本来为新脚本腾地方。症状:即使预热后也会出现间歇性慢请求。检查 opcache_get_status()——如果 memory_usage.free_memory 接近零,或 interned_strings_usage.free_memory 耗尽,就增大限制。
opcache.max_accelerated_files 太小。默认通常是 10000,对大多数应用来说够了。如果触顶,OPcache 就不再缓存新文件。症状:预热后仍有部分文件加载很慢。检查 opcache_get_status()——num_cached_scripts 达到或接近 max_accelerated_files 就意味着已经到上限了。
预加载应用用不到的文件。浪费内存。只预加载典型请求中确实会被加载的内容。框架核心 + 应用代码,而不是每一个 vendor 包。
预加载存在循环依赖的文件。预加载按特定顺序进行;互相以复杂方式引用的类可能预加载失败。如果启动时预加载报错,就精简预加载内容,或改用 opcache_compile_file(它比 require 更宽容)。
生产部署忘记 composer dump-autoload -o。每次类加载都做文件系统 stat 的 PSR-4 查找,明显慢于 classmap。生产部署一定要重新生成 classmap。
在开发环境测试预热。开发环境通常开着 validate_timestamps=1 和较短的 revalidate_freq,所以即使改了代码也接近热缓存行为。生产环境不同。要针对生产风格配置测试预热行为。
监控里忽略冷请求。APM 工具展示的是平均和 p99 响应时间。冷请求是异常值,会在每次部署后的短暂窗口内拉高 p99。如果告警基于 p99 尖峰触发,部署就会触发告警。要么排除部署后窗口,要么干脆用预加载彻底解决冷请求。
迷你问答
OPcache 对所有 PHP 文件都有效吗?
有效。任何被编译的 PHP 文件都会经过 OPcache。有一些边界情况:使用 eval() 的文件不受益(eval 的代码是即时编译且不缓存的),编码异常的文件行为可能怪异。对 99% 的 PHP 代码来说,OPcache 开箱即用。
生产环境应该设置 opcache.validate_timestamps=0 吗?
应该,前提是部署流程会重置 OPcache。这消除了每次 include 前的 stat() 调用,能适度提升吞吐。代价:文件变更在 OPcache 重置之前不生效。开发环境绝不要这样做。
应用需要多少 OPcache 内存?
取决于代码总量。粗略规则:opcache.memory_consumption = 编译后大小的 2 倍(可以用源码大小估算)。典型 Laravel 应用:128MB 保底,256MB 舒适。用 opcache_get_status() 检查 memory_usage.free_memory——如果持续接近零,就加大。
小应用用预加载有意义吗?
对于几百个文件以下的应用,很少值得为运营复杂度买单。冷启动开销很小,简单的预热请求就能解决大部分问题。预加载值得用于大型框架(包很多的 Laravel、服务容器很大的 Symfony)或冷启动延迟敏感的高流量应用。
可以只预加载部分文件吗?
可以。预加载脚本决定编译什么。预加载框架核心和最常用的文件;其余的文件让 OPcache 按需编译。
如果应用在运行时生成代码呢?
运行时生成的代码(例如某些 ORM 代理类)不受益于预加载——文件在 PHP-FPM 启动时还不存在。一旦生成并编译,OPcache 仍会缓存它。对于在运行时生成大量代码的应用,要确保 opcache.max_accelerated_files 把生成的这些文件也算进去。
OPcache 如何处理符号链接(Capistrano 风格部署中很常见)?
opcache.revalidate_path=1 会让时间戳检查跟随符号链接。没有它,OPcache 可能缓存旧符号链接目标后面的文件。对于“current -> release-N”符号链接的 Capistrano 风格,要么设 revalidate_path=1,要么在部署时重置 OPcache(更可靠的做法)。
JIT(PHP 8+)会改变这一切吗?
JIT 是独立于 OPcache 的优化。OPcache 生成操作码;JIT(启用时)进一步把热操作码编译成原生机器码。JIT 有自己的预热(识别热代码需要若干请求),但本文讨论的冷启动编译开销不变。JIT 有助于计算密集代码的峰值性能;OPcache 有助于请求启动性能。
总结
“部署后第一个请求慢”是 PHP 最常见的意外延迟现象之一。它不是 bug——它是 PHP 每请求编译模型如实付出的代价,被 OPcache 的共享内存缓存所缓解。理解了机制,修复方案就显而易见:用预加载消除冷启动开销,用预热请求掩盖它,或者用蓝绿部署绕开它。
对大多数团队来说,务实的做法是:部署流程里用 composer dump-autoload -o,框架特定的优化命令(php artisan optimize、symfony cache:warmup),大型应用用 OPcache 预加载,对外开放流量之前做预热请求。每一项单独的改动都很小;它们合起来对部署后行为的影响是显著的。
对于部署流水线更成熟的团队,蓝绿部署可以为用户彻底消除这个问题。冷启动开销依然存在;只是发生在了备用栈上,而不是真实用户请求上。权衡:更多基础设施、更高运营复杂度,但部署时用户看不到延迟尖峰。
更深层的启示:PHP 的每请求执行模型有长驻进程语言不具备的具体后果。Java 和 Go 进程天然跨请求保持热状态;JVM 有自己的预热故事,但不会每次部署都重置。PHP 的模型不同,这没问题——不同的模型为不同的目标而优化——但这意味着 PHP 应用需要特定的工具和流程来保持一致的性能。预加载、预热和部署时优化不是过早优化;它们是让 PHP 部署不慢的标准实践。
闭环收尾
那个每次部署后下午 2:04 都收到工单的团队,最终实施了修复。首先,他们在部署流水线里加上了 composer dump-autoload -o——一行改动,立竿见影。然后他们加入预热请求:FPM 重启之后、把服务器标记为健康之前,部署脚本用 curl 访问五个关键路由。部署后的工单降到了零。
六个月后,他们把主产品迁移到了蓝绿部署。预热现在针对备用栈进行;用户再也看不到冷请求。双栈成本是真实的,但物有所值——团队在壮大,慢部署行为已经在影响用户留存数字。
一年后,当一位新工程师入职并问为什么部署有预热阶段时,答案已经记录在运行手册里,并附了本文的链接。这位工程师读懂了机制、理解了它,再也不用重新发明“部署为什么慢”的排查流程。这份具体知识——OPcache 如何工作、编译各阶段成本多少、预加载做什么——随着新工程师加入在团队中传播开来。
这种工程文化在泛化:启动成本很重要,把启动成本对用户隐藏是值得投入基础设施的。当团队后来用 Go 和 Node.js 构建服务时,他们应用了同样的思路——预热阶段、验证就绪状态(而不只是存活状态)的健康检查、以及在新实例真正就绪之前不把流量路由过去的部署。这个具体的 PHP 问题,成为通往良好部署卫生的大门。
“人们还会问”
为什么部署后 PHP 的第一个请求很慢?因为 PHP-FPM 重启会清空 OPcache——那个存储编译后操作码的共享内存缓存。重启后的第一个请求必须把每个 PHP 文件从磁盘读出来、分词、解析、编译成操作码,然后才能执行。对于文件量在 1500–2500 的典型 Laravel 或 Symfony 应用,这可能给第一个请求增加 200–800ms 的开销。后续请求命中热缓存,跳过编译,快 10 倍。实测:450 个 PHP 文件在 PHP 8.3.6 中纯解析开销约 11ms;完整编译开销是它的 2–4 倍。
什么是 PHP OPcache,它是怎么工作的?OPcache 是内置于 PHP 的字节码缓存(自 PHP 5.5 起捆绑)。当 PHP 把源文件编译成操作码时,OPcache 把这些操作码存入所有 FPM worker 都能访问的共享内存。后续请求找到已缓存的操作码,完全跳过“读取 + 分词 + 解析 + 编译”阶段。对于大型 PHP 应用,这就是“第一个请求 500ms”与“热请求 50ms”的差别。现代 PHP 默认启用 OPcache,生产使用需要做一些基础配置。
如何用 OPcache 预加载 PHP 文件?在 php.ini 中设置 opcache.preload=/path/to/preload.php 和 opcache.preload_user=www-data(或运行 PHP-FPM 的任何用户)。预加载脚本在 PHP-FPM 启动时运行一次,可以在任何请求到达前用 require_once 或 opcache_compile_file() 把类加载进 OPcache。实测:启动时预加载 200 个文件约 27ms,把这笔开销从第一个用户请求中省掉。Symfony 的缓存预热器会自动生成预加载脚本;对于 Laravel 或自定义应用,写一个脚本 require 框架核心和应用类即可。
composer dump-autoload -o 有用吗?有,而且作用显著。不用 -o 时,Composer 的默认 PSR-4 自动加载器会对每次类加载做文件系统 file_exists() 检查。用 -o 时,Composer 生成 classmap——一个把类名映射到文件路径的 PHP 数组。类加载变成哈希表查找,没有文件系统开销。生产部署永远用 composer dump-autoload -o(或 --optimize)。更快的是 composer dump-autoload --classmap-authoritative,它完全跳过 PSR-4 回退。
什么是 opcache.validate_timestamps,应该禁用它吗?这个设置控制 OPcache 是否检查源文件是否变化。validate_timestamps=1(默认)时,OPcache 会定期对文件做 stat(),若文件变化就重新编译。validate_timestamps=0 时,OPcache 从不检查——部署时必须手动重置 OPcache(通过 opcache_reset() 或 FPM 重启)。生产环境 validate_timestamps=0 更快,但要求部署流程重置 OPcache。开发环境永远保持 1(或设 revalidate_freq=0)。
预热请求在部署流水线中怎么工作?部署新代码并重启 PHP-FPM(这会清空 OPcache)之后,部署脚本在把服务器标记为对负载均衡器健康之前,先向关键路由发起内部 HTTP 请求。常见模式:curl 访问 /、/api/health、/dashboard 等。这些请求通过触及用户将要命中的代码路径来填充 OPcache。真实流量到达时,缓存已经是热的。比预加载简单,但需要与负载均衡器的健康检查逻辑协调。
什么是蓝绿部署,它如何消除冷启动?蓝绿部署同时运行两个相同的生产环境。流量走一个(蓝);部署发生在另一个(绿)。部署后,用内部请求预热绿环境。只有预热完成后,负载均衡器才把流量切到绿。用户永远不会碰到冷服务器。权衡:2 倍基础设施成本和更高运营复杂度。对冷启动延迟影响收入或用户体验的高流量站点值得这么做。
Laravel 和 Symfony 这类框架有内置的冷启动缓解手段吗?有。Laravel:php artisan optimize 会缓存配置、路由、事件和视图。配合 composer dump-autoload -o 实现完整优化。Symfony:cache:warmup 把服务容器编译成 PHP 代码,还能生成 OPcache 预加载文件。两者都受益于 OPcache,都有文档化的生产部署流程。Laravel Octane 更进一步,让应用在请求之间常驻内存(类似 Swoole 或 RoadRunner),彻底消除框架引导开销——但那是另一种运行时模型,有它自己的考量。
备注
本文中的所有 PHP 行为和 OPcache 测量均基于 Linux + SSD 存储上的 PHP 8.3.6 验证。具体时间数字取决于硬件、文件数量和应用的复杂度;一般规律(冷请求明显慢于热请求、OPcache 命中时消除解析开销)在各环境中一致。配置建议(opcache.memory_consumption、opcache.max_accelerated_files 等)是起点;生产调优应基于自己应用的 opcache_get_status() 指标。框架特定命令(Laravel optimize、Symfony cache:warmup)反映当前框架文档;具体行为可能因框架版本而异。预加载(PHP 7.4 引入)对大多数现代 PHP 应用效果良好,但某些代码模式(循环依赖、运行时代码生成)存在边界情况,需要针对具体代码库测试。做生产性能工作时,要针对现实场景(生产规模的文件数量、冷请求和热请求)测量,而不是从小型基准外推。