From 21cd9135a2b5b70e0afc321973c2421a8ebad8a8 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 15:29:49 +0800 Subject: [PATCH] =?UTF-8?q?perf(worker):=20=E3=80=8C=E6=88=91=E7=9A=84?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E3=80=8D=E5=88=97=E8=A1=A8=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=85=88=E5=8F=96=20id=20=E5=86=8D=E5=8F=96=E8=AF=A6=E6=83=85?= =?UTF-8?q?=EF=BC=8C=E6=8E=92=E5=BA=8F=E9=98=B6=E6=AE=B5=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E7=89=A9=E5=8C=96=E5=AE=BD=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打手端我的订单的排序是表达式: COALESCE(CASE WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at END, CASE WHEN worker_share.status != 'cancelled' THEN worker_share.created_at END) DESC NULLS LAST, wo.id DESC 它依赖打手参数与拼单表,**没有任何索引能支撑**,规划器只能把该打手全部可见单 连同宽列(每行约 1.2KB material_json)物化后排序。生产实测(真实 service 路径): 名下 6609 单的打手首访 1112ms、5198 单的 808ms、3140 单的 772ms,每次读约 60MB (列表缓存 2 秒一失效,所以这个代价每天要重复付很多次)。 改法:该排序走"延迟分页"——第一阶段只 SELECT wo.id(窄投影 + 同一 ORDER BY + 同一 LIMIT/OFFSET),第二阶段用 `WHERE wo.id = ANY($1::bigint[])` 取这一页的详情, 再按 id 顺序还原(ANY 不保证顺序)。行集、顺序、总数语义完全不变。 验证:后端 542 项单测全绿(新增 1 项守卫);生产上先用部署前的 id 顺序留底, 部署后用同一批打手比对首 20 条 id 与总数,并对比首访耗时。 --- .../worker-platform/work-order-query-repo.ts | 31 +++++++++++++++++++ .../worker-platform-service.test.ts | 21 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 3cf59912..2e5be9d3 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -295,6 +295,37 @@ export async function listWorkOrders({ : 'wo.id DESC' const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy params.push(pageSize, offset) + + // 打手端「我的订单」的排序是表达式(依赖打手参数与拼单表),**没有索引能支撑**, + // 规划器只能把该打手全部可见单连同宽列(含 1.2KB 的 material_json)物化后排序。 + // 生产实测:名下 6600 单的打手首访 1112ms、每次读约 60MB(列表缓存 2 秒一失效, + // 所以这个代价每分钟会重复付好几次)。这里改成"先只取当前页的 id,再按 id 取详情": + // 排序阶段只带窄列,宽列只回表 20 行。语义(行集、顺序、总数)完全不变。 + if (sort === 'worker_claimed_at_desc' && workerSharingId && !pinnedFirst) { + const idsResult = await query<{ id: number }>( + `SELECT wo.id + FROM work_orders wo + ${joins} + ${whereClause} + ORDER BY ${effectiveOrderBy} + LIMIT $${params.length - 1} OFFSET $${params.length}`, + params, + ) + const orderedIds = idsResult.rows.map((row) => Number(row.id)) + const [itemsResult, total] = await Promise.all([ + orderedIds.length === 0 + ? Promise.resolve({ rows: [] as WorkOrderRow[] }) + : query(`${WORK_ORDER_SELECT} WHERE wo.id = ANY($1::bigint[])`, [orderedIds]), + includeTotal ? countWorkOrdersWithWhere(joins, whereClause, countParams) : Promise.resolve(0), + ]) + // ANY() 不保证顺序,按 id 顺序还原分页结果 + const rowsById = new Map(itemsResult.rows.map((row) => [Number(row.id), row])) + const items = orderedIds + .map((id) => rowsById.get(id)) + .filter((row): row is WorkOrderRow => Boolean(row)) + return { items, total } + } + // 宽详情只查当前页;不将 COUNT 窗口放进详情查询,避免 LIMIT 前处理全量 JSON。 const [itemsResult, total] = await Promise.all([ query( 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 4793a55a..ae70f176 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 @@ -2787,3 +2787,24 @@ test('统计的「状态+日期范围」组合必须有 created_at 键的覆盖 '迁移在事务内执行,不能使用 CONCURRENTLY', ) }) + +test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分页', async () => { + const { readFile } = await import('node:fs/promises') + const src = await readFile( + new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url), + 'utf8', + ) + assert.ok( + src.includes("sort === 'worker_claimed_at_desc' && workerSharingId && !pinnedFirst"), + '缺少打手排序的延迟分页分支:该排序是表达式,无索引可支撑,会物化全部可见单的宽列', + ) + assert.ok( + src.includes('SELECT wo.id'), + '第一阶段必须只取 id(窄投影),否则排序仍会读 material_json', + ) + assert.ok( + src.includes('WHERE wo.id = ANY($1::bigint[])'), + '第二阶段必须按 id 批量取详情,而不是重新跑一遍带排序的宽查询', + ) + assert.ok(src.includes('rowsById.get(id)'), 'ANY() 不保证顺序,必须按 id 顺序还原分页结果') +})