36 先优化工作,再升级机器
我们拿了一个小应用的工作负载,在一台笔记本上对本书的示例数据运行,每次只改一件事。同一台机器,同样的数据,同样的请求。最初每个事务读取 91,448 页,每秒完成 13 个事务。八次改动之后,每个事务读取 38 页,每秒运行一万多个事务。
这些改动没有一项是换更大的机器,也没有一项是加副本、做分区或换数据库。它们是三个索引、两次改写、一个表设置、把二十一条查询换成一条、一个连接池、几项配置,以及一张汇总表。
机器从来就不是问题所在。
这就是本章的主张:让 Postgres 变快,主要在于顺序。找出昂贵的工作,从成本最低的地方修起,每改一项都测量。硬件排在接近末尾的位置,横向扩展在那之后。
顺序就是方法
下面各节是可以采取的优化手段,按每投入一小时通常能获得的收益排序:便宜、收益大、容易撤销的在前;昂贵、收益小、难以撤销的在后。测量证明没有问题的项目可以跳过,但不要越级去做后面的事。
这份排名不是自然定律:一个有 3,000 个未使用连接池的客户端的系统,应先加连接池,再动索引。因此,第 1 项是测量。
整张清单都遵循两条规则。每次只改一件事,改动前后测量相同的工作负载(只改一件事,并端到端测量)。数页面,不数毫秒:每次调用访问的页面数在不同运行间几乎不变,因此即使机器上有干扰,它也能告诉你改动起了什么作用(缓冲区才是证据)。书末的检查清单也采用这个顺序,每个习惯一行。
1. 找出昂贵的工作
你正在想的那条慢查询,往往不是代价最高的那条。按总时间和每次调用的块数对 pg_stat_statements 排序,采样等待事件,判断会话是在工作、读取还是排队。然后在动手修改之前写下一句话:“我们的瓶颈是 X,因为 Y 显示了 Z。”
**相关内容:**用四种方式给同一视图排序,先看总时间、采样等待,看看时间花在哪里、慢查询的方法。
2. 修查询和索引
大多数收益都在这里。为了返回 20 行而读取 35,000 页的查询,问题是缺少索引,或谓词无法利用索引;修复只需一行 DDL 或改一下 where:本书的第一个索引就把一条查询的读取量从 35,249 页降到 91 页。深度 OFFSET 分页也属于这里:它会读取跳过的每一行,而键集分页(keyset pagination)只读取要返回的那一页。信号是:每次调用的块数很高,Rows Removed by Filter 远大于返回行数。
**相关内容:**从查询出发设计索引,而不是从列出发、保持索引列裸露、OFFSET 会读取它跳过的每一行、错误计划有四种原因。
3. 修模式
模式会长久地决定每行数据要花多少页面。可用的手段包括:窄类型、按对齐要求排列列顺序,把 8 字节类型放在前面(某张表上节省了 27%)、顺序键或 UUIDv7 键,以及适合 HOT 更新的表。它排在查询之后,只是因为重塑一张大表需要做迁移,不能只发一次版。信号是:HOT 占比低、行宽、每次插入产生的 WAL 量。
**相关内容:**每个字节都要按行付费、慎选键,少后悔、HOT 是一次性做出的设计决定。
4. 减少往返
一个请求发出 21 条很快的查询,就要付出 21 次往返的代价,而 pg_stat_statements 永远不会为它亮红灯,因为没有哪一条语句很慢。修复方式都是“更少、更大的语句”:用连接或 lateral 代替 N+1 循环,用批量写入代替每行一次插入。信号是:每个请求的查询数。
**相关内容:**N+1 是一个循环,不是慢查询、快速加载数据。
5. 保持清理和统计信息健康
这属于日常维护,不是调优。一旦落后,前面所有手段都会失效:死行会增加每次扫描的工作量,陈旧统计信息则按一张已经不存在的表来规划。信号是:n_dead_tup、最老事务的年龄、Heap Fetches。
**相关内容:**对大表来说,默认值不对、清理无法移除旧快照可能需要的内容、自动清理的分析会漏掉三类变化。
6. 使用连接池
每个连接都是一个进程。每个请求都新建连接,就要每次承担 fork、认证和冷缓存的代价;让几千个连接保持打开,就要付出内存和争用的代价。使用连接池,按核心数而不是用户数来确定池大小。信号是:连接速率,以及按状态统计的连接数。
**相关内容:**每个连接都是一个进程、根据核心数而不是用户数确定连接池大小。
7. 调整配置
配置为工作分配资源预算。它不能让查询少读页面:一次扫描要读 35,000 页,无论 shared_buffers 设为多少,都还是 35,000 页。它能做的是解除上限,因此要在你能说清上限在哪里时才调:临时文件、请求触发的检查点、未命中缓存的工作集。
**相关内容:**内存:先做预算,再调优、从这份 4 vCPU、16 GB 配置开始,然后测量。
8. 预计算与缓存
当每条查询都只读取所需数据之后,就别再把同一个昂贵的问题问两遍了。这一步排得这么靠后,是因为汇总表(rollup)或缓存都是同一份事实的第二份副本,需要持续维护它的新鲜度。信号是:每次调用的块数随租户规模增长。
**相关内容:**预先算好要展示的内容、把工作移出请求路径。
9. 购买耗尽的资源
现在,而且只有到现在,才轮到机器:购买测量表明已经耗尽的资源,而不是直接升到下一档。
**相关内容:**用数据争取更大的机器、迁移之前,先证明问题出在磁盘。
10. 只有相应信号出现时,才横向扩展
副本、分区和分片各自都带有永远不会消失的代价,因此每一种都要等到自己的信号出现。
**相关内容:**证据说到头了,纵向扩容才算到头。
深入阅读:各项手段通常能带来什么,以及撤销它们的成本
以下是典型结果;有测量数据的地方,采用本书的测量。你的收益取决于起点离合理状态有多远。
| 手段 | 典型收益 | 投入 | 撤销 |
|---|---|---|---|
| 查询和索引 | 语句读页量的缩减倍数为 10 到 1,000 | 几小时 | 删除索引 |
| 模式 | 页数最多减少约 30%;非 HOT 更新更少 | 一次迁移 | 再做一次迁移 |
| 往返 | N 条查询变成 1 条;延迟的降低幅度取决于网络距离 | 一次代码改动 | 容易 |
| 清理和统计信息 | 防止性能衰退;修正坏计划 | 按表设置 | 容易 |
| 连接池 | 每个请求都建连接时,吞吐量达到原来的 5 到 70 倍 | 一天 | 容易 |
| 配置 | 消除上限;页面数不变 | 几小时,可能需要重启 | 容易 |
| 汇总表和缓存 | 聚合工作量不再随数据增长 | 几天,再加新鲜度维护 | 中等 |
| 更大的实例或存储 | 为耗尽的资源增加余量 | 每月付费 | 缩回原来的规格 |
| 副本 | 读容量;故障切换 | 几天,再加路由 | 中等 |
| 分区 | 低成本的保留期管理和清理 | 一个迁移项目 | 困难 |
| 分片 | 写入和存储容量 | 数月 | 很困难 |
去哪里寻找表明下一项该做什么的数字:查询和索引看 pg_stat_statements 与 EXPLAIN;模式看 pg_stat_user_tables 与 pg_column_size;往返看应用追踪;连接看 pg_stat_activity 和连接池统计;配置看日志和 pg_stat_checkpointer;汇总表看 pg_stat_statements 随时间的变化;硬件看等待采样和 pg_stat_io;分区看 pg_stat_progress_vacuum;分片看 pg_stat_wal 和写入等待。
实证:一个应用,八次改动
写一张清单不费什么事。所以我们拿本书的示例数据实际跑了一遍:同一份工作负载,按照这份操作指南的顺序,每次只调整一项,每次改完都测量。下面的每个结论,在实验中都有对应检查。
这个应用有四个端点,写法都是第一版常见的样子。仪表盘按天统计某个账户 30 天内的事件,筛选条件里有类型转换(created_at::date >= ...)。动态列表使用 OFFSET 分页。项目页加载 20 个项目,再分别为每个项目发送一条查询,获取其最新事件:这就是 ORM 替你写出的 N+1 循环。摄入端点插入一条部署事件,并递增项目计数器。而且应用每个请求都会新建连接,就像一个无服务器函数。events 最初只有主键。
页面数是确定的:你得到的数字应该只相差几个百分点。吞吐量属于我们的笔记本,而且在性能较高的阶段,实验各次执行的结果会相差两到三倍,所以表中并排列出了两次完整执行的结果。实验只验证差距足够大的变化方向。
实验还设置了 synchronous_commit = off,因为在 Docker Desktop 中,每次提交的刷新都要几十毫秒,会淹没其他所有效果。这样会抬高吞吐量,尤其是写入吞吐量,但它改变的是提交等待多久,不是读取了多少页面(提交意味着刷新)。在真实磁盘上启用持久提交后,预计数字会更低,但变化趋势相同。
深入阅读:四个端点,以及我们的测量方法
每个端点都对应一份 pgbench 脚本,位于实验的 workload/playbook 文件夹中,按权重混合:30% 仪表盘、30% 动态列表、10% 项目页、30% 摄入。
每个请求新建连接,就是 pgbench -C。每次运行都按相同顺序发送相同请求。“每个事务的页面数”是各端点语句的 shared hit 加 read,除以其事务数,再按混合权重加权。
每次 pgbench 运行都用 4 个客户端。在实验的一次完整执行中,基线只运行一次,总共 120 个事务,因为它很慢;添加索引的步骤运行一次,共 1,000 个事务;后续每个步骤都运行三次,每次 4,000 个事务,吞吐量取三次的中位数。整个实验耗时两分半到四分钟,所以较快步骤的一次运行只有一两秒,这也是吞吐量波动的部分原因。结果表中的每个吞吐量列,都代表实验的一次完整执行。
基线:每个事务 91,448 页
没有索引时,仪表盘和动态列表各自读取整张 events 表的 35,173 页,分别返回 30 行和 20 行。项目页把整张表读了 20 遍:一次页面访问执行 21 条语句,读取约 703,000 页。混合负载平均每个事务读取 91,448 页,每秒运行 13 个事务。
动手验证 · lab 35-playbook
先调配置,重要的东西并没有改变
我们先做了那件看起来顺理成章的事:调优服务器。work_mem = 64MB、random_page_cost = 1.1、effective_cache_size = 8GB、jit = off,每条查询四个并行工作进程。
每个事务的页面数为 91,510,与基线的差距在 0.1% 以内。吞吐量一次为 8,另一次为 17,基线是 13。只是噪声。配置改变工作如何运行,却不改变有多少工作。
动手验证 · lab 35-playbook
按操作指南的顺序,做八次改动
每一步都保留前面所有步骤的改动。两列吞吐量来自实验的两次完整执行;每个数字都是该步骤各次运行的中位数。
| 步骤 | 手段 | 每事务页数 | 事务/秒,实验 1 | 事务/秒,实验 2 |
|---|---|---|---|---|
| 0 | 基线 | 91,448 | 13 | 13 |
| 先调配置(未保留) | 91,510 | 8 | 17 | |
| 1 | 索引 | 752 | 299 | 329 |
| 2 | 直接对列做日期范围筛选 | 214 | 1,024 | 1,197 |
| 3 | 键集分页 | 143 | 1,128 | 1,005 |
| 4 | 填充因子 90 | 143 | 1,185 | 1,260 |
| 5 | 一条查询,代替 21 条 | 144 | 1,113 | 1,273 |
| 6 | 连接池 | 81 | 10,624 | 9,076 |
| 7 | 配置 | 82 | 4,135 | 11,297 |
| 8 | 每日汇总表 | 38 | 13,978 | 17,028 |
页面数来自第一次完整执行;第二次与之相比,差异不超过 1%。
动手验证 · lab 35-playbook
深入阅读:步骤 1 到 3,三个索引、一次类型转换和键集分页
**步骤 1,索引。**每个索引都从需要它的查询出发设计,等值列在前,排序列在后:
每个事务的页面数从 91,448 降至 752,缩减为约 1/120。项目页现在读取约 108 页:每次项目查找四页(三个索引页、一个堆页),再加项目列表。仪表盘每次调用仍读取约 1,960 页:created_at::date 给列套上了函数,因此索引找出了该账户一整年约 2,000 行数据,却无法将范围缩小到 30 天。
**步骤 2,裸列。**把同一个筛选条件写成 and created_at >= '2026-09-06'(实验会话使用 UTC,因此结果相同)。仪表盘降到约 172 页,吞吐量变为原来的三倍多。
**步骤 3,键集分页。**平均偏移量为 240 时,OFFSET 为了返回每页 20 行,会读取约 260 个页面,且随翻页深度增长。从用户看到的最后一行开始,同一个索引只需约 22 个页面就能提供任意一页结果:
每个事务的页面数从 214 降到 143。吞吐量几乎没变,步骤 6 会说明原因。
动手验证 · lab 35-playbook
深入阅读:步骤 4 到 8,HOT、一条查询、连接池、配置和汇总表
**步骤 4,填充因子。**更新计数器不会修改索引列,因此是 HOT-safe 的,即满足 HOT 对不改索引列的要求。但 projects 加载时按填充因子 100 填满了页面,所以大多数页上的第一次更新找不到空间,只能进行非 HOT 更新,为每个索引写入新条目。步骤 0 到 3 中,有几百次这样的更新。
从那以后,每次计数器更新都是 HOT。在这么短的运行中,每个事务的页面数没有变化;收益在于索引不再增长、WAL 更少、清理工作更少(让更新保持 HOT)。
**步骤 5,一条查询。**把项目页改成单条 left join lateral (... order by created_at desc limit 1)。页数不变,约为 108,因为数据库仍要执行同样的 20 次索引查找;省掉的是 20 次往返、解析和规划。在同一主机上运行 pgbench 时,两次实验的平均延迟都下降了(3.3 ms 降到 1.5 ms,3.0 ms 降到 2.4 ms)。如果跨真实网络、每次往返半毫秒,每次页面访问就能省下 10 ms 或更多。
**步骤 6,连接池。**步骤 2 到 5 的吞吐量一直在 1,000 到 1,200 左右,而页面数已经减少了三分之一。瓶颈是建立连接,在我们的机器上约需 2 ms。保持四个连接打开后,吞吐量达到 9,000 到 10,600。页面数也从 144 降至 81,因为摄入从约 239 页降到了 36 页:新会话要读取系统目录页,为插入的外键检查做准备,而连接池中的会话只需做一次。
**步骤 7,配置。**和“先调配置”时相同的五项设置。页数为 82,之前是 81。这里没有哪项工作排序多到需要溢出、扫描多到需要并行,或代价高到需要 JIT。这不是在反对真正重要的配置,那些设置能预防一个持续几分钟的实验无法体现的问题。这里反对的是用调参代替修问题。
**步骤 8,汇总表。**一张包含 (account_id, day, events, failed_deploys)、主键为 (account_id, day) 的表,由增量刷新逐日写入和构建,只需约 34 页就能服务仪表盘。刷新任务不在混合负载中;它就是用插入或更新保持新鲜中的定时 upsert。
每个端点每次调用,页面花在了哪里:
| 步骤 | 仪表盘 | 动态列表 | 项目页 | 摄入 |
|---|---|---|---|---|
| 0 基线 | 35,182 | 35,208 | 702,683 | 207 |
| 1 索引 | 1,963 | 262 | 108 | 247 |
| 2 裸列范围筛选 | 172 | 261 | 108 | 245 |
| 3 键集分页 | 175 | 23 | 109 | 242 |
| 4 填充因子 90 | 178 | 23 | 108 | 239 |
| 5 一条查询 | 182 | 23 | 108 | 239 |
| 6 连接池 | 179 | 23 | 102 | 36 |
| 7 配置 | 182 | 23 | 102 | 36 |
| 8 汇总表 | 34 | 23 | 102 | 36 |
动手验证 · lab 35-playbook
表格告诉了我们什么
**第一项手段做了大部分工作。**仅靠索引,每个事务的页面数就降为原来的 1/120;后续所有改动又在此基础上降到约 1/20。几乎所有系统都是这个样子:一两条语句占据了大部分代价,而修好它们很便宜。
**瓶颈移到哪里,吞吐量才会随之变化。**添加索引、改写日期条件、使用连接池时,吞吐量都会跃升。在这些步骤之间,键集分页和 N+1 修复减少了实际工作,吞吐量却原地不动,因为每个请求仍要付出新建连接的代价,而这是 pg_stat_statements 看不到的。要测量整个请求。
**有些手段的收益要以后才显现。**填充因子在这里没有带来变化。它改变的是更新一个月之后,索引会变成什么样。
**更大的机器也帮不上忙。**每个事务依然要读 91,448 页。同一台笔记本,按这份操作指南花一个下午,处理的事务数就比原来多了三个数量级。有人要求更大的数据库时,先做这个实验。
横向扩展之前
你已经按操作指南做过一轮。如果测量仍然表明某项资源耗尽,现在可以考虑更大的机器。本书下一部分就从这里开始:先纵向扩容,再横向扩展把这些证据变成升级规格的申请,然后继续攀登后面的台阶。
本章要点
- 按顺序修:找出昂贵的工作、查询与索引、模式、往返、日常维护、连接池、配置、预计算。之后才购买硬件或横向扩展。
- 从
pg_stat_statements和一次等待采样开始,写下瓶颈是什么。 - 每次只改一件事;数页面,页面数不会随噪声波动。
- 仅靠索引,每个事务的页面数就缩减至原来的 1/120;八次改动将页数从 91,448 降至 38,在同一台笔记本上让吞吐量达到原来的约一千倍。
- 先调配置,页面数几乎没变。配置设置上限,却不能让一次扫描少读数据。
- 瓶颈移到哪里,吞吐量才会随之变化:每个请求新建连接,掩盖了所有查询修复的收益,直到我们使用连接池。
- 先用数据证明,该换更大的机器了:升级规格的申请应附带证据。
延伸阅读
- Performance Tips,PostgreSQL 文档。从源头介绍 EXPLAIN、统计信息和数据库装载。
- pg_stat_statements,PostgreSQL 文档。每个字段,包括本章用调用次数作分母计算的缓冲区计数。
- pgbench,PostgreSQL 文档。自定义脚本、权重、
--random-seed和-C,实验中都用到了。 - The cumulative statistics system,PostgreSQL 文档。等待事件,以及 HOT 占比背后的表统计信息。
- Server configuration: query planning,PostgreSQL 文档。
random_page_cost、effective_cache_size和代价模型的其他部分。 - Routine vacuuming,PostgreSQL 文档。为什么清理属于维护,而不是调优。