跳至内容

17.1 先证明单机边界

“单机扛不住”必须是证据结论,而不是架构会议里的气氛。

最常见的误判有两类:

局部问题被说成容量问题
  一条坏 SQL / 一个缺失索引 / 一次统计失真
  -> “PostgreSQL 不适合分析”

容量问题被说成局部问题
  工作集、写入、维护窗口或故障域已越过单节点
  -> “再调一个参数就好”

本节不预设答案。先把目标、负载和瓶颈拆成可测量的对象,再决定应该优化、 隔离、扩容,还是分布。

17.1.1 定义数据量、并发、延迟与新鲜度目标

“数据量”至少有六种尺寸

只报“十亿行”几乎没有决策价值。十亿个窄整数与十亿个宽 JSONB 的存储、 缓存和扫描成本不同;十亿行均匀访问与 99% 查询只看最近一天也不同。

基线至少记录:

维度示例问题可验证证据
逻辑规模行数、租户数、时间跨度?count(*)、业务目录
物理规模heap、TOAST、索引各多大?pg_relation_sizepg_total_relation_size
工作集查询真正反复访问哪部分?计划 buffers、时间谓词、缓存命中
增长每日新增、更新、删除多少?时序采样与容量预测
倾斜最大租户/日期/键占多少?percentile、top-N、直方图
生命周期热、温、冷数据如何变化?保留、归档与访问统计

本章 fixture 的身份不是一句“24 万行”:

8 tenants
400 accounts
2026-01-01 .. 2026-04-30
240,000 sales
2,000 heap pages in the verified run
local facts + daily summary + two remote shard copies

它还有明确限制:数据均匀、确定、合成,无法代表真实倾斜、缓存冷启动、 网络、WAL、vacuum 或生产并发。

并发不是 QPS 的同义词

分析系统常见四种并发:

arrival concurrency
  同时到达多少请求

active database concurrency
  同时在 PostgreSQL 内执行多少语句

in-query parallelism
  一条语句使用多少 parallel workers

background concurrency
  autovacuum、checkpoint、备份、复制、ETL、刷新同时做什么

一个 dashboard 打开时发出 30 条 SQL,不等于数据库应该同时运行 30 个重 聚合。连接池可以排队,应用可以合并请求,汇总层可以复用结果。反过来, “线上只有 20 个连接”也不表示压力小:每条查询可能启动多个 worker、多个 sort/hash 节点并产生大临时文件。

因此基线应同时记录:

request rate
queue time
active sessions
parallel workers planned/launched
statements per request
rows scanned / returned
temporary bytes
CPU and I/O saturation

延迟要有分位数和查询类别

“平均 800ms”会掩盖两类事实:

  • 99% 查询 10ms,1% 查询 80s;
  • 所有查询稳定在 800ms。

两者的容量和用户体验完全不同。至少按 workload class 报告:

类别典型目标
单租户交互明细P50/P95/P99 与超时率
dashboard 聚合首屏、完整加载与刷新周期
批量报表完成窗口与失败重跑时间
数据导出吞吐、并发上限与资源封顶
ETL/刷新截止时间、WAL/lag 与恢复点

不要把一次 EXPLAIN ANALYZE 的执行时间直接当 SLO。它只是一条 SQL 在某个 缓存、数据、参数和系统负载下的一次观察。SLO 需要在代表性并发、冷暖缓存和 运行周期下统计。

新鲜度独立于查询速度

分析请求常把两个目标混成一个:

query latency: 用户发出查询后多久返回
data freshness: 返回的数据距离真实业务现在有多旧

一个物化汇总可以在 20ms 返回昨天的数据;一条扫描原表的查询可以在 2s 返回刚提交的数据。谁更好取决于合同,而不是毫秒数。

新鲜度目标应写成可验证形式:

event-time freshness <= 5 minutes at P99
daily financial close complete by 02:00 UTC
late events within 24 hours must be included in next rebuild
dashboard may lag primary commit by 60 seconds

若使用副本,还要区分:

source event lag
ingestion lag
replication replay lag
summary refresh lag
cache lag

只看其中一个指标会把旧数据误报成“查询很快”。

正确性是第一项 SLO

所有候选必须在相同输入下得到相同业务结果。本章冻结 32 行月报,并同时固定:

business checksum = 42fb8ab5444469eba1f104a8e1e529dd
monthly checksum  = 644d45544ebbc2a80c42270c38ac6885
CSV SHA-256       = 64b045809e10364fd84a587121d919e8562a15335c4c6c015e91a0ead3a44323

四条计算路径逐字节比较:

cmp frozen-monthly.csv monthly-local.csv
cmp frozen-monthly.csv monthly-summary.csv
cmp frozen-monthly.csv monthly-distributed.csv
cmp frozen-monthly.csv monthly-two-stage.csv

如果某个候选“快很多”但少一个租户,它不是优化,而是错误。

用目标表替代形容词

一个可评审的初始目标可以长这样:

指标当前目标测量条件
单租户明细 P951.8s< 500ms50 并发、30 日窗口
全局月报完成时间24min< 10min冷缓存、完整月
dashboard 新鲜度 P9912min< 5min按事件时间
temp write/小时800GB< 100GB正常峰值
primary CPU P9592%< 70%OLTP+分析同时
replica replay lag P999min< 60s报表窗口

当前值未知时写 unknown,随后安排测量。不要用“应该没问题”填表。

工作负载清单

选型前收集每类查询:

SQL fingerprint
business owner
read/write
frequency and concurrency
parameters and selectivity
rows scanned / returned
latency distribution
temporary I/O
lock behavior
freshness requirement
retry/idempotency behavior
failure consequence

同时固定 schema、统计信息、参数、数据生成方式和版本。否则两次跑分比较的 可能不是同一个系统。

本章的 fixture-manifest.json 保存生成器、行数、分片、校验和与限制;生产基线还应保存脱敏 workload manifest 和运行环境 manifest。

17.1.2 区分 CPU、I/O、内存、锁与计划瓶颈

先问“时间花在哪里”

慢查询的第一层分类:

waiting
  lock / I/O / client / WAL / remote / worker

running
  CPU expression / decompression / hash / sort / aggregation

planned badly
  row estimate / join order / access path / partition pruning

doing too much work
  wrong grain / no predicate / repeated calculation / data transfer

分类不是互斥的。错误估算可能选择大量随机 I/O;内存不足可能产生 temp I/O; 锁等待可能让 CPU 很空但延迟很高。

用执行计划建立因果链

推荐从:

EXPLAIN (
  ANALYZE,
  BUFFERS,
  WAL,
  SETTINGS,
  VERBOSE
)
SELECT ...;

开始,但要理解风险:

  • ANALYZE 会真正执行语句;
  • 对写语句使用时会真的修改数据,除非放在可回滚且外部副作用可控的事务中;
  • BUFFERS 展示 PostgreSQL buffer/I/O 计数,不等于操作系统层面的完整因果;
  • 一次计划不是延迟分布;
  • planner estimate 与 actual 的差距比节点名字本身更重要。

PostgreSQL 官方 Using EXPLAIN 解释 plan tree、cost、actual rows、loops、buffers 与不同节点的读法。

先检查:

actual rows × loops
estimated rows versus actual rows
rows removed by filter
heap fetches
sort method / memory / disk
hash batches
shared/local/temp buffers
workers planned / launched
partition subplans actually visited
remote SQL and returned rows

CPU 瓶颈

常见信号:

  • runnable CPU 长期接近可用核心上限;
  • 查询主要读取 cached buffers,物理 I/O 不高;
  • 大量表达式、JSON、正则、排序、哈希、聚合或 JIT 消耗;
  • 增加并发只增加排队,吞吐不再提高;
  • parallel worker 增加后单查询变快、系统总吞吐却下降。

CPU 证据必须区分:

database process CPU
kernel CPU
steal/throttling
per-query CPU
background maintenance CPU

不能从 PostgreSQL Execution Time 单独推导 CPU 时间。

可尝试的方向:

  • 减少扫描和返回行;
  • 改善连接顺序与聚合粒度;
  • 避免对每行重复做昂贵表达式;
  • 使用预计算/物化;
  • 审计并行度与并发;
  • 扩大单机 CPU;
  • 只有工作可被安全分片时再横向扩 CPU。

I/O 瓶颈

常见信号:

  • cache miss 后读取延迟高;
  • shared read 与系统块设备队列共同上升;
  • 顺序大扫描把 OLTP 热页挤出缓存;
  • temp read/write 大量增长;
  • checkpoint、backup、vacuum 与分析抢同一存储;
  • 增加 CPU 不改善吞吐。

要区分三类 I/O:

base relation/index I/O
temporary spill I/O
WAL/checkpoint/backup/replication I/O

它们的修复不同。缺索引与低选择性扫描不是同一问题;给全表聚合增加 B-tree 也未必比顺序扫描好。

内存与 spill

本章用同一排序证明:

SET work_mem = '64kB';
-- external merge, temp read/write

SET work_mem = '32MB';
-- quicksort in memory

计划来自:

冻结 24 万行上观察到:

64kB: external merge, Disk about 5920kB
32MB: quicksort, Memory about 13645kB

不要据此设置:

work_mem = 32MB

然后乘上几百连接。work_mem 是许多执行节点各自可以使用的预算,不是整个 查询或实例的硬上限;并行查询还会放大消费者。正确步骤是:

  1. 找到真实 spill 的 SQL 和节点;
  2. 判断能否通过索引、过滤、聚合顺序减少数据;
  3. 估算峰值并发 × 每查询节点 × worker;
  4. 优先用角色、数据库、会话或任务级设置;
  5. 同时设置超时、并发和 temp_file_limit 一类护栏;
  6. 用压力回放核对实例 RSS、OOM 与总吞吐。

PostgreSQL 的 Resource Consumptionwork_memhash_mem_multiplier、maintenance memory 与 huge pages 等 参数的版本基准。

锁瓶颈

分析查询通常只读,不等于不会造成并发问题:

  • 长事务延长 snapshot 生命周期,阻碍 vacuum 清理;
  • DDL 等待或被 ACCESS SHARE 阻塞;
  • REFRESH MATERIALIZED VIEW 的锁行为影响读者;
  • 报表函数可能隐含写临时/业务表;
  • 导出事务可能持有 snapshot 很久;
  • standby 上长查询可能与 WAL replay 冲突。

诊断要同时看:

SELECT
  pid,
  wait_event_type,
  wait_event,
  xact_start,
  query_start,
  state,
  application_name
FROM pg_catalog.pg_stat_activity
WHERE datname = current_database();

以及 blocking graph,而不是只数连接。第 10、12 章的事务、锁与慢查询诊断 方法在这里继续适用。

计划瓶颈

错误计划常见来源:

stale or insufficient statistics
correlated columns not represented
parameter-sensitive selectivity
implicit casts/collations
function-wrapped predicates
partition key not exposed
wrong join cardinality
generic plan versus custom plan
foreign table statistics drift

本章对外表执行 ANALYZE。官方 postgres_fdw 文档指出:本地统计可以减少 远端估算开销,但远端频繁变化时会很快过期;use_remote_estimate 则会增加 远端 planning 往返。两者都不是无条件更好。

“做太多工作”比节点选择更根本

原始月报与日汇总都得到 32 行:

raw plan:
  Parallel Seq Scan on sales_fact
  240,000 facts contribute

summary plan:
  Seq Scan on daily_tenant_summary
  2,880 summaries contribute

即使原始扫描计划完全正确,它仍在重复计算已经稳定的历史粒度。若业务允许 分钟或日级新鲜度,汇总可能比继续微调原表扫描更有效。

同理,分布式计划若把 240,000 行传到协调端再聚合,远端每个 Seq Scan 都 可能是“正确计划”,整体数据流却仍不合理。

一张瓶颈—证据—动作表

怀疑至少需要的证据优先动作
CPUCPU 饱和、每查询 CPU、计划工作量少做工作、审计并行与表达式
base I/Obuffer/read、设备延迟、访问形状索引、裁剪、缓存/存储
temp I/Osort/hash 方法、temp bytes减少输入、局部内存与并发
blocker、wait event、事务年龄缩短事务、调度/锁语义
计划estimate/actual、统计、参数统计、SQL、索引、版本基线
重复计算相同历史范围反复聚合汇总、缓存、批处理
远端传输Remote SQL、返回行、网络下推、局部聚合、分布键

17.1.3 单机未被正确使用前不急于分布式

“单机优先”是一条证据顺序

合理的升级阶梯:

1. 业务口径与 SQL 正确
2. 统计、索引、分区裁剪正确
3. 内存与并行在并发预算内
4. 重复分析有汇总/批处理
5. OLTP 与 OLAP 有资源隔离
6. 单节点纵向容量仍不足
7. 分布键与主要查询天然对齐
8. 团队能承担分布式运维
9. 才进入横向分布

这不是要求永远把单机压到 100%。生产需要安全余量、维护窗口和故障容忍。 “正确使用”是达到经过评审的安全上限,而不是让事故替你找到极限。

先拒绝伪瓶颈

一个值得写进 ADR 的反例:

症状:
  租户 3 的 4 月明细聚合慢

错误推断:
  表有 24 万行,因此需要分片

证据:
  合适 covering index 后只读 7,500 个索引项
  Index Only Scan
  Heap Fetches: 0

结论:
  当前问题是访问路径,不是节点容量

索引定义:

CREATE INDEX sales_fact_tenant_day_idx
ON shop_ch17.sales_fact (
  tenant_id,
  occurred_on,
  account_id
)
INCLUDE (amount, units, channel);

fixture 重建结束后显式:

VACUUM (ANALYZE) shop_ch17.sales_fact;

这是计划合同的一部分。刚装载的 heap 尚未有足够 all-visible 位时,PostgreSQL 可能选择 Bitmap Heap Scan;不能把之前一次 autovacuum 留下的状态当可重复 前置条件。

BRIN 是相关性工具,不是“更小的 B-tree”

本章还创建:

CREATE INDEX sales_fact_day_brin_idx
ON shop_ch17.sales_fact
USING brin (occurred_on)
WITH (pages_per_range = 16);

BRIN 对“列值与物理位置天然相关”的大表按 block range 保存摘要,索引很小, 但返回候选 page range 后仍需 recheck,是 lossy 路径。它适合追加顺序与时间 大体一致的巨大事实表,不适合替代每种选择性 B-tree。

冻结小表只验证目录中存在 date_minmax_ops,并观察 BRIN 比 covering B-tree 小;不宣称这个查询上 BRIN 更快。官方 BRIN Indexes 说明 block range、物理相关性、lossy recheck、pages_per_range 与 summarization 行为。

单机边界应是曲线,不是一个点

容量实验应逐级增加:

data scale
concurrency
query mix
ingest rate
background maintenance
cache state

记录:

throughput
P50/P95/P99
queueing
CPU
read/write IOPS and latency
temp bytes
WAL
checkpoint
vacuum debt
replica lag
error/timeout rate

理想结果是一组曲线:

低并发:延迟稳定,吞吐线性增长
接近饱和:排队上升,吞吐增幅变小
过载:延迟和错误率急升,吞吐可能下降

生产容量线应位于拐点之前,并包含节点故障、维护和增长余量。

什么时候单机证据足以支持“继续单机”

可以暂缓分布式,当:

  • 调优后 SLO 在峰值与故障演练下满足;
  • 未来容量预测仍位于安全余量内;
  • 物化/批处理的新鲜度合同可接受;
  • offline replica 能隔离读负载;
  • 主要风险是可通过纵向扩容或存储升级解决;
  • 业务需要大量跨实体事务与灵活 JOIN,分片会显著破坏局部性;
  • 团队尚未具备分片备份、恢复、再平衡与值班能力。

什么时候不能再用“继续调优”拖延

应正式进入分布式评审,当代表性证据显示:

  • 单节点 CPU、内存、存储容量或 I/O 已越过安全上限;
  • 维护、vacuum、备份或恢复无法在窗口内完成;
  • 即使隔离到副本,分析吞吐仍受单节点资源限制;
  • 业务故障域或地域要求不能由一个集群满足;
  • 主要访问天然按租户/实体局部化,跨分片比例可控;
  • 硬件纵向升级的边际成本和上限不再可接受;
  • 团队已经定义跨分片事务、部分失败、重平衡和退出流程。

本节的停止条件

在以下问题没有答案前,不进入“选哪个分布式产品”:

目标是什么?
当前瓶颈是哪一种资源?
哪条 SQL、哪个粒度、哪类并发造成?
单机优化后曲线在哪里拐弯?
未来多久越过安全容量?
哪些查询可以按一个分布键局部化?
哪些事务一定跨边界?
如果一个节点不可用,业务允许什么结果?

下一节先把 PostgreSQL 单节点内部可用的并行、索引、BRIN、分区、物化和 负载隔离工具讲透,再讨论真正的分布式门槛。


返回本章目录 · 下一节:单机分析能力 · 查看全书目录 · 查看索引中心

最后更新于