From 987553909d890a1818c873a2d8b4f05b84664a2e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 15:23:57 +0800 Subject: [PATCH] =?UTF-8?q?perf(db):=20=E7=BB=9F=E8=AE=A1=E7=9A=84?= =?UTF-8?q?=E3=80=8C=E7=8A=B6=E6=80=81+=E6=97=A5=E6=9C=9F=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E3=80=8D=E7=BB=84=E5=90=88=E8=A1=A5=20created=5Fat=20?= =?UTF-8?q?=E9=94=AE=E8=A6=86=E7=9B=96=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 项守卫)。 --- ...work_order_statistics_date_cover_index.sql | 30 +++++++++++++++++++ .../worker-platform-service.test.ts | 26 ++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql 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', + ) +})