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:
@@ -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',
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user