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,
|
normalizeOptionalId,
|
||||||
normalizeStatuses,
|
normalizeStatuses,
|
||||||
} from './mappers.js'
|
} from './mappers.js'
|
||||||
|
import { WorkerListCache } from './worker-list-cache.js'
|
||||||
import { getRequiredWorkOrder } from './worker-session-context-service.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) {
|
export async function getAdminWorkOrderEvents(workOrderId: number | string) {
|
||||||
await getRequiredWorkOrder(workOrderId)
|
await getRequiredWorkOrder(workOrderId)
|
||||||
const events = await listWorkOrderEventsByOrderId(workOrderId)
|
const events = await listWorkOrderEventsByOrderId(workOrderId)
|
||||||
@@ -60,7 +95,9 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
|||||||
}
|
}
|
||||||
const [{ items }, statistics] = await Promise.all([
|
const [{ items }, statistics] = await Promise.all([
|
||||||
listWorkOrders({ page, pageSize, includeTotal: false, ...filters }),
|
listWorkOrders({ page, pageSize, includeTotal: false, ...filters }),
|
||||||
getWorkOrderStatistics(filters),
|
workOrderStatisticsCache.getOrSet(buildWorkOrderStatisticsCacheKey(filters), () =>
|
||||||
|
getWorkOrderStatistics(filters),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
const itemIds = items.map((item) => Number(item.id))
|
const itemIds = items.map((item) => Number(item.id))
|
||||||
|
|
||||||
|
|||||||
@@ -2589,3 +2589,32 @@ test('会话 touch 必须同时保留鉴权语义与写入节流', async () => {
|
|||||||
'写入必须带 last_seen_at 未刷新判定,否则等于没节流',
|
'写入必须带 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