From 905236d83a3b4e61574e4476e28ff5118b14a363 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 17:21:21 +0800 Subject: [PATCH] =?UTF-8?q?perf(db):=20=E5=B9=B2=E6=8E=89=E5=88=97?= =?UTF-8?q?=E8=A1=A8/=E8=AE=A1=E6=95=B0=E7=83=AD=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E3=80=8C=E6=95=B4=E8=A1=A8=E6=8B=BC=E5=8D=95?= =?UTF-8?q?=E8=81=9A=E5=90=88=E3=80=8D=EF=BC=8C=E5=B9=B6=E7=BB=99=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=95=99=E5=87=BA=201=20=E6=A0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测(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 通过。 --- .env.server.example | 9 +- .../src/config/env-documentation.test.ts | 3 + .../092_hot_table_autovacuum_tuning.sql | 62 ++++++++++++ .../share-summary-scope.test.ts | 94 +++++++++++++++++++ .../worker-platform/work-order-query-repo.ts | 40 ++++---- docker-compose.yml | 17 +++- 6 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 apps/backend/src/db/migrations/092_hot_table_autovacuum_tuning.sql create mode 100644 apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts diff --git a/.env.server.example b/.env.server.example index d641b2b7..2fdb8c00 100644 --- a/.env.server.example +++ b/.env.server.example @@ -37,11 +37,18 @@ POSTGRES_LOG_MIN_DURATION_STATEMENT_MS=500 # 服务器设置;数据库独占或内存更足时可上调 shared_buffers 与 # effective_cache_size(后者只是规划器提示,不实际占用内存): 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_EFFECTIVE_CACHE_SIZE=4GB POSTGRES_WORK_MEM=16MB 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 容器限额(内存紧张的小机器可相应收紧) BACKEND_MEM_LIMIT=768m BACKEND_CPUS=1.5 diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index a8e180f4..3f6a02ff 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -33,6 +33,9 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'POSTGRES_EFFECTIVE_CACHE_SIZE', 'POSTGRES_WORK_MEM', 'POSTGRES_MAINTENANCE_WORK_MEM', + 'POSTGRES_MAX_PARALLEL_WORKERS_PER_GATHER', + 'POSTGRES_CHECKPOINT_TIMEOUT', + 'POSTGRES_MAX_WAL_SIZE', 'BACKEND_MEM_LIMIT', 'BACKEND_CPUS', 'BACKEND_NODE_OPTIONS', diff --git a/apps/backend/src/db/migrations/092_hot_table_autovacuum_tuning.sql b/apps/backend/src/db/migrations/092_hot_table_autovacuum_tuning.sql new file mode 100644 index 00000000..1c62ab2a --- /dev/null +++ b/apps/backend/src/db/migrations/092_hot_table_autovacuum_tuning.sql @@ -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; diff --git a/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts new file mode 100644 index 00000000..f4ec8554 --- /dev/null +++ b/apps/backend/src/repositories/worker-platform/share-summary-scope.test.ts @@ -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('无打手范围时一次聚合全部拼单'), + '保留理由(无打手范围时一次聚合优于逐行关联)必须写在注释里,避免被误删', + ) +}) diff --git a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts index 8a2461c7..af2a4a4b 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts @@ -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( diff --git a/docker-compose.yml b/docker-compose.yml index 20d9fc73..e4fd0375 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,11 +26,26 @@ services: "work_mem=${POSTGRES_WORK_MEM:-16MB}", "-c", "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 # 让 PostgreSQL 优雅关闭有足够时间完成检查点;无限制时 OOM 可能拖垮整机。 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 stop_grace_period: 60s logging: *json-log-rotation