来源:https://www.pgedge.com/blog/checkpoints-write-storms-and-you
检查点、写入风暴
肖恩·托马斯 | 2026年4月10日
每个数据库都必须调和两个令人不适的事实:内存快速但易失,磁盘缓慢但持久。Postgres 通过其预写日志 (WAL) 来处理这种紧张关系,该日志在每次更改发生前就记录下更改。但 WAL 不能无限增长。在某个时刻,Postgres 需要将所有累积的脏页刷新到磁盘,并声明一个干净的起点。这个过程称为检查点,当它出错时,可能会使系统吞吐量陷入瘫痪。
关于检查点的一些知识
在正常操作下,Postgres 对检查点的处理是非常"礼貌"的。checkpoint_timeout 参数(默认 5 分钟)告诉 Postgres 执行计划检查点的频率,而 checkpoint_completion_target 参数(默认 0.9)告诉它将产生的写入操作分散到该间隔的 90% 时间内。因此,5 分钟的检查点超时意味着 Postgres 将在大约 4.5 分钟内将脏页慢慢地写入磁盘,从而将对 IO 的影响降至最低。
这仅适用于定时检查点行为。
max_wal_size 参数设置了检查点之间可以累积的 WAL 数量的软限制。当 WAL 接近该阈值(默认为 1GB)时,Postgres 不会等待下一个计划的检查点。相反,它会立即强制执行一个检查点。
这些强制 (或称为请求)的检查点不 遵循 checkpoint_completion_target。Postgres 需要回收 WAL 空间,因此它会以 IO 子系统允许的最快速度将每个脏缓冲区刷新到磁盘。在一个繁忙的系统上,如果有一个装满修改页面的大的 shared_buffers 池,这可能会在几秒钟内完全耗尽磁盘 IO 资源。
这就像试图从消防水管中喝水。
纸上谈兵不如实战演练
为了观察实际效果,我们设置了一个适中的测试环境:
- 虚拟机监控程序: Proxmox
- CPU: 4 个 AMD EPYC 9454 核心
- 内存: 4GB
- 数据库存储: 100GB @ 2,000 IOPS
- WAL 存储: 100GB @ 2,000 IOPS
- 操作系统: Debian 12 Bookworm
我们使用 pgbench 以 800 的缩放因子初始化了数据库,产生了大约 12GB 的数据(可用内存的 3 倍,以减少缓存命中)。我们还遵循传统建议,将 shared_buffers 设置为 RAM 的 25%,在本例中为 1GB。所有其他设置保持默认值。
每个测试都遵循相同的模式:执行手动 CHECKPOINT 以从干净状态开始,然后运行 pgbench 60 秒,每秒报告进度,并使用 16 个并发客户端来保持所有 CPU 核心繁忙:
bash
pgbench --progress=1 --time=60 --clients=16 demo
我们从默认的 max_wal_size(1GB)开始,观察系统的行为。这个设置在优化过程中经常被忽视,因此它应该能很好地说明基线操作的情况。
在测试的前 41 秒,吞吐量稳定在 1,000 到 1,100 TPS 之间。缓冲区开始变热,IO 子系统跟上了步伐,延迟保持较低水平。在第 42 秒标记处,WAL 输出达到了 1GB,Postgres 强制执行了一个检查点。TPS 立即暴跌至大约 620------下降了近 40%!在基准测试的剩余时间内,它再也没有恢复过来。
在第二次测试中,我们将 max_wal_size 增加到 4GB。这是一个适度的提升,但足以满足本次演示的目的。这次吞吐量开始时约为 1,000 TPS,并随着共享缓冲区的预热逐渐攀升,到测试结束时达到了 1,200 TPS。在此硬件上,一分钟的 pgbench 活动不足以产生 4GB 的 WAL,这意味着没有强制检查点。
结果基本上不言自明:
pgbench TPS 对比:强制检查点的伤害
哎呀!两次测试在前 40 秒的追踪轨迹几乎相同。然后,1GB 配置撞上了墙,而 4GB 配置继续攀升。
强制检查点的代价
在定时检查点期间,Postgres 通常会使用 checkpoint_completion_target 来计算写入预算。如果检查点间隔为 5 分钟,目标值为 0.9,则它可以将其脏页写入分散到 270 秒内。这是一个将数据慢慢写入磁盘的漫长过程,每秒的 IO 影响微乎其微。
强制检查点则没有这种奢侈。WAL 已满(或接近满),Postgres 现在就需要回收空间。它会尽可能快地将脏缓冲区写入磁盘,直接与活动查询竞争磁盘 IO。在一个限制为 2,000 IOPS 的系统上,这种竞争非常激烈。每个用于刷新检查点数据的 IOPS 本质上都是从用户查询中"窃取"来的。
严重程度在很大程度上取决于硬件。拥有快速 NVMe 存储和数万(或数十万)IOPS 的系统可能几乎不会注意到。但是,云实例、虚拟化环境或任何有 IO 限制的环境(这非常普遍)将会感受到痛苦。我们为测试系统配置了每个卷 2,000 IOPS,按云标准来说这已经相对慷慨了,但仍然经历了显著的影响。
基准测试本身只是故事的一半。在此之前,我们不得不使用 pgbench --initialize 来初始化 12GB 的测试数据库。当 pgbench 使用默认的 1GB max_wal_size 生成示例数据时,Postgres 触发了 18 次强制检查点。将 max_wal_size 设置为 20GB 后再次尝试,强制检查点的数量降为零。
那又怎样?这不过是初始化,对吧?但请考虑,同样的模式适用于任何批量数据操作:COPY 导入、大型 INSERT INTO ... SELECT 语句、大表上的 CREATE INDEX、REINDEX,甚至是大量的 UPDATE 批次。如果这些操作中的任何一个与生产 OLTP 工作负载同时运行,那就意味着有 18 次 IO 风暴在与应用程序查询竞争。
一个每晚加载几 GB 数据的 ETL 作业可能会触发一连串的强制检查点,从而导致系统上所有其他查询的延迟飙升。批量操作本身也会变慢,因为它需要与自身的检查点 IO 争夺磁盘带宽。
当检查点无法分散写入活动时,所有人都是输家。
发现问题
Postgres 会跟踪检查点统计信息,检查这些信息应该成为任何常规健康评估的一部分。要使用的系统目录取决于 Postgres 版本。
在 Postgres 17 及更高版本中,使用 pg_stat_checkpointer:
sql
SELECT num_timed, num_requested, num_done,
write_time, sync_time, buffers_written
FROM pg_stat_checkpointer;
在旧版本中,相同的信息位于 pg_stat_bgwriter 中:
sql
SELECT checkpoints_timed, checkpoints_req,
checkpoint_write_time, checkpoint_sync_time,
buffers_checkpoint
FROM pg_stat_bgwriter;
这里的关键比率是:定时检查点与请求检查点的比率 。在一个调优良好的系统中,相对于 num_timed,num_requested(或 checkpoints_req)应该接近于零。如果请求的检查点占总数的很大一部分,那么 max_wal_size 对于当前的写入工作负载来说太小了,性能很可能不是最优的。
同时关注 write_time 和 sync_time 也很有价值。如果 sync_time 持续偏高,说明存储子系统在努力跟上检查点刷新的步伐,这进一步证实了检查点期间存在 IO 瓶颈。
至于日志记录,我们强烈建议设置 log_checkpoints = on 来记录检查点活动:
sql
log_checkpoints = on
这会使 Postgres 记录关于每个检查点的详细信息,例如写入的缓冲区数量、花费的时间(包括同步时间)以及其他有用的指标。启用后,Postgres 日志应该会显示如下所示的检查点活动:
LOG: checkpoint starting: wal
LOG: checkpoint complete: wrote 2069 buffers (1.6%), wrote 2 SLRU buffers;
0 WAL file(s) added, 1 removed, 32 recycled; write=2.132 s, sync=0.092 s,
total=2.282 s; sync files=35, longest=0.090 s, average=0.003 s;
distance=553713 kB, estimate=553713 kB; lsn=6/FEBDB228,
redo lsn=6/DFFFFC60
LOG: checkpoints are occurring too frequently (2 seconds apart)
HINT: Consider increasing the configuration parameter "max_wal_size".
LOG: checkpoint starting: wal 这一行就是确凿的证据。它表示这个检查点是因为 WAL 达到了限制而被强制执行的,而不是因为超时到期。定时检查点会显示 checkpoint starting: time。
这是免费的法医信息。日志记录开销可以忽略不计,并提供清晰的检查点行为轨迹。这是另一个应该默认启用的设置,从 Postgres 15 开始已经如此。那些使用旧版本集群的用户将需要手动启用它。
找到合适的 max_wal_size
那么,是不是每个人都应该将 max_wal_size 提高到一个巨大的值,然后就不管了呢?不完全是这样。这其中有权衡。
更大的 max_wal_size 允许在检查点之间积累更多的 WAL,这意味着在崩溃恢复期间必须重放更多的数据。如果 Postgres 崩溃时有 20GB 的 WAL 需要重放,启动时间必然比只有 1GB 时要长。这种恢复时间的差异通常只有几秒钟,但值得注意。
另一个考虑因素是磁盘空间。WAL 文件会消耗存储空间,而 max_wal_size 是一个软限制。在繁重的写入负载下,WAL 可能会暂时超过它。WAL 卷上应有足够的空间余量来应对突发情况,而不会完全耗尽空间。那将是一个比检查点缓慢严重得多的问题。
对于写入密集型 OLTP 工作负载,一个合理的起始点是 10GB 到 20GB。具有大量批量加载或大型批处理操作的系统可能会受益于 50GB 甚至更多。目标是让强制检查点变得足够罕见,以至于基本上所有的检查点都是定时检查点,并能优雅地分散在 checkpoint_completion_target 内。
我们建议通过持续监控 pg_stat_checkpointer(或 pg_stat_bgwriter)来验证设置。让系统在典型负载下运行一天或一周,然后检查比率。如果请求的检查点增加了,就提高 max_wal_size 并重复此过程。
sql
-- 重置统计信息,并在观察窗口后重新检查
SELECT pg_stat_reset_shared('checkpointer');
-- ... 一段时间后 ...
SELECT num_timed, num_requested FROM pg_stat_checkpointer;
如果你不想自己动手,我实际上写了一个名为 pg_walsizer 的 Postgres 扩展。它会启动一个后台工作进程,监控检查点活动,并根据在配置的 checkpoint_timeout 内发生了多少个检查点,自动增加 max_wal_size。只需设置它,然后就不用管了!
总结
检查点是 Postgres 内部机制中那些大多数人直到出问题才会考虑的东西。毕竟,周期性的延迟峰值可能有无数种原因。并非所有 DBA 都会检查 WAL 活动,也不一定意识到它与磁盘刷新的关系------大多数人首先会归咎于 vacuum。
按照传统,max_wal_size 的默认值 1GB 是一个保守的值。它最大限度地减少了崩溃恢复时间,并且对于轻量级工作负载来说效果很好,同时不会占用大量存储空间。不幸的是,繁忙的系统会很快超过这个默认值并开始受到影响。我们的测试在适中的硬件上显示了 40% 的 TPS 暴跌;具有更重负载和更紧张 IO 预算的生产系统情况可能会更糟。
对于大多数生产环境,我们建议从一个更合适的 max_wal_size 开始。如果 log_checkpoints 尚未启用,也请优先启用它。最后,pg_stat_checkpointer 或 pg_stat_bgwriter 应该突出地显示在监控仪表板上。对任何请求检查点的增加都应持怀疑态度。
最后,max_wal_size 是那种罕见的参数,只需调整它一个,就能带来显著的性能提升,而且几乎没有任何负面影响。所以,去检查一下你的检查点统计信息吧。你可能会对发现的结果感到惊讶!