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