[修复]性能与可靠性审计及修复交付说明

👑Lv.11 元老 🌞 骨干会员
2026-10-25 00:43:51

本文由 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_register 26 个、layout_head_end 21 个;
  • main.php 布局里有 9 处 Plugin::hook();探针内 SQL 仅 8~10 次——慢的是插件 PHP 代码执行,不是数据库查询。

「加了功能就变慢」= 启用插件数从早期个位数涨到 37 个,而钩子是「每请求、按名 include、无预编译索引」的模式,线性叠加。

1.4 数据层无严重 N+1(页面自报 vs 端到端)

页面自报渲染耗时数据查询次数端到端(curl)
/tags0.152 s9 次1.30 s
/blog0.226 s12 次1.49 s
/forum?page=30.296 s11 次1.70 s
/thread/10.483 s17 次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.phpP0已修复(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 PRAGMASP1未修(待生产实测)
P1-2性能模板渲染 0.4~1.6 s(引导 153 ms + 收尾 668 ms 之外的大头);asset() 每次 file_exists + filemtimeTemplateCompiler::display()、app/Core/TemplateCompiler.php:369P1部分缓解(钩子提速后)
P2-1性能I18n 每请求 48.4 ms:require 100KB 语言包 + 遍历全部插件读 lang/*.php 合并app/Helpers/I18n.php:39/58P2未修
P2-2性能插件扫描固定税:每请求 glob() + 51 次 stat,未启用的 14 个插件也参与app/Helpers/Plugin.php:475 cacheFresh()P2未修
P2-3性能登录用户永不命中前置缓存(前置缓存仅对游客生效)index.php:77-156P2未修
P2-4性能前端大资源:editor.js 107KB、modern.css 89.7KB、marked.min.js 39.9KBassets/、main.phpP2核验后无需改(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-257P2已修复(B3)
P3-4性能Queue::initTable 每次入队都跑 DDL(CREATE TABLE + 2 索引 + PRAGMA table_info)app/SplitDB/Queue.php:33-100P2已修复(B4)
R-1可靠性跨库写回复非原子:begin() 只包 business 表,桶库 + main_index 写入在事务外(已知设计约束,Post::insert() 有补偿兜底)ThreadController.php:135-157、Post.php:71-128P2已加强(D7 补偿)
R-2可靠性ID 生成器共享写锁(BEGIN IMMEDIATE,busy_timeout 1000ms + 3 次重试),全局串行化点app/SplitDB/IDGenerator.php:49-115P3记录为扩容红线,未改
R-3可靠性ViewCounter CAS 补偿缺口:$claimed[$key] 赋值在写库之后,SQL 抛异常则该键计数永久丢失app/SplitDB/ViewCounter.php:152-189P1已修复(D5)
R-4可靠性QueueConsumer 无心跳续租:超 300s 的长任务可能被另一 worker 重复领取app/SplitDB/QueueConsumer.php:29/236-273P1已修复(D6)
R-5可靠性升级器 resetState() 先清进度、后检查陈旧锁,存在「清了进度却拿不到锁」的中间态UpdateController.php:424-435P2已修复(D1)
R-6可靠性rollback 同 owner 允许续约,可能在他人持锁语义下续锁UpdateController.php:752-776、Updater.php:1378-1391P2已修复(D2)
R-7可靠性prunePackages 把 .zip 与 .zip.fh_tmp 归入同一组按 mtime 排序,临时文件可能挤掉有效正式包app/Helpers/Updater.php:2204-2254P2已修复(D4)
R-8可靠性MarketController::prune 无 applyInProgress 守卫、无 AuditLog,升级中可能误删市场缓存MarketController.php:262-270P2已修复(D3)
R-9可靠性Search 回退过宽:catch (\Throwable) 无条件回退 searchLIKE,把真实故障静默降级成全表 LIKEapp/Helpers/Search.php:39-42P2已修复(C2 收窄)
R-10正确性blog 查询 title LIKE :q1 OR content LIKE :q2 未加括号,AND 优先级高于 OR 导致越界匹配app/Helpers/Search.php:356/395P1已修复(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_VERSION 1→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/#14 2026-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:436 resetState() 在 clearProgress() 之前插入 applyInProgress() 守卫;:782 rollback() 在取升级锁之前插入守卫;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 行)。

五、遗留与后续建议

  1. 「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 高亮 / 移动端入口。建议从优化项中永久划掉。
  2. 新发现:route_before_dispatch 每请求触发两次(未改,待定):index.php:406(带 ['router' => $router])与 app/Core/Router.php:93(无参数)各一次,3 个插件(forum_required_tag / mod_system / user_level)每请求被执行两遍。建议删前者并给后者补参,或加一次性守卫。收益小、语义更干净。
  3. rollback 联锁位置在 safeBackupDir() 之后(未改):备份目录恰好被删/改名时会先返回 400「目录非法」盖住真正原因;真实 UI 不会踩,仅为报错优先级问题。可将 rollback() / deleteBackup() 的联锁前移 4 行。
  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(登录用户列表片段缓存,需防用户态数据串号)。
  5. 环境事项:本机 Windows 数字不可外推 Linux 生产(所有相对收益成立,绝对耗时不外推);本机 PHP 无 apcu 扩展,D5 补偿只在 APCu 可用环境生效(生产无 APCu 则走 inc() 直写、无此风险)——建议 php -m | grep apcu 确认。
  6. 运行时新增两个文件(自动创建,不进包):data/runtime/hook_symbols.json(SYMBOL_CACHE_VERSION=2)、data/runtime/cache_bucket_guard.json(TTL 60s)。上线后首次请求冷建一次(本机实测 29.5 ms),之后走热路径。桶索引为懒建:user_version 1→2 后各桶首次打开时补建(本站 32 桶已全建,他站一次性、幂等)。
  7. 页面缓存未清:data/runtime/pages/ 未清理,如需改动立刻对游客可见,手动清一次即可。
  8. 可复用验证资产(.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。
轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 2 | Replies 0

All Replies (0)

No replies yet

Please Log in