perf(db): 打手端「我的订单」概览加覆盖索引与短缓存
这是数据库累计耗时第一的热路径: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 项守卫,覆盖索引列与缓存位置都纳入断言)。
This commit is contained in:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
'逾期结算必须留在缓存之外(它有写副作用,被缓存会漏结算)',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user