[审计]P-1 / P-2 实施方案(含逻辑链路审计结论)

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

本文由 FlintHub 开发文档归档发布。原始文档日期见标题前缀,来源:WWW/待办事宜/P-1_P-2_实施方案.md。

P-1 / P-2 实施方案(含逻辑链路审计结论)

审计对象:app/ 目录下统计刷新链路 + reply_index 索引

审计方式:静态链路追踪(全仓 grep 调用方)+ 本机 main_index.sqlite 实测 EXPLAIN QUERY PLAN

状态:仅审计与方案,未改动任何生产代码,待指令后实施


一、结论速览

项原判断审计结论能否照原方案直接改
P-1 新增走全量 COUNT属实核心链路属实,但存在 3 个阻塞项,照搬改法会把性能问题换成数据正确性问题❌ 需补 3 处前置条件
P-2 reply_index 缺 status 索引属实实测验证通过,改法零副作用;但发现 1 处遗漏、1 处部署缺口⚠️ 需补存量站迁移

最重要的一条:Settings::flushCounters() 全仓只在 index.php:280 注册。而 runtimeIncr() 是「延迟到请求结束才落库」的机制。当前之所以没事,是因为 runtimeBuild() 内部是同步直写 DB。一旦按原方案把 runtimeBuild() 换成 runtimeIncr(),CLI / cron / 子进程入口的计数增量会随进程结束被丢弃。


二、P-1 链路逐条核验

2.1 属实部分(已逐行确认)

① 两处调用点位置准确

// app/Models/Post.php:101-104
$mode = \app\Helpers\Settings::get('queue_mode', 'sync');
if ($mode === 'sync') {
    \app\Helpers\Settings::runtimeBuild();   // ← 行 104 ✓

// app/Models/Thread.php:112-116
$mode = \app\Helpers\Settings::get('queue_mode', 'sync');
if ($mode === 'sync') {
    \app\Helpers\Search::indexThread($id);
    \app\Helpers\Settings::runtimeBuild();   // ← 行 116 ✓

② 「4 次全量 COUNT + 4 次写」完全属实

Settings::runtimeBuild()(Settings.php:442-448)遍历 4 个键 → runtimeBuildSingle()(398-437)每键 1 次 COUNT + 1 次 UPSERT:

计数器COUNT 语句所在库
total_usersCOUNT(*) FROM usersbusiness.sqlite
total_threadsCOUNT(*) FROM topic_index WHERE status=0 AND deleted_at IS NULLmain_index.sqlite
total_postsCOUNT(*) FROM reply_index WHERE status=0main_index.sqlite
total_blogsCOUNT(*) FROM blogsbusiness.sqlite

→ 「4 次 COUNT + 4 次写」「全挤在 main_index / business 两个全局单点」属实。

③ 删除/恢复方向确实已用增量(不对称属实)

位置调用语义
Post.php:317runtimeDecr('total_posts')回帖软删
Post.php:347runtimeIncr('total_posts')回帖恢复
Thread.php:604runtimeDecr('total_threads')主题软删
Thread.php:632runtimeIncr('total_threads')主题恢复
Thread.php:707 / 709runtimeDecr('total_threads' / 'total_posts')主题硬删
Thread.php:821 / 823runtimeDecr(..., N)主题批量删

→ 「便宜的那条路已经写好了,只是没用在新增方向」属实。

2.2 需修正的表述(不影响结论)

原文写「删除方向(Post.php:347、Thread.php:632)用增量」——Post.php:347 与 Thread.php:632 实际是 restore()(恢复),删除方向是 Post.php:317 / Thread.php:604。结论方向一致,仅行号语义标注有偏差。

2.3 🔴 阻塞项 ①:flushCounters() 只在 index.php 注册

这是本次审计发现的最关键风险。

// app/Helpers/Settings.php:329-360  runtimeIncr()
self::$pendingCounters[$key] += $amount;   // 只累加内存,不写 DB
self::$cache[$dbKey] = (string)self::$runtimeCache[$key];
// 注释原文:「不同步到 DB(延迟到 flushCounters 批量写入)」

// app/Helpers/Settings.php:365  flushCounters()  ← 真正落库的地方

全仓 grep register_shutdown_function 结果:

文件注册内容
index.php:280✅ Settings::flushCounters() + ViewCounter::flush()
cli/_guard.php❌ 无
cli/worker.php❌ 无
cron_trigger.php:65❌ 只注册了 flock 释放

当前不出问题的原因:runtimeBuild() → runtimeBuildSingle() 是同步直写 DB(Settings.php:430-433 的 UPSERT),不依赖 flushCounters。

改 P-1 之后的后果:Post::insert / Thread::insert 在非 index.php 入口产生的增量只停留在内存,进程结束即丢 → 计数器永久不更新。

受影响的真实入口(queue_mode=sync 时会走到模型 insert):

  • plugins/ai_assistant/Plugin.php:1477 → (new \app\Models\Post())->insert($data)(AI 自动回帖,经「立即处理」子进程 / cron 触发)
  • 待办事宜/bench_write.php:245, 284 → Post::insert(CLI 压测脚本)
  • 任何后续新增的 CLI 直写脚本

附带发现:runtimeDecr 若在 cron_trigger.php 进程内被触发,其增量今天就已经会丢(同样的注册缺失)。这是既有潜在缺陷,不是 P-1 引入的,但建议一并修掉。

2.4 🔴 阻塞项 ②:cron/cli 分支未被 P-1 覆盖

Post.php:105-108 / Thread.php:117-122 的 else 分支是 Queue::push('stats', ...),而 handler 在两处都调全量重建:

// cli/worker.php:62-64
case 'stats':
    \app\Helpers\Settings::runtimeBuild();   // 全量 4 COUNT

// cron_trigger.php:81-83
case 'stats':
    \app\Helpers\Settings::runtimeBuild();   // 全量 4 COUNT

而删除方向在 cron/cli 模式下不入队,是直接 runtimeDecr。

→ 同一个不对称在 cron/cli 模式下完整存在,且原方案的改法对它完全无效。 也就是说:只改 Post.php:104 + Thread.php:116,只修好了 sync 模式;cron/cli 模式下每条回帖依然触发一次全量 4 COUNT,且在 worker 里串行执行。

注意:handler 用全量重建可能是有意为之——它是幂等自校正,队列丢任务也能收敛;改增量则丢任务即永久漂移。所以这里不建议简单改成增量,见 §五 Q1。

2.5 🔴 阻塞项 ③:增量会固化已有漂移

runtimeIncr() 是在「当前 settings 值」上做加减(Settings.php:352 self::$runtimeCache[$key] += $amount)。若升级前 _runtime_total_posts / _runtime_total_threads 已存在漂移,增量会把漂移永久固化,且再无自校正机会。

→ 上线前必须先执行一次全量重建建立干净基线。

MaintenanceController.php:58-79 的 action=runtime_rebuild 分支确实存在(两步式:写 .rebuild_pending → 重定向后执行)——原文所说「该按钮已存在,无需新增」属实 ✅。


三、P-2 链路逐条核验

3.1 索引现状(Schema.php:732-746)

$db->exec('CREATE TABLE IF NOT EXISTS reply_index (... status INTEGER DEFAULT 0 ...)');
$db->exec('CREATE INDEX IF NOT EXISTS idx_reply_index_pid ON reply_index (pid)');
// 顺序红线注释:必须位于 reply_index 建表之后
$db->exec('CREATE INDEX IF NOT EXISTS idx_reply_index_uid ON reply_index (uid, status)');

无 status 打头的索引——原文属实 ✅。

3.2 实测执行计划(本机 data/meta/main_index.sqlite)

实测环境:reply_index 1381 行(status=0 共 1367 行),topic_index 66 行。

改前:

EXPLAIN QUERY PLAN SELECT COUNT(*) FROM reply_index WHERE status = 0;
→ SCAN reply_index USING COVERING INDEX idx_reply_index_uid

改后(实验库上执行 CREATE INDEX idx_reply_index_status ON reply_index (status)):

→ SEARCH reply_index USING COVERING INDEX idx_reply_index_status (status=?)

✅ 与原文预期完全一致。

一处精确化:现状是索引全扫描(SQLite 挑了更窄的 idx_reply_index_uid 做覆盖扫描),而非表扫描。但依然是 O(N)、无法 seek,原文「退化为全表扫描」的结论方向正确。

副作用检查(零回归):

查询加索引后计划是否劣化
COUNT(*) WHERE status=0SEARCH ... idx_reply_index_status✅ 变快
COUNT(*) WHERE uid=? AND status=0(user_profile)SEARCH ... idx_reply_index_uid✅ 不变
WHERE pid=?SEARCH ... idx_reply_index_pid✅ 不变

3.3 🔴 补充发现 A:total_threads 有同样的问题,但不能照 P-2 的方式修

原文只点了 reply_index,遗漏了 topic_index。实测:

EXPLAIN QUERY PLAN SELECT COUNT(*) FROM topic_index WHERE status = 0 AND deleted_at IS NULL;
→ SCAN topic_index USING COVERING INDEX idx_topic_index_uid     ← 同样全扫描

但加索引会造成前台列表页回归。实测给它加 (status, deleted_at) 索引:

total_threads → SEARCH topic_index USING COVERING INDEX idx_topic_index_stats_test (status=? AND deleted_at=?)   ✅
列表查询      → SEARCH topic_index USING INDEX idx_topic_index_stats_test (status=? AND deleted_at=?)             ❌
                USE TEMP B-TREE FOR ORDER BY                                                                     ❌

换用部分索引 (status) WHERE deleted_at IS NULL 同样触发 USE TEMP B-TREE FOR ORDER BY。

这正好复现了 Schema.php:727-730 那条注释记录的回归(注释原文:曾加覆盖索引,但规划器会为列表查询误选 → 大表 TEMP B-TREE 排序回归,结论是不需要此索引)。

结论:topic_index 保持不动。 total_threads 的正确解法就是 P-1(把全量 COUNT 降为低频),而不是加索引。这个发现反过来加强了 P-1 的价值。

3.4 补充发现 B:P-2 与 P-1 耦合,收益定位需重估

做了 P-1 之后,sync 模式下 runtimeBuildSingle() 几乎不再被调用。剩余调用点只有:

调用点频率
后台「重建统计」按钮(MaintenanceController.php:70)人工,极低频
cli/worker.php:63 / cron_trigger.php:82 的 stats handlercron/cli 模式下每条回复一次
runtimeGet() 冷启动兜底(Settings.php:320)每进程首次
runtimeIncr() 零值自校正(Settings.php:341)计数为 0 时

→ P-2 的真实收益主要在 cron/cli 模式与冷启动,而非 sync 模式。两者是耦合的,建议一起决策(见 §五 Q4)。

3.5 🔴 部署缺口:ensureMainIndex() 对存量站不会执行

这是原方案缺失的一环,会导致「代码改了、索引没建」。

Database.php:38-44
$root = ...;
if (!is_dir($root . '/meta')) {
    Schema::bootstrap();          // ← 仅当 data/meta 不存在(全新安装)才跑
}
$this->db = Schema::businessDb();
Schema::ensureColumnPatches($this->db);   // ← 每请求只跑这个,且只处理 business.sqlite 的「列」

ensureMainIndex() 只被 bootstrap() 调用(Schema.php:73)。而:

  • install.php:276 → 纯净安装入口(检测到已有用户会主动中止)
  • update/1/install.php:276 → 安装流程
  • cli/migrate.php:477 → 「从旧库迁移」工具,需要 source,不是通用升级入口

→ 只把 CREATE INDEX 写进 ensureMainIndex(),存量站升级后不会建索引。 必须额外补一条显式迁移路径(见 §四 阶段 2b)。


四、实施方案

阶段 0 — 前置(无代码改动)

  1. 备份 data/ 与 protected/(bench_write.php 顶部安全须知也要求如此)。
  2. 记录基线并判断是否已漂移:

```sql

-- 实际值

SELECT COUNT(*) FROM reply_index WHERE status = 0;

SELECT COUNT(*) FROM topic_index WHERE status = 0 AND deleted_at IS NULL;

```

与 settings 表中 _runtime_total_posts / _runtime_total_threads 比对。

  1. 确认当前 queue_mode 值(决定 P-1 是否命中、以及 P-2 收益大小)。

阶段 1 — P-1(3 处改动,必须捆绑,缺一不可)

1a / 1b. 核心替换

// app/Models/Post.php:104
- \app\Helpers\Settings::runtimeBuild();
+ \app\Helpers\Settings::runtimeIncr('total_posts');

// app/Models/Thread.php:116
- \app\Helpers\Settings::runtimeBuild();
+ \app\Helpers\Settings::runtimeIncr('total_threads');

1c. 补 flushCounters 注册点(新增,原方案缺失)

推荐做法(一处修复、全入口覆盖):

// app/Helpers/Settings.php — runtimeIncr() 内首次调用时惰性注册
private static bool $flushRegistered = false;

public static function runtimeIncr(string $key, int $amount = 1): void
{
    if (!self::$flushRegistered) {
        self::$flushRegistered = true;
        \register_shutdown_function([self::class, 'flushCounters']);
    }
    ... 原逻辑 ...
}

配套:常驻进程(cli/worker.php)下 shutdown 只在进程退出时触发,需在 worker handler 末尾补一次显式 flush:

// cli/worker.php — $handler 内 stats 分支之后,或每任务处理完后
\app\Helpers\Settings::flushCounters();

备选(更简单但覆盖不全):在 cli/_guard.php(所有 cli 脚本都 require)与 cron_trigger.php 各加一行 register_shutdown_function(['\app\Helpers\Settings','flushCounters']);。

验收判据:在 CLI 下执行一次 Post::insert,进程结束后 _runtime_total_posts 必须 +1。当前(未做 1c)会不变——这就是缺失的现场证据。

阶段 2 — P-2

2a. 新装路径(按原方案)

// app/SplitDB/Schema.php — 在 746 行 idx_reply_index_uid 之后追加
// 统计口径索引:runtimeBuildSingle('total_posts') 执行 COUNT(*) WHERE status=0,
// 首列为 uid 的复合索引用不上 → 全扫描;本索引使该查询退化为 SEARCH。
// 顺序红线:必须位于 reply_index 建表之后。
$db->exec('CREATE INDEX IF NOT EXISTS idx_reply_index_status ON reply_index (status)');

2b. 存量站补索引(新增,原方案缺失) — 三选一:

方案做法评价
i(推荐)在 Schema::ensureColumnPatches 同级增加一个 marker 门控的「索引补丁」:对 Schema::mainIndexDb() 执行 CREATE INDEX IF NOT EXISTS。与补列同机制(Schema.php:628-673),升级后首个请求自动生效,之后命中 marker 零开销自动、幂等、零每请求税
ii新增 cli/migrate_indexes.php 一次性脚本,升级说明中要求执行需人工执行,易漏
iii后台维护页加「重建索引」按钮需改视图 + 控制器

2c. 验证

EXPLAIN QUERY PLAN SELECT COUNT(*) FROM reply_index WHERE status = 0;
-- 期望:SEARCH reply_index USING COVERING INDEX idx_reply_index_status (status=?)

阶段 3 — 漂移兜底

  1. 上线后立即执行一次后台「重建统计」(/admin/maintenance → runtime_rebuild)建立干净基线。
  2. 在 cron_trigger.php 的 $onceEvery 节流段挂每日校准(与 archive_mark、audit_log_purge 同写法):

```php

if ($onceEvery('runtime_calibrate', 86400)) {

\app\Helpers\Settings::runtimeBuild();

}

```

  1. 后台按钮保留为人工校准入口。

阶段 4 — 压测验证

⚠️ bench_write.php 当前无法直接运行:它在 待办事宜/,但第 43 行 require_once __DIR__ . '/_guard.php' 指向 待办事宜/_guard.php(不存在),而 cli/_guard.php 才存在。脚本自身的用法说明也写的是 cli/bench_write.php。

→ 先移到 cli/ 目录再跑(其余相对路径 ../config.php、../app/Core/Autoloader.php、../protected/ 在 cli/ 下均正确)。

压测矩阵:

# 改前基线
php cli/bench_write.php --mode=hot --thread=<测试帖ID> --concurrency=32 --per=20 --yes
# 改后再跑,对比 P95 与吞吐
# 建议 queue_mode=sync 与 cli 各跑一次(脚本尾部解读提示亦如此建议)
php cli/bench_write.php --cleanup --thread=<测试帖ID>

提示:做完 P-1 但未做 1c 时,bench 期间 total_posts 会停止增长——这本身就是 1c 缺失的现场证据,可用来验证 1c 是否补齐。


五、待你决策的 4 个问题

#问题我的建议
Q1stats handler(cli/worker.php:63 / cron_trigger.php:82)要不要也改增量?保持全量重建,但加「同批次合并」节流:一轮 drain 内多个 stats 任务只重建一次。理由是全量重建是幂等自校正,改增量后队列丢任务即永久漂移
Q2flushCounters 补齐走哪种方案?惰性注册 + worker 每任务后显式 flush(覆盖最全)
Q3存量站补索引走哪种?方案 i(marker 门控索引补丁)
Q4P-2 是否仍单独实施,还是与「cron/cli 模式优化」合并做?建议合并——P-2 的收益主要来自 cron/cli 模式,单独做收益偏弱


六、风险与回滚

风险说明回滚
计数丢失未做 1c 就改 P-1 → CLI/cron 入口计数不再更新改回 runtimeBuild(),无 schema 变更
计数漂移固化未先重建基线就改增量执行一次后台「重建统计」即恢复
索引写入放大reply_index 每次插入多维护 1 个索引(2→3)DROP INDEX idx_reply_index_status;
列表页排序回归若误给 topic_index 加 status 索引 → TEMP B-TREE(已实测复现)不要加,见 §3.3
压测脚本不可用bench_write.php 在 待办事宜/ 下缺 _guard.php移到 cli/


审计依据:静态链路追踪(全仓 grep)+ 本机 main_index.sqlite 实测 EXPLAIN QUERY PLAN(含索引前后对照实验)。

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

All Replies (0)

No replies yet

Please Log in