perf: 筛选统计去掉 JSON 兜底,并补上 orders 覆盖索引

去掉 work_orders 堆访问后,JOIN 会把 orders 整表扫进 Hash Right Join。
领取页在快照新鲜时不再打上游;快手出网统一 10 秒超时。倒计时改为自适应间隔。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-01 15:05:38 +08:00
co-authored by Cursor
parent f4c405d51a
commit 1239393fc2
20 changed files with 404 additions and 65 deletions
@@ -27,15 +27,14 @@ import { getRequiredWorkOrder } from './worker-session-context-service.js'
/**
* 「本次筛选统计」的短缓存。
*
* 为什么要缓存:这条统计要扫全部命中工单(生产 9.6 万行宽表 + 关联 orders + 拼单汇总),
* 实测稳定在 ~400ms。金额落到派生列后已经比原来(3.5~4.4 秒)快了约 9 倍,但**成本随工单量
* 线性增长**——数据再翻几倍就会重新撞上 500ms 慢查询线。这里按"筛选指纹"缓存 10 秒并合并
* 并发请求,把单位时间内的计算次数封顶在 TTL 上,而不是跟着访问量走。
* 为什么要缓存:这条统计要扫全部命中工单(关联 orders + 拼单汇总)。派生列和覆盖索引
* 把单次计算压到几十毫秒之后,10 秒 TTL 只会让分页总数滞后,体感就是「数量对不上」。
* 这里按筛选指纹缓存 3 秒并合并并发请求:挡住同一瞬间的重复刷新,又把滞后收进一次轮询。
*
* 代价是统计与分页总数最多滞后 10 秒:期间操作产生的变化,前端已由实时事件快照就地修正
* 期间操作产生的变化,前端已由实时事件快照就地修正
* (见 admin-realtime-work-orders.ts 同步 statistics 的逻辑),刷新页面后也会自动对齐。
*/
const WORK_ORDER_STATISTICS_CACHE_TTL_MS = 10_000
const WORK_ORDER_STATISTICS_CACHE_TTL_MS = 3_000
const workOrderStatisticsCache = new WorkerListCache(WORK_ORDER_STATISTICS_CACHE_TTL_MS, 32)
/** 只把真正影响统计结果的筛选字段拼进指纹,避免无关参数造成缓存穿透。 */
@@ -2540,7 +2540,8 @@ test('筛选统计必须优先读派生列 source_order_amount_fen,不再逐
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
// 统计表达式:列 → JSON 兜底 → orders.total_amount,COALESCE 从左到右短路
// 统计表达式只读派生列。SQL 文本里一旦出现 material_json,规划器就会回表,
// 执行器短路也救不了 index-only scan。
assert.ok(
queryRepo.includes('wo.source_order_amount_fen'),
'统计没有走派生列,又会退化成每次解析全表 JSON(生产实测 2.8 秒)',
@@ -2550,13 +2551,12 @@ test('筛选统计必须优先读派生列 source_order_amount_fen,不再逐
queryRepo.indexOf('export async function getWorkOrderStatistics'),
)
assert.ok(
paymentBlock.indexOf('wo.source_order_amount_fen') <
paymentBlock.indexOf("material_json->'source'->>'orderAmountFen'"),
'派生列必须排在 JSON 兜底之前,否则短路失效、等于没优化',
!paymentBlock.includes('material_json'),
'统计金额不能再引用 material_json,否则覆盖索引做不了 index-only scan',
)
assert.ok(
paymentBlock.includes('COALESCE(o.total_amount, 0)'),
'最后一级兜底必须与改造前一致(回退 orders.total_amount)',
paymentBlock.includes('COALESCE(wo.source_order_amount_fen, o.total_amount, 0)'),
'NULL 必须回退 orders.total_amount,与 083 列定义一致',
)
// 两个写入点都要维护派生列,否则新单全是 NULL(虽然能兜底,但白优化)
@@ -2604,9 +2604,10 @@ test('筛选统计必须有短缓存 + 并发合并,避免成本随工单量
src.includes('workOrderStatisticsCache.getOrSet('),
'统计必须走 getOrSet(同时合并并发请求,避免多人同时刷新时重复计算)',
)
assert.ok(
/WORK_ORDER_STATISTICS_CACHE_TTL_MS = \d[\d_]*/.test(src),
'TTL 必须是秒级常量:太长会让统计与分页总数明显滞后',
assert.equal(
src.includes('WORK_ORDER_STATISTICS_CACHE_TTL_MS = 3_000'),
true,
'统计降到几十毫秒后 TTL 保持 3 秒:再长会让分页总数滞后',
)
// 指纹必须覆盖所有影响统计的筛选字段,否则不同筛选会互相污染
for (const field of [
@@ -2685,6 +2686,57 @@ test('按运营态筛选的计数必须有覆盖索引(生产实测每次读 2
)
})
test('筛选统计覆盖索引必须吃掉 085,且不能为 VIP 把 JSON 列加回去', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(
new URL('../../db/migrations/087_work_order_statistics_cover_index.sql', import.meta.url),
'utf8',
)
assert.ok(migration.includes('idx_work_orders_statistics_cover'))
for (const column of [
'status',
'sharing_enabled',
'sharing_total_quantity',
'source_order_amount_fen',
'reward_amount',
'order_id',
]) {
assert.ok(migration.includes(column), `统计覆盖索引缺少 ${column}`)
}
assert.ok(
migration.includes('DROP INDEX IF EXISTS idx_work_orders_operational_status_cover'),
'087 必须删掉 085,两条近乎重复的索引不能并存',
)
const workOrdersIndex = migration.slice(
migration.indexOf('CREATE INDEX IF NOT EXISTS idx_work_orders_statistics_cover'),
migration.indexOf('CREATE INDEX IF NOT EXISTS idx_orders_statistics_cover'),
)
assert.ok(!workOrdersIndex.includes('acceptance_json'), '不要把 acceptance_json 放进覆盖索引')
assert.ok(!workOrdersIndex.includes('material_json'), '不要把 material_json 放进覆盖索引')
assert.ok(
migration.includes('idx_orders_statistics_cover'),
'缺 orders 覆盖索引时,统计会把 26 万行 orders 整表扫来做 Hash Right Join',
)
assert.ok(migration.includes('INCLUDE (pay_status, order_status, total_amount)'))
assert.ok(
migration.includes('低于 5%'),
'Heap Fetches 真空后仍有约 1.7%,判据不能写成必须为 0',
)
assert.ok(
!/^\s*CREATE INDEX CONCURRENTLY/m.test(migration),
'迁移在事务内执行,不能使用 CONCURRENTLY',
)
const queryRepo = await readFile(
new URL('../../repositories/worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
queryRepo.includes('不要把 acceptance_json 补进索引'),
'VIP 回表降级必须写在统计查询旁,避免以后有人把 JSON 列加回索引',
)
})
test('三处"只差索引"的热查询缺口必须有迁移兜底(生产已前后对比验证)', async () => {
const { readFile } = await import('node:fs/promises')
const migration = await readFile(