Skip to content

perf(logs): 给 cached_tokens 聚合补覆盖索引,消除仪表盘 2 秒"加载中" - #63

Merged
fuzhengwei merged 1 commit into
fuzhengwei:v0.2.8from
freakojc:perf/dashboard-cached-tokens-index
Sep 5, 2026
Merged

perf(logs): 给 cached_tokens 聚合补覆盖索引,消除仪表盘 2 秒"加载中"#63
fuzhengwei merged 1 commit into
fuzhengwei:v0.2.8from
freakojc:perf/dashboard-cached-tokens-index

Conversation

@freakojc

@freakojc freakojc commented Sep 4, 2026

Copy link
Copy Markdown

症状

切离仪表盘再切回来,"加载中"要持续 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_logs 7 ms
SELECT SUM(cached_tokens) FROM request_logs 812 ms

开销随 request_body 总字节线性增长(实测 0.44–0.51 ms/MB)。这解释了为什么它
"突然出现":库从 205 MB 涨到 1.6 GB 的过程中,同一查询从约 90 ms 恶化到 800 ms,
而代码一行没改。

改动

CREATE INDEX IF NOT EXISTS 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 过滤与覆盖扫描。

也试过 7 列宽索引(model, created_at, prompt, completion, total, cached, duration),
它能覆盖 get_model_stats反而修不好 get_token_trend(首列是 model,无法按
created_at range-seek)。两者各覆盖三条中的三条但不同三条,见下"未覆盖项"。

端到端验证

复制真实库到 migration=27 的基线,用本分支构建的 waliapi-web 正常启动一次来触发
真实 sqlx 迁移路径
(不是手工建索引),耗时统一用 Python 的 sqlite3 量,前后同一量具:

查询 迁移前 迁移后
stats SUM(cached) WHERE created_at LIKE ? 707.4 ms 0.1 ms
stats SUM(cached) FROM request_logs 743.2 ms 0.1 ms
get_model_stats 733.2 ms 737.8 ms(未变,见下)
get_token_trend 765.7 ms 0.5 ms
合计 2949.4 ms 738.5 ms(4.0×)

迁移落地校验:_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,症状消除。

代价

  • 建索引一次性约 0.9 秒(1.57 GB 库)。老客户端升级后首次启动会慢这几秒,
    且项目现有的"迁移前自动备份"会先 VACUUM INTO 一份,属正常不是卡死。
  • 插入写放大不可测: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_bodyrequest_logs 拆出或压缩存储,让所有统计列回到本地
载荷内 —— 那是架构级改动,不该塞进本提交。

测试

本分支未改任何 Rust 代码,只新增一个迁移文件。

  • cargo test --no-fail-fast751 passed / 2 failed
    security_gate_block_zero_upstreamcodex_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。另有一条 PR(fix/stream-log-499-and-repair)引入 028
request_logs.usage_source)。建议让那条先合,避免版本号出现临时跳号。
两条 PR 无代码交集,可独立评审。

版本号 / CHANGELOG

未改,按仓库惯例留给维护者发版时统一处理。

症状:切回仪表盘时"加载中"要 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 并行,
建议让那条先合,避免版本号出现临时跳号。
@fuzhengwei
fuzhengwei merged commit f1910bd into fuzhengwei:v0.2.8 Sep 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants