perf(db): 干掉列表/计数热路径上的「整表拼单聚合」,并给数据库留出 1 核
生产实测(2026-10-03,order_site):postgres 在 22.6 小时里烧了 12.2 CPU·小时 (均 0.54 核),却因 2 核配额累计被 cgroup 节流 9,337 秒;top 12 条语句占全部 DB CPU 的 69.4%,其中三条最热的就是「工单列表 / 计数 / 大厅候选集」。 一、查询形状(根因,不是缺索引) 接单大厅那条 COUNT 被调 71,539 次、均 103ms,EXPLAIN(ANALYZE,BUFFERS) 显示 82.8ms 里 35ms 花在「把整张 work_order_shares 聚合一遍再 join」: Index Only Scan rows=22,987 → GroupAggregate → 最终只用到 11 行;同一条 SQL 里 还有第二遍同样的全局聚合(filter_share_summary)。已改成按工单限定的关联子查询 (走 idx_work_order_shares_work_order): - buildWorkOrderWhere 的 excludeFilledSharing 过滤条件; - buildHallCandidateIdsSql 的 candidate_shares。 同参数实测:50.3ms → 2.1ms、35.9ms → 3.8ms、28.4ms → 1.8ms,buffer 10,145 → 187, 三种参数下结果逐行一致(11/11、5/5、11/11)。 有意保留 operational_share_summary 那一处全局聚合:全表统计(103,073 行)时 关联子查询要 103,073 次探测(330ms),一次聚合只要 55ms;已有用例钉住「只允许剩一处」。 二、容器与数据库参数 - POSTGRES_CPUS 2 → 3(4 核机器,同机其它容器实测只用 0.2-0.4 核且几乎不节流); - max_parallel_workers_per_gather 2 → 1(>1 时一条查询要 leader+2 worker=3 核,必撞配额); - checkpoint_timeout 300 → 900s、max_wal_size 1GB → 4GB(43 天累计 21,778 次检查点, sar 里就是每 20-40 分钟一轮写突发)。 三、迁移 092:热表 autovacuum 调参 可见性图陈旧导致 index-only scan 回表 5,580 次;work_orders/order_items/orders/ kuaishou_industry_vouchers/fulfillment_tasks 死元组 1.9 万-5.3 万、上次清理在前一天 甚至 9/29。把这些表的 scale_factor 0.2→0.05、analyze 0.1→0.02、cost_limit 200→800, 把「攒批猛扫」拆成「小步勤清」,并打开 5s 以上的 autovacuum 日志便于观测。 用例:新增 share-summary-scope.test.ts(钉住热路径不再出现整表聚合、只保留一处且有注释说明), env-documentation 白名单补 3 个新增部署变量。全量 599 通过,typecheck/lint/check:sql 通过。
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
/**
|
||||
* 2026-10-03 生产复盘(库 order_site @47.114.114.225):
|
||||
*
|
||||
* 这条「接单大厅 COUNT」50 小时里被调 71,539 次、平均 103ms,EXPLAIN 显示
|
||||
* 82.8ms 中有 35ms 花在「把整张 work_order_shares 聚合一遍再 join」上
|
||||
* (Index Only Scan rows=22,987 → GroupAggregate → 最终只用到 11 行),
|
||||
* 另外还有一遍同样的全局聚合在同一条 SQL 里(外层 filter_share_summary)。
|
||||
*
|
||||
* 关键点:这不是索引能解决的问题——候选只有几十行,却每次重算全表聚合。
|
||||
* 现在改成按工单限定的关联子查询(走 idx_work_order_shares_work_order),
|
||||
* 同参数下 50.3ms → 2.1ms,buffer 10,145 → 187,结果逐行一致。
|
||||
*
|
||||
* 唯一保留全局聚合的地方是 operational_share_summary(无打手范围的全表统计):
|
||||
* 实测 103,073 行的全表统计里,关联子查询要 103,073 次索引探测(330ms),
|
||||
* 而一次全局聚合只要 55ms,所以那里是有意保留的。这个用例把「只允许剩一处」
|
||||
* 钉死,避免以后又在热路径上复制粘贴出全局聚合。
|
||||
*/
|
||||
async function readRepoSource() {
|
||||
return readFile(new URL('./work-order-query-repo.ts', import.meta.url), 'utf8')
|
||||
}
|
||||
|
||||
/** 去掉 SQL 行注释(--)与 TS 行注释(//),避免注释里提到旧写法时被误判成代码。 */
|
||||
function stripComments(source: string) {
|
||||
return source
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/--.*$/, '').replace(/\/\/.*$/, ''))
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
test('excludeFilledSharing 不再拼接「整表拼单聚合」,改用按工单限定的关联子查询', async () => {
|
||||
const source = await readRepoSource()
|
||||
|
||||
assert.ok(
|
||||
!source.includes('filter_share_summary.active_share_quantity'),
|
||||
'excludeFilledSharing 的过滤条件仍引用全局聚合别名 filter_share_summary',
|
||||
)
|
||||
|
||||
const excludeBlock = source.slice(
|
||||
source.indexOf('if (excludeFilledSharing) {'),
|
||||
source.indexOf('if (excludeFilledSharing) {') + 1200,
|
||||
)
|
||||
assert.ok(excludeBlock.includes('FROM work_order_shares wos'), '缺少按工单限定的拼单查询')
|
||||
assert.ok(
|
||||
excludeBlock.includes('WHERE wos.work_order_id = wo.id'),
|
||||
'excludeFilledSharing 的拼单查询没有按 wo.id 限定,会退化成全表聚合',
|
||||
)
|
||||
})
|
||||
|
||||
test('大厅候选集(buildHallCandidateIdsSql)不再聚合整表拼单', async () => {
|
||||
const source = stripComments(await readRepoSource())
|
||||
const start = source.indexOf('function buildHallCandidateIdsSql')
|
||||
const hallSql = source.slice(start, start + 2600)
|
||||
|
||||
assert.ok(!hallSql.includes('candidate_shares'), '大厅候选集仍在用全局聚合别名 candidate_shares')
|
||||
assert.ok(!hallSql.includes('GROUP BY work_order_id'), '大厅候选集仍在聚合整张 work_order_shares')
|
||||
assert.ok(
|
||||
hallSql.includes('WHERE wos.work_order_id = candidate.id'),
|
||||
'大厅候选集的拼单份额没有按候选工单限定',
|
||||
)
|
||||
})
|
||||
|
||||
test('全仓库只允许保留一处「整表拼单聚合」,且必须是无打手范围的全表统计分支', async () => {
|
||||
const raw = await readRepoSource()
|
||||
const source = stripComments(raw)
|
||||
const globalAggregations = [
|
||||
...source.matchAll(/GROUP BY\s+wos\.work_order_id|GROUP BY\s+work_order_id/g),
|
||||
]
|
||||
|
||||
assert.equal(
|
||||
globalAggregations.length,
|
||||
1,
|
||||
`整表拼单聚合只应剩 operational_share_summary 一处,实际 ${globalAggregations.length} 处:` +
|
||||
'新增的每一处都会在列表/计数热路径上被乘以每秒数次调用',
|
||||
)
|
||||
|
||||
const onlyRemaining = globalAggregations[0]!
|
||||
const around = source.slice(Math.max(0, onlyRemaining.index - 900), onlyRemaining.index + 200)
|
||||
assert.ok(
|
||||
around.includes('operational_share_summary'),
|
||||
'保留的那处整表聚合不在 operational_share_summary 分支里,需要确认是否有意为之',
|
||||
)
|
||||
|
||||
// 注释在 stripComments 里被去掉了,所以断言理由时用原文
|
||||
const rawIndex = raw.indexOf('GROUP BY wos.work_order_id')
|
||||
const rawAround = raw.slice(Math.max(0, rawIndex - 1200), rawIndex + 200)
|
||||
assert.ok(
|
||||
rawAround.includes('无打手范围时一次聚合全部拼单'),
|
||||
'保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删',
|
||||
)
|
||||
})
|
||||
@@ -891,16 +891,10 @@ export function buildWorkOrderWhere({
|
||||
}
|
||||
let workerSharePlaceholder = ''
|
||||
|
||||
if (excludeFilledSharing) {
|
||||
joins.push(`
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
wos.work_order_id,
|
||||
SUM(wos.quantity) FILTER (WHERE wos.status != 'cancelled') AS active_share_quantity
|
||||
FROM work_order_shares wos
|
||||
GROUP BY wos.work_order_id
|
||||
) filter_share_summary ON filter_share_summary.work_order_id = wo.id`)
|
||||
}
|
||||
// 旧的 excludeFilledSharing 写法:LEFT JOIN 一份「整张 work_order_shares 的聚合」
|
||||
// (GROUP BY work_order_id)再在 WHERE 里比较。列表/计数每次调用都要重算这份全局聚合,
|
||||
// 但真正要用到的只是候选工单那几十行——生产 EXPLAIN 里它占了 COUNT 的主要成本。
|
||||
// 这里改成和 adminOperationalStatusExpression 同一口径的关联子查询:只按 wo.id 探测。
|
||||
|
||||
// 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。
|
||||
// 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。
|
||||
@@ -1187,7 +1181,12 @@ export function buildWorkOrderWhere({
|
||||
if (excludeFilledSharing) {
|
||||
filters.push(`(
|
||||
wo.sharing_enabled IS NOT TRUE
|
||||
OR COALESCE(filter_share_summary.active_share_quantity, 0) < wo.sharing_total_quantity
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = wo.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < wo.sharing_total_quantity
|
||||
)`)
|
||||
}
|
||||
return {
|
||||
@@ -1217,17 +1216,20 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
|
||||
) AS sub_category_rank
|
||||
FROM work_orders candidate
|
||||
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
work_order_id,
|
||||
SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity
|
||||
FROM work_order_shares
|
||||
GROUP BY work_order_id
|
||||
) candidate_shares ON candidate_shares.work_order_id = candidate.id
|
||||
WHERE candidate.status = 'open'
|
||||
AND (
|
||||
candidate.sharing_enabled IS NOT TRUE
|
||||
OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity
|
||||
-- 只按候选工单取拼单份额。历史写法是先把整张 work_order_shares 聚合一遍
|
||||
-- (GROUP BY work_order_id)再 join:生产实测那次聚合 22,987 行 / 35ms / 5,025 buffer,
|
||||
-- 而候选只有 38 笔,聚合结果 99% 被丢掉;而且它是这条 COUNT 的主成本
|
||||
-- (EXPLAIN: Aggregate 82.8ms → Index Only Scan + GroupAggregate 35ms)。
|
||||
-- 关联子查询走 idx_work_order_shares_work_order,每笔候选一次索引探测即可。
|
||||
OR COALESCE((
|
||||
SELECT SUM(wos.quantity)
|
||||
FROM work_order_shares wos
|
||||
WHERE wos.work_order_id = candidate.id
|
||||
AND wos.status != 'cancelled'
|
||||
), 0) < candidate.sharing_total_quantity
|
||||
)
|
||||
) ranked
|
||||
WHERE ranked.primary_category_rank <= COALESCE(
|
||||
|
||||
Reference in New Issue
Block a user