perf(worker-platform): 店铺筛选下拉加 5 分钟缓存,避免全表扫描拖垮生产
This commit is contained in:
@@ -49,9 +49,11 @@ router.get(
|
||||
)
|
||||
|
||||
// 「店铺」筛选下拉:固定全量候选,不随列表筛选变化(前端 Select 自带搜索过滤)。
|
||||
// 服务端有 5 分钟缓存,这里再给浏览器 30 秒:后台切换页签时不必每次都回源。
|
||||
router.get(
|
||||
'/worker-platform/orders/shop-options',
|
||||
requireAdminPermission('worker_order.view'),
|
||||
setHttpCache(30),
|
||||
createJsonHandler(() => listAdminWorkOrderShopNames(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取店铺筛选选项失败',
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './mappers.js'
|
||||
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
||||
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
||||
import { invalidateWorkOrderShopOptionsCache } from './work-order-shop-options-cache.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
import { normalizeWorkOrderTimeoutPolicy } from './work-order-timeout-policy.js'
|
||||
|
||||
@@ -182,6 +183,8 @@ export async function deleteAdminWorkOrder(workOrderId: number | string) {
|
||||
errorCode: 'work_order_delete_failed',
|
||||
})
|
||||
}
|
||||
// 删掉某店铺的最后一张工单后,它不该继续出现在「店铺」筛选候选里。
|
||||
invalidateWorkOrderShopOptionsCache()
|
||||
publishWorkOrderRealtimeChange({
|
||||
workOrderId: Number(workOrder.id),
|
||||
hallChanged: workOrder.status === WORK_ORDER_STATUS.OPEN,
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
listConsignmentInventoriesByIds,
|
||||
listWorkOrderEventsByOrderId,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
listWorkOrderShopNames,
|
||||
listWorkOrders,
|
||||
sumPendingUnfreezeByOrderIds,
|
||||
type ConsignmentInventoryRow,
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
normalizeOptionalId,
|
||||
normalizeStatuses,
|
||||
} from './mappers.js'
|
||||
import { listCachedWorkOrderShopNames } from './work-order-shop-options-cache.js'
|
||||
import { WorkerListCache } from './worker-list-cache.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
|
||||
@@ -194,7 +194,13 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
|
||||
*
|
||||
* 固定全量去重(不随状态/日期等筛选变化),下拉里带搜索框由前端过滤即可;
|
||||
* 数据口径见 repository 的 listWorkOrderShopNames(orders.shop_name,精确匹配)。
|
||||
*
|
||||
* 结果走 work-order-shop-options-cache:这条 SQL 每次要并行全表扫 orders 并与
|
||||
* work_orders 做半连接(生产实测 1.6 秒 / 200MB 磁盘读),而候选只有十几个、
|
||||
* 极少变化,绝不能每次打开页面都重算。
|
||||
*/
|
||||
export async function listAdminWorkOrderShopNames(): Promise<{ items: string[] }> {
|
||||
return { items: await listWorkOrderShopNames() }
|
||||
const items = await listCachedWorkOrderShopNames()
|
||||
// 返回副本:缓存里的是共享数组,调用方(含序列化前的映射)不该改到它。
|
||||
return { items: [...items] }
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
resolveWorkOrderSharingEnabled,
|
||||
} from './mappers.js'
|
||||
import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js'
|
||||
import { registerWorkOrderShopName } from './work-order-shop-options-cache.js'
|
||||
import { decideOrderItemsFulfillmentModes } from '../fulfillment/fulfillment-mode-service.js'
|
||||
|
||||
export async function syncWorkerOrdersForSourceOrder(
|
||||
@@ -271,6 +272,8 @@ export async function syncWorkerOrdersForSourceOrder(
|
||||
}
|
||||
|
||||
created.push(workOrder)
|
||||
// 店铺第一次产生工单时要能出现在后台「店铺」筛选里;老店铺出单不会动缓存。
|
||||
registerWorkOrderShopName(order.shop_name)
|
||||
decisions.push(
|
||||
buildWorkerConsumerDecision(item, {
|
||||
result: 'created',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { listWorkOrderShopNames } from '../../repositories/worker-platform/index.js'
|
||||
import { WorkerListCache } from './worker-list-cache.js'
|
||||
|
||||
/**
|
||||
* 后台工单「店铺」筛选下拉的缓存归属模块。
|
||||
*
|
||||
* 为什么要缓存:这条 SQL 从 orders(约 43 万行)出发做并行全表扫描,再与 work_orders
|
||||
* 做哈希半连接,生产实测单次 1.6 秒、读 22~28 万块(≈200MB 磁盘),而结果只有十几个
|
||||
* 店铺名,且只在「某个店铺第一次产生工单」时才会变。后台工单页每次挂载、每次改单后的
|
||||
* refreshAll 都会重新全表扫一遍,几条并发就能吃掉 4 核里的一到两个核,连接池随即见底
|
||||
* (2026-10-30 18:57 的生产事故就是这么放大的)。
|
||||
*
|
||||
* 为什么单独一个文件:缓存实例要同时被「读」(admin-work-order-query-service)和
|
||||
* 「写」(建单/删单路径主动失效)引用,抽成只依赖 WorkerListCache 的中立模块,
|
||||
* 服务层之间不会形成循环依赖。
|
||||
*
|
||||
* 失效策略是「精确」而不是「无脑作废」:高峰期每 10~20 秒就建一张新工单,
|
||||
* 如果建单就整体失效,等于没缓存。所以记住当前候选集合,只有真的出现新店铺名才作废。
|
||||
* TTL 取 5 分钟只是兜底,正常情况下靠 registerWorkOrderShopName 即时对齐。
|
||||
*/
|
||||
const WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000
|
||||
const workOrderShopOptionsCache = new WorkerListCache(WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS, 1)
|
||||
|
||||
/** 全量固定口径只有一份结果,键用常量即可;getOrSet 同时合并并发请求。 */
|
||||
const WORK_ORDER_SHOP_OPTIONS_CACHE_KEY = 'worker-platform:order-shop-options'
|
||||
|
||||
/** 当前候选集合的快照,用来判断「这次建单是否引入了新店铺」。 */
|
||||
let knownShopNames: Set<string> | null = null
|
||||
|
||||
export function listCachedWorkOrderShopNames(): Promise<string[]> {
|
||||
return workOrderShopOptionsCache.getOrSet(WORK_ORDER_SHOP_OPTIONS_CACHE_KEY, async () => {
|
||||
const items = await listWorkOrderShopNames()
|
||||
knownShopNames = new Set(items)
|
||||
return items
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 建单后登记店铺名,返回是否因此作废了缓存。
|
||||
*
|
||||
* 只有「候选里从没出现过的店铺」才需要重算:老店铺出单不影响下拉内容。
|
||||
* 缓存尚未建立时直接忽略(下一次读取自然会带上新店铺)。
|
||||
*/
|
||||
export function registerWorkOrderShopName(shopName: string): boolean {
|
||||
const name = String(shopName || '').trim()
|
||||
if (!name || !knownShopNames || knownShopNames.has(name)) return false
|
||||
// 先记账再作废:并发的同店铺建单不会重复触发全表扫描。
|
||||
knownShopNames.add(name)
|
||||
workOrderShopOptionsCache.clear()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 删单可能让某个店铺彻底不再挂着工单,此时直接作废(删单是低频操作)。
|
||||
* 返回逐出的条目数,便于测试与观测。
|
||||
*/
|
||||
export function invalidateWorkOrderShopOptionsCache(): number {
|
||||
knownShopNames = null
|
||||
return workOrderShopOptionsCache.clear()
|
||||
}
|
||||
@@ -24,3 +24,19 @@ test('WorkerListCache coalesces concurrent reads and expires entries', async ()
|
||||
assert.deepEqual(await cache.getOrSet('same', factory), { calls: 2 })
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('WorkerListCache.clear 逐出全部条目(含在途 Promise),并返回逐出数量', async () => {
|
||||
const cache = new WorkerListCache(60_000)
|
||||
let calls = 0
|
||||
const factory = async () => {
|
||||
calls += 1
|
||||
return calls
|
||||
}
|
||||
|
||||
assert.equal(await cache.getOrSet('a', factory), 1)
|
||||
assert.equal(await cache.getOrSet('b', factory), 2)
|
||||
assert.equal(cache.clear(), 2)
|
||||
// 清空后同一个键要重新计算,而不是继续命中旧值
|
||||
assert.equal(await cache.getOrSet('a', factory), 3)
|
||||
assert.equal(cache.clear(), 1)
|
||||
})
|
||||
|
||||
@@ -45,4 +45,11 @@ export class WorkerListCache {
|
||||
}
|
||||
return evicted
|
||||
}
|
||||
|
||||
/** 逐出全部条目(只有一份结果的配置类缓存用),返回逐出数量。 */
|
||||
clear(): number {
|
||||
const evicted = this.entries.size
|
||||
this.entries.clear()
|
||||
return evicted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2913,6 +2913,83 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量
|
||||
}
|
||||
})
|
||||
|
||||
test('后台「店铺」筛选下拉必须缓存 + 写路径失效(单次全表扫约 200MB)', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
|
||||
// 背景:listWorkOrderShopNames 从 orders(43 万行)做并行全表扫描,再与 work_orders
|
||||
// 做哈希半连接。生产实测单次 1.6 秒、读 22~28 万块(≈200MB),结果却只有十几个店铺名。
|
||||
// 2026-10-30 18:57 的事故里,后台工单页每次挂载、每次改单后的 refreshAll 都重算一遍,
|
||||
// 几条并发就把 4 核打满并拖垮连接池,所以这里必须有缓存 + 主动失效。
|
||||
const cacheModule = await readFile(
|
||||
new URL('./work-order-shop-options-cache.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(cacheModule.includes('new WorkerListCache('), '缺少店铺下拉缓存实例')
|
||||
assert.ok(
|
||||
cacheModule.includes('WORK_ORDER_SHOP_OPTIONS_CACHE_TTL_MS = 5 * 60_000'),
|
||||
'TTL 必须拉长到 5 分钟:这份候选只在「新店铺第一次出单」时变化',
|
||||
)
|
||||
assert.ok(
|
||||
cacheModule.includes('workOrderShopOptionsCache.getOrSet('),
|
||||
'必须走 getOrSet:多人同时刷新要合并成一次全表扫描',
|
||||
)
|
||||
assert.ok(cacheModule.includes('clear()'), '缺少主动失效入口,只能等 TTL 自然过期')
|
||||
assert.ok(
|
||||
cacheModule.includes('registerWorkOrderShopName('),
|
||||
'缺少建单登记入口:新店铺必须立刻可选中',
|
||||
)
|
||||
assert.ok(
|
||||
cacheModule.includes('knownShopNames.has(name)'),
|
||||
'建单必须只对「新店铺」作废缓存:高峰期每 10~20 秒一单,无条件作废等于没缓存',
|
||||
)
|
||||
|
||||
const service = await readFile(
|
||||
new URL('./admin-work-order-query-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
service.includes('listCachedWorkOrderShopNames()'),
|
||||
'店铺下拉没走缓存,仍会每次全表扫 orders',
|
||||
)
|
||||
assert.ok(
|
||||
service.includes('items: [...items]'),
|
||||
'缓存里的数组必须复制后返回,否则调用方改到共享数组会污染缓存',
|
||||
)
|
||||
|
||||
// 浏览器侧也挡住:后台切页签时不必每次都回源(服务端缓存 5 分钟,这里只加 30 秒)
|
||||
const route = await readFile(
|
||||
new URL('../../routes/admin/worker-platform/orders.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const routeBody = route.slice(
|
||||
route.indexOf("'/worker-platform/orders/shop-options'"),
|
||||
route.indexOf("'/worker-platform/orders/mock/custom-shops'"),
|
||||
)
|
||||
assert.ok(routeBody.includes('setHttpCache('), '店铺下拉接口缺少 Cache-Control')
|
||||
|
||||
// 写路径精确失效:建单只在「新店铺」时重算,删单可能让某个店铺的候选消失
|
||||
const syncSrc = await readFile(
|
||||
new URL('./sync-worker-orders-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
syncSrc.includes('registerWorkOrderShopName(order.shop_name)'),
|
||||
'建单路径缺少店铺登记:新店铺要等 5 分钟才出现在下拉里',
|
||||
)
|
||||
assert.ok(
|
||||
!syncSrc.includes('invalidateWorkOrderShopOptionsCache()'),
|
||||
'建单不能整体作废缓存:老店铺出单同样会触发,等于没缓存',
|
||||
)
|
||||
const manageSrc = await readFile(
|
||||
new URL('./admin-work-order-management-service.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
manageSrc.includes('invalidateWorkOrderShopOptionsCache()'),
|
||||
'删单路径缺少失效:删掉某店铺最后一张工单后它仍留在下拉里',
|
||||
)
|
||||
})
|
||||
|
||||
test('残留字段 work_orders.task_id 不得再被当成关联键 JOIN(迁移 091)', async () => {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user