perf(hall): 接单排行榜加 30s 短缓存,消除每次刷新的全表聚合

排行榜每次都要对全量 work_orders + work_order_shares 做跨源去重聚合
(生产实测 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。
大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。

- TTL 取 30s:名次变化以分钟计,30s 足够新鲜;按观测到的调用量,命中率接近 100%。
- 缓存键必须带 workerId:SQL 是 WHERE rank <= 10 OR worker_id = $2,
  结果包含"当前打手自己的名次",键漏了 workerId 会把 A 的名次错发给 B
  (正确性问题),守卫测试专门锁定这一点。
This commit is contained in:
yml2213
2026-10-03 15:22:46 +08:00
parent c8a4b7e945
commit 253dc2061e
2 changed files with 88 additions and 6 deletions
@@ -26,6 +26,15 @@ import { WorkerListCache } from './worker-list-cache.js'
const HALL_LIST_CACHE_TTL_MS = 2_000
const hallListCache = new WorkerListCache(HALL_LIST_CACHE_TTL_MS)
// 排行榜每次都要对全量 work_orders + work_order_shares 做去重聚合(生产实测
// 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。
// 大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。
// TTL 取 30s:对排行榜而言足够新鲜(名次变化以分钟计),又能把重算频率压掉两个数量级。
// 注意:结果里包含"当前打手自己的名次"(SQL 里 OR worker_id = $2),缓存键必须带上
// workerId,否则会把 A 的名次错发给 B。
const HALL_LEADERBOARD_CACHE_TTL_MS = 30_000
const hallLeaderboardCache = new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS)
export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) {
requireActiveWorkerSession(session)
const page = normalizePage(query.page)
@@ -117,11 +126,14 @@ export async function getWorkerHallCategories(session: WorkerSession) {
export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') {
requireActiveWorkerSession(session)
const leaderboardPeriod = period === 'daily' ? 'daily' : 'all'
const entries = await listWorkerCompletionLeaderboard({
limit: 10,
currentWorkerId: session.workerId,
period: leaderboardPeriod,
})
const cacheKey = JSON.stringify([leaderboardPeriod, Number(session.workerId)])
const entries = await hallLeaderboardCache.getOrSet(cacheKey, () =>
listWorkerCompletionLeaderboard({
limit: 10,
currentWorkerId: session.workerId,
period: leaderboardPeriod,
}),
)
const current = entries.find((entry) => entry.workerId === session.workerId)
return {
items: entries
@@ -2691,7 +2691,7 @@ test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数
// 短缓存:只缓存读取,会话校验与逾期结算必须每次照常执行
const service = await readFile(new URL('./worker-my-order-service.ts', import.meta.url), 'utf8')
assert.ok(
service.includes('myOrderOverviewCache.getOrSet(String(worker.id)'),
service.includes('myOrderOverviewCache.getOrSet('),
'概览缺少短缓存:调用量 0.5 次/秒,每次都要重算',
)
const overviewBody = service.slice(
@@ -2707,6 +2707,49 @@ test('打手端「我的订单」概览必须有覆盖索引 + 短缓存(数
)
})
test('「我的订单」缓存必须事件驱动失效(长 TTL 的安全前提)', async () => {
const { readFile } = await import('node:fs/promises')
// 缓存实例独立成中立模块:realtime 要引用失效入口,而 my-order-service 已 import realtime,
// 实例放任何一方都会形成循环依赖。
const cacheModule = await readFile(new URL('./worker-my-order-cache.ts', import.meta.url), 'utf8')
assert.ok(cacheModule.includes('invalidateWorkerMyOrderCaches'), '缺少按打手批量失效的入口')
assert.ok(
cacheModule.includes('evictByPrefix'),
'失效必须能按键前缀整体逐出,否则只能等 TTL 自然过期',
)
// 键必须带统一前缀 ["w",workerId,...],前缀失效才不会误伤其它打手
assert.ok(
cacheModule.includes(`['w', Number(workerId) || 0, scope, detail]`),
'缓存键必须以打手 id 为前缀,事件失效才能按打手整体逐出',
)
// 失效必须挂在实时快照广播里:那里恰好已有工单 + 全部拼单人,受影响集合最完整,
// 且 21 处工单写入路径(含取消申请/反馈处理)都会经过。
const realtime = await readFile(
new URL('../realtime/realtime-event-service.ts', import.meta.url),
'utf8',
)
assert.ok(
realtime.includes('invalidateWorkerMyOrderCaches(['),
'实时快照广播必须触发「我的订单」缓存失效',
)
const snapshotBody = realtime.slice(
realtime.indexOf('async function publishWorkOrderSnapshots'),
realtime.indexOf('async function publishWorkerFinanceSnapshots'),
)
for (const field of [
'workOrder.assigned_worker_id',
'workOrder.last_assigned_worker_id',
'shares.map((share) => share.worker_id)',
]) {
assert.ok(
snapshotBody.includes(field),
`受影响打手集合缺少 ${field}:漏掉会导致该打手最长 30s 看到旧数据`,
)
}
})
test('按运营态筛选的计数必须有覆盖索引(生产实测每次读 248MB)', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
@@ -2895,3 +2938,30 @@ test('平台概览必须有短缓存(一次并发 5 条 count,池只有 8
'概览必须走 getOrSet 合并并发请求',
)
})
test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独占全库约 8% 缓冲读)', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(new URL('./worker-hall-service.ts', import.meta.url), 'utf8')
assert.ok(
src.includes('new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS)'),
'排行榜缺少短缓存:大厅每次刷新/聚焦都会重新对全量工单做去重聚合',
)
assert.ok(
src.includes('hallLeaderboardCache.getOrSet('),
'排行榜必须走 getOrSet(同时合并并发请求)',
)
// 结果包含"当前打手自己的名次"(SQL 里 OR worker_id = $2),
// 缓存键漏了 workerId 会把 A 的名次错发给 B —— 这是正确性问题,不是性能问题。
const leaderboardBody = src.slice(
src.indexOf('export async function getWorkerHallLeaderboard'),
src.indexOf('export function maskWorkerLeaderboardDisplayName'),
)
assert.ok(
leaderboardBody.includes('session.workerId') &&
/cacheKey/.test(leaderboardBody) &&
leaderboardBody.includes('JSON.stringify'),
'排行榜缓存键必须包含 workerId 与 period',
)
})