From 92d7887108c163896d425d943970a3012f710936 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 13:30:06 +0800 Subject: [PATCH] =?UTF-8?q?perf(db):=20=E8=BF=90=E8=90=A5=E6=80=81?= =?UTF-8?q?=E8=AE=A1=E6=95=B0/=E7=BB=9F=E8=AE=A1=E5=8A=A0=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E7=B4=A2=E5=BC=95=EF=BC=8C=E6=B6=88=E9=99=A4=20248MB/?= =?UTF-8?q?=E6=AC=A1=E7=9A=84=E5=85=A8=E8=A1=A8=E6=89=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) 前后对比。 --- ...k_order_operational_status_cover_index.sql | 28 +++++++++++++++++++ .../worker-platform-service.test.ts | 25 +++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql 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', + ) +})