perf(worker): 单号检索收敛为可走索引的等值/前缀,并支持关联单号反查

问题:后台「按单号检索」在平台单号/工单号分支里 OR 了一支
`work_order_no ILIKE '%'||$n||'%'`。前置通配符非 sargable,任何 B-Tree 都用不上,
整条查询退化成 work_orders 的 Seq Scan。

生产实测(10.3 万行 / 572MB,pg_stat_statements 46 小时窗口):
  - 列表查询 + 统计查询用同一份 filters 并行执行,一次检索打两遍全表;
    两者合计占全库 66% 的磁盘读(1.1TB/46h),单次读 26,442 个块(约 211MB)、398ms、返回 1 行。
  - 收敛后同库同参数:等值 0.19ms,前缀 30ms(约 2000 倍)。
  - 真实统计查询端到端 760ms → 61.6ms。

改动:
1. 平台单号/工单号分支收敛为「等值 + 前缀」,与 order-repo / task-repo 既有写法一致;
   工单号含随机小写段(10.2 万行里 7.4 万行非全大写),保留 LOWER() 比较。
2. 迁移 090 补 text_pattern_ops 前缀索引:当前库 collation 为 en_US.utf8,
   前缀 LIKE 在默认 B-Tree 上会退化成全索引扫(实测 438ms)。
3. 新增关联单号反查分支:SUB/INV/AS/DT 不在 work_orders 上,
   经关联表反查工单 id,四条链路均命中唯一索引。
4. 前端 isExplicitOrderNoSearch 同步补齐(否则搜到的单会被状态页签挡掉、
   实时补丁还会把行删掉)。

验证:生产 EXPLAIN 逐条比对 + 14 项仓储单测 + 6 项分类器单测。
This commit is contained in:
yml2213
2026-10-03 14:27:32 +08:00
parent f0d745ea50
commit 90de6b9419
8 changed files with 289 additions and 6 deletions
@@ -0,0 +1,29 @@
-- 090_work_order_lookup_pattern_indexes.sql —— 后台「按单号检索」的前缀索引缺口。
--
-- 背景:work-order-query-repo 的单号检索分支曾经 OR 了一支 `work_order_no ILIKE '%'||$n||'%'`,
-- 前置通配符非 sargable,把整条查询拖成 work_orders 的 Seq Scan。该 SQL 已在代码侧收敛为
-- 「等值 + 前缀」两段(与 order-repo / task-repo 一致),但前缀段还需要本迁移的索引支撑。
--
-- 生产实测(2026-10-03,10.3 万行 / 572MB,datcollate = en_US.utf8):
-- 收敛前:Seq Scan,Rows Removed by Filter: 102,631,26,442 buffers(≈211MB),398ms
-- pg_stat_statements 46 小时窗口里,同一形状的列表查询 + 统计查询合计占全库 66% 磁盘读
--
-- 为什么不能直接用已有索引:
-- idx_work_orders_platform_order / work_orders_work_order_no_key 都是默认 collation 的
-- B-Tree,而当前库 collation 为 en_US.utf8,前缀 LIKE 无法转换成范围条件 —— 实测
-- `platform_order_id LIKE '262740%'` 退化成 Index Only Scan 全索引 + Rows Removed by
-- Filter: 99,471,438ms,几乎等于全表扫。text_pattern_ops 才能让前缀走真正的范围扫描。
CREATE INDEX IF NOT EXISTS idx_work_orders_platform_order_pattern
ON work_orders (platform_order_id text_pattern_ops);
-- 工单号必须大小写不敏感:10.2 万行里有 7.4 万行的随机段含小写(生成器见 utils/random.ts),
-- 所以检索条件用 LOWER(work_order_no) 比较,索引也必须建在同一个表达式上。
-- 实测:等值命中 0.048ms(Bitmap Index Scan),前缀 'WO2610' 走 Bitmap Heap Scan 30~234ms
-- (较收敛前的 398ms Seq Scan 已是量级改善,且不再产生 200MB 级缓冲读)。
CREATE INDEX IF NOT EXISTS idx_work_orders_work_order_no_lower_pattern
ON work_orders (LOWER(work_order_no) text_pattern_ops);
COMMENT ON INDEX idx_work_orders_platform_order_pattern IS
'后台平台单号前缀检索:en_US.utf8 collation 下前缀 LIKE 需要 text_pattern_ops 才能走范围扫';
COMMENT ON INDEX idx_work_orders_work_order_no_lower_pattern IS
'后台工单号大小写不敏感的等值/前缀检索(工单号随机段含小写)';
@@ -116,6 +116,88 @@ test('空白搜索词不生成全表 LIKE 条件', () => {
assert.deepEqual(query.params, [])
})
test('平台单号检索只生成可走索引的等值/前缀条件,不得出现前置通配符', () => {
const query = buildWorkOrderWhere({ keyword: '2627401680611492' })
assert.match(
query.whereClause,
/\(wo\.platform_order_id = \$1 OR wo\.platform_order_id LIKE \$1 \|\| '%'\)/,
)
// 前置通配符非 sargable:一旦混进来,整条查询会退化成 work_orders 全表扫,
// 生产实测每次读 211MB / 398ms,列表与统计并行触发一次打两遍。
assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/)
assert.deepEqual(query.params, ['2627401680611492'])
})
test('工单号检索大小写不敏感且只走索引,不得出现前置通配符', () => {
const query = buildWorkOrderWhere({ keyword: 'WO26100113008697' })
assert.match(
query.whereClause,
/\(LOWER\(wo\.work_order_no\) = LOWER\(\$1\) OR LOWER\(wo\.work_order_no\) LIKE LOWER\(\$1\) \|\| '%'\)/,
)
assert.doesNotMatch(query.whereClause, /ILIKE\s*'%'/)
assert.deepEqual(query.params, ['WO26100113008697'])
})
test('SUB/INV/AS/DT 关联单号经关联表反查工单,不回落到通配符', () => {
// 寄售线索号:SUB → result_inventory_id → bound_work_order_id
const sub = buildWorkOrderWhere({ keyword: 'SUB26100301127318' })
assert.match(sub.whereClause, /FROM consignment_submissions cs/)
assert.match(sub.whereClause, /ci\.bound_work_order_id IS NOT NULL/)
assert.doesNotMatch(sub.whereClause, /LIKE LOWER/)
assert.deepEqual(sub.params, ['SUB26100301127318'])
// 寄售库存号:INV → bound_work_order_id
const inv = buildWorkOrderWhere({ keyword: 'INV26100310477194' })
assert.match(inv.whereClause, /FROM consignment_inventories ci/)
assert.doesNotMatch(inv.whereClause, /LIKE LOWER/)
// 售后案号:AS → work_order_id
const ase = buildWorkOrderWhere({ keyword: 'ASde06bcb25aca' })
assert.match(ase.whereClause, /FROM worker_after_sales_cases ac/)
assert.match(ase.whereClause, /ac\.work_order_id IS NOT NULL/)
assert.doesNotMatch(ase.whereClause, /LIKE LOWER/)
// 履约任务号:DT → work_orders.task_id(历史数据为空,但链路必须保留)
const dt = buildWorkOrderWhere({ keyword: 'DTb946808bcd5b' })
assert.match(dt.whereClause, /FROM fulfillment_tasks ft/)
assert.match(dt.whereClause, /wo_task\.task_id = ft\.id/)
assert.doesNotMatch(dt.whereClause, /LIKE LOWER/)
})
test('普通关键词仍然走 trgm 表达式模糊搜索', () => {
const query = buildWorkOrderWhere({ keyword: '指挥官密钥' })
assert.match(query.whereClause, /LIKE LOWER\(\$\d+\)/)
assert.deepEqual(query.params, ['%指挥官密钥%'])
})
test('单号检索分支不得回退成裸列模糊匹配(源码级防退化守卫)', async () => {
const { readFile } = await import('node:fs/promises')
const src = await readFile(
new URL('./worker-platform/work-order-query-repo.ts', import.meta.url),
'utf8',
)
// 这里是 46 小时窗口内占全库 66% 磁盘读的形状:裸列 + 前置通配符。
assert.doesNotMatch(
src,
/wo\.work_order_no ILIKE '%' \|\|/,
'工单号模糊匹配必须改用 trgm 表达式,或收敛为等值/前缀',
)
assert.doesNotMatch(
src,
/wo\.work_order_no ILIKE \$\{paramIndex\} \|\|/,
'工单号前缀匹配必须用 LOWER() 走 idx_work_orders_work_order_no_lower_pattern',
)
assert.match(
src,
/platform_order_id LIKE \$\{paramIndex\} \|\| '%'/,
'平台单号前缀条件必须保留(迁移 090 的 text_pattern_ops 索引依赖它)',
)
})
test('大厅候选按分类上限取队首后再受全局上限限制', () => {
const query = buildWorkOrderWhere({
hallCandidateLimit: 20,
@@ -9,6 +9,8 @@ import {
import {
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
resolveRelatedWorkOrderLookupKind,
type RelatedWorkOrderLookupKind,
} from '../../utils/platform-order-id.js'
export const WORK_ORDER_SELECT = `
@@ -141,6 +143,54 @@ function workOrderSearchExpression(alias = 'wo') {
)`
}
/**
* 关联单号(SUB / INV / AS / DT)反查工单的条件。
*
* 这些号码不存在于 work_orders 上,只能经关联表回溯。四条链路分别命中:
* consignment_submissions_submission_no_key / consignment_inventories_inventory_no_key
* / worker_after_sales_cases_case_no_key / fulfillment_tasks_task_no_key
* 全部是唯一索引等值查找,因此不会出现「按单号搜索反而全表扫」的情况。
*
* 回溯不到时不返回任何行(而不是回落到通配符搜索)——SUB/INV 在库存被工单消耗前
* 本来就没有对应工单,此时"查不到"是正确结果。
*/
function buildRelatedWorkOrderLookupFilter(
kind: RelatedWorkOrderLookupKind,
paramIndex: string,
): string {
if (kind === 'consignment_submission') {
return `wo.id IN (
SELECT ci.bound_work_order_id
FROM consignment_submissions cs
JOIN consignment_inventories ci ON ci.id = cs.result_inventory_id
WHERE cs.submission_no = ${paramIndex}
AND ci.bound_work_order_id IS NOT NULL
)`
}
if (kind === 'consignment_inventory') {
return `wo.id IN (
SELECT ci.bound_work_order_id
FROM consignment_inventories ci
WHERE ci.inventory_no = ${paramIndex}
AND ci.bound_work_order_id IS NOT NULL
)`
}
if (kind === 'after_sales_case') {
return `wo.id IN (
SELECT ac.work_order_id
FROM worker_after_sales_cases ac
WHERE ac.case_no = ${paramIndex}
AND ac.work_order_id IS NOT NULL
)`
}
return `wo.id IN (
SELECT wo_task.id
FROM fulfillment_tasks ft
JOIN work_orders wo_task ON wo_task.task_id = ft.id
WHERE ft.task_no = ${paramIndex}
)`
}
/**
* 打手订单个人状态:拼单按个人份额状态计算。
* 传入 workerId 时,代练中但存在待审核取消申请或未处理反馈的订单归入问题单,
@@ -930,18 +980,35 @@ export function buildWorkOrderWhere({
filters.push(`wo.id = $${params.length}`)
}
if (normalizedKeyword) {
// 单号检索只允许「可走索引的等值/前缀」条件,与 order-repo / task-repo 的既有写法保持一致。
// 这两支曾经额外 OR 了 `work_order_no ILIKE '%' || $n || '%'`:前置通配符非 sargable,
// 任何 B-Tree 都用不上,会把整条查询拖成 work_orders 的 Seq Scan。
// 生产实测(10.3 万行 / 572MB,pg_stat_statements 46 小时窗口):
// 后台按平台单号检索 → Seq Scan 每次读 26,442 个块(约 211MB)、398ms、返回 1 行;
// 列表与统计用同一份 filters 并行执行,一次检索打两遍全表,
// 两者合计占全库 66% 的磁盘读(1.1TB/46h)。
// 收敛后同库同参数:Index Scan 0.19ms,前缀检索 30ms(约 2000 倍)。
const relatedLookupKind = resolveRelatedWorkOrderLookupKind(normalizedKeyword)
if (isPlatformOrderIdLookupKeyword(normalizedKeyword)) {
params.push(normalizedKeyword)
const paramIndex = `$${params.length}`
filters.push(
`(wo.platform_order_id = ${paramIndex} OR wo.platform_order_id LIKE ${paramIndex} || '%' OR wo.work_order_no ILIKE '%' || ${paramIndex} || '%')`,
`(wo.platform_order_id = ${paramIndex} OR wo.platform_order_id LIKE ${paramIndex} || '%')`,
)
} else if (isWorkOrderNoLookupKeyword(normalizedKeyword)) {
params.push(normalizedKeyword)
const paramIndex = `$${params.length}`
// 工单号含随机小写段(10.2 万行里 7.4 万行非全大写),必须 LOWER() 比较;
// 配套迁移 090 的 idx_work_orders_work_order_no_lower_pattern 才能让前缀检索走范围扫描。
filters.push(
`(wo.work_order_no = ${paramIndex} OR wo.work_order_no ILIKE ${paramIndex} || '%' OR wo.work_order_no ILIKE '%' || ${paramIndex} || '%' OR LOWER(wo.work_order_no) = LOWER(${paramIndex}))`,
`(LOWER(wo.work_order_no) = LOWER(${paramIndex}) OR LOWER(wo.work_order_no) LIKE LOWER(${paramIndex}) || '%')`,
)
} else if (relatedLookupKind) {
// 关联单号(SUB/INV/AS/DT)本身不在 work_orders 上,必须先经关联表反查工单 id。
// 四条链路都命中唯一索引,属于索引驱动的等值查询;查不到时直接返回空集,
// 不会像通配符那样退化成全表扫。
params.push(normalizedKeyword)
filters.push(buildRelatedWorkOrderLookupFilter(relatedLookupKind, `$${params.length}`))
} else {
params.push(`%${normalizedKeyword}%`)
filters.push(`${workOrderSearchExpression()} LIKE LOWER($${params.length})`)
@@ -1,7 +1,11 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isPlatformOrderIdLookupKeyword, isWorkOrderNoLookupKeyword } from './platform-order-id.js'
import {
isPlatformOrderIdLookupKeyword,
isWorkOrderNoLookupKeyword,
resolveRelatedWorkOrderLookupKind,
} from './platform-order-id.js'
test('识别纯数字平台订单号', () => {
assert.equal(isPlatformOrderIdLookupKeyword('20247230034021572'), true)
@@ -29,3 +33,29 @@ test('拒绝普通关键词', () => {
assert.equal(isPlatformOrderIdLookupKeyword(''), false)
assert.equal(isWorkOrderNoLookupKeyword('皮肤店'), false)
})
test('识别需要反查工单的关联单号', () => {
// 生产真实值
assert.equal(resolveRelatedWorkOrderLookupKind('SUB26100301127318'), 'consignment_submission')
assert.equal(resolveRelatedWorkOrderLookupKind('INV26092619393769'), 'consignment_inventory')
assert.equal(resolveRelatedWorkOrderLookupKind('ASde06bcb25aca'), 'after_sales_case')
assert.equal(resolveRelatedWorkOrderLookupKind('DTb946808bcd5b'), 'fulfillment_task')
// 大小写不敏感,前后空格容忍
assert.equal(resolveRelatedWorkOrderLookupKind(' sub26100301127318 '), 'consignment_submission')
assert.equal(resolveRelatedWorkOrderLookupKind('asDE06BCB25ACA'), 'after_sales_case')
})
test('关联单号识别不得误伤工单号、平台单号与普通关键词', () => {
// WO 走自己的分支,不能被关联识别截胡
assert.equal(resolveRelatedWorkOrderLookupKind('WO26100314105769'), null)
// 平台单号(纯数字 / 带连字符)同样走自己的分支
assert.equal(resolveRelatedWorkOrderLookupKind('2627602160531614'), null)
assert.equal(resolveRelatedWorkOrderLookupKind('261003-072613926530472'), null)
// 前缀相近但不是单号的普通词
assert.equal(resolveRelatedWorkOrderLookupKind('SUBWAY'), null)
assert.equal(resolveRelatedWorkOrderLookupKind('ASO优化'), null)
assert.equal(resolveRelatedWorkOrderLookupKind('DT'), null)
assert.equal(resolveRelatedWorkOrderLookupKind(''), null)
assert.equal(resolveRelatedWorkOrderLookupKind(null), null)
})
@@ -14,3 +14,51 @@ export function isWorkOrderNoLookupKeyword(value: unknown): boolean {
const trimmed = String(value || '').trim()
return /^WO[0-9a-zA-Z]{4,}$/i.test(trimmed)
}
/**
* 关联单号类型:号码本身不在 work_orders 上,必须经关联表反查工单 id。
* - consignment_submission: 寄售线索号 SUB... → 产出的库存 → 被工单消耗后的工单
* - consignment_inventory: 寄售库存号 INV... → 被工单消耗后的工单
* - after_sales_case: 售后案号 AS... → 直接带 work_order_id
* - fulfillment_task: 履约任务号 DT... → work_orders.task_id
*
* 说明:这里按 schema 上的关联链路判定,不看当前数据填充率。
* 例如 SUB/INV 目前只有约一半能关联到工单(库存未被消耗前本就没有工单),
* DT 的 work_orders.task_id 目前全为 NULL(历史数据),
* 但链路是通的,后续数据填上后即可直接命中。
*/
export type RelatedWorkOrderLookupKind =
| 'consignment_submission'
| 'consignment_inventory'
| 'after_sales_case'
| 'fulfillment_task'
const RELATED_WORK_ORDER_PATTERNS: Array<{
kind: RelatedWorkOrderLookupKind
pattern: RegExp
}> = [
// SUB + 年月日时分 + 4 位随机,形如 SUB26100301127318
{ kind: 'consignment_submission', pattern: /^SUB\d{8,}$/i },
// INV + 年月日时分 + 4 位随机,形如 INV26100310477194
{ kind: 'consignment_inventory', pattern: /^INV\d{8,}$/i },
// AS + 12 位随机 hex,形如 ASde06bcb25aca
{ kind: 'after_sales_case', pattern: /^AS[0-9a-f]{8,}$/i },
// DT + 12 位随机 hex,形如 DTb946808bcd5b
{ kind: 'fulfillment_task', pattern: /^DT[0-9a-f]{8,}$/i },
]
/**
* 判断关键词是否是「需要反查工单」的关联单号。
*/
export function resolveRelatedWorkOrderLookupKind(
value: unknown,
): RelatedWorkOrderLookupKind | null {
const trimmed = String(value || '').trim()
if (!trimmed) return null
for (const item of RELATED_WORK_ORDER_PATTERNS) {
if (item.pattern.test(trimmed)) {
return item.kind
}
}
return null
}
@@ -1802,7 +1802,7 @@ export default function WorkOrdersPanel() {
<Input
allowClear
value={keywordInput}
placeholder="工单号 / 平台单号 / 商品名 / 游戏ID / 游戏昵称"
placeholder="工单号 / 平台单号 / 寄卖单号 / 商品名 / 游戏ID / 游戏昵称"
style={{ width: 280 }}
onChange={(event) => {
const nextVal = event.target.value
@@ -100,11 +100,15 @@ function matchesCreatedRange(
}
/**
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号。
* 是否为「显式单号检索」——工单号 / 平台单号 / 纯数字单号 / 关联单号。
*
* 两处依赖它,必须保持一致,所以放在这里而不是面板内部:
* 1. 面板搜索时据此清空排他的状态/阶段筛选(避免流转后单子脱靶);
* 2. 实时补丁判定据此短路为「只原地更新」,绝不把正在看的单删掉。
*
* 关联单号(寄卖提报号 SUB / 仓库号 INV / 售后案号 AS / 履约任务号 DT)与后端
* work-order-query-repo 的反查分支一一对应:客服界面「只认寄卖单号」,
* 粘进来的就是 SUB,漏掉它会导致搜到的单被状态页签挡掉。
*/
export function isExplicitOrderNoSearch(text: unknown): boolean {
const trimmed = String(text || '').trim()
@@ -112,7 +116,11 @@ export function isExplicitOrderNoSearch(text: unknown): boolean {
return (
/^WO[0-9a-zA-Z]{4,}$/i.test(trimmed) ||
/^\d{6,}(-\d{6,})?$/.test(trimmed) ||
/^OD[0-9a-zA-Z]{4,}$/i.test(trimmed)
/^OD[0-9a-zA-Z]{4,}$/i.test(trimmed) ||
/^SUB\d{8,}$/i.test(trimmed) ||
/^INV\d{8,}$/i.test(trimmed) ||
/^AS[0-9a-f]{8,}$/i.test(trimmed) ||
/^DT[0-9a-f]{8,}$/i.test(trimmed)
)
}
@@ -418,3 +418,22 @@ test('isExplicitOrderNoSearch 只认单号形态,普通关键词不误判', ()
assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`)
}
})
test('关联单号(寄卖/仓库/售后/履约)也算显式单号检索', () => {
// 客服界面「只认寄卖单号」,粘进来的就是 SUB;漏判会让搜到的单被状态页签挡掉
for (const orderNo of [
'SUB26100301127318',
'INV26100310477194',
'ASde06bcb25aca',
'DTb946808bcd5b',
// 大小写与空格容忍
' sub26100301127318 ',
]) {
assert.equal(isExplicitOrderNoSearch(orderNo), true, `${orderNo} 应识别为单号`)
}
// 前缀相近但不是单号的普通词,不能被误判(误判会清掉客服的筛选条件)
for (const keyword of ['SUBWAY', 'ASO优化', 'DT', 'INV', 'SUB123', 'ASzzz']) {
assert.equal(isExplicitOrderNoSearch(keyword), false, `${keyword} 不应识别为单号`)
}
})