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:
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user