本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/docs/插件内存问题核实报告_2026-10-16.md。
FlintHub「插件增多 → 内存爆掉」论断核实报告
- 核实日期:2026-10-16
- 核实对象:用户提供的《FlintHub 插件增多后内存爆掉的原因分析》
- 核实方式:全部结论均来自本机实测(真实 HTTP 请求 + 进程 RSS + 反射探针),不采用推测
- 环境:本机 phpstudy PHP 8.2.9 nts(Windows),站点根
D:/phpstudy_pro/WWW,插件 59 个目录 / 37 个已启用 - 探针与原始日志:
.workbuddy-ai/tmp/memprobe/(mem.log为 184 条真实请求打点原始数据)
一、结论摘要
| # | 原文论断 | 核实结果 | 判定 |
|---|---|---|---|
| 1 | PHP 类/函数定义不可卸载,FPM worker 会跨请求累积插件代码,形成「阶梯上涨曲线」 | 同一进程连续 184 次请求,class_exists() 前置判定恒为 false,memory_get_peak_usage(true) 183/184 恒为 8 MB,无任何累积 | ❌ 不成立 |
| 2 | OPcache 的作用是「避免每个 worker 各背一份编译后的类定义」 | FPM 请求模型下类表本就是按请求建立/销毁的;opcache 的真实作用是避免每请求重复编译 | ⚠️ 方向对、理由错 |
| 3 | ①「每请求都重新 include 钩子文件」是内存放大因素 | 现象属实(实测每请求 include 248 个文件),但内存成本极低;且代码已有 hookIndex 一次性构建 + 符号判定磁盘缓存两项优化 | ⚠️ 部分成立 |
| 4 | ② 语言包全量合并 → 「几万键的巨型数组」,瞬时峰值打爆 FPM | 实测单语言 4470 keys / 0.83 MB / 87.7 ms;三语合计 2.42 MB。不存在「几万键」,且每请求只构建当前语言一个字典 | ❌ 数字夸大 ~5–10 倍 |
| 5 | ③ 插件用 $GLOBALS 通信,若挂大对象(整帖列表/整页评论)内存占到请求结束 | 约定确实存在(3 个官方约定 + 若干插件内部标记),但实测全部是标量/小数组(bool、字符串、ID→计数映射),无大对象 | ⚠️ 存在但未发生 |
| 6 | ④ 每个 PDO SQLite 连接占 1~2 MB,8 个句柄池抖动抬高峰值 | 实测 8 个句柄 PHP 侧合计仅 +0.06 MB(约 60 KB/连接),夸大 20–30 倍。真正的 page cache 走 C malloc(不计入 memory_get_usage),但全站 SQLite 仅 5.18 MB,受库大小封顶 | ❌ 数字夸大 |
| 7 | ⑤ hookIndex() 每请求重建,「每次分配的内存不小」 | 实测 0.54 ms / 134 KB | ❌ 不成立 |
| 8 | 插件越多,单 worker 内存线性增长,× worker 数 → OOM | 实测 1 个插件 → 37 个插件,钩子源码仅从 15 KB 增至 371 KB(36 个插件合计只增加 356 KB) | ❌ 因果链不成立 |
| 9 | P0:开 OPcache、设 pm.max_requests、限制 pm.max_children | 方向正确,是全文唯一真正有效的部分 | ✅ 成立 |
| 10 | P2:Plugin::hookDeclaresSymbols、Plugin::$callerPlugin 仍无上限 | $hookDeclaresSymbols 是请求级数组(上限=被 include 的钩子文件数)+ 磁盘缓存(17 KB);$callerPlugin 是 ?string,上限为 1 | ❌ 不成立 |
一句话结论:原文的根因模型(跨请求累积)是错的,因此它推导出的「插件架构必然导致 OOM」这个结论站不住脚;但它在部署层给出的 P0 建议(opcache + FPM 参数)方向是对的——只是理由需要修正。真正的 OOM 风险在 FPM 进程池配置,不在插件架构。
二、逐条核实(证据)
论断 1:跨请求累积 ❌
原文主张:include_once 过的插件类、钩子文件里定义的函数/常量,全部留在该 worker 的内存里,形成累积曲线。
实测(.workbuddy-ai/tmp/memprobe/probe_cumul.php,同一 PID=21128 连续 5 次请求):
pid=21128 beforeClass=0 beforeFn=0 afterClass=1 ← 第 1 次:类不存在 → include 后存在
pid=21128 beforeClass=0 beforeFn=0 afterClass=1 ← 第 2 次:类**依然不存在**(未累积)
pid=21128 beforeClass=0 beforeFn=0 afterClass=1 ← 第 3 次:同上
pid=21128 beforeClass=0 beforeFn=0 afterClass=1
pid=21128 beforeClass=0 beforeFn=0 afterClass=1
classes=177 included_files=2 (每次完全相同)
mem=395560 mem_real=2097152 (每次完全相同,2 MB 不增长)184 次真实请求的峰值分布(mem.log):
peak_real 取值分布: {"8388608":183, "6291456":1} ← 8 MB × 183 次
用户态 peak 最小/最大: 5.44 / 7.52 MB
included_files 最小/最大: 132 / 249机理:PHP 的 included_files 去重表、类表(class table)、用户态静态变量都是按请求(per-request)建立并在 php_request_shutdown 时销毁的,与 FPM / cli-server / mod_php 无关。include_once 的去重只在单次请求内有效,跨请求必然重新 include。
补充:唯一「类定义真的常驻」的场景是 CLI 常驻进程(
cli/worker.php/worker2.php)。但实测grep Plugin::hook cli/ cron_trigger.php无任何命中 —— 这两个队列消费者不触发任何插件钩子,与插件数量无关。
进程 RSS 长跑测试(PID 13592,每轮 60 个 /login 请求):
| 阶段 | RSS |
|---|---|
| 起始 | 36,320 K |
| 60 请求后 | 39,492 K(+3,172 K) |
| 120 请求后 | 40,236 K(+744 K) |
| 180 请求后 | 40,552 K(+316 K) |
增量 3,172 → 744 → 316 K,明显收敛(典型 allocator 高水位 + SQLite page cache 按需分配),不是线性泄漏。
论断 3:每请求重新 include 钩子文件 ⚠️
现象属实。实测一次完整前台请求(/login,探针 router_probe.php):
| 分类 | 文件数 | 源码字节 |
|---|---|---|
| 核心 | 38 | 731,230 |
| 插件钩子文件 | 123 | 178,080 |
| 插件其它(Plugin.php / FrontController / views) | 87 | 1,174,889 |
| 合计 | 248 | 2,084,199 |
→ 插件相关共 210 个文件 / 1.35 MB,占单请求代码加载量的约 65% —— 这是原文唯一站得住的量化直觉。
但:
- 绝对量只有 1.35 MB 源码,编译后 opcode 约 2–4 MB;
- 代码里已经有两项针对性优化(原文未提及):
Plugin::hookIndex()(app/Helpers/Plugin.php:738-779):请求级一次性构建「钩子名 → 文件列表」,实测 0.54 ms / 134 KB;Plugin::hookDeclaresSymbols()的磁盘缓存(Plugin.php:790-825,落盘data/runtime/hook_symbols.json):实测 214 个文件判定,缓存命中 10.14 ms,无缓存 186.14 ms → 节省 95%。
论断 4:语言包「几万键」 ❌
实测(probe_i18n_db.php):
核心 lang/zh.php: 1641 keys
插件 lang(36 个文件): 2829 keys
合并后 load('zh'): 4470 keys, 内存增量 0.83 MB, 耗时 87.7 ms
三语合计常驻: 2.42 MBapp/Helpers/I18n.php:58-75 的 mergePluginLangs() 确实每请求遍历 plugins/*(含每个插件一次 plugin.json 读取 + json_decode),但每请求只构建当前语言的一个字典(self::$loaded 请求内缓存),成本 0.83 MB。
论断 6:SQLite 连接 1~2 MB ❌
实测(probe_i18n_db.php):
连接 data/adslot.sqlite → 增量 0.058 MB
连接 data/ai_assistant.sqlite → 增量 0.058 MB
...(8 个句柄)
8 个句柄后 PHP 侧内存增量: 0.06 MBapp/SplitDB/DBFactory.php:18 MAX_HANDLES = 8 属实,LRU 淘汰逻辑属实,但 PDO 对象本身只有 ~60 KB。
原文漏掉的一点:
DBFactory.php:43PRAGMA cache_size = -20000(负值单位 KB)= 单连接 page cache 上限 20 MB,这部分走 SQLite 的 Cmalloc,不计入 PHP 的memory_get_usage/memory_limit,只体现在进程 RSS。这是原文完全没提、但真实存在的内存项。不过它是懒分配且受库大小封顶的——实测全站 SQLite 共 5.18 MB(插件 50 个库合计 5.09 MB),所以实际占用上限就是几 MB 量级。
论断 8:插件数量 → 内存 ❌
实测(probe_hookindex.php,反射替换 Plugin::$plugins 后调私有 hookIndex()):
| 启用插件数 | 钩子名数 | 唯一钩子文件 | 源码字节 |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 1 | 6 | 6 | 15,162 |
| 5 | 16 | 31 | 59,855 |
| 10 | 22 | 53 | 94,964 |
| 20 | 39 | 109 | 173,830 |
| 37 | 42 | 214 | 379,874 |
36 个插件合计只增加 356 KB 源码(平均 ~10 KB/插件)。相对单请求 8 MB 峰值,插件数量的边际影响在几百 KB 量级,不构成「越多越爆」的因果链。
论断 10:静态缓存「无上限」 ❌
Plugin::$hookDeclaresSymbols(Plugin.php:21):请求级数组,键为文件绝对路径,上限 = 被 include 的钩子文件数(实测 ~123);配套磁盘缓存hook_symbols.json实测 17 KB,按文件数封顶。Plugin::$callerPlugin(Plugin.php:18):类型?string,上限就是 1。Thread::DECORATE_CACHE_MAX = 500(app/Models/Thread.php:43)、Post::THREAD_INDEX_CACHE_MAX = 500(Post.php:621)、User::BATCH_CACHE_MAX = 500(User.php:25)确实都已存在——原文这点是对的。
三、重新归因:内存真正的账在哪
把实测数字代进 FPM 的真实模型:
单请求 PHP 用户态峰值 ≈ 8 MB (实测)
单 worker 进程 RSS ≈ 35 MB (实测 13592 = 35.5 MB;Linux php-fpm 通常 20–30 MB)
└ 其中大头是 PHP 二进制/扩展映射 + allocator 保留 + SQLite page cache,
而不是「插件代码累积」(后者实测 0 累积)于是 OOM 的算式是:
PHP-FPM 总内存 = 单 worker RSS × pm.max_children关键:pm.max_children 才是乘数。 若面板默认给了 50(宝塔部分版本默认值偏高,需在面板实际确认):
50 × 35 MB = 1.75 GB ← 2 核 2G 机器上,加上系统 + Nginx + 数据库必然 OOM而插件数量对这个乘积的贡献是:每个插件约 +10 KB 源码。
次要但真实的放大项(与插件数量无关,与「有没有配好」有关):
- opcache 未启用:本机
php -m完全没有 opcache 扩展。无 opcache 时每请求要重新编译 248 个文件,实测单请求 264–980 ms(本机 Windows)。生产若同样未开,响应慢 → 并发堆积 → 触发更多 worker → 内存上升。注意:Plugin::saveCache()(Plugin.php:593-595)与flushSymbolDiskCache()(Plugin.php:889-891)都主动调用了opcache_invalidate()—— 说明作者本就预期 opcache 是开启的,当前未开属于部署缺失。 pm.max_requests未设:若为 0(unlimited),worker 永不重启。实测 RSS 虽收敛,但设一个上限仍是低成本的保险。- PageCache 覆盖率:
index.php:112-156的游客前置静态缓存能完全跳过框架引导(含全部插件钩子),是降低单请求成本最有效的手段;覆盖率越高,FPM 压力越小。
四、改善方案(按性价比排序)
⚠️ 以下均为方案,未改任何代码。涉及生产配置的项请确认后再动。
🔴 P0 —— 只改部署配置,不动一行代码(这才是治 OOM 的)
- 开启 OPcache
```ini
opcache.enable=1
opcache.memory_consumption=128 ; 本站 opcode 量不大,128 MB 足够
opcache.max_accelerated_files=10000 ; 本站 PHP 文件约 1500+,10000 有余量
opcache.validate_timestamps=1 ; ★ 见下方注意
opcache.revalidate_freq=60
```
⚠️ 本项目特有约束:FlintHub 的在线升级、插件启停都依赖「文件改动后立即生效」。Plugin::refresh() / Plugin::saveCache() / clearSymbolCache() 已显式调用 opcache_invalidate(),因此即使 validate_timestamps=0,代码里这些路径也能正确失效;但手工上传/覆盖文件(不经上述入口)在 validate_timestamps=0 下不会自动生效。建议先用 validate_timestamps=1 + revalidate_freq=60,稳妥后再考虑收紧。
- 按实测 RSS 反算并限制
pm.max_children
```
pm.max_children = 可用内存 / 单 worker RSS
```
2G 机器扣掉系统 + Nginx + 数据库后按 1.2 G 可用、单 worker 30–35 MB 计 → 建议 20–25,先取 20。宁可排队,不要 OOM。
- 设置
pm.max_requests = 500(worker 处理 500 请求后自动重启,兜住任何潜在慢泄漏)。
- 确认
memory_limit:本站env_check.php:141会打印该项,建议显式设为128M,不要留空或设成 unlimited。
🟡 P1 —— 值得做,收益中等
- 在
cli/env_check.php里补一条 OPcache 检查:目前env_check.php:141检查了allow_url_fopen / memory_limit / max_execution_time / upload_max_filesize / post_max_size / max_file_uploads / open_basedir / date.timezone,唯独没有 opcache。而代码里已在依赖它。加一行opcache.enable+opcache_get_status()输出,能让「没开 opcache」这件事在部署时就被发现,而不是等 OOM。
- 把「FPM 参数 + opcache」写进
INSTALL.md的部署章节:目前全站文档(docs/、INSTALL.md、README.md)grep 不到任何opcache/pm.max_children/pm.max_requests的部署建议(只有docs/性能与可靠性修复_交付说明_2026-10-05.md:68一句提及测试脚本「带 opcache」)。这是当前文档的真实空白。
- 提高 PageCache 覆盖率:确认首页 /
/forum//forum/category/N之外,还有哪些游客可缓存的列表页(如/tags、/tag/{id})可以纳入。每多覆盖一类页面,就少一类「加载 210 个插件文件」的请求。
🟢 P2 —— 收益很小,可不做
- 语言包按需加载:实测单语言 0.83 MB,优化收益上限就是这 0.83 MB。投入产出比很低。
- 钩子文件合并 / 减少文件数:实测 214 个钩子文件共 371 KB,合并省不下什么。
$GLOBALS改返回值/参数引用:实测当前全是标量,无内存问题。若要改,动机应是契约清晰度(审计报告.md:194已记录该弱契约),而非内存。opcache.preload:本站 opcode 量小,preload 收益有限,且与在线升级的文件替换流程有冲突风险,不建议现在上。
⚪ 需要进一步确认的(本机数据量太小,测不出来)
- 单请求内存的「数据相关」峰值:本机 SQLite 数据量小(业务库 2.36 MB),无法复现「大数据量下某个页面/插件内存暴涨」。建议在生产(或数据完整的环境)用同样的探针复测一次:把
.workbuddy-ai/tmp/memprobe/router_probe.php挂成 php-fpm 的auto_prepend_file(FPM 支持该指令),跑几天收集mem.log,看是否存在某个 URI 的 peak 显著高于 8 MB。若存在,那才是需要针对具体插件优化的点——而它与「插件数量」无关。
五、复现方法
# 1. 跨请求不累积(决定性实验)
php -S 127.0.0.1:8899 -t D:/phpstudy_pro/WWW
curl http://127.0.0.1:8899/.workbuddy-ai/tmp/memprobe/probe_cumul.php # 连打多次,看 beforeClass 恒为 0
# 2. 真实请求打点(分类统计)
php -S 127.0.0.1:8904 -t D:/phpstudy_pro/WWW \
D:/phpstudy_pro/WWW/.workbuddy-ai/tmp/memprobe/router_probe.php
for i in $(seq 1 60); do curl -s -o /dev/null http://127.0.0.1:8904/login; done
tasklist //FI "PID eq " //FO CSV # 对比 RSS
# 3. 成本探针(CLI,只读)
php .workbuddy-ai/tmp/memprobe/probe_i18n_db.php # I18n + DB 句柄
php .workbuddy-ai/tmp/memprobe/probe_hookindex.php # 插件数 → 钩子文件
php .workbuddy-ai/tmp/memprobe/probe_hooksymbol.php # 索引 + 符号判定副作用声明:探针全程零写入(唯一例外是打真实路由 /login 等,等价于用浏览器访问几次,会正常写 session/在线状态)。mem.log 与全部探针脚本保留在 .workbuddy-ai/tmp/memprobe/,需要清理时按文件名精确删除即可。