17.1 先证明单机边界
“单机扛不住”必须是证据结论,而不是架构会议里的气氛。
最常见的误判有两类:
局部问题被说成容量问题
一条坏 SQL / 一个缺失索引 / 一次统计失真
-> “PostgreSQL 不适合分析”
容量问题被说成局部问题
工作集、写入、维护窗口或故障域已越过单节点
-> “再调一个参数就好”本节不预设答案。先把目标、负载和瓶颈拆成可测量的对象,再决定应该优化、 隔离、扩容,还是分布。
17.1.1 定义数据量、并发、延迟与新鲜度目标
“数据量”至少有六种尺寸
只报“十亿行”几乎没有决策价值。十亿个窄整数与十亿个宽 JSONB 的存储、 缓存和扫描成本不同;十亿行均匀访问与 99% 查询只看最近一天也不同。
基线至少记录:
| 维度 | 示例问题 | 可验证证据 |
|---|---|---|
| 逻辑规模 | 行数、租户数、时间跨度? | count(*)、业务目录 |
| 物理规模 | heap、TOAST、索引各多大? | pg_relation_size、pg_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如果某个候选“快很多”但少一个租户,它不是优化,而是错误。
用目标表替代形容词
一个可评审的初始目标可以长这样:
| 指标 | 当前 | 目标 | 测量条件 |
|---|---|---|---|
| 单租户明细 P95 | 1.8s | < 500ms | 50 并发、30 日窗口 |
| 全局月报完成时间 | 24min | < 10min | 冷缓存、完整月 |
| dashboard 新鲜度 P99 | 12min | < 5min | 按事件时间 |
| temp write/小时 | 800GB | < 100GB | 正常峰值 |
| primary CPU P95 | 92% | < 70% | OLTP+分析同时 |
| replica replay lag P99 | 9min | < 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 rowsCPU 瓶颈
常见信号:
- 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 是许多执行节点各自可以使用的预算,不是整个
查询或实例的硬上限;并行查询还会放大消费者。正确步骤是:
- 找到真实 spill 的 SQL 和节点;
- 判断能否通过索引、过滤、聚合顺序减少数据;
- 估算峰值并发 × 每查询节点 × worker;
- 优先用角色、数据库、会话或任务级设置;
- 同时设置超时、并发和
temp_file_limit一类护栏; - 用压力回放核对实例 RSS、OOM 与总吞吐。
PostgreSQL 的
Resource Consumption
是 work_mem、hash_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 都 可能是“正确计划”,整体数据流却仍不合理。
一张瓶颈—证据—动作表
| 怀疑 | 至少需要的证据 | 优先动作 |
|---|---|---|
| CPU | CPU 饱和、每查询 CPU、计划工作量 | 少做工作、审计并行与表达式 |
| base I/O | buffer/read、设备延迟、访问形状 | 索引、裁剪、缓存/存储 |
| temp I/O | sort/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、分区、物化和 负载隔离工具讲透,再讨论真正的分布式门槛。
返回本章目录 · 下一节:单机分析能力 · 查看全书目录 · 查看索引中心