反馈修复 [已修复] P-1 统计刷新改增量 + P-2 索引补丁-1.0.2

2026-09-15 10:56:59
版本 V1.0.2version.php,末位数字 2 = 目录号 2)
主题 P-1 新增方向统计刷新改增量;P-2 reply_indexstatus 索引
目标环境 IIS + 腾讯云函数(每 5 分钟触发 cron_trigger.php);无任务计划程序、无命令行、无常驻 worker
涉及静态资源 (未改 CSS / JS,故 sw.jsCACHE_VERSION 不需要递增,本包不含 sw.js
数据库结构变更 仅新增 1 个索引,无表结构变更、无数据迁移
部署关键点 queue_mode 保持 cron,切勿改为 sync(详见 §3.2)

一、变更文件清单

# 文件 变更类型
1 app/Helpers/Settings.php runtimeIncr() 内惰性注册 flushCounters(1c)
2 app/Models/Post.php insert() 同步分支 runtimeBuild()runtimeIncr('total_posts')(1a)
3 app/Models/Thread.php insert() 同步分支 runtimeBuild()runtimeIncr('total_threads')(1b)
4 app/SplitDB/Schema.php 新装索引 + marker 门控的存量站索引补丁(2a / 2b)
5 cli/worker.php stats 合并节流 + 显式 flushCounters(Q1 / Q2)
6 cron_trigger.php stats 合并节流 + 每日计数器校准兜底(Q1 / 阶段 3)
7 cli/bench_write.php 待办事宜/ 移入 cli/;补无 pcntl 时的顺序回退

二、逐项改动说明

1. app/Helpers/Settings.php — 惰性注册 flushCounters(1c,最关键

runtimeIncr() 只把增量累加进内存 $pendingCounters,真正落库靠 flushCounters(); 而 flushCounters 原先只在 index.php:280 注册。因此 CLI / cron / 子进程入口调用 runtimeIncr 时,增量会随进程结束被丢弃。

现改为在 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']);
    }
    ...
}

重复调用由 $flushRegistered 去重,无额外开销;已注册过的入口(index.php)不受影响。

本项不涉及任何插件文件 —— plugins/ 目录下无一处改动,因此包内也不含插件。 「哪些插件受益、哪些本来就正常」的界定见 §五 第 2 条。

为什么必须与 1a / 1b 捆绑:改动前 runtimeBuild()同步直写 DB,不依赖 flushCounters,所以任何入口都正确。一旦换成 runtimeIncr(),持久化就完全依赖 flushCounters。缺了本项 = 把性能问题换成数据正确性问题。

2 / 3. Post.php / Thread.php — 同步分支改增量

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

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

删除 / 恢复方向原本就是增量Post.php:317 runtimeDecrPost.php:347 runtimeIncrThread.php:604/632/707/709/821/823),本次改动只是把新增方向对齐到同一口径, 消除「新增走全量重建、删除走增量」的不对称。

Thread.php 中的 Search::indexThread($id) 保持不变(属搜索索引,非统计重建)。

4. app/SplitDB/Schema.php — 索引(2a)与存量站补丁(2b)

2a — 新装路径,在 ensureMainIndex()idx_reply_index_uid 之后追加 (遵守该文件既有的「索引顺序红线:必须位于 CREATE TABLE 之后」):

$db->exec('CREATE INDEX IF NOT EXISTS idx_reply_index_status ON reply_index (status)');

2b — 存量站补丁(原方案缺失的一环)Schema::bootstrap() 只在 data/meta 不存在时调用(app/Core/Database.php:39),每请求只跑 ensureColumnPatches()。 即只改 ensureMainIndex() 的话,存量站升级后不会建索引

因此扩展了 ensureColumnPatches():新增 $indexPatches 清单,并把它并入既有的 marker 哈希。补丁清单变更 → 哈希变 → 升级后首个请求自动执行,之后命中 marker 短路,零每请求开销。

$indexPatches = [
    'idx_reply_index_status' => 'CREATE INDEX IF NOT EXISTS idx_reply_index_status ON reply_index (status)',
];
$marker = $runtimeDir . '/schema_patches_' . md5(
    (string)json_encode($columnPatches) . (string)json_encode($indexPatches)
) . '.ok';

补丁执行块放在写标记之前:失败则不写标记 → 下次请求自动重试(补列与回填均幂等)。 建索引失败只记日志、不抛异常(索引缺失仅影响性能,不影响功能)。

补丁对 main_index.sqlite 执行,故内部另取 self::mainIndexDb()$db 参数是 business 连接)。

5. cli/worker.php — stats 合并节流(Q1)+ 显式 flush(Q2)

stats 任务保留全量重建runtimeBuild() 是幂等自校正,改增量后队列一旦丢任务即永久漂移), 但不再「每条各重建一次」,改为「脏标记 + 统一重建」:

  • case 'stats':只 $statsDirty = true; $statsPending++;,不做 COUNT;
  • 时间兜底窗口 5s:持续积压导致队列不空闲、onIdle 不触发时,保证最长 5s 重建一次;
  • 出口三处:--once 的 drain 结束后、常驻模式的 $onIdle()(置于扩容 60s 节流之前,不受其限制);
  • 重建成功后调用 Settings::flushCounters(),把本进程累计的 runtimeIncr 增量按批落库, 避免常驻进程被 kill 时丢失;
  • 新增一行可观测输出:[worker] stats 合并重建:N 条 stats 任务 → 1 次全量重建

6. cron_trigger.php — 合并节流(Q1)+ 每日校准(阶段 3)

  • handler 的 case 'stats' 改为只置 $statsDirty
  • 新增 ⑤b 合并重建 stats:3 个队列全部消费完毕后统一 runtimeBuild() 一次;
  • 新增 d) 运行时计数器每日校准:挂入既有的 $onceEvery 节流段,每日一次 runtimeBuild(),作为 P-1 增量化的漂移兜底。

后台「维护 → 重建统计」(MaintenanceController::runtime_rebuild保留为人工校准入口。

7. cli/bench_write.php — 位置修正 + 无 pcntl 回退

  • 原位于 待办事宜/,但第 43 行 require_once __DIR__ . '/_guard.php' 指向不存在的文件 (cli/_guard.php 才存在)→ 直接运行会 fatal。现移入 cli/,相对路径全部正确。
  • Windows PHP 无 pcntl 扩展(该扩展仅 Unix 可用),原脚本会直接 exit。现改为 退化为单进程顺序执行同一工作量,输出「执行方式」与相应解读提示; 有 pcntl 时行为与原来完全一致(fork 分支逻辑未变,仅抽出共用的 $oneWrite 闭包)。

三、升级步骤

3.1 覆盖文件

  1. 备份 data/protected/
  2. 将本包内文件按目录结构覆盖到站点根目录:
    update/2/version.php                  →  version.php
    update/2/cron_trigger.php             →  cron_trigger.php
    update/2/app/Models/Post.php          →  app/Models/Post.php
    update/2/app/Models/Thread.php        →  app/Models/Thread.php
    update/2/app/Helpers/Settings.php     →  app/Helpers/Settings.php
    update/2/app/SplitDB/Schema.php       →  app/SplitDB/Schema.php
    update/2/cli/worker.php               →  cli/worker.php
    update/2/cli/bench_write.php          →  cli/bench_write.php
    
  3. 触发一次任意请求(访问首页即可):marker 门控索引补丁会自动建 idx_reply_index_status,并在 data/runtime/ 生成新的 schema_patches_*.ok。 可执行以下 SQL 确认:
    EXPLAIN QUERY PLAN SELECT COUNT(*) FROM reply_index WHERE status = 0;
    -- 期望:SEARCH reply_index USING COVERING INDEX idx_reply_index_status (status=?)
    
  4. 登录后台执行一次「维护 → 重建统计」,建立干净的计数器基线 (P-1 改增量后,增量会固化历史漂移,故必须先校准一次)。
  5. 本环境无常驻 worker,无需重启任何进程。 (仅当将来改用 CLI 常驻模式时,才需重启 supervisor 让新的 cli/worker.php 生效。)

3.2 本环境必做确认项(IIS + 腾讯云函数)

★ ① queue_mode 必须保持 cron —— 不要改成 sync

腾讯云函数每 5 分钟触发一次 cron_trigger.php,这就是本项目需要的外部定时器, queue_mode = cron 下队列会被正常消费。

若把 queue_mode 改成 sync,后果比"没有定时器"更严重。 cron_trigger.php 开头有模式守卫(第 31–35 行):

$mode = \app\Helpers\Settings::get('queue_mode', 'sync');
if ($mode !== 'cron') {
    echo 'queue_mode = ' . $mode . ',非 cron 模式,拒绝执行。' . PHP_EOL;
    exit(0);
}

即切到 sync 后云函数会直接退出,下列功能全部停摆:

功能 停摆后果
AI 助手待执行任务消费 自动回复永不发出
会话 GC(300s 节流) sessions 过期行只增不删
审计日志清理(每日) 日志无限增长
旧帖归档标记(每日) 帖子永不归档
孤儿附件清理(每周) 附件只增不减
自动扩容检查 分片不再自动扩
每日计数器校准 漂移失去兜底

注意 P-1 在本环境的作用点app/Models/Post.phpapp/Models/Thread.php 的 增量改动(1a / 1b)只在 sync 分支生效。本环境是 cron 模式,走的是 else 分支的 Queue::push,因此 1a / 1b 对本环境不生效。本环境下 P-1 的实际收益来自 cron_trigger.php⑤b 合并重建:同一轮 5 分钟内 N 条 stats 任务, 从「N 次全量重建」合并为「1 次」。

代价是 total_posts / total_threads 与搜索索引最多滞后 5 分钟。这是异步模式的固有取舍; 版块统计与「最后发表」快照仍会同步刷新(Category::invalidateCategoryStats()refreshLatestSnapshot() 在写入路径上直接调用),只有全站级计数会滞后。

★ ② 腾讯云函数超时设为 ≥ 300s

cron_trigger.php 内部各阶段的时间预算:

顺序 阶段 最坏耗时
轻量维护(会话 GC / 审计清理 / 每日校准) 数秒
e) AI 助手 flushset_time_limit(300) 最长 300s
队列消费(3 队列共享总预算) 240s
⑤b stats 合并重建 ~10ms
自动扩容检查 数秒
重活(归档 / 孤儿附件) 15s

单次最坏耗时 ≈ 560s,已超过 300s 的触发间隔 —— 这是既有设计(靠 lock/cron_trigger.lockflock(LOCK_EX|LOCK_NB) 让重叠的那轮直接跳过), 并非本次改动引入。

但存在一个真实风险e) AI flush 排在 ⑤ 队列消费之前,且自身可占用 300s。 若云函数超时短于 AI flush 的实际耗时,请求会在 AI flush 阶段被掐断, 后面的「队列消费」永远执行不到 → 统计与搜索索引全部停更。 因此请把云函数超时设为 ≥ 300s(SCF 上限 900s,留足余量即可)。

★ ③ 用云函数日志验证 cron 真的跑完了

在云函数日志中检索是否出现:

queue_0: 处理 N 个任务(本队列预算 ...s)
queue_1: 处理 N 个任务(本队列预算 ...s)
queue_2: 处理 N 个任务(本队列预算 ...s)
统计计数已合并重建(本轮 stats 任务合并为 1 次全量重建)。
cron_trigger 完成,共处理 N 个任务。

只要看不到最后那行 cron_trigger 完成,共处理 N 个任务。,就说明每次触发都被提前掐断了, 队列实际上一直没被消费 —— 这是最需要优先排查的情形。

若日志出现 已有 cron_trigger 正在执行,本次跳过。,说明上一轮尚未跑完, 属正常的防并发行为,无需处理。

④ IIS 前置条件与上传安全性

  • 需安装 URL Rewrite 模块并配置 PHP FastCGI 处理器映射,否则 web.config 规则不生效。
  • web.confighiddenSegments 已包含 app / protected / update / data,并有 Deny Core Directories 规则对 ^(app|protected|update)(/|$) 返回 403, 本包上传到 update/2/ 是安全的(不会被外部下载)。
  • cli/worker.phpcli/bench_write.phprequire cli/_guard.php,非 CLI SAPI 一律 403, Web 直访安全;但在无命令行环境下这两个文件用不上,放着无害。

⑤ 关于每日校准

新增的 d) 运行时计数器每日校准 挂在既有 $onceEvery('runtime_calibrate', 86400) 节流上, 只要云函数在跑就会每天自动执行一次,无需额外配置。 人工校准入口仍保留:后台「维护 → 重建统计」。


四、验证结果(本机实测)

阶段 0 — 基线

reply_index 真值(status=0) 1367
_runtime_total_posts 1367 → 无漂移
topic_index 真值(status=0 AND deleted_at IS NULL) 66
_runtime_total_threads 66 → 无漂移
queue_mode cron

queue_mode=cron 意味着本机 1a / 1b 命中的 sync 分支平时不执行, 走的是「入队 stats → handler 全量重建」路径 —— 因此 Q1 的合并节流是本机的主优化点

阶段 1 — 1c 验证(CLI 下 Post::insert 是否让 _runtime_total_posts +1)

场景 插入前 DB 同进程内存 进程退出后 DB 结论
启用惰性注册 1367 1368 1368 ✅ 增量成功落库
临时禁用惰性注册(反证) 1368 1369 1368 ❌ 增量丢失,证实 1c 必要

反证测试完成后已立即还原代码并复核。

阶段 2c — EXPLAIN QUERY PLAN 前后对比

查询 改前 改后
COUNT(*) WHERE status=0(total_posts) SCAN reply_index USING COVERING INDEX idx_reply_index_uid SEARCH reply_index USING COVERING INDEX idx_reply_index_status (status=?)
COUNT(*) WHERE uid=? AND status=0(getStats) SEARCH ... idx_reply_index_uid SEARCH ... idx_reply_index_uid未变,零回归
WHERE pid=? SEARCH ... idx_reply_index_pid SEARCH ... idx_reply_index_pid未变,零回归

存量站补丁也已验证生效:data/runtime/ 下 marker 由 schema_patches_c5ce0916….ok 轮换为 schema_patches_558e990e….ok,且 idx_reply_index_status 已出现在 main_index.sqlite 上。

阶段 4 — 压测报告

A. Q1 合并节流(16 万行 reply_index,200 条 stats 任务)

版本 耗时 重建次数
改前(每条各重建) 4.145 s 200
改后(Q1 合并) 1.310 s 2

提速 3.16x。输出可见:1 条 → 1 次 + 199 条 → 1 次。 小数据量(1381 行)下同样成立:100 条任务 → 2 次重建(1 条 + 99 条)。

B. P-1 写入端(16 万行,queue_mode=sync,640 次写入)

指标 改前 runtimeBuild 改后 runtimeIncr 变化
总耗时 44.481 s 34.991 s −21.3%
吞吐 14.4 写入/秒 18.3 写入/秒 +27.1%
avg 69.5 ms 54.7 ms −21.3%
p50 67.1 ms 53.0 ms −21.0%
p95 75.2 ms 59.2 ms −21.3%
p99 146.5 ms 80.7 ms −44.9%
max 286.0 ms 254.2 ms −11.1%

C. 单次统计刷新成本(隔离微基准,300 次迭代)

实现 avg p50 p95 max
OLD runtimeBuild() 0.2646 ms 0.2351 ms 0.4041 ms 1.0619 ms
NEW runtimeIncr() 0.0008 ms 0.0010 ms 0.0012 ms 0.0091 ms

→ 单次刷新成本降幅 99.7%,每次回帖省下 8 条 SQL(4 次 COUNT + 4 次 UPSERT)。

D. P-2 索引在 16 万行量级的收益(合成库实测)

场景 改前 改后
total_posts COUNT 15.106 ms(SCAN) 9.315 ms(SEARCH)

→ 约 1.6x。说明:status 取值高度倾斜(约 99% 为 0),SEARCH 仍需遍历绝大多数索引项, 收益主要来自索引项变窄(status+rowid 对比 uid+status+rowid),而非跳跃式定位。 计划形态确实由 SCAN 变为 SEARCH,但不要期待数量级提升

E. 小数据量端到端(1381 行,queue_mode=sync,640 次写入)

指标 改前 改后
吞吐 18.2 写入/秒 18.1 写入/秒
p95 59.5 ms 59.8 ms

本地小数据量下无可见差异。原因是写入路径被其它环节主导:实测 Post::insert 单次约 55–87 ms,而其中统计刷新仅占 0.26–4 ms(<5%)。 数据量增长后 COUNT 成本线性上升(16 万行时单次重建约 15 ms),收益才显现(见 B)。


五、注意事项与残留风险

  1. 必须先做一次「重建统计」再上线:P-1 改增量后,增量是在当前 settings 值上加减, 会把历史漂移固化。每日 cron 校准会兜底(本环境云函数在跑,该兜底已确认会生效), 但首次上线仍需人工校准一次。

  2. 1c 修复的是「非 Web 入口」的增量丢失 —— 与任何插件文件无关flushCounters 原先只在 index.php:280 注册,因此任何非 Web 入口调用 runtimeIncr 时,增量都会随进程结束被丢弃。本次惰性注册修复了该既有缺陷。

    • 确实会命中的路径:CLI / 子进程 / cron 中以 sync 模式调用核心 Post::insert / Thread::insert。典型实例是 AI 助手插件派发的 plugins/ai_assistant/cli/worker.php,其发布回复经 Plugin.php:1477(new \app\Models\Post())->insert($data) 落到核心写入。 本环境 queue_mode = cron,该路径是入队而非增量,故不受影响; 但切到 sync 模式、或用 CLI 脚本批量导入时即会命中。
    • 不受影响的plugins/qaplugins/daily_checkinplugins/floor_replyruntimeIncr 调用点全部位于 Web 控制器qa/QaController.php:143:216daily_checkin/CheckinController.php:223floor_reply/FrontController.php:111),而 Web 上下文本就有 index.php:280 注册的 shutdown 钩子,此前也能正常落库。 → 这些插件文件本次一字未改,因此不在本包内,也无需打包。
    • 另:cli/worker.php(本包内)在 stats 重建后额外显式调用了一次 flushCounters(), 使常驻进程的增量按批落库,避免被 kill 时丢失。

    若进程被 kill -9,最后一个批次的增量仍可能丢失 —— 属可接受的近似统计范畴。

  3. 不要给 topic_index 加 status 打头的索引:实测 (status, deleted_at) 与 部分索引 (status) WHERE deleted_at IS NULL 都会让前台列表查询被规划器误选, 出现 USE TEMP B-TREE FOR ORDER BY 排序回归(印证 Schema.php:727-730 既有注释)。 total_threads 的全扫描靠 P-1 降频解决,不靠索引。

  4. bench_write.php 在 Windows 上无法复现并发争用(无 pcntl), 只能测单次写入的工作量变化;并发 / 锁争用需在 Linux 环境验证。

  5. 本次验证期间对测试数据的处理:验证用的合成行与测试回复已全部清理, reply_index 行数(1381)、status=0 计数(1367)、计数器(1367 / 66) 均已核对回阶段 0 基线;queue_mode 已恢复为 cron

  6. cron 阶段顺序风险(本环境重点)e) AI flush 位于 ⑤ 队列消费 之前且可占用 300s, 云函数超时若短于该耗时,队列消费将永不执行。详见 §3.2②。 这是本环境唯一需要人工确认的配置项 —— 其余改动(索引补丁、惰性 flush 注册、 合并重建、每日校准)全部自动生效,无需人工干预。


六、回滚

变更 回滚方式
1a / 1b / 1c / Q1 用上一版文件覆盖即可,无 schema 变更
2a / 2b 索引 DROP INDEX idx_reply_index_status;Schema.phpIF NOT EXISTS 不会自动重建,需一并回滚代码)

回滚后计数器如有偏差,执行一次后台「维护 → 重建统计」即可归一。

最后由 flinthub 于 2026-09-15 12:19 编辑
我喜欢在我的自留地里瞎逛,FlintHub!
| 浏览 0 | 回复 0

全部回复 (0)

暂无回复

登录

×