perf(worker): 「我的订单」列表改成先取 id 再取详情,排序阶段不再物化宽列

打手端我的订单的排序是表达式:
  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 与总数,并对比首访耗时。
This commit is contained in:
yml2213
2026-10-01 15:29:49 +08:00
parent 987553909d
commit 21cd9135a2
2 changed files with 52 additions and 0 deletions
@@ -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<WorkOrderRow>(`${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<WorkOrderRow>(
@@ -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 顺序还原分页结果')
})