perf(worker): 「我的订单」缓存改为事件驱动失效,TTL 2s/5s → 30s

这是当前最大瓶颈的根治:打手端三件套(列表/计数/概览)占全库约 75% 缓冲读,
单查询成本是 O(打手单量) 的固有成本(重打手 8,586 单逐行探测),
索引层面已无可抠,只能把"计算次数"和"访问次数"解耦。

原先 TTL 只有 2s/5s,是因为文档担心"再长会让分页总数滞后"。
但该数据只在该打手自己的工单流转时才变,而项目已有 realtime 快照广播
精确感知每次流转——把同一事件流接到缓存失效上,长 TTL 与新鲜度可以兼得,
正确性反而优于固定 5s(那同样存在滞后窗口)。

- WorkerListCache 新增 evictByPrefix:按键前缀整体逐出;
- 缓存实例抽到中立模块 worker-my-order-cache.ts:realtime 需要引用失效入口,
  而 my-order-service 已 import realtime,实例放任何一方都会循环依赖;
- 缓存键统一为 ["w",workerId,scope,detail],前缀失效不会误伤其它打手;
- 失效挂在 publishWorkOrderSnapshots:那里恰好已有工单 + 全部拼单人,
  受影响集合取 当前指派 + 历史保留 + 全部拼单人 + 调用方指定,
  21 处工单写入路径(含取消申请/反馈处理)都会经过;
  工单已删除的分支同样失效,避免打手继续看到已删的单。

最坏情况(漏失效)是打手最多 30s 看到旧状态,与原 5s TTL 的滞后窗口同性质。
守卫测试锁定:失效入口存在、键前缀格式、受影响集合三要素齐全。
This commit is contained in:
yml2213
2026-10-03 15:23:11 +08:00
parent fbdef6ecab
commit 69e3d9a396
5 changed files with 157 additions and 17 deletions
@@ -29,6 +29,7 @@ import {
mapWorkerUser,
resolveWorkerPermissions,
} from '../worker-platform/mappers.js'
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
export type RealtimeEventType =
| 'admin_notification.changed'
@@ -249,6 +250,9 @@ async function publishWorkOrderSnapshots(input: {
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
const workOrder = await getWorkOrderById(workOrderId)
if (!workOrder) {
// 工单已不存在(删除):调用方传入的打手集合仍需失效缓存,
// 否则他们的「我的订单」会继续展示已删除的单。
invalidateWorkerMyOrderCaches(input.workerIds || [])
publishRealtimeEvent({
type: 'work_order.changed',
entityId: workOrderId,
@@ -262,6 +266,18 @@ async function publishWorkOrderSnapshots(input: {
const pendingUnfreezeByOrderId = await sumPendingUnfreezeByOrderIds([workOrderId])
const shares = await listWorkOrderShares(workOrderId)
// 事件驱动缓存失效:这里已经拿到的工单与拼单快照恰好给出完整的受影响打手集合
// (当前指派 + 历史保留 + 全部拼单人 + 调用方显式指定的)。
// 先失效再广播,保证打手端随后的拉取一定看到新数据;这也是「我的订单」缓存
// 能放心用 30s 长 TTL 的前提(见 worker-my-order-cache.ts)。
invalidateWorkerMyOrderCaches([
...(input.workerIds || []),
workOrder.assigned_worker_id,
workOrder.last_assigned_worker_id,
...shares.map((share) => share.worker_id),
])
const joinedQuantity = shares
.filter((share) => share.status !== 'cancelled')
.reduce((sum, share) => sum + Number(share.quantity || 0), 0)
@@ -29,4 +29,20 @@ export class WorkerListCache {
this.entries.set(key, { expiresAt: now + this.ttlMs, value })
return value
}
/**
* 逐出所有键以指定前缀开头的缓存项,返回逐出数量。
* 供事件驱动失效使用:数据变更时按主体(如打手 id)整体作废,
* 让长 TTL 与新鲜度可以兼得。
*/
evictByPrefix(prefix: string): number {
let evicted = 0
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.entries.delete(key)
evicted += 1
}
}
return evicted
}
}
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { WorkerListCache } from './worker-list-cache.js'
test('evictByPrefix 只逐出匹配前缀的条目,不影响其它打手', async () => {
const cache = new WorkerListCache(60_000)
let workerA = 0
let workerB = 0
await cache.getOrSet('["w",7,"list",[1]]', async () => {
workerA++
return 'a1'
})
await cache.getOrSet('["w",7,"overview",0]', async () => {
workerA++
return 'a2'
})
await cache.getOrSet('["w",9,"list",[1]]', async () => {
workerB++
return 'b1'
})
const evicted = cache.evictByPrefix('["w",7,')
assert.equal(evicted, 2, '应逐出打手 7 的两条,别的打手不受影响')
// 再取:打手 7 的缓存已失效会重算,打手 9 直接命中
await cache.getOrSet('["w",7,"list",[1]]', async () => {
workerA++
return 'a1'
})
await cache.getOrSet('["w",9,"list",[1]]', async () => {
workerB++
return 'b1'
})
assert.equal(workerA, 3, '打手 7 被逐出后应重新计算')
assert.equal(workerB, 1, '打手 9 未被逐出,应命中缓存')
})
test('evictByPrefix 对不匹配的前缀返回 0', () => {
const cache = new WorkerListCache(60_000)
assert.equal(cache.evictByPrefix('["w",999,'), 0)
})
test('evictByPrefix 会连并发合并中的在途 Promise 一起逐出', async () => {
const cache = new WorkerListCache(60_000)
let computed = 0
const first = cache.getOrSet('["w",7,"list",[1]]', async () => {
computed++
return 'value'
})
// 在途未完成时就逐出:条目应被移除,失败重试语义不受影响
cache.evictByPrefix('["w",7,')
assert.equal(await first, 'value')
await cache.getOrSet('["w",7,"list",[1]]', async () => {
computed++
return 'value'
})
assert.equal(computed, 2, '在途时被逐出后,下一次读取应重新计算')
})
@@ -0,0 +1,50 @@
import { WorkerListCache } from './worker-list-cache.js'
/**
* 打手端「我的订单」列表/概览的缓存归属模块。
*
* 为什么单独一个文件:缓存实例需要同时被两方引用——
* 1. worker-my-order-service(读,getOrSet);
* 2. realtime-event-service(写,事件驱动失效)。
* 而前者已经 import 了后者,把实例放在任何一方都会形成循环依赖,
* 所以抽成只依赖 WorkerListCache 的中立模块。
*
* TTL 策略:配合 publishWorkOrderSnapshots 里的精确失效,把 TTL 从 2s/5s 提到 30s。
* 失效在工单流转(含指派/拼单/取消申请/反馈处理等 21 处写入路径)时触发,
* 所以 30s 只是兜底——正常情况下数据一变缓存立刻作废,长 TTL 不会造成可见滞后;
* 漏掉失效的最坏情况是打手最多 30s 看到旧状态(原 5s TTL 同样存在这个窗口)。
*/
const MY_ORDER_LIST_CACHE_TTL_MS = 30_000
const MY_ORDER_OVERVIEW_CACHE_TTL_MS = 30_000
const myOrderListCache = new WorkerListCache(MY_ORDER_LIST_CACHE_TTL_MS)
const myOrderOverviewCache = new WorkerListCache(MY_ORDER_OVERVIEW_CACHE_TTL_MS)
/** 统一前缀,让事件失效可以按打手整体逐出该打手的全部「我的订单」缓存。 */
function buildMyOrderCacheKey(workerId: number | string, scope: string, detail: unknown): string {
return JSON.stringify(['w', Number(workerId) || 0, scope, detail])
}
/**
* 逐出指定打手的「我的订单」列表与概览缓存。
* 在工单发生影响该打手视图的变更时调用(见 realtime-event-service 的快照广播)。
* 返回逐出的条目数,便于测试与观测。
*/
export function invalidateWorkerMyOrderCaches(
workerIds: Array<number | string | null | undefined>,
): number {
const normalized = new Set(
workerIds.map((item) => Number(item || 0)).filter((item) => Number.isFinite(item) && item > 0),
)
if (normalized.size === 0) return 0
let evicted = 0
for (const workerId of normalized) {
const prefix = `["w",${workerId},`
evicted += myOrderListCache.evictByPrefix(prefix)
evicted += myOrderOverviewCache.evictByPrefix(prefix)
}
return evicted
}
export { myOrderListCache, myOrderOverviewCache, buildMyOrderCacheKey }
@@ -44,23 +44,19 @@ import {
type WorkerSession,
} from './worker-session-context-service.js'
import { settleOverdueWorkOrders } from './worker-order-timeout-service.js'
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)
import {
buildMyOrderCacheKey,
myOrderListCache,
myOrderOverviewCache,
} from './worker-my-order-cache.js'
/**
* 「我的订单」概览(各状态订单数 + 预计/已结算金额)的短缓存。
*
* 这条查询 32 天被调用 130 万次(每秒约 0.5 次),是数据库累计耗时第一的热路径;
* 迁移 084 的覆盖索引把它从最坏 4.5~9 秒压到秒级以下,但每次仍要扫一遍索引,
* 所以再按打手缓存 5 秒:页面轮询/重复刷新时直接命中,把"计算次数"和"访问次数"解耦。
*
* 「我的订单」列表/概览的缓存实例与 TTL 策略见 worker-my-order-cache.ts:
* TTL 30s + 工单流转时按打手精确失效(realtime-event-service 里触发),
* 正常情况数据一变缓存立刻作废,30s 只是漏失效时的兜底。
* 只缓存读取本身:会话校验与 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)
@@ -71,8 +67,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
const permissions = resolveWorkerPermissions(worker)
const statuses = normalizeStatuses(query.status)
const keyword = String(query.keyword || '').trim()
const cacheKey = JSON.stringify([
worker.id,
const cacheKey = buildMyOrderCacheKey(worker.id, 'list', [
page,
pageSize,
statuses,
@@ -143,8 +138,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 myOrderOverviewCache.getOrSet(String(worker.id), () =>
getWorkerOrderOverview(worker.id),
const rows = await myOrderOverviewCache.getOrSet(
buildMyOrderCacheKey(worker.id, 'overview', 0),
() => getWorkerOrderOverview(worker.id),
)
const items = rows.map((row) => ({
status: row.status,