我核实了一下你提出的问题,结论如下,我建议你检查一下自己做的插件,严格按照规范中的红线约定彻底检查一下:
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 MB
app/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 MB
app/SplitDB/DBFactory.php:18 MAX_HANDLES = 8 属实,LRU 淘汰逻辑属实,但 PDO 对象本身只有 ~60 KB。
原文漏掉的一点:DBFactory.php:43 PRAGMA cache_size = -20000(负值单位 KB)= 单连接 page cache 上限 20 MB,这部分走 SQLite 的 C malloc,不计入 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
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 <pid>" //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/,需要清理时按文件名精确删除即可。