本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:合并篇(见正文内 merged_from 列表)。
性能与可靠性审计及修复交付说明
本篇由 3 篇原始文档合并去重而成:性能审计报告 + 综合审计报告 + 修复交付说明。
一、审计背景与范围
- 审计对象:
D:\phpstudy_pro\WWW(站点根即仓库),版本基线 1.1.8(彼时已发布 1.1.9)。 - 审计方式(实测优先,不采信「看起来」的结论):
- 真实 HTTP 实测:
php -S+ 双实例 A/B(开/关 OPcache,PHP 8.2.9 nts),curl 计时 + 会话 cookie 复用; - 分段探针:独立 HTTP 探针文件,在
init.php/ 各钩子 /Database::closeAll()前后打microtime打点; - 微基准:SQLite 开/关在不同 PRAGMA 组合下的对比(WAL/DELETE × synchronous NORMAL/OFF × wal_autocheckpoint)、路由注册、
realpath/file_get_contents; - 静态核验:官方 manifest 逐文件 sha256 比对(1.1.5 vs 1.1.9,按
path建索引)、源码锚点回溯。 - 审计范围:请求引导链路、SplitDB 数据层、插件系统、缓存体系、模板渲染、前端资源;可靠性覆盖升级器 / 队列 / 计数器 / 搜索。
- 外部输入核验:GPT《FlintHub 1.1.9 代码审计与性能回退报告》逐条核验——13 条成立 / 1 条部分成立 / 0 条不成立;其中「220 个文件改动」系我方比对脚本 bug(把 manifest 数组下标当成路径),已修正为 16 个。
1.1 实测症状(修复前基线)
| 请求类型 | 实测耗时 | 说明 |
|---|---|---|
| 游客命中前置缓存(首页/版块页) | 6 ms | 直接输出静态 HTML,跳过框架 |
| 游客未命中缓存(列表页首渲) | 1.6 ~ 3.0 s | 发帖/回帖会清空全部列表缓存 |
| 登录用户任意页面 | 1.1 ~ 2.6 s | 登录用户永不命中前置缓存 |
纯 API(不渲染模板,如 /api/pow) | 0.72 ~ 0.87 s | 其中约 0.6 s 花在关闭 SQLite 连接 |
| 404 页 | 1.28 ~ 1.35 s | 光渲染一个布局就要约 0.5 s |
一句话:站点"快"只在游客命中静态缓存时成立;所有登录用户、所有 API、缓存失效后的首访,都在付 1~2.6 秒的固定税。
1.2 三个主要开销来源
| # | 开销层 | 实测 | 是否跨平台 |
|---|---|---|---|
| ① | SQLite 连接关闭(WAL checkpoint + fsync) | ~635 ms/请求 | Windows 特有,Linux 生产应显著更低 |
| ② | 插件钩子执行 | ~265 ms/请求 | 跨平台一致 |
| ③ | 模板渲染 | 0.4 ~ 1.6 s/请求 | 跨平台一致 |
1.3 「最近变慢」的真因
不是核心代码回退,是插件数量增长。1.1.5 → 1.1.9 核心仅 16 个文件(15 改 + 1 增),增量几乎全在后台 / 升级器 / 多语言(合计 < 0.5 ms/请求),热路径查询文件(Post.php / Schema.php / Search.php / Settings.php)一个都没动。而 plugins/ 下已有 51 个插件目录、37 个已启用、14 个关闭:
init_after挂了 32 个、admin_route_register挂了 31 个(每请求都执行,哪怕访问前台)、route_register26 个、layout_head_end21 个;main.php布局里有 9 处Plugin::hook();探针内 SQL 仅 8~10 次——慢的是插件 PHP 代码执行,不是数据库查询。
「加了功能就变慢」= 启用插件数从早期个位数涨到 37 个,而钩子是「每请求、按名 include、无预编译索引」的模式,线性叠加。
1.4 数据层无严重 N+1(页面自报 vs 端到端)
| 页面 | 自报渲染耗时 | 数据查询次数 | 端到端(curl) |
|---|---|---|---|
/tags | 0.152 s | 9 次 | 1.30 s |
/blog | 0.226 s | 12 次 | 1.49 s |
/forum?page=3 | 0.296 s | 11 次 | 1.70 s |
/thread/1 | 0.483 s | 17 次 | 2.55 s |
查询数很正常(9~17 次),没有严重 N+1;自报与端到端的差额全部落在模板渲染与请求收尾上。
二、发现的问题(表格)
严重度沿用原始审计分级;「状态」为本次交付口径。P = 性能,R = 可靠性。
| 编号 | 类别 | 问题 | 位置/文件 | 严重度 | 状态 |
|---|---|---|---|---|---|
| P0-1 | 性能 | 插件钩子每请求 ~265 ms:hook() 每次遍历 51 个插件逐个判 activated + realpath ×2 + file_exists;符号判定结果只存请求级静态数组,每请求重复 file_get_contents 读源码;admin_route_register 前台请求也执行 | app/Helpers/Plugin.php:604/636、main.php、index.php | P0 | 已修复(A1/A2) |
| P0-2 | 性能 | layout_body_end 仅 2 个插件却耗 54.6 ms(~27 ms/插件,明显异常) | plugins/notice_center、plugins/external_link_guard 的 renderPopup()(plugins/*/hook/layout_body_end.php) | P0 | 未修(未排期) |
| P1-1 | 性能 | SQLite 连接关闭 ~635 ms(WAL 关闭时 checkpoint + fsync;Windows fsync 极慢,须先在生产 Linux 实测) | app/SplitDB/DBFactory.php:39 PRAGMAS | P1 | 未修(待生产实测) |
| P1-2 | 性能 | 模板渲染 0.4~1.6 s(引导 153 ms + 收尾 668 ms 之外的大头);asset() 每次 file_exists + filemtime | TemplateCompiler::display()、app/Core/TemplateCompiler.php:369 | P1 | 部分缓解(钩子提速后) |
| P2-1 | 性能 | I18n 每请求 48.4 ms:require 100KB 语言包 + 遍历全部插件读 lang/*.php 合并 | app/Helpers/I18n.php:39/58 | P2 | 未修 |
| P2-2 | 性能 | 插件扫描固定税:每请求 glob() + 51 次 stat,未启用的 14 个插件也参与 | app/Helpers/Plugin.php:475 cacheFresh() | P2 | 未修 |
| P2-3 | 性能 | 登录用户永不命中前置缓存(前置缓存仅对游客生效) | index.php:77-156 | P2 | 未修 |
| P2-4 | 性能 | 前端大资源:editor.js 107KB、modern.css 89.7KB、marked.min.js 39.9KB | assets/、main.php | P2 | 核验后无需改(editor.js 受 needsEditor 门控,仅编辑页加载,GPT 此条正确) |
| P3-1 | 性能 | 回复分页缺组合索引:仅单列 idx_reply_pid(pid),ORDER BY create_time ASC, id ASC 走临时 B-TREE,随单帖回复数增长劣化 | app/SplitDB/Schema.php:983、Post.php:243 getByThread() | P1 | 已修复(B1) |
| P3-2 | 性能 | 桶容量守卫全表 SELECT COUNT() FROM topic + COUNT() FROM reply,桶越大越慢,每请求首次调用必触发 | app/Models/Post.php:172 guardBucketCapacity() | P2 | 已修复(B2) |
| P3-3 | 性能 | 主题附件「全量加载」:渲染当前页却加载该帖全部附件,大帖拖慢并抬内存 | app/Helpers/Settings.php:1320 getThreadAttachmentsGrouped()、ThreadController.php:240-257 | P2 | 已修复(B3) |
| P3-4 | 性能 | Queue::initTable 每次入队都跑 DDL(CREATE TABLE + 2 索引 + PRAGMA table_info) | app/SplitDB/Queue.php:33-100 | P2 | 已修复(B4) |
| R-1 | 可靠性 | 跨库写回复非原子:begin() 只包 business 表,桶库 + main_index 写入在事务外(已知设计约束,Post::insert() 有补偿兜底) | ThreadController.php:135-157、Post.php:71-128 | P2 | 已加强(D7 补偿) |
| R-2 | 可靠性 | ID 生成器共享写锁(BEGIN IMMEDIATE,busy_timeout 1000ms + 3 次重试),全局串行化点 | app/SplitDB/IDGenerator.php:49-115 | P3 | 记录为扩容红线,未改 |
| R-3 | 可靠性 | ViewCounter CAS 补偿缺口:$claimed[$key] 赋值在写库之后,SQL 抛异常则该键计数永久丢失 | app/SplitDB/ViewCounter.php:152-189 | P1 | 已修复(D5) |
| R-4 | 可靠性 | QueueConsumer 无心跳续租:超 300s 的长任务可能被另一 worker 重复领取 | app/SplitDB/QueueConsumer.php:29/236-273 | P1 | 已修复(D6) |
| R-5 | 可靠性 | 升级器 resetState() 先清进度、后检查陈旧锁,存在「清了进度却拿不到锁」的中间态 | UpdateController.php:424-435 | P2 | 已修复(D1) |
| R-6 | 可靠性 | rollback 同 owner 允许续约,可能在他人持锁语义下续锁 | UpdateController.php:752-776、Updater.php:1378-1391 | P2 | 已修复(D2) |
| R-7 | 可靠性 | prunePackages 把 .zip 与 .zip.fh_tmp 归入同一组按 mtime 排序,临时文件可能挤掉有效正式包 | app/Helpers/Updater.php:2204-2254 | P2 | 已修复(D4) |
| R-8 | 可靠性 | MarketController::prune 无 applyInProgress 守卫、无 AuditLog,升级中可能误删市场缓存 | MarketController.php:262-270 | P2 | 已修复(D3) |
| R-9 | 可靠性 | Search 回退过宽:catch (\Throwable) 无条件回退 searchLIKE,把真实故障静默降级成全表 LIKE | app/Helpers/Search.php:39-42 | P2 | 已修复(C2 收窄) |
| R-10 | 正确性 | blog 查询 title LIKE :q1 OR content LIKE :q2 未加括号,AND 优先级高于 OR 导致越界匹配 | app/Helpers/Search.php:356/395 | P1 | 已修复(C1) |
三、修复措施
总览:改动 13 个文件(全部在 app/ 与 index.php),未触碰任何插件文件;17 套件回归全 PASS;生产数据影响为零(探针全部沙箱 / 快照还原)。
3.1 阶段一 · 插件钩子提速(性能,收益最大)
A1 钩子索引化(对应 P0-1 根因 1、3)
- 改法:
Plugin::hook()(Plugin.php:677)不再每次遍历 51 个插件目录,改为首次调用经hookIndex()(:738)一次性构建hookName → [条目]索引,之后 O(1) 查表;index.php:369-383让admin_route_register只在/admin或/admin/前缀请求触发(原每请求都跑,含前台)。 - 效果:
hook()省掉每次调用的 51 次遍历 +activated判断 +realpath×2;前台请求省掉 31 个插件的后台路由注册(实测约 36.5 ms)。预估回收 150~200 ms/请求。
A2 符号判定落盘(对应 P0-1 根因 2)
- 改法:
hookDeclaresSymbols()(Plugin.php:790)判定钩子文件是否声明顶层 function/class/interface/trait,结果按[mtime,size]缓存到data/runtime/hook_symbols.json(:861,SYMBOL_CACHE_VERSION=2),消除每请求file_get_contents。 - 效果:冷 29.5 ms → 热 4.3 ms,省 25.2 ms/请求;207 个文件判定结果与直接读文件 100% 一致。
- ⚠️ 关键约束(务必保留):该判定对
nav_plugin_links这类文件必须返回 false——hook()对「声明了符号」的文件降级为include_once,若误判为 true,第二次触发会被静默吃掉(插件开发规范 §9.3 契约)。
3.2 阶段二 · 防劣化加固(数据量增长前必须做)
B1 回复分页组合索引(P3-1)
- 改法:
BUCKET_SCHEMA_VERSION1→2,新增组合索引idx_reply_pid_status_ct (pid, status, create_time, id)(Schema.php:904/992)。列序刻意让status不打头(规划器会误选 → TEMP B-TREE 回归);DDL 写在CREATE TABLE之后(本项目已知坑:topic_index禁 status 打头索引)。 - 效果:
EXPLAIN QUERY PLAN实测SEARCH reply USING INDEX idx_reply_pid_status_ct (pid=? AND status=?),无 TEMP B-TREE;32 个桶全部建好,分页不再随单帖回复数增长劣化。
B2 桶负载计数缓存(P3-2)
- 改法:
guardBucketCapacity()(Post.php:191)改走bucketLoadCached()(请求内内存 → 磁盘data/runtime/cache_bucket_guard.json,TTL 60s);insert()成功后bumpBucketLoad()(:147)就地 +1。 - 效果:首次 33 次计数 0.16 ms → 缓存命中 0.003 ms(约 50 倍),与
COUNT(*)真值一致。阈值语义未变:100% 抛RuntimeException(1001)、90% 触发emergencyExpand。
B3 附件按页取(P3-3)
- 改法:
ThreadController.php:263由getThreadAttachmentsGrouped($threadId)(拉全帖)改为getThreadAttachmentsForPage($threadId, $postIds)(Settings.php:1347)——主楼附件恒给,回帖附件按当前页post_id白名单过滤 + 去重,返回结构与原方法一致。 - 效果:thread 70(1 主楼 + 5 回帖附件)实测——全页 == 全量,子页 5→2 条,空页仅主楼;大帖(数十张图)不再为渲染一页而加载全部附件,内存/耗时随页收敛。
B4 Queue::initTable 短路(P3-4)
- 改法:
Queue.php:31/44/67新增static array $tableReady,按spl_object_id($db)标记;只在全部 DDL 成功后才标记(失败不标记,下次重试)。 - 效果:同一 PDO 实例后续入队省掉 3 条 DDL +
PRAGMA table_info。
3.3 Search 正确性修复(提前做)
C1 blog LIKE 的 OR 加括号(R-10)
- 改法:
Search.php:393(COUNT)/:395(列表)把title LIKE :q1 OR content LIKE :q2 AND created_at >= :since改为(title LIKE :q1 OR content LIKE :q2) AND created_at >= :since。 - 效果:A/B 双跑实测,旧 SQL 泄漏 3 行越界数据(#9
2026-09-01、#13/#142026-09-02),新 SQL 仅返回区间内 #16;无过滤条件时行为不变。
C2 回退收窄(R-9)
- 改法:
Search.php:44/59新增isIndexUnavailable():只对PDOException且 message 含no such table/no such column/malformed/file is not a database/unable to open database时回退 LIKE,其余异常继续上抛。
3.4 阶段三/四 · 可靠性(故障注入式修复)
D1/D2 升级器 apply 联锁(R-5 / R-6)
- 改法:
UpdateController.php:436resetState()在clearProgress()之前插入applyInProgress()守卫;:782rollback()在取升级锁之前插入守卫;apply 进行中一律 409 拒绝。判据统一到Updater::applyInProgress()(与升级锁同口径LOCK_TTL),陈旧进度可自愈。 - 效果:注入伪造
data/runtime/update_progress.json(step=apply)后实测——reset-state / rollback 均返回 409,进度文件 sha256 不变、备份目录 sentinel 未被触碰;进度at = now - 4000s时返回 200 正常清理(陈旧锁不永久锁死)。
D3 MarketController::prune 保护(R-8)
- 改法:
MarketController.php:269加applyInProgress()守卫(302 带err=apply_in_progress回跳,PRG 模式),:277补AuditLog::log('market_prune_packages', ...)。 - 效果:伪造进度 + 4 个假包实测 302 且包数量不变。
D4 prunePackages tmp 分离(R-7)
- 改法:
Updater.php:2221/2236分组键对半截包追加:tmp后缀,.zip与.zip.fh_tmp不再互相挤占「留最新 N」名额。P0 回归 65/65 覆盖。
D5 ViewCounter::flush CAS 补偿(R-3)
- 改法:
ViewCounter.php:167把$claimed[$key] = $views从「写库成功后」提到 CAS 抢占成功后、写库之前。 - 效果:注入「
prepare()必抛」的 PDO 子类后,被 CAS 置 0 的计数原样归还 7;A/B 反证(沙箱变体换回旧顺序)同注入下计数丢成 0 → 测试确实能捕获该缺陷。
D6 QueueConsumer 心跳(R-4)
- 改法:新增
$currentLease(:53)+heartbeat()(:257);processTask()在 try 里设置租约、finally清空(:219/236),并把$this作为第 4 实参传给处理器回调。 - 效果:有心跳时 8/8 续租成功,
recoverTimeout()一次都没回收任务,正常收尾done;无心跳的故障版则被回收(status=pending, retry_count=1,迟到的 done 不命中)→ 证实「同一任务被并发执行两次」故障真实存在且已被消除。边界:不在处理中 / 空 token / 任务已 done 均返回 false。
D7 跨库回帖补偿 purgeJustInserted(R-1)
- 改法:新增
Post::purgeJustInserted()(Post.php:415)——按reply_index.bucket_path定位,删桶行 +reply_index行 + extern 正文;刻意不碰计数器(避免双扣);索引行不存在则返回 false(幂等)。ThreadController.php:164在回帖catch里$postId>0时调用。 - 效果:
VACUUM INTO一致快照沙箱 + 在main_index上装BEFORE INSERT … RAISE(ABORT)触发器,实测 21/21——insert 失败不留孤儿(桶行 / extern 都撤掉),补偿后三处产物全清、reply_count一个不动、二次调用幂等返回 false。
四、验证与回归
运行器 .workbuddy-ai/tmp/fh_regress_all.sh,完整输出存档 .workbuddy-ai/tmp/regress_final.log。17 套件全 PASS / 0 非 PASS:
| # | 套件 | 结果 |
|---|---|---|
| ① | 语法 php -l(13 文件 × PHP 8.2 + PHP 8.0) | 26 次全通过 |
| ② | 全角标点吞噬变量(php_var_cjk_check.php) | 13 文件零命中 |
| ③ | P0 回归(fh_updater_diff_test.php,升级器纯逻辑) | 65/65 |
| ④ | P4 沙箱(fh_p4_sandbox_test.php) | 32/32 |
| ⑤ | i18n(fh_i18n_check.php) | 55/55,缺键 0 |
| ⑥ | 零内联(规范 §7.3 P2 原文命令) | 前台零命中(后台既有 1 处为插件旧基线) |
| ⑦ | 阶段一 等价性 + HTTP 冒烟(p1_equiv.php / p1_smoke.sh) | 4/4 + 7/7 |
| ⑧ | 阶段二 索引 / 计数 / 附件 / Queue(p2_verify.php 等) | 11/11 + 5/5 + 6/6 |
| ⑨ | Search 括号 A/B(p3_search.php) | 7/7 |
| ⑩ | 阶段四 升级锁/回滚/清理 HTTP 注入(p4_fault.sh) | 11/11 |
| ⑪ | ViewCounter CAS 补偿(打桩 + A/B,p4b_viewcounter.php) | 12/12 |
| ⑫ | QueueConsumer 心跳 A/B(p4c_queue.php) | 13/13 |
| ⑬ | 跨库回帖补偿沙箱注入(p4d_crossdb.php) | 21/21 |
| ⑭ | 探针临时产物清理 | 残留 0 |
要点:
- 等价性验证:
p1_equiv.php在同一进程跑「新索引」与「旧逐插件枚举」两条路径,38 个钩子名 / 207 条目(含顺序)完全一致,符号判定 207/207 一致。 - 故障注入而非「改完就算」:伪造升级进度 JSON、全局命名空间打桩
apcu_*(本机无 apcu 扩展)、必抛异常的 PDO 子类、VACUUM INTO沙箱快照(零生产写入)。 - A/B 反证:ViewCounter、QueueConsumer 两项均把真码复制成沙箱变体、换回旧实现,同注入下缺陷复现(计数归零 / 任务被重复领取),证明测试确实有效。
- 冒烟覆盖:真实
php -S(带 OPcache)curl 打/、/thread/1、/admin、/admin/suiyu等,200 + 关键 DOM 容器在位 + 服务端日志零致命错误。 - 探针产物零残留;一次探针全角括号警告污染
protected/error.log已修源并精确清理(20 → 15 行)。
五、遗留与后续建议
- 「main.php 合并重复钩子」——主动放弃,因为会破坏功能。取证:
nav_plugin_links有两个真实调用点且参数不同(main.php:246无参数 /_components/left_sidebar.php:100带['__nav_uri' => $currentUri]),插件suiyu/hook/nav_plugin_links.php:35分支依赖该参数做 PC 高亮;nav_user_menu_items两次分别是placement=pc(main.php:274)与placement=mobile(:388)两个不同容器。这是核心与插件的显式契约(一次渲染、两套容器、两套参数),合并 = 破坏 PC 高亮 / 移动端入口。建议从优化项中永久划掉。 - 新发现:
route_before_dispatch每请求触发两次(未改,待定):index.php:406(带['router' => $router])与app/Core/Router.php:93(无参数)各一次,3 个插件(forum_required_tag/mod_system/user_level)每请求被执行两遍。建议删前者并给后者补参,或加一次性守卫。收益小、语义更干净。 rollback联锁位置在safeBackupDir()之后(未改):备份目录恰好被删/改名时会先返回 400「目录非法」盖住真正原因;真实 UI 不会踩,仅为报错优先级问题。可将rollback()/deleteBackup()的联锁前移 4 行。- 未修的性能项:P0-2(
layout_body_end两插件 54.6 ms,建议单独 profile 其renderPopup(),加请求级缓存或会话节流);P1-1(SQLite 关闭 ~635 ms 系 Windows 特性,先在生产 Linux 实测,若偏高再加PRAGMA wal_autocheckpoint=0+ 宝塔计划任务定时PRAGMA wal_checkpoint(TRUNCATE);不建议降synchronous);P1-2 余量(建议 Xdebug 采样/thread/1定位布局热点);P2-1(合并语言包落盘缓存,估省 ~40 ms/请求);P2-3(登录用户列表片段缓存,需防用户态数据串号)。 - 环境事项:本机 Windows 数字不可外推 Linux 生产(所有相对收益成立,绝对耗时不外推);本机 PHP 无 apcu 扩展,D5 补偿只在 APCu 可用环境生效(生产无 APCu 则走
inc()直写、无此风险)——建议php -m | grep apcu确认。 - 运行时新增两个文件(自动创建,不进包):
data/runtime/hook_symbols.json(SYMBOL_CACHE_VERSION=2)、data/runtime/cache_bucket_guard.json(TTL 60s)。上线后首次请求冷建一次(本机实测 29.5 ms),之后走热路径。桶索引为懒建:user_version1→2 后各桶首次打开时补建(本站 32 桶已全建,他站一次性、幂等)。 - 页面缓存未清:
data/runtime/pages/未清理,如需改动立刻对游客可见,手动清一次即可。 - 可复用验证资产(
.workbuddy-ai/tmp/):fh_regress_all.sh、p1_equiv.php、p2_verify.php、p2_verify2.php、p2_att.php、p3_search.php、p4b_viewcounter.php、p4c_queue.php、p4d_crossdb.php、p4_fault.sh、regress_final.log。