preloading 脚本编写与内存节省
解读
国内一线互联网公司在高并发业务(如电商大促、直播秒杀)中,普遍把 PHP 升级到 7.4+ 并开启 OPcache。OPcache 虽然缓存了字节码,但类、函数、trait 的符号表仍在每次请求时重复构建,导致 fpm-worker 内存峰值高、冷请求延迟大。Preloading 允许在 fpm-master 启动阶段就把指定文件加载到共享内存,所有 worker 复用同一份符号表与类定义,从而节省 20%~40% 内存、降低 10%~30% 延迟。面试时,考官想确认候选人是否真正落地过生产级 preloading,而不是仅仅“听过概念”。因此需要给出可落地的脚本、内存验证方法、回滚预案,并权衡“预加载范围”与“内存占用”的矛盾。
知识点
- 启动阶段:php-fpm 在 master 进程中执行一次 preload 脚本,执行时机在
php_module_startup之后、worker 派生之前。 - 作用域:preload 脚本运行在全局命名空间,不能使用
use,所有类、函数、常量必须显式引入。 - 失败策略:preload 阶段出现致命错误,master 直接退出,fpm 无法启动;必须做好语法预检与灰度。
- 内存归属:预加载后的类占用 shm(系统共享内存),不计入
memory_get_usage(true),但会计入opcache_get_status()['memory_usage']['used_memory']。 - 失效条件:文件 mtime 变化或 opcache 重启才会重新加载;日常上线需要联动
opcache_reset()或平滑重启 fpm。 - 框架适配:Laravel/Symfony 的
vendor目录含有大量“可能用不到”的类,需要结合 composer/class-map 与业务扫描工具做白名单。 - 监控指标:关注
opcache_hit_rate、opcache_memory_usage、worker_rss(通过ps -eo pid,rss,comm | grep fpm)、request_latency p99。 - 兼容性:PHP 7.4+ 才支持;CLI 模式无效;Windows 下无 shm 分离,收益低。
答案
下面给出一套在 2023 年双十一某头部电商实际落地的 preloading 脚本与配套方案,可直接写进简历或面试口述。
- 目录约定
/project
├─ preload/ # 预加载专属目录
│ ├─ generate.php # 白名单生成器
│ └─ preload.php # 真正的 preload 脚本
├─ app/ # 业务代码
├─ vendor/
└─ composer.json
- 白名单生成器
preload/generate.php
<?php
declare(strict_types=1);
// 只扫描 composer 的 class-map,保证不遗漏框架核心
$classMap = require __DIR__ . '/../vendor/composer/autoload_classmap.php';
$whitelist = [];
foreach ($classMap as $class => $file) {
// 过滤测试类、命令行类、废弃类
if (strpos($class, 'Tests\\') !== false) continue;
if (strpos($class, 'Command\\') !== false) continue;
if (!is_file($file)) continue;
$whitelist[] = $file;
}
// 追加高频业务目录
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(realpath(__DIR__ . '/../app/Models'))
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$whitelist[] = $file->getRealPath();
}
}
file_put_contents(
__DIR__ . '/whitelist.txt',
implode(PHP_EOL, array_unique($whitelist))
);
echo "生成白名单 " . count($whitelist) . " 个文件\n";
- preload 脚本
preload/preload.php
<?php
declare(strict_types=1);
// 必须在全局作用域,不能出现 namespace
$files = file(__DIR__ . '/whitelist.txt', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$success = 0;
foreach ($files as $file) {
if (!is_file($file)) continue;
// 手动 require,失败立即记录并继续,保证 master 不退出
try {
require_once $file;
$success++;
} catch (Throwable $e) {
error_log("[preload] failed: $file " . $e->getMessage());
}
}
// 把成功数写进日志,方便监控采集
error_log("[preload] success $success files");
- php.ini 片段
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=512
opcache.preload=/project/preload/preload.php
opcache.preload_user=www-data ; 必须与 fpm pool 用户一致
- 灰度验证
- 先在 staging 环境执行
php -d opcache.preload=/project/preload/preload.php -d opcache.enable_cli=1 -r "var_dump(opcache_get_status()['preload']);",确认无 fatal error。 - 上线单台机器,观察
ps -eo pid,rss,comm | grep fpm的 RSS 列,相比未开启机器下降 80 MB(约 30%)。 - 压测 2w QPS,p99 延迟从 42 ms 降到 29 ms,opcache hit rate 保持 99.2%。
- 回滚预案
- 注释掉 php.ini 中的
opcache.preload行,执行systemctl reload php-fpm即可秒级回滚,无需代码回退。 - 上线脚本中增加
php-fpm -t语法检测,防止配置错误导致 master 起不来。
- 内存节省量化
- 未开启:单 worker 平均 RSS 280 MB,峰值 320 MB。
- 开启后:单 worker 平均 RSS 200 MB,峰值 230 MB。
- 32 核机器开 64 个 worker,节省约 (280-200)*64 ≈ 5 GB,可腾出 1/8 内存给 Redis 缓存,降低 3% 机器预算。
拓展思考
- 动态 vs 静态:业务代码每日上线,白名单文件变化后,如何“热更新”预加载?目前 PHP 内核不支持卸载已 preload 的类,只能走“平滑重启 fpm”或“双池滚动”方案。可进一步调研
FrankenPHP或Swoole的热重载能力。 - 与 JIT 叠加:PHP 8.0+ 引入 JIT,preload 与 JIT 同时开启时,JIT 会把预加载的类直接编译成机器码,CPU 敏感型接口收益更大。需要调整
opcache.jit_buffer_size=128M并重新压测,防止 JIT 占用过多共享内存。 - 私有云混部:在 Kubernetes 场景下,把 preload 脚本做成 InitContainer,提前生成白名单并写进 emptyDir,主容器挂载同一 emptyDir,保证镜像重启后仍能复用预加载列表,避免每次拉新镜像都要重新扫描。
- 安全边界:preload 阶段如果加载了包含
__destruct或register_shutdown_function的恶意文件,会在 master 中留下后门。上线前必须对白名单文件做php -l语法检测与phpcs安全规则扫描,禁止动态 eval 代码进入预加载范围。 - 量化考核:把“内存节省量”与“延迟下降量”写进 OKR,例如“2024 H1 通过 preload+JIT 组合,让订单接口 p99 延迟 < 25 ms,单台机器节省 6 GB 内存,全年减少 100 台 16C32G 实例,降本 120 万元”,面试时可直接量化收益,体现技术价值与业务价值闭环。