diff --git a/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql b/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql new file mode 100644 index 00000000..5487002f --- /dev/null +++ b/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql @@ -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 + ); 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 8e754475..4793a55a 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 @@ -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', + ) +})