本文由 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_users | COUNT(*) FROM users | business.sqlite |
total_threads | COUNT(*) FROM topic_index WHERE status=0 AND deleted_at IS NULL | main_index.sqlite |
total_posts | COUNT(*) FROM reply_index WHERE status=0 | main_index.sqlite |
total_blogs | COUNT(*) FROM blogs | business.sqlite |
→ 「4 次 COUNT + 4 次写」「全挤在 main_index / business 两个全局单点」属实。
③ 删除/恢复方向确实已用增量(不对称属实)
| 位置 | 调用 | 语义 |
|---|---|---|
Post.php:317 | runtimeDecr('total_posts') | 回帖软删 |
Post.php:347 | runtimeIncr('total_posts') | 回帖恢复 |
Thread.php:604 | runtimeDecr('total_threads') | 主题软删 |
Thread.php:632 | runtimeIncr('total_threads') | 主题恢复 |
Thread.php:707 / 709 | runtimeDecr('total_threads' / 'total_posts') | 主题硬删 |
Thread.php:821 / 823 | runtimeDecr(..., 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=0 | SEARCH ... 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 handler | cron/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 — 前置(无代码改动)
- 备份
data/与protected/(bench_write.php顶部安全须知也要求如此)。 - 记录基线并判断是否已漂移:
```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 比对。
- 确认当前
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 — 漂移兜底
- 上线后立即执行一次后台「重建统计」(
/admin/maintenance→runtime_rebuild)建立干净基线。 - 在
cron_trigger.php的$onceEvery节流段挂每日校准(与archive_mark、audit_log_purge同写法):
```php
if ($onceEvery('runtime_calibrate', 86400)) {
\app\Helpers\Settings::runtimeBuild();
}
```
- 后台按钮保留为人工校准入口。
阶段 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 个问题
| # | 问题 | 我的建议 |
|---|---|---|
| Q1 | stats handler(cli/worker.php:63 / cron_trigger.php:82)要不要也改增量? | 保持全量重建,但加「同批次合并」节流:一轮 drain 内多个 stats 任务只重建一次。理由是全量重建是幂等自校正,改增量后队列丢任务即永久漂移 |
| Q2 | flushCounters 补齐走哪种方案? | 惰性注册 + worker 每任务后显式 flush(覆盖最全) |
| Q3 | 存量站补索引走哪种? | 方案 i(marker 门控索引补丁) |
| Q4 | P-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(含索引前后对照实验)。