perf(admin-worker): 筛选统计加 10 秒短缓存,把成本增长与访问量解耦
金额落到派生列后,统计查询从生产实测 3.5~4.4 秒降到 ~400ms(约 9 倍), 但仍要扫全部命中工单(9.6 万行宽表 + 关联 orders + 拼单汇总),成本随工单量线性增长, 数据再翻几倍会重新撞上 500ms 慢查询线。 因此按"筛选指纹"缓存 10 秒并合并并发请求(WorkerListCache.getOrSet): 单位时间内的计算次数被封顶在 TTL 上,不再跟着刷新次数走;多人同时刷列表只会算一次。 代价是统计与分页总数最多滞后 10 秒,期间前端已由实时事件快照就地修正 statistics, 刷新后自动对齐。 指纹覆盖 statuses/status/workOrderId/keyword/workerSharingId/workerId/workerLevelIds/ categoryId/acceptanceMode/giftPhase/vipEvidencePending/useOperationalStatus/创建日期, 并加静态守卫防止后续漏字段导致不同筛选互相污染。 验证:后端 531 项单测全绿(新增 1 项守卫)。
This commit is contained in:
@@ -21,8 +21,43 @@ import {
|
||||
normalizeOptionalId,
|
||||
normalizeStatuses,
|
||||
} from './mappers.js'
|
||||
import { WorkerListCache } from './worker-list-cache.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
|
||||
/**
|
||||
* 「本次筛选统计」的短缓存。
|
||||
*
|
||||
* 为什么要缓存:这条统计要扫全部命中工单(生产 9.6 万行宽表 + 关联 orders + 拼单汇总),
|
||||
* 实测稳定在 ~400ms。金额落到派生列后已经比原来(3.5~4.4 秒)快了约 9 倍,但**成本随工单量
|
||||
* 线性增长**——数据再翻几倍就会重新撞上 500ms 慢查询线。这里按"筛选指纹"缓存 10 秒并合并
|
||||
* 并发请求,把单位时间内的计算次数封顶在 TTL 上,而不是跟着访问量走。
|
||||
*
|
||||
* 代价是统计与分页总数最多滞后 10 秒:期间操作产生的变化,前端已由实时事件快照就地修正
|
||||
* (见 admin-realtime-work-orders.ts 同步 statistics 的逻辑),刷新页面后也会自动对齐。
|
||||
*/
|
||||
const WORK_ORDER_STATISTICS_CACHE_TTL_MS = 10_000
|
||||
const workOrderStatisticsCache = new WorkerListCache(WORK_ORDER_STATISTICS_CACHE_TTL_MS, 32)
|
||||
|
||||
/** 只把真正影响统计结果的筛选字段拼进指纹,避免无关参数造成缓存穿透。 */
|
||||
function buildWorkOrderStatisticsCacheKey(filters: JsonObject): string {
|
||||
return JSON.stringify([
|
||||
filters.statuses || [],
|
||||
filters.status || '',
|
||||
filters.workOrderId || 0,
|
||||
filters.keyword || '',
|
||||
filters.workerSharingId || 0,
|
||||
filters.workerId || 0,
|
||||
filters.workerLevelIds || [],
|
||||
filters.categoryId || 0,
|
||||
filters.acceptanceMode || '',
|
||||
filters.giftPhase || '',
|
||||
filters.vipEvidencePending === true,
|
||||
filters.useOperationalStatus === true,
|
||||
filters.createdFrom || '',
|
||||
filters.createdTo || '',
|
||||
])
|
||||
}
|
||||
|
||||
export async function getAdminWorkOrderEvents(workOrderId: number | string) {
|
||||
await getRequiredWorkOrder(workOrderId)
|
||||
const events = await listWorkOrderEventsByOrderId(workOrderId)
|
||||
@@ -60,7 +95,9 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
}
|
||||
const [{ items }, statistics] = await Promise.all([
|
||||
listWorkOrders({ page, pageSize, includeTotal: false, ...filters }),
|
||||
getWorkOrderStatistics(filters),
|
||||
workOrderStatisticsCache.getOrSet(buildWorkOrderStatisticsCacheKey(filters), () =>
|
||||
getWorkOrderStatistics(filters),
|
||||
),
|
||||
])
|
||||
const itemIds = items.map((item) => Number(item.id))
|
||||
|
||||
|
||||
@@ -2589,3 +2589,32 @@ test('会话 touch 必须同时保留鉴权语义与写入节流', async () => {
|
||||
'写入必须带 last_seen_at 未刷新判定,否则等于没节流',
|
||||
)
|
||||
})
|
||||
|
||||
test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量线性传导到每次刷新', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const src = await readFile(
|
||||
new URL('./admin-work-order-query-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('new WorkerListCache('),
|
||||
'统计缺少短缓存:这条查询要扫全量命中工单,成本随数据量线性增长',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('workOrderStatisticsCache.getOrSet('),
|
||||
'统计必须走 getOrSet(同时合并并发请求,避免多人同时刷新时重复计算)',
|
||||
)
|
||||
assert.ok(
|
||||
/WORK_ORDER_STATISTICS_CACHE_TTL_MS = \d[\d_]*/.test(src),
|
||||
'TTL 必须是秒级常量:太长会让统计与分页总数明显滞后',
|
||||
)
|
||||
// 指纹必须覆盖所有影响统计的筛选字段,否则不同筛选会互相污染
|
||||
for (const field of [
|
||||
'filters.keyword',
|
||||
'filters.statuses',
|
||||
'filters.workerLevelIds',
|
||||
'filters.categoryId',
|
||||
]) {
|
||||
assert.ok(src.includes(field), `缓存指纹缺少 ${field}`)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user