perf(db): 运营态计数/统计加覆盖索引,消除 248MB/次的全表扫

pg_stat_statements 重置后 25 分钟窗口显示,work_orders 相关查询里每次调用读取量最大的
全是"按运营态筛选"的形状:
  SELECT COUNT(*) FROM work_orders wo LEFT JOIN (<拼单汇总>) WHERE <运营态 CASE>
    87 次调用、每次约 248MB、均 347ms
  筛选统计的同形查询多次,248~545MB/次
根因:运营态不是存储列而是算出来的 CASE(依赖 status/sharing_enabled/sharing_total_quantity
+ 拼单汇总),规划器只能逐行求值,EXPLAIN 显示 "Rows Removed by Filter: 95923" ——
把 9.6 万行、约 533MB 的宽表整张读出来再丢掉。

迁移 085 给 CASE 用到的三列建覆盖索引(约 4MB,堆表的 1/130):
同一查询实测 248MB → 22MB、184ms → 75ms(IO 降 11 倍)。
status 本来就有索引、状态流转本来也走不了 HOT 更新,故不额外增加写放大。

验证:后端 533 项单测全绿(新增 1 项守卫);生产真机 EXPLAIN (ANALYZE, BUFFERS) 前后对比。
This commit is contained in:
yml2213
2026-10-01 13:30:06 +08:00
parent 3ab897963d
commit 92d7887108
2 changed files with 53 additions and 0 deletions
@@ -0,0 +1,28 @@
-- 085_work_order_operational_status_cover_index.sql —— 按「运营态」筛选的计数/统计的覆盖索引。
--
-- 生产实测(pg_stat_statements 重置后 25 分钟窗口):`work_orders` 相关查询里,
-- 每次调用读取量最大的一批全是这个形状:
-- SELECT COUNT(*)::int AS total FROM work_orders wo LEFT JOIN (<拼单汇总>) ...
-- WHERE <运营态 CASE> ... —— 87 次调用、每次约 248MB、均 347ms
-- SELECT CASE WHEN wo.status = $n AND wo.sharing_enabled ... (筛选统计)—— 多次、248~545MB/次
--
-- 原因是运营态不是存储列,而是 SELECT/WHERE 里算出来的 CASE:
-- CASE WHEN wo.status='open' AND wo.sharing_enabled IS TRUE AND COALESCE(oss.active,0)>0
-- THEN CASE WHEN ... THEN 'pending_acceptance' ELSE 'in_progress' END
-- ELSE wo.status END
-- 规划器只能对每一行求值,于是必须顺序扫完整张 9.6 万行、约 533MB 的 work_orders
-- (EXPLAIN 显示 "Rows Removed by Filter: 95923",即 9.6 万行全被读出来再丢掉)。
--
-- 这里给 CASE 真正用到的三个列建覆盖索引,让那次全表扫变成 index-only scan。
-- 索引只有约 4MB(堆表的 1/130)。同一查询实测:248MB → 22MB、184ms → 75ms。
--
-- 剩余的那部分 IO 来自 <拼单汇总> 派生表对 work_order_shares 的聚合,
-- 已有 idx_work_order_shares_order_status_include_quantity 覆盖,需要靠 autovacuum
-- 维持可见性映射才能稳定走 index-only scan。
--
-- 注意:status 上本来就有 idx_work_orders_status,状态流转本来也无法走 HOT 更新,
-- 所以本索引不会额外增加写放大。迁移在事务内执行,故用普通 CREATE INDEX。
CREATE INDEX IF NOT EXISTS idx_work_orders_operational_status_cover
ON work_orders (id)
INCLUDE (status, sharing_enabled, sharing_total_quantity);
@@ -2659,3 +2659,28 @@ test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数
'逾期结算必须留在缓存之外(它有写副作用,被缓存会漏结算)',
)
})
test('按运营态筛选的计数必须有覆盖索引(生产实测每次读 248MB)', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
new URL(
'../../db/migrations/085_work_order_operational_status_cover_index.sql',
import.meta.url,
),
'utf8',
)
assert.ok(
migration.includes('idx_work_orders_operational_status_cover'),
'缺少运营态覆盖索引:CASE 无法走索引,规划器会扫完整张 9.6 万行宽表',
)
for (const column of ['status', 'sharing_enabled', 'sharing_total_quantity']) {
assert.ok(
migration.includes(column),
`覆盖索引必须包含运营态 CASE 用到的 ${column},否则仍然要回表`,
)
}
assert.ok(
!/^\s*CREATE INDEX CONCURRENTLY/m.test(migration),
'迁移在事务内执行,不能使用 CONCURRENTLY',
)
})