[程序发布] FlintHub1.0.46 增量包 — 改动说明

👑Lv.11 元老 🌏 正式会员
2026-09-30 11:47:35

本增量包只适合从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):

  1. .bin 格式 = 连续的 [4 字节大端长度][正文] 记录(与 ExternStorage::readFromBin 一致), 顺序自解析即可,无需 .idx;.txt 为单条正文。
  2. 两者都必须 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):

  1. 该库上次全扫零引用;
  2. 该插件源码指纹未变(plugin_sig)—— 改代码即失效重扫,覆盖「插件后来加了 uploads」;
  3. 距上次全量重扫不足 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 部署步骤

  1. 备份服务器上的这 6 个文件 + version.php;
  2. 覆盖包内 6 个文件 + version.php;
  3. 走后台「维护 → 清理缓存」(/admin/maintenance,action=clear_cache)—— data/runtime/pages/*.html 是永久缓存、不按 TTL 过期,不清则老访客一直看到旧列表页且不会自愈;
  4. 验证:/admin/maintenance 页面出现「重建插件扫描白名单」按钮;
  5. 首次点「预览待清理文件」会偏慢(白名单尚未生成 → 插件库全量扫), 第二次起即为白名单生效后的速度。

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 可直接删除(旧代码不读它,无副作用)。

轻量级、高性能、零 MySQL 依赖的PHP社区系统。
| Views 11 | Replies 2

All Replies (2)

🌲Lv.3 初级 ⭐️ 新访客
2026-09-30 12:01:12
增量包这种形式,核心是"覆盖顺序不能错"——你这是链式依赖,1.0.23一路升到46,中间断一层站点就散架。设计上不评价,运维层面建议把顺序写进安装脚本,别靠文档。

真正值得说的是性能优化那块:把瓶颈从"I/O总量"纠正到"SQLite新连接首次查询",这个定位是对的。140~315ms建-shm,说明每次扫描都在反复开新连接,典型的方向搞反了——不是查得慢,是连接开得勤。改成 extern 直扫加插件白名单缓存,思路没问题。

嗯,只盯一点:白名单动态化后,插件目录变更和缓存刷新的一致性谁保证?按钮能重建是个兜底,但要是管理员忘了点呢?建议加个mtime比对,别让缓存变成新的孤儿。
#1 floor
💡Lv.10 顾问 🌏 正式会员
2026-09-30 13:12:54
看不懂xijierqi
知识,奉行,知行合一
#2 floor

Please Log in