From 2f2d44f601f010ff50f47268d49d45c01ba25465 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 1 Oct 2026 13:02:09 +0800 Subject: [PATCH] =?UTF-8?q?perf(admin-worker):=20=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=8A=A0=2010=20=E7=A7=92=E7=9F=AD=E7=BC=93?= =?UTF-8?q?=E5=AD=98=EF=BC=8C=E6=8A=8A=E6=88=90=E6=9C=AC=E5=A2=9E=E9=95=BF?= =?UTF-8?q?=E4=B8=8E=E8=AE=BF=E9=97=AE=E9=87=8F=E8=A7=A3=E8=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 金额落到派生列后,统计查询从生产实测 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 项守卫)。 --- .../admin-work-order-query-service.ts | 39 ++++++++++++++++++- .../worker-platform-service.test.ts | 29 ++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts index a70b8bdf..9c100da2 100644 --- a/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts +++ b/apps/backend/src/services/worker-platform/admin-work-order-query-service.ts @@ -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)) diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 6274e1ab..8ed74cd3 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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}`) + } +})