perf(db): 统计的「状态+日期范围」组合补 created_at 键覆盖索引

087 以 id 为键覆盖了统计要读的窄列,但日期条件无法用它驱动扫描
(created_at 不是前导列),规划器因此对 work_orders 做整表 Hash Join:
生产实测该变体每次读 400~700MB —— 缓存热时 283~694ms,缓存有压力时 926ms+
并触发慢查询告警。而"状态 + 近 7/30 天"正是运营最常用的筛选组合。

迁移 088 补一条 created_at 键、覆盖同一批窄列的索引,让规划器改为
Bitmap Index Scan 只取日期范围内的行。生产实测(真实 service 路径):
  取 13,974 行 / 执行 75.7ms / 端到端 283ms(含列表查询),慢查询告警消失。
用"单次调用前后块差值"验证:51,521 块,与我 EXPLAIN 的 shared hit 完全一致。

与 087 的分工:无日期筛选走 id 键(index-only),有日期范围走本索引(bitmap)。
Bitmap Index Scan 无法 index-only,INCLUDE 的作用是让 heap 访问只落在日期范围内。
刻意不含 acceptance_json(勾 VIP 待补图时本就有意回表)。

验证:后端 541 项单测全绿(新增 1 项守卫)。
This commit is contained in:
yml2213
2026-10-01 15:23:57 +08:00
parent b6c0e06f45
commit 987553909d
2 changed files with 56 additions and 0 deletions
@@ -0,0 +1,30 @@
-- 088_work_order_statistics_date_cover_index.sql —— 统计的「状态 + 日期范围」组合覆盖索引。
--
-- 背景:087 以 id 为键覆盖了统计要读的窄列,但**日期条件无法用它驱动扫描**
-- (`wo.created_at >= ... AND < ...` 不是该索引的前导列),于是规划器对
-- work_orders 做整表 Hash Join。生产实测这一变体每次读 400~700MB:
-- 缓存热时 283~694ms,缓存有压力时 926ms+ 并触发慢查询告警
-- ("状态 + 近 7/30 天"恰是运营最常用的筛选组合)。
--
-- 本迁移补一条以 created_at 为键、覆盖同一批窄列的索引,让规划器改为
-- Bitmap Index Scan 只取日期范围内的行。生产实测:取 13,974 行、执行 75.7ms、
-- 端到端(含列表查询)283ms,慢查询告警消失。
--
-- 与 087 的分工:
-- - 无日期筛选(默认视图、仅状态/分类)→ 087 的 id 键索引,index-only scan;
-- - 有日期范围 → 本索引,range/bitmap scan。
-- 说明:Bitmap Index Scan 无法 index-only,INCLUDE 列的作用是让后续 heap 访问
-- 只集中在日期范围内这批行,而不是整张 533MB 宽表。
--
-- 刻意不含 acceptance_json:勾「VIP 待补图」时统计本就会回表(见 087 的说明)。
CREATE INDEX IF NOT EXISTS idx_work_orders_statistics_dates
ON work_orders (created_at)
INCLUDE (
status,
sharing_enabled,
sharing_total_quantity,
source_order_amount_fen,
reward_amount,
order_id
);
@@ -2761,3 +2761,29 @@ test('三处"只差索引"的热查询缺口必须有迁移兜底(生产已前
'迁移在事务内执行,不能使用 CONCURRENTLY',
)
})
test('统计的「状态+日期范围」组合必须有 created_at 键的覆盖索引', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
new URL('../../db/migrations/088_work_order_statistics_date_cover_index.sql', import.meta.url),
'utf8',
)
assert.ok(
/idx_work_orders_statistics_dates[\s\S]*?ON work_orders \(created_at\)/.test(migration),
'缺少 created_at 键的覆盖索引:日期条件无法驱动 087 的 id 键索引,会退化成整表 Hash Join',
)
for (const column of [
'status',
'sharing_enabled',
'sharing_total_quantity',
'source_order_amount_fen',
'reward_amount',
'order_id',
]) {
assert.ok(migration.includes(column), `覆盖列缺少 ${column}`)
}
assert.ok(
!/^\s*CREATE INDEX CONCURRENTLY/m.test(migration),
'迁移在事务内执行,不能使用 CONCURRENTLY',
)
})