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:
yml2213
2026-10-03 17:21:21 +08:00
parent ccbcb7cbae
commit 905236d83a
6 changed files with 204 additions and 21 deletions
@@ -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(