perf(logs): 给 cached_tokens 聚合补覆盖索引,消除仪表盘 2 秒"加载中" - #63
Merged
fuzhengwei merged 1 commit intoSep 5, 2026
Merged
Conversation
症状:切回仪表盘时"加载中"要 2 秒以上。三条统计命令在真实库上合计约 2.9–3.4 秒,
其中 98.9% 的开销来自读取 `cached_tokens` 这一个列。
根因是列位置,不是扫描本身。`cached_tokens` 由迁移 026 以 `ALTER TABLE ADD COLUMN`
加入,列号 cid=39,排在 cid=18 的 `request_body`(Agent 流量下平均约 900 KB)之后。
SQLite 每行的本地载荷只有一页左右,`request_body` 把它撑满后,cid>=19 的列值全部
落在 overflow 页链上。于是任何读 `cached_tokens` 的聚合(SUM/COUNT/MAX 都一样)
必须走完每一行的 overflow 链,等价于把 `request_body` 的字节总量重读一遍;而读
cid<=18 的 `prompt_tokens` 只碰本地载荷,同样全表扫只要几毫秒。
同一份 1.57 GB 真实库副本上的对照:`SUM(prompt_tokens)` 7 ms,
`SUM(cached_tokens)` 812 ms —— 相差 100 倍以上,且两者查询计划完全相同
(`SCAN request_logs`)、都没有索引参与。开销随 `request_body` 总字节线性增长
(实测 0.44–0.51 ms/MB),所以库小的时候无感、涨到 1.6 GB 后变成 2 秒。
修复:加覆盖索引 `idx_logs_created_cached ON request_logs(created_at, cached_tokens)`。
列序为什么必须是 `(created_at, cached_tokens)` 而不是单列 `(cached_tokens)`:
单列索引只能覆盖无 WHERE 的那条聚合;带 `created_at LIKE ?` 过滤的查询因为索引
不含 `created_at`、仍需回表走 overflow 链,实测 845 ms 无改善。`created_at` 打头
才能同时服务 range 过滤与覆盖扫描。这是实测出来的,不是推的。
端到端验证(复制真实库到 migration=27 的基线,用本分支构建的 waliapi-web 正常启动
一次触发真实 sqlx 迁移路径,而不是手工建索引;耗时统一用 Python 的 sqlite3 量,
前后同一量具):
迁移前 migration=27,无新索引
stats SUM(cached) WHERE created_at LIKE ? 707.4 ms
stats SUM(cached) FROM request_logs 743.2 ms
get_model_stats 733.2 ms
get_token_trend 765.7 ms
合计 2949.4 ms
启动后 _sqlx_migrations 最高版本 = 29,记录 (29,'request log cached tokens index',1)
索引 idx_logs_created_cached 已存在,PRAGMA integrity_check = ok
stats SUM(cached) WHERE created_at LIKE ? 0.1 ms
stats SUM(cached) FROM request_logs 0.1 ms
get_model_stats 737.8 ms (未变,见下)
get_token_trend 0.5 ms
合计 738.5 ms (4.0x)
控制"加载中"的是 `get_dashboard_stats`(前端只在 stats 为空时渲染占位),它从
1451 ms 降到 0.2 ms,所以症状消除。
代价:建索引一次性约 0.9 秒(1.57 GB 库);插入写放大不可测
(0.48 ms/行 -> 0.43 ms/行,噪声级,插入成本由 request_body 主导)。
未覆盖项(有意不做):`get_model_stats` 仍约 740 ms。它 `GROUP BY model` 且需要
5 个数值列,只有 7 列宽索引才能覆盖(实测可降到 0.2 ms),但那种索引挂在每请求
一写的日志表上换 740 ms 不划算;而且它不控制 spinner,只是模型统计那块晚 0.7 秒
填充。彻底解法是把 `request_body` 从 `request_logs` 拆出或压缩存储,让所有统计列
回到本地载荷内 —— 那是架构级改动,不塞进本提交。
测试:本分支未改任何 Rust 代码,只新增一个迁移文件。`cargo test --no-fail-fast`
751 passed / 2 failed(`security_gate_block_zero_upstream`、
`codex_login::export_is_nested_private_...` 为 v0.2.8 继承失败,与本提交无关;
已确认 `git diff main v0.2.8 -- src/security/ src/auth_provider/` 为空)。
所有跑 `sqlx::migrate!("./migrations")` 的测试均通过,即迁移 029 在真实迁移路径上
可干净应用。`cargo fmt --check` 与 v0.2.8 基线完全一致(净增 0);
`cargo clippy` lib 告警 211 条与基线持平。
编号说明:本迁移取 029。若与另一条引入 028(request_logs.usage_source)的 PR 并行,
建议让那条先合,避免版本号出现临时跳号。
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
症状
切离仪表盘再切回来,"加载中"要持续 2 秒以上。三条统计命令在真实库上合计约
2.9–3.5 秒,其中 98.9% 的开销来自读取
cached_tokens这一个列。根因:列位置,不是扫描本身
cached_tokens由迁移 026 以ALTER TABLE ... ADD COLUMN加入,列号 cid=39,排在 cid=18 的
request_body(Agent 流量下平均约 900 KB)之后。SQLite 每行的本地载荷只有一页左右,
request_body把它撑满后,cid>=19 的列值全部落在 overflow 页链上。于是任何读
cached_tokens的聚合(SUM/COUNT/MAX表现一致)都必须走完每一行的 overflow 链 —— 等价于把
request_body的字节总量重读一遍。而读 cid<=18 的
prompt_tokens只碰本地载荷,同样是全表扫只要几毫秒。同一份 1.57 GB 真实库副本上的对照,两者查询计划完全相同、都没有索引参与:
SELECT SUM(prompt_tokens) FROM request_logsSELECT SUM(cached_tokens) FROM request_logs开销随
request_body总字节线性增长(实测 0.44–0.51 ms/MB)。这解释了为什么它"突然出现":库从 205 MB 涨到 1.6 GB 的过程中,同一查询从约 90 ms 恶化到 800 ms,
而代码一行没改。
改动
为什么列序必须是
(created_at, cached_tokens)而不是单列(cached_tokens)——这是实测结论,不是推断:单列索引只能覆盖无
WHERE的那条聚合;带created_at LIKE ?过滤的查询因为索引不含created_at、仍需回表走 overflow 链,实测 845 ms 无改善。
created_at打头才能同时服务 range 过滤与覆盖扫描。也试过 7 列宽索引(
model, created_at, prompt, completion, total, cached, duration),它能覆盖
get_model_stats但反而修不好get_token_trend(首列是model,无法按created_atrange-seek)。两者各覆盖三条中的三条但不同三条,见下"未覆盖项"。端到端验证
复制真实库到
migration=27的基线,用本分支构建的waliapi-web正常启动一次来触发真实 sqlx 迁移路径(不是手工建索引),耗时统一用 Python 的 sqlite3 量,前后同一量具:
stats SUM(cached) WHERE created_at LIKE ?stats SUM(cached) FROM request_logsget_model_statsget_token_trend迁移落地校验:
_sqlx_migrations最高版本 = 29、记录(29, 'request log cached tokens index', success=1)、索引idx_logs_created_cached已存在、
PRAGMA integrity_check = ok。控制"加载中"的是
get_dashboard_stats(前端DashboardPage.tsx只在stats为空时渲染占位),它对应上表前两条,从 1451 ms 降到 0.2 ms,症状消除。
代价
且项目现有的"迁移前自动备份"会先
VACUUM INTO一份,属正常不是卡死。request_body本身主导。未覆盖项(有意不做)
get_model_stats仍约 740 ms。它GROUP BY model且需要 5 个数值列,只有 7 列宽索引才能覆盖(实测可降到 0.2 ms),但那种索引挂在每请求一写的日志表上换 740 ms 不划算;
而且它不控制 spinner,只是模型统计那块晚约 0.7 秒填充。
彻底解法是把
request_body从request_logs拆出或压缩存储,让所有统计列回到本地载荷内 —— 那是架构级改动,不该塞进本提交。
测试
本分支未改任何 Rust 代码,只新增一个迁移文件。
cargo test --no-fail-fast:751 passed / 2 failed(
security_gate_block_zero_upstream、codex_login::export_is_nested_private_...为 v0.2.8 继承失败,已确认
git diff main v0.2.8 -- src/security/ src/auth_provider/为空,与本提交无关)
sqlx::migrate!("./migrations")的测试均通过,即迁移 029 在真实迁移路径上可干净应用
cargo fmt --check与 v0.2.8 基线完全一致(净增 0);cargo clippylib 告警 211 条与基线持平
编号说明
本迁移取 029。另有一条 PR(
fix/stream-log-499-and-repair)引入 028(
request_logs.usage_source)。建议让那条先合,避免版本号出现临时跳号。两条 PR 无代码交集,可独立评审。
版本号 / CHANGELOG
未改,按仓库惯例留给维护者发版时统一处理。