| 项 | 值 |
|---|---|
| 版本 | V1.0.2(version.php,末位数字 2 = 目录号 2) |
| 主题 | P-1 新增方向统计刷新改增量;P-2 reply_index 补 status 索引 |
| 目标环境 | IIS + 腾讯云函数(每 5 分钟触发 cron_trigger.php);无任务计划程序、无命令行、无常驻 worker |
| 涉及静态资源 | 无(未改 CSS / JS,故 sw.js 的 CACHE_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 runtimeDecr、Post.php:347 runtimeIncr、
Thread.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 覆盖文件
- 备份
data/与protected/。 - 将本包内文件按目录结构覆盖到站点根目录:
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 - 触发一次任意请求(访问首页即可):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=?) - 登录后台执行一次「维护 → 重建统计」,建立干净的计数器基线 (P-1 改增量后,增量会固化历史漂移,故必须先校准一次)。
- 本环境无常驻 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.php与app/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 助手 flush(set_time_limit(300)) |
最长 300s |
| ⑤ | 队列消费(3 队列共享总预算) | 240s |
| ⑤b | stats 合并重建 | ~10ms |
| ⑥ | 自动扩容检查 | 数秒 |
| ⑧ | 重活(归档 / 孤儿附件) | 15s |
单次最坏耗时 ≈ 560s,已超过 300s 的触发间隔 —— 这是既有设计(靠
lock/cron_trigger.lock 的 flock(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.config的hiddenSegments已包含app/protected/update/data,并有Deny Core Directories规则对^(app|protected|update)(/|$)返回 403, 本包上传到update/2/是安全的(不会被外部下载)。cli/worker.php与cli/bench_write.php均require 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)。
五、注意事项与残留风险
必须先做一次「重建统计」再上线:P-1 改增量后,增量是在当前 settings 值上加减, 会把历史漂移固化。每日 cron 校准会兜底(本环境云函数在跑,该兜底已确认会生效), 但首次上线仍需人工校准一次。
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/qa、plugins/daily_checkin、plugins/floor_reply的runtimeIncr调用点全部位于 Web 控制器 (qa/QaController.php:143与:216、daily_checkin/CheckinController.php:223、floor_reply/FrontController.php:111),而 Web 上下文本就有index.php:280注册的 shutdown 钩子,此前也能正常落库。 → 这些插件文件本次一字未改,因此不在本包内,也无需打包。 - 另:
cli/worker.php(本包内)在 stats 重建后额外显式调用了一次flushCounters(), 使常驻进程的增量按批落库,避免被 kill 时丢失。
若进程被
kill -9,最后一个批次的增量仍可能丢失 —— 属可接受的近似统计范畴。- 确实会命中的路径:CLI / 子进程 / cron 中以
不要给
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 降频解决,不靠索引。bench_write.php在 Windows 上无法复现并发争用(无pcntl), 只能测单次写入的工作量变化;并发 / 锁争用需在 Linux 环境验证。本次验证期间对测试数据的处理:验证用的合成行与测试回复已全部清理,
reply_index行数(1381)、status=0计数(1367)、计数器(1367 / 66) 均已核对回阶段 0 基线;queue_mode已恢复为cron。cron 阶段顺序风险(本环境重点):
e) AI flush位于⑤ 队列消费之前且可占用 300s, 云函数超时若短于该耗时,队列消费将永不执行。详见 §3.2②。 这是本环境唯一需要人工确认的配置项 —— 其余改动(索引补丁、惰性 flush 注册、 合并重建、每日校准)全部自动生效,无需人工干预。
六、回滚
| 变更 | 回滚方式 |
|---|---|
| 1a / 1b / 1c / Q1 | 用上一版文件覆盖即可,无 schema 变更 |
| 2a / 2b 索引 | DROP INDEX idx_reply_index_status;(Schema.php 的 IF NOT EXISTS 不会自动重建,需一并回滚代码) |
回滚后计数器如有偏差,执行一次后台「维护 → 重建统计」即可归一。