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
+8 -1
View File
@@ -37,11 +37,18 @@ POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500
# 服务器设置;数据库独占或内存更足时可上调 shared_buffers 与 # 服务器设置;数据库独占或内存更足时可上调 shared_buffers 与
# effective_cache_size(后者只是规划器提示,不实际占用内存): # effective_cache_size(后者只是规划器提示,不实际占用内存):
POSTGRES_MEM_LIMIT=1.5g POSTGRES_MEM_LIMIT=1.5g
POSTGRES_CPUS=2 # 4 核共用机器上的推荐值:postgres 是最大单一消耗者,2 核会被突发打满并触发
# cgroup 节流(生产实测累计被节流 2.6 小时);同机其它容器实测只用 0.2–0.4 核。
POSTGRES_CPUS=3
POSTGRES_SHARED_BUFFERS=384MB POSTGRES_SHARED_BUFFERS=384MB
POSTGRES_EFFECTIVE_CACHE_SIZE=4GB POSTGRES_EFFECTIVE_CACHE_SIZE=4GB
POSTGRES_WORK_MEM=16MB POSTGRES_WORK_MEM=16MB
POSTGRES_MAINTENANCE_WORK_MEM=256MB POSTGRES_MAINTENANCE_WORK_MEM=256MB
# 单条查询并行度:>1 时一条查询要 leader + 2 worker = 3 核,容易撞容器配额被节流
POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER=1
# 检查点频率与 WAL 上限:放宽后写突发从「5 分钟一轮」变成「15 分钟一轮」
POSTGRES_CHECKPOINT_TIMEOUT=900
POSTGRES_MAX_WAL_SIZE=4GB
# 后端与 Web 容器限额(内存紧张的小机器可相应收紧) # 后端与 Web 容器限额(内存紧张的小机器可相应收紧)
BACKEND_MEM_LIMIT=768m BACKEND_MEM_LIMIT=768m
BACKEND_CPUS=1.5 BACKEND_CPUS=1.5
@@ -33,6 +33,9 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [
'POSTGRES_EFFECTIVE_CACHE_SIZE', 'POSTGRES_EFFECTIVE_CACHE_SIZE',
'POSTGRES_WORK_MEM', 'POSTGRES_WORK_MEM',
'POSTGRES_MAINTENANCE_WORK_MEM', 'POSTGRES_MAINTENANCE_WORK_MEM',
'POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER',
'POSTGRES_CHECKPOINT_TIMEOUT',
'POSTGRES_MAX_WAL_SIZE',
'BACKEND_MEM_LIMIT', 'BACKEND_MEM_LIMIT',
'BACKEND_CPUS', 'BACKEND_CPUS',
'BACKEND_NODE_OPTIONS', 'BACKEND_NODE_OPTIONS',
@@ -0,0 +1,62 @@
-- 092_hot_table_autovacuum_tuning.sql —— 让热表的 autovacuum 更勤、更快。
--
-- 背景(2026-10-03 生产实测,库 order_site @47.114.114.225,4 核机器):
--
-- 1) 可见性图陈旧,index-only scan 退化成回表:
-- 接单大厅那条 COUNT 的 EXPLAIN 里 `Index Only Scan on work_order_shares`
-- 出现 **Heap Fetches: 5580**(22,987 行里 1/4 要回堆),10,145 个 buffer 命中
-- 换回 1 行 COUNT 结果。heap fetch 是随机页访问,比顺序扫还贵。
--
-- 2) 默认 autovacuum_vacuum_scale_factor = 0.2,等于「死元组攒到 20% 才清」:
-- work_orders 死元组 18,798(15.4%,最后一次 autovacuum 是前一天 15:42)、
-- order_items 52,803(15.8%,上次 9/29)、orders 19,610(上次 9/30)、
-- kuaishou_industry_vouchers 38,325(10.9%,1,007,929 次更新)。
-- 攒一大批再扫一次 → 单次 vacuum 又长又重,正好和「CPU 忽高忽低」的
-- 周期性尖峰、以及 sar 里每 20–40 分钟一轮的读突发(rtps 95/s、6MB/s)对上。
--
-- 3) 这几张表更新极频繁(work_orders 649,751 次更新、fulfillment_tasks 2,314,711 次),
-- 是典型的「小步勤清」比「攒批猛扫」更划算的场景。
--
-- 因此对最热的 5 张表单独调参(只改表级 storage parameter,不动全局默认):
-- autovacuum_vacuum_scale_factor 0.2 → 0.05 触发阈值降到 5% 死元组
-- autovacuum_analyze_scale_factor 0.1 → 0.02 统计信息更及时
-- autovacuum_vacuum_cost_limit 200 → 800 单次 vacuum 更快跑完(IO 更碎而不是更长)
--
-- 安全性:这些只是把「一次大扫」拆成「多次小扫」,单位时间总 IO 相近;
-- 机器白天有 60–70% 空闲(sar: idle 62.9%),碎片化清理比尖峰更可控。
-- 回滚:ALTER TABLE ... RESET (autovacuum_*)。
ALTER TABLE work_orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 800
);
ALTER TABLE order_items SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 800
);
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 800
);
ALTER TABLE kuaishou_industry_vouchers SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 800
);
ALTER TABLE fulfillment_tasks SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_analyze_scale_factor = 0.02,
autovacuum_vacuum_cost_limit = 800
);
-- 打开 autovacuum 观测(原先 log_autovacuum_min_duration = 600000,
-- 只有超过 10 分钟的 vacuum 才会留日志,等于什么都看不到)。
-- 这里改成表级不可设,只能改库级:记录超过 5 秒的自动清理,便于事后核对清理节奏。
ALTER DATABASE order_site SET log_autovacuum_min_duration = 5000;
@@ -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 = '' let workerSharePlaceholder = ''
if (excludeFilledSharing) { // 旧的 excludeFilledSharing 写法:LEFT JOIN 一份「整张 work_order_shares 的聚合」
joins.push(` // (GROUP BY work_order_id)再在 WHERE 里比较。列表/计数每次调用都要重算这份全局聚合,
LEFT JOIN ( // 但真正要用到的只是候选工单那几十行——生产 EXPLAIN 里它占了 COUNT 的主要成本。
SELECT // 这里改成和 adminOperationalStatusExpression 同一口径的关联子查询:只按 wo.id 探测。
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`)
}
// 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。 // 先从打手的整单、历史单和拼单记录组成可见工单集合,再关联工单详情。
// 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。 // 这样 COUNT 和列表都不会先扫描全量工单,再逐行检查该打手的拼单状态。
@@ -1187,7 +1181,12 @@ export function buildWorkOrderWhere({
if (excludeFilledSharing) { if (excludeFilledSharing) {
filters.push(`( filters.push(`(
wo.sharing_enabled IS NOT TRUE 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 { return {
@@ -1217,17 +1216,20 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
) AS sub_category_rank ) AS sub_category_rank
FROM work_orders candidate FROM work_orders candidate
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id 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' WHERE candidate.status = 'open'
AND ( AND (
candidate.sharing_enabled IS NOT TRUE 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 ) ranked
WHERE ranked.primary_category_rank <= COALESCE( WHERE ranked.primary_category_rank <= COALESCE(
+16 -1
View File
@@ -26,11 +26,26 @@ services:
"work_mem=${POSTGRES_WORK_MEM:-16MB}", "work_mem=${POSTGRES_WORK_MEM:-16MB}",
"-c", "-c",
"maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-256MB}", "maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-256MB}",
# 并行度 1:生产实测单条大查询一旦开 2 个 parallel worker,就需要
# 「leader + 2 worker = 3 核」,而容器配额只有 2 核 → 必然撞配额被节流
# (累计 nr_throttled 94,197 次 / throttled_usec 9,337s)。重查询已优化到
# 10ms 量级,用不到并行。
"-c",
"max_parallel_workers_per_gather=${POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER:-1}",
# 检查点:默认 5 分钟一次 + max_wal_size 1GB,生产 43 天累计 21,778 次检查点,
# sar 里就是每 20–40 分钟一轮写突发(wtps 130、8MB/s)。放宽到 15 分钟 / 4GB。
"-c",
"checkpoint_timeout=${POSTGRES_CHECKPOINT_TIMEOUT:-900}",
"-c",
"max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}",
] ]
restart: unless-stopped restart: unless-stopped
# 让 PostgreSQL 优雅关闭有足够时间完成检查点;无限制时 OOM 可能拖垮整机。 # 让 PostgreSQL 优雅关闭有足够时间完成检查点;无限制时 OOM 可能拖垮整机。
mem_limit: ${POSTGRES_MEM_LIMIT:-1.5g} mem_limit: ${POSTGRES_MEM_LIMIT:-1.5g}
cpus: ${POSTGRES_CPUS:-2} # 3 核:这台机器 4 核,postgres 是最大单一消耗者(22.6h 烧 12.2 CPU·h、均 0.54 核),
# 却被 2 核配额卡住,累计 2.6 小时处于被节流状态。同机其它容器实测只用 0.2–0.4 核,
# 且几乎不节流,所以这里让出 1 核给数据库的突发。
cpus: ${POSTGRES_CPUS:-3}
shm_size: 512mb shm_size: 512mb
stop_grace_period: 60s stop_grace_period: 60s
logging: *json-log-rotation logging: *json-log-rotation