本增量包只适合从Flinthub1.0.45版本覆盖
如果全新安装请从1.0.23-26-39-40-45-46的顺序进行安装、升级覆盖。
给大家造成的不便,敬请谅解。
- 生成日期:2026-09-30
- 基线版本:站点根
version.php原为V1.0.45;本包携带V1.0.46(站点根已同步升到V1.0.46) - 主题:「清理孤儿附件」性能优化(方案 A = A1 extern 直扫 + A2 插件库动态白名单)
- 依据文档:
docs/清理孤儿附件覆盖率审计_2026-09-29.md第十节(性能优化全过程 + 等价性证明 + 残余风险) - 覆盖范围:仅 6 个文件(4 个功能文件 + 2 个语言包之外的 3 个语言包),无删除项、无新增业务文件
一、包内容与校验
- 镜像文件 6 个 +
version.php,目录结构与站点根一致(非平铺) - 全部镜像与站点根对应文件 SHA256 逐字节一致,0 处不一致
- 语法检查:6 个 PHP 文件全部
No syntax errors detected(php 8.2.9nts) - 编码:全部 UTF-8 无 BOM + LF
- 未改
js/css/ 图片 /sw.js,无新增静态资源
文件清单
| # | 文件 | 行数 | 动作 | 改动要点 |
|---|---|---|---|---|
| 1 | app/Helpers/Settings.php |
1369 | 改 | 第 4 段 bucket 枚举 → data/extern 直扫;第 5 段抽 scanPluginUploads() 并加动态白名单;新增 6 个方法 + 2 个常量 |
| 2 | app/Controllers/Admin/MaintenanceController.php |
141 | 改 | 新增 action=cleanup_rebuild_plugin_cache 分支 |
| 3 | app/Views/admin/maintenance.php |
163 | 改 | 新增「重建插件扫描白名单」按钮 + 说明文案 |
| 4 | lang/zh.php |
1422 | 改 | 新增 3 键 |
| 5 | lang/zh_tw.php |
1422 | 改 | 新增 3 键 |
| 6 | lang/en.php |
1422 | 改 | 新增 3 键 |
| 7 | version.php |
9 | 改 | V1.0.45 → V1.0.46(SW 缓存名由版本号派生,必须随包覆盖) |
SHA256(部署后可逐条比对)
f5edbea966a06292a14a4b745c57985e6876a3f44991d86490e8bd9676c649c0 version.php
3f10f0a37eb36630f1315b903ef979d7186515f73e9f299dc28f8499c8a515f6 app/Helpers/Settings.php
4b66924ea01b026d18d19db1ef4580fcb19fc19eb7382d0756cdfb6feafda6b7 app/Controllers/Admin/MaintenanceController.php
fb093460a361e7f472bc5944e61c79aa1b2d48fea698d63eca00695091221401 app/Views/admin/maintenance.php
a87a2fc012412762e13e660200bf405c4830df72d190566e17d015ea5681787a lang/zh.php
52f836202e178fbfc1f18f6ff4f376b2f205cc82a52ec3e41b5132807d939e28 lang/zh_tw.php
735bc339e316373831a958503ec99a97f56d52ab6e11ac3e6cf56f58aafa3c31 lang/en.php
二、改动前现状与根因
2.1 现状
上一轮把「清理孤儿附件」的覆盖面从 3 个来源扩到 8 个来源 + 插件库兜底(正确性大幅提升),
代价是耗时从 3.2s 涨到 **9.5s**。当时归因为「I/O 总量,代码层优化不掉」。
该结论不成立。 本次实测证明瓶颈是连接数,可优化。
2.2 根因:不是 I/O 总量,是「每个全新 SQLite 连接的首个查询」
微基准(.workbuddy-ai/tmp/cleanup_perf_micro.php,3 轮):
| 动作 | 单次耗时 |
|---|---|
同一连接重复 COUNT(*) |
0.8 ~ 1.1 ms |
| 全新连接 + 首次查询 | 143 ~ 315 ms(建 -shm) |
new PDO 但不查询 |
3.6 ~ 6.2 ms(SQLite 惰性打开,根本没开文件) |
纯 file_get_contents 同一文件 |
1.2 ~ 4.0 ms |
★ 反证:同一个文件重开 32 次同样慢(实测 4579 ~ 10096 ms) → 成本与数据量、文件大小完全无关,就是「每个全新连接的首个查询」。
段分解(cleanup_perf_compare.php,3 轮):
| 段 | 耗时 |
|---|---|
现状:32 个 bucket 库(连接 + extern_path 查询 + 读 1654 条正文) |
3556 ~ 5339 ms |
改为:直扫 data/extern(45 .txt + 92 .bin = 1669 正文块) |
221 ~ 329 ms |
| 插件库段(39 库全扫) | 3506 ~ 4843 ms |
插件库段内部:39 库中 11 个有 uploads 引用 / 28 个零引用;
有引用库合计 1256 ms,零引用库合计 3733 ms(plugin_whitelist_probe.php)。
三、逐项改动细则
3.1 A1:bucket 段改为直扫 data/extern
原做法:遍历 32 个 bucket 库取 reply / topic.extern_path → ExternStorage::read 逐条读正文。
新做法:直接遍历 data/extern 下的 .txt / .bin 正文文件(app/Helpers/Settings.php:831-866)。
$dataPath = defined('SPLITDB_DATA_PATH') ? \rtrim(SPLITDB_DATA_PATH, '/\\') : '';
$externDir = $dataPath !== '' ? $dataPath . '/extern' : '';
if ($externDir !== '' && \is_dir($externDir)) {
$eIt = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($externDir, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($eIt as $eFile) {
if (!$eFile->isFile()) continue;
$eExt = \strtolower($eFile->getExtension());
if ($eExt !== 'txt' && $eExt !== 'bin') continue;
$eRaw = @\file_get_contents($eFile->getPathname());
if ($eRaw === false || $eRaw === '') continue;
if ($eExt === 'txt') {
$eBody = \app\Helpers\Content::decode($eRaw);
if (\preg_match_all($urlRe, $eBody, $m)) {
foreach ($m[1] as $fname) self::markUsedUpload($used, (string)$fname);
}
continue;
}
// .bin:顺序解析长度前缀记录(长度异常即停止该块,与 readFromBin 的容错口径一致)
$eLen = \strlen($eRaw);
$eOff = 0;
while ($eOff + 4 <= $eLen) {
$bl = \unpack('N', \substr($eRaw, $eOff, 4))[1];
$eOff += 4;
if ($bl <= 0 || $eOff + $bl > $eLen) break;
$eBody = \app\Helpers\Content::decode(\substr($eRaw, $eOff, $bl));
$eOff += $bl;
if (\preg_match_all($urlRe, $eBody, $m)) {
foreach ($m[1] as $fname) self::markUsedUpload($used, (string)$fname);
}
}
}
}
两条格式约定(app/Helpers/Settings.php:828-830):
.bin格式 = 连续的[4 字节大端长度][正文]记录(与ExternStorage::readFromBin一致), 顺序自解析即可,无需.idx;.txt为单条正文。- 两者都必须
Content::decode()后再匹配 —— 正文可能是 base64 编辑器提交态, 直接正则匹配不到 → 会误删裂图。
等价性证明(.workbuddy-ai/tmp/equiv2.php):
| 指标 | 结果 |
|---|---|
| 直扫 extern | 1714 正文块 → 34 个去重引用 |
DB 逐条 read |
1654 条 → 19 个去重引用 |
| DB 有、直扫没有(漏标记) | 0 ✅ |
| DB 读出的正文未在直扫集合命中 | 0 ✅ |
| 直扫有、DB 没有(多标记) | 15(安全侧:多标记只会少删,不会误删) |
原因:直扫覆盖 data/extern 下全部正文(含已删除主题/回复的遗留正文),
DB 侧只覆盖「当前仍存在 topic / reply 行」的正文 → 前者必然是后者的超集。
3.2 A2:插件库动态白名单
为什么必须兜底:改动前的 1~4 段只覆盖核心自己的 8 个来源,插件上传的图不在集合里
→ 会被当孤儿 unlink。实测(2026-09-29):本机 dry-run 曾误判 12 个「碎语时光」配图
(sy_posts.images)+ 6 个云存储对象(bitiful_storage.bs_objects,status=pending)为孤儿。
性能改造:39 个库全扫实测 3.2 ~ 5.0s,其中 28 个「零 uploads 引用」的库占 ~3.7s (瓶颈同样是「每个全新连接的首个查询」,非数据量)→ 引入动态白名单, 把「已证明零引用且插件源码未变」的库跳过,实测 4989ms → 1256ms。
缓存文件:data/runtime/plugins_uploads_cache.json
{
"updated_at": 1790737579,
"next_full_scan_at": 1793329579,
"no_uploads_libs": ["ai_assistant/data/meta/task_queue/queue_0.sqlite", "..."],
"plugin_sig": { "props": "d11ed632f7d8...", "..." : "..." },
"last_scan": { "scanned": 11, "skipped": 28, "total": 39 }
}
粒度 = 库(不是插件):实测 ai_assistant 的 4 个库里只有 1 个有引用,
另外 3 个 task_queue 库恰是全场最慢的(185 ~ 257 ms/个)。
按插件粒度白名单收益 61%,按库粒度 75%。
跳过前提严格为「真的没有」(三者同时满足,Settings.php:1021):
- 该库上次全扫零引用;
- 该插件源码指纹未变(
plugin_sig)—— 改代码即失效重扫,覆盖「插件后来加了 uploads」; - 距上次全量重扫不足 30 天(
next_full_scan_at)。
⚠️ 打开失败的库既不进白名单也不标记(Settings.php:1037-1043):
绝不能把「打不开」当成「无引用」,否则下次会被永久跳过 → 该库的引用全丢 → 误删。
缓存缺失 / 损坏 → 退化为全量重扫(安全侧)。
新增的方法与常量:
| 符号 | 位置 | 作用 |
|---|---|---|
PLUGIN_UPLOADS_CACHE |
Settings.php:944 |
缓存文件名 plugins_uploads_cache.json |
PLUGIN_UPLOADS_FULL_SCAN_INTERVAL |
Settings.php:947 |
30 天 = 2592000 秒 |
rebuildPluginUploadsCache() |
Settings.php:954 |
public,后台手动重建入口(forceFull=true) |
scanPluginUploads() |
Settings.php:973 |
扫描插件库 + 维护白名单 |
pluginUploadsCachePath() |
Settings.php:1070 |
缓存路径(目录不存在则创建) |
loadPluginUploadsCache() |
Settings.php:1082 |
读缓存,缺失/损坏一律返回空数组 |
savePluginUploadsCache() |
Settings.php:1095 |
写缓存,失败静默 |
pluginSourceSig() |
Settings.php:1112 |
插件源码指纹(sha1) |
插件源码指纹口径:插件目录下除 data/ 外全部文件的「相对路径:mtime:size」排序串的 sha1。
data/ 下的库文件变更不影响指纹 —— 那是数据变化,不会凭空产生新的「引用形态」;
若把数据变更也算进去,白名单会被高频写的库(队列 / 签到)天天击穿而形同虚设。
数据库连接口径(Settings.php:879-884、:1029-1036):用「默认(可写)模式的裸 PDO + busy_timeout」。
SQLite 会自动以库头记录的 WAL 模式工作,能读到未 checkpoint 的最新数据。
⚠️ 切勿改成 ?mode=ro:只读连接无法创建 -shm,那才会真的读到 WAL 旧快照
(「裸 PDO = 旧快照」这条红线的实际触发条件是 mode=ro)。
同时不设 PRAGMA journal_mode = WAL —— DBFactory 那句在 Windows 上每库多花 ~93ms,
是整段扫描的主要瓶颈。
3.3 后台手动重建入口
app/Controllers/Admin/MaintenanceController.php:121-133,插在 $this->view(...) 之前:
if ($action === 'cleanup_rebuild_plugin_cache') {
Csrf::verifyOrDie($_POST['csrf'] ?? '');
try {
$r = Settings::rebuildPluginUploadsCache();
$message = \app\Helpers\I18n::get('admin.mt_cleanup_cache_rebuilt', [
'scanned' => $r['scanned'], 'skipped' => $r['skipped'],
'norefs' => \count($r['no_uploads']),
]);
} catch (\Exception $e) {
$error = \app\Helpers\I18n::get('admin.mt_cleanup_failed', ['err' => $e->getMessage()]);
}
}
3.4 后台按钮
app/Views/admin/maintenance.php:151-158,紧跟「预览待清理文件」表单之后:
<form method="POST" action="<?php echo $this->url('/admin/maintenance'); ?>" class="mn-mt-10 mn-mb-0" x-data="{ rebuilding: false }" x-on:submit.prevent="if(rebuilding) return; rebuilding = true; $el.submit()">
<input type="hidden" name="csrf" value="<?php echo $csrfToken; ?>">
<input type="hidden" name="action" value="cleanup_rebuild_plugin_cache">
<button type="submit" class="btn btn-secondary" :disabled="rebuilding" x-text="...">
<?php echo $this->t('admin.mt_cleanup_rebuild_cache_btn'); ?>
</button>
<p class="mn-fs-12 c-666 mn-mb-0" style="margin-top:6px;"><?php echo $this->t('admin.mt_cleanup_rebuild_cache_hint'); ?></p>
</form>
3.5 三语 i18n
新增 3 键(lang/*.php:909-911):
| 键 | 中文 |
|---|---|
admin.mt_cleanup_rebuild_cache_btn |
重建插件扫描白名单 |
admin.mt_cleanup_rebuild_cache_hint |
插件库扫描使用动态白名单(跳过已确认无图片引用的库)以加速;安装或升级插件后如担心漏扫,可点此强制全量重扫一遍。 |
admin.mt_cleanup_cache_rebuilt |
插件白名单已重建:本次扫描 {scanned} 个库、跳过 {skipped} 个,其中 {norefs} 个库无图片引用。 |
三语(zh / zh_tw / en)各 1358 键、集合零差异、键序一致。
四、验证方式与结果
4.1 正确性:结果与改动前完全一致
| 项 | 结果 |
|---|---|
cleanup_orphan_real.php(调真实函数 dryRun) |
deleted = 3 |
cleanup_orphan_dryrun.php(独立复刻旧逻辑) |
deleted = 3,清单完全相同 |
| 3 个孤儿的全库字节级检索 | data/ + plugins/*/data/ 零命中 → 确为真孤儿 |
| uploads 文件数 | 387 → 387(dry-run 未删任何文件) |
4.2 性能
同一台机、同一份数据:
| 场景 | 耗时 |
|---|---|
| 旧逻辑(复刻) | 8632 ms |
| 新逻辑 · 首跑(白名单未生成 → 插件库全量扫) | 5215 ms |
| 新逻辑 · 白名单生效 | 1119 ~ 1768 ms |
4.3 白名单机制行为(cleanup_rebuild_cache_probe.php)
| 场景 | 结果 |
|---|---|
强制全量(forceFull=true) |
scanned=39 / skipped=0 ✅ |
| 缓存命中 | skipped=28 ✅ |
| 缓存损坏 | 退化为全量重扫 ✅ |
| 缓存被删除 | 全量重扫并重建 ✅ |
| 插件源码 mtime 改动 | 该插件指纹变化并被重扫 ✅ |
4.4 后台功能(HTTP 层)
| 分支 | 结果 |
|---|---|
action=cleanup_rebuild_plugin_cache |
✅ 提示「插件白名单已重建:本次扫描 39 个库、跳过 0 个,其中 28 个库无图片引用。」 |
action=cleanup_preview |
✅ 清单表头三列齐全 + 确认删除按钮 + 汇总文案「待清理 3 个文件」 |
4.5 静态门禁
语法 / 编码(UTF-8 无 BOM + LF)/ 调试残留 / 卫生(无 config.php、无 data/、无 sw.js)全部通过;
全角标点紧跟变量的检查(.workbuddy-ai/tools/php_var_cjk_check.php)通过;插桩扫描无残留。
五、部署与回滚
5.1 传什么、不传什么
| 内容 | 传不传 | 原因 |
|---|---|---|
包内 6 个代码文件 + version.php |
要传 | 升级主体 |
data/(含 sessions.sqlite、data/runtime/) |
绝对不要传 | 会用「下载那一刻」的快照顶掉服务器真实数据 |
data/runtime/plugins_uploads_cache.json |
不要传 | 运行时缓存,生产首次运行会自动生成 |
assets/uploads/ |
不要传 | 本次未新增任何图片,无需传 |
config.php |
不在包内 | 包内不含该文件(安装生成物) |
5.2 部署步骤
- 备份服务器上的这 6 个文件 +
version.php; - 覆盖包内 6 个文件 +
version.php; - 走后台「维护 → 清理缓存」(
/admin/maintenance,action=clear_cache)——data/runtime/pages/*.html是永久缓存、不按 TTL 过期,不清则老访客一直看到旧列表页且不会自愈; - 验证:
/admin/maintenance页面出现「重建插件扫描白名单」按钮; - 首次点「预览待清理文件」会偏慢(白名单尚未生成 → 插件库全量扫), 第二次起即为白名单生效后的速度。
5.3 ⚠️ 上线前必须核查的两件事(生产侧)
① 节流时间戳(决定「会不会立刻自动清理」)
三个触发点共用节流键 orphan_attachments(文件 data/runtime/cron_last_orphan_attachments.ts):
| 触发点 | 位置 | 频率 |
|---|---|---|
| 后台 | MaintenanceController::index() |
手动 |
| CLI 常驻 | cli/worker2.php:251 |
7 天节流 |
| 云函数 / crontab | cron_trigger.php:263 |
7 天节流 |
后两者只是调用同一个函数,逻辑在 Settings 内 → 本次改动自动覆盖全部部署形态,
无需修改这两个文件。
ls -la /www/wwwroot/flinthub/data/runtime/cron_last_orphan_attachments.ts
cat /www/wwwroot/flinthub/data/runtime/cron_last_orphan_attachments.ts
date +%s
若已过期(本机实测上次执行 = 2026-09-18 22:40,7 天早已过去), 上线前先把它写成当前时间,避免上线瞬间就触发一次真实清理:
echo $(date +%s) > /www/wwwroot/flinthub/data/runtime/cron_last_orphan_attachments.ts
② extern 文件数(新方案的残余风险)
新方案成本 ≈ extern 下文件数 × ~0.8ms(本机 293 文件 = 240ms)。
ExternMerger 只在后台手动触发、不在 cron 里 → 未合并的 .txt 会持续累积。
find /www/wwwroot/flinthub/data/extern -type f | wc -l
若数值巨大,先在后台跑一次「外部正文合并(ExternMerger)」再上线。
⚠️ 但新方案在任何情况下都不会明显劣于旧方案:旧方案同样是 O(正文数),
差异仅「是否多读已删除帖的遗留正文」,且在本机现状下快 16 倍。
5.4 回滚
把这 6 个文件 + version.php 换回备份即可。无数据库结构变更、无业务数据变更,
回滚无需动数据。data/runtime/plugins_uploads_cache.json 可直接删除(旧代码不读它,无副作用)。