diff --git a/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql b/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql new file mode 100644 index 00000000..11a95234 --- /dev/null +++ b/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql @@ -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); diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index ea2d9991..28ca8387 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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', + ) +})