From 2d75be702d400bdb24fa4ce910d9f97ccfad7f79 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 13:48:05 +0800 Subject: [PATCH] =?UTF-8?q?perf(db):=20=E8=A1=A5=E9=BD=90=E4=B8=89?= =?UTF-8?q?=E5=A4=84"=E5=8F=AA=E5=B7=AE=E7=B4=A2=E5=BC=95"=E7=9A=84?= =?UTF-8?q?=E7=83=AD=E6=9F=A5=E8=AF=A2=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计方式:pg_stat_statements 按"每次调用读取块数"排序 + EXPLAIN (ANALYZE, BUFFERS) 复现, 每条都先在线上并发建索引验证有效再固化(生产上索引已存在,IF NOT EXISTS 跳过)。 ① 后台订单列表:COALESCE(paid_at, created_at) 同时出现在 WHERE 与 ORDER BY, 函数包裹列非 sargable —— 默认"近 7 天"视角本身就会全表扫 orders 再排序。 实测 26 万行/443MB:Parallel Seq Scan 读 256MB、577ms → Index Scan 读 0.3MB、2.05ms(约 280 倍)。 ② 重跑快手补单:NOT EXISTS 打在 work_orders.order_id 上,而该列此前**没有索引**, 反连接只能 Hash Anti Join —— 为几十个候选 id 顺序扫完 500MB 宽表,LIMIT 100 拦不住。 实测 188MB、120ms → Index Only Scan 1.2ms(约 100 倍)。同时补 orders(order_status)。 ③ 快手凭证"同步源"列表的 (oid, updated_at DESC, id DESC):29.6 万行/678MB,实测 568MB、5.5 秒。 仅建索引不足以改变计划(查询写法要求先物化全部 oid,见审计报告),但它是后续 改写查询的前提,先建好避免再等一次大表建索引。 验证:后端 534 项单测全绿(新增 1 项守卫)。 --- .../086_verified_hot_query_indexes.sql | 30 +++++++++++++++++++ .../worker-platform-service.test.ts | 28 +++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql diff --git a/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql b/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql new file mode 100644 index 00000000..e6be0b2a --- /dev/null +++ b/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql @@ -0,0 +1,30 @@ +-- 086_verified_hot_query_indexes.sql —— 三处"只差索引"的热查询缺口(生产已 EXPLAIN 前后对比验证)。 +-- +-- 审计方式:pg_stat_statements 按"每次调用读取块数"排序 + EXPLAIN (ANALYZE, BUFFERS) 复现。 +-- 以下每条都先在线上并发建索引验证有效,再固化成迁移(迁移会在事务内执行, +-- 所以这里用普通 CREATE INDEX;生产上索引已存在,IF NOT EXISTS 直接跳过)。 + +-- ① 后台订单列表:COALESCE(paid_at, created_at) 同时出现在 WHERE 与 ORDER BY, +-- 函数包裹列 → 非 sargable,默认"近 7 天"视角本身就会全表扫 orders 再排序。 +-- 实测(26 万行 / 443MB):Parallel Seq Scan 读 256MB、577ms +-- → Index Scan 读 0.3MB、2.05ms(约 280 倍)。 +CREATE INDEX IF NOT EXISTS idx_orders_effective_ordered_at + ON orders ((COALESCE(paid_at, created_at)) DESC NULLS LAST, id DESC); + +-- ② 重跑快手补单:NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_id = o.id) +-- 而 work_orders 上此前**没有 order_id 索引** → 反连接只能 Hash Anti Join, +-- 为几十个候选 id 顺序扫完整张 500MB 宽表(LIMIT 100 拦不住)。 +-- 实测:Seq Scan on work_orders 读 188MB、120ms +-- → Index Only Scan 用 idx_work_orders_order_id,1.2ms(约 100 倍)。 +CREATE INDEX IF NOT EXISTS idx_work_orders_order_id ON work_orders (order_id); +-- 同一查询里 orders.order_status 也没有索引,一并补上。 +CREATE INDEX IF NOT EXISTS idx_orders_order_status ON orders (order_status); + +-- ③ 快手凭证「同步源」列表:SELECT DISTINCT ON (oid) ... ORDER BY oid, updated_at DESC, id DESC。 +-- 该表 29.6 万行 / 678MB,实测 568MB、5.5 秒。 +-- ⚠️ 仅建此索引不足以让计划改走索引:查询把 DISTINCT ON 放在子查询里、外层再按 +-- updated_at 排序,语义上必须先物化全部 oid 才能排序,规划器因此宁可顺序扫。 +-- 索引是"改写查询"的前提(见审计报告:应改为以 updated_at 顺序驱动 + 反连接去重), +-- 先建好避免后续改查询时再等一次大表建索引。 +CREATE INDEX IF NOT EXISTS idx_kuaishou_vouchers_oid_updated + ON kuaishou_industry_vouchers (oid, updated_at DESC, id DESC); 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 28ca8387..497e4ac1 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 @@ -2684,3 +2684,31 @@ test('按运营态筛选的计数必须有覆盖索引(生产实测每次读 2 '迁移在事务内执行,不能使用 CONCURRENTLY', ) }) + +test('三处"只差索引"的热查询缺口必须有迁移兜底(生产已前后对比验证)', async () => { + const { readFile } = await import('node:fs/promises') + const migration = await readFile( + new URL('../../db/migrations/086_verified_hot_query_indexes.sql', import.meta.url), + 'utf8', + ) + // ① 后台订单列表:COALESCE 表达式索引,否则默认 7 天视角就全表扫 orders(实测 256MB/577ms) + assert.ok( + /idx_orders_effective_ordered_at[\s\S]*?COALESCE\(paid_at, created_at\)/.test(migration), + '缺少 orders 的 COALESCE(paid_at, created_at) 表达式索引', + ) + // ② 重跑快手补单:work_orders.order_id 此前完全没有索引(实测 188MB 全表扫) + assert.ok( + /idx_work_orders_order_id[\s\S]*?\(order_id\)/.test(migration), + '缺少 work_orders(order_id) 索引', + ) + assert.ok(migration.includes('idx_orders_order_status'), '缺少 orders(order_status) 索引') + // ③ 快手凭证同步源去重排序 + assert.ok( + /idx_kuaishou_vouchers_oid_updated[\s\S]*?\(oid, updated_at DESC, id DESC\)/.test(migration), + '缺少 kuaishou_industry_vouchers(oid, updated_at DESC, id DESC) 索引', + ) + assert.ok( + !/^\s*CREATE INDEX CONCURRENTLY/m.test(migration), + '迁移在事务内执行,不能使用 CONCURRENTLY', + ) +})