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:
@@ -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 顺序还原分页结果')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user