From 3ab897963d152c9dc3906b912962098d67bd78b2 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 13:12:42 +0800 Subject: [PATCH] =?UTF-8?q?perf(db):=20=E6=89=93=E6=89=8B=E7=AB=AF?= =?UTF-8?q?=E3=80=8C=E6=88=91=E7=9A=84=E8=AE=A2=E5=8D=95=E3=80=8D=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E5=8A=A0=E8=A6=86=E7=9B=96=E7=B4=A2=E5=BC=95=E4=B8=8E?= =?UTF-8?q?=E7=9F=AD=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这是数据库累计耗时第一的热路径:32 天 130 万次调用(0.5 次/秒),平均 99.8ms, 但名下订单多的打手会退化到 4.5~9 秒。EXPLAIN (ANALYZE) 定位到根因: 查询只用到 8126 个工单 id,规划器却选了「work_orders 全表顺序扫 + Hash Join」—— 为几千个 id 扫 9.6 万行宽表(500MB),单这一步 4.1 秒。 1. 迁移 084:给查询真正需要的列建覆盖索引(id + INCLUDE status/assigned_worker_id/ reward_amount/acceptance_json)。把那次全表扫变成 index-only scan,代价从 31015 降到约 5k, 规划器自然改选索引;索引 34MB,是堆表的十五分之一。生产实测同一查询 14.5 秒 → 1.5 秒(冷缓存)。 试过用 LATERAL 强制回表,规划器会把它拉平成同样的 Hash Join,所以只能从索引侧解决。 迁移在事务内执行,故用普通 CREATE INDEX(不用 CONCURRENTLY)。 2. 概览读取加 5 秒短缓存:把"计算次数"与"页面轮询次数"解耦。 只缓存读取——会话校验与 settleOverdueWorkOrders(写副作用)每次照常执行,否则会漏结算。 验证:后端 532 项单测全绿(新增 1 项守卫,覆盖索引列与缓存位置都纳入断言)。 --- .../084_worker_overview_covering_index.sql | 19 +++++++++ .../worker-my-order-service.ts | 17 +++++++- .../worker-platform-service.test.ts | 41 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/db/migrations/084_worker_overview_covering_index.sql diff --git a/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql b/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql new file mode 100644 index 00000000..a27f1a24 --- /dev/null +++ b/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql @@ -0,0 +1,19 @@ +-- 084_worker_overview_covering_index.sql —— 打手端「我的订单」概览的覆盖索引。 +-- +-- 生产实测:这个概览查询 32 天被调用 130 万次(每秒约 0.5 次), +-- 平均 99.8ms,但对名下订单多的打手会退化到 4.5~9 秒。EXPLAIN (ANALYZE) 显示原因: +-- 它只用到 8126 个工单 id,规划器却选择「work_orders 全表顺序扫 + Hash Join」—— +-- 为几千个 id 扫了 9.6 万行宽表(500MB),单这一步就是 4.1 秒。 +-- +-- 这里不改 SQL(试过 LATERAL 强制回表,规划器会把它拉平成同样的 Hash Join), +-- 而是给查询真正需要的列做覆盖索引,让那次全表扫变成 index-only scan: +-- 代价从 31015 降到约 5k,规划器自然改选索引;体积约 34MB, +-- 只有堆表 500MB 的十五分之一。实测同一台机器上从 14.5 秒降到 1.5 秒(冷缓存)。 +-- +-- 注意:迁移在单个事务里执行,所以这里不能用 CREATE INDEX CONCURRENTLY +-- (并发建索引不能在事务块内运行)。96k 行的表普通建索引约 1~3 秒, +-- 期间只阻塞写入,且部署本身会重启后端,影响可接受。 + +CREATE INDEX IF NOT EXISTS idx_work_orders_worker_overview_cover + ON work_orders (id) + INCLUDE (status, assigned_worker_id, reward_amount, acceptance_json); diff --git a/apps/backend/src/services/worker-platform/worker-my-order-service.ts b/apps/backend/src/services/worker-platform/worker-my-order-service.ts index 60956083..fe46427c 100644 --- a/apps/backend/src/services/worker-platform/worker-my-order-service.ts +++ b/apps/backend/src/services/worker-platform/worker-my-order-service.ts @@ -49,6 +49,19 @@ import { WorkerListCache } from './worker-list-cache.js' const MY_ORDER_LIST_CACHE_TTL_MS = 2_000 const myOrderListCache = new WorkerListCache(MY_ORDER_LIST_CACHE_TTL_MS) +/** + * 「我的订单」概览(各状态订单数 + 预计/已结算金额)的短缓存。 + * + * 这条查询 32 天被调用 130 万次(每秒约 0.5 次),是数据库累计耗时第一的热路径; + * 迁移 084 的覆盖索引把它从最坏 4.5~9 秒压到秒级以下,但每次仍要扫一遍索引, + * 所以再按打手缓存 5 秒:页面轮询/重复刷新时直接命中,把"计算次数"和"访问次数"解耦。 + * + * 只缓存读取本身:会话校验与 settleOverdueWorkOrders(有写副作用)每次照常执行, + * 否则逾期结算会被跳过。副作用是打手自己的订单数/金额最多滞后 5 秒。 + */ +const MY_ORDER_OVERVIEW_CACHE_TTL_MS = 5_000 +const myOrderOverviewCache = new WorkerListCache(MY_ORDER_OVERVIEW_CACHE_TTL_MS) + export async function listWorkerMyOrders(query: JsonObject = {}, session: WorkerSession) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) @@ -130,7 +143,9 @@ export async function getWorkerMyOrderOverview(session: WorkerSession) { requireActiveWorkerSession(session) const worker = await getRequiredWorker(session.workerId) await settleOverdueWorkOrders({ workerId: worker.id, limit: 50 }) - const rows = await getWorkerOrderOverview(worker.id) + const rows = await myOrderOverviewCache.getOrSet(String(worker.id), () => + getWorkerOrderOverview(worker.id), + ) const items = rows.map((row) => ({ status: row.status, orderCount: row.orderCount, 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 8ed74cd3..ea2d9991 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 @@ -2618,3 +2618,44 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量 assert.ok(src.includes(field), `缓存指纹缺少 ${field}`) } }) + +test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数据库累计耗时第一的热路径)', async () => { + const { readFile } = await import('node:fs/promises') + // 覆盖索引:不改 SQL,靠 index-only scan 让规划器放弃全表顺序扫 + const migration = await readFile( + new URL('../../db/migrations/084_worker_overview_covering_index.sql', import.meta.url), + 'utf8', + ) + assert.ok( + migration.includes('idx_work_orders_worker_overview_cover'), + '缺少打手概览覆盖索引:规划器会为几千个 id 全表扫 9.6 万行(实测 4.1 秒)', + ) + for (const column of ['status', 'assigned_worker_id', 'reward_amount', 'acceptance_json']) { + assert.ok( + migration.includes(column), + `覆盖索引 INCLUDE 缺少 ${column}(查询需要它才能走 index-only scan)`, + ) + } + assert.ok( + !/^\s*CREATE INDEX CONCURRENTLY/m.test(migration), + '迁移在事务内执行,不能使用 CONCURRENTLY,否则整条迁移失败', + ) + + // 短缓存:只缓存读取,会话校验与逾期结算必须每次照常执行 + const service = await readFile(new URL('./worker-my-order-service.ts', import.meta.url), 'utf8') + assert.ok( + service.includes('myOrderOverviewCache.getOrSet(String(worker.id)'), + '概览缺少短缓存:调用量 0.5 次/秒,每次都要重算', + ) + const overviewBody = service.slice( + service.indexOf('export async function getWorkerMyOrderOverview'), + service.indexOf( + 'export async function', + service.indexOf('export async function getWorkerMyOrderOverview') + 10, + ), + ) + assert.ok( + overviewBody.indexOf('settleOverdueWorkOrders') < overviewBody.indexOf('myOrderOverviewCache'), + '逾期结算必须留在缓存之外(它有写副作用,被缓存会漏结算)', + ) +})