PostgreSQL 教程: 优化检查点

七月 23, 2024

摘要:在本教程中,您将学习如何优化 PostgreSQL 中的检查点。

目录

在执行大量写入的系统上,调整检查点对于获得良好的性能至关重要。然而,检查点是我们经常发现存在困惑和配置问题的一个领域,无论是在社区邮件列表上,还是在为客户提供服务支持期间。本文旨在解释什么是检查点 - 它的作用和数据库的实现原理 - 以及如何调整它们。

检查点的作用是什么?

PostgreSQL 是一种依赖于预写式日志(WAL)的数据库 - 在对数据文件执行任何更改之前,它会记录在单独的日志中(更改日志流)。这提供了持久性,因为在崩溃的情况下,数据库可以使用 WAL 来执行恢复 - 从 WAL 读取更改,并将其重新应用于数据文件。

乍一看,这似乎效率低下,因为它使写入量增加了一倍 - 我们在更改数据文件时,也会将更改写入日志。但是,由于几个原因,它实际上可能会提高性能。COMMIT 只需要等待 WAL 的持久化(写入并刷新到磁盘),而数据文件只要在内存(共享缓冲区)中修改,然后可能在稍后的某个时间写入磁盘/刷新。这很好,因为 WAL 是以顺序方式写入的,而对数据文件的写入通常是相当随机的,因此刷新 WAL 的开销要低得多。此外,在共享缓冲区中,数据页可能会被多次修改,然后只用一次物理 I/O 写入来持久化 - 这是另一个显著的好处。

假设系统崩溃,数据库需要执行恢复。最简单的方法是从头开始 - 以一个新实例开始,从头开始应用所有 WAL。最后,我们应该得到一个完整(且正确)的数据库。当然,明显的缺点是需要保留和重放自实例创建以来的所有 WAL。通常来说,我们处理的数据库不会非常大(例如,几百 GB),但非常活跃,每天产生几 TB 的 WAL。想象一下,在运行数据库一年后,您需要多少磁盘空间来保留所有 WAL,以及恢复需要多长时间。显然,这似乎不是一个非常实用的方法。

但是,如果数据库可以保证,对于给定的 WAL 位置(日志中的偏移量,称为“日志序列号” - LSN),直到该位置的所有数据文件的更改都已刷新到磁盘,那会怎样?然后,它可以在恢复期间确定此位置,并仅重放剩余部分的 WAL(自此位置以来)。这将大大减少恢复时间,并且还可以丢弃在“已知良好”的位置之前的 WAL,因为恢复不需要它。

这正是检查点的作用 - 确保在某个 LSN 之前的 WAL 不再需要用于恢复,从而减少磁盘空间要求和恢复时间。数据库仅查看当前 WAL 位置,并将所有未完成的更改(可能会需要较老的 WAL)刷新到磁盘,并记录 LSN,以用于恢复的需要。

这些检查点由数据库定期生成,要么按照时间,要么根据生成的 WAL 日志量(自上一个检查点开始)。

我们稍后会讨论影响检查点发生频率的配置部分,但让我们先简要谈谈两种极端配置。

我们已经描述了一个极端情况 - 当检查点根本不发生,或者很少发生时。这样最大化地带来了一些好处(将数据文件的更改合并为一次异步写入,也通常减少了对数据文件进行写入的需要),但它也有一个缺点,即必须保留所有 WAL,并在崩溃/异常关闭后执行长时间的恢复。

让我们也讨论下另一个极端 - 执行非常频繁的检查点(例如,每秒左右)。这似乎是个好主意,因为它只允许保留少量的 WAL,而且恢复速度也非常快(只需重放那少量的 WAL)。但它也会将对数据文件的异步写入转换为同步写入,并且大大降低了将多个更改合并为单次物理写入的可能性。这会严重影响用户(例如,增加 COMMIT 的延迟,降低吞吐量)。

因此,在实践中,这是一种权衡 - 我们希望检查点发生的频率足够低,以免影响用户,但也要足够频繁,以限制恢复持续时间和磁盘空间要求。

触发检查点

大约有三种或四种方法可以触发检查点:

前两点与本文无关 - 这些是罕见的事件,手动触发,主要与维护操作相关。本文是关于如何配置其他两种情况的,这些情况会影响常规的定期检查点。

这些时间/大小限制是使用两个配置选项设置的:

使用这些值(默认值),PostgreSQL 将每 5 分钟触发一次 CHECKPOINT,或者在写入大约 1/2 GB 的 WAL 后触发一次,以先发生者为准。

注意:max_wal_size 是总 WAL 大小的软限制,这有两个主要后果。首先,数据库将尽量不超过限制,但是允许超过,因此请在分区上保留足够的可用空间,并对其进行监控。其次,它是对 WAL 总量的限制,而不是 “每个检查点” - 由于分散检查点(稍后解释),配额涵盖 1-2 个检查点(或 PG 11 之前的 2-3 个)。因此,当 max_wal_size = 1GB 时,数据库将在写入 500 - 1000 MB 的 WAL 后启动 CHECKPOINT,具体取决于 checkpoint_completion_target。

默认值相当保守(偏低),就像样例配置文件中的许多其他默认值一样,其大小甚至可以在 Raspberry Pi 等小型系统上运行。您可能需要大幅调高限制,才能充分利用可用的硬件能力。

但是如何为您的系统/应用程序确定良好的值呢?如前所述,目标是不要太频繁地进行检查点,也不要太少地进行检查点。我们的“最佳调优实践”方法包括两个步骤:

  1. 选择一个“合理的” checkpoint_timeout
  2. 设置 max_wal_size 足够高,让它很少能达到

对于 checkpoint_timeout 来说,“合理”的值是多少?显然,自上次检查点以来,我们积累的越多,在恢复期间需要做的工作就越多,因此它与恢复时间目标(RTO)有关,即我们希望恢复完成的速度(崩溃后)。

注意:如果您担心崩溃后恢复需要很长时间,因为系统需要满足一些高可用性或 SLA 要求,您可能应该考虑设置副本,而不仅仅是依赖检查点。系统可能需要长时间的重新启动、更换硬件部件等。较短的检查点无法解决任何这些问题,故障转移到副本是一种行之有效的解决方案。

这有点微妙,因为 checkpoint_timeout 是对生成 WAL 所花费时间的限制,而不是对恢复时间的限制。但是,虽然 WAL 通常由多个进程(运行 DML 的后端进程)生成,但恢复是由单个进程执行的 - 因此仅限于单个 CPU,可能会在等待 I/O 时停顿。这不仅会影响本地恢复,还会影响流式复制,在这种复制中,副本可能无法跟上主副本的步伐。恢复时缓存可能是冷的(例如,在重新启动后),由于 I/O 访问开销高,使得单个进程特别慢。

注意:PostgreSQL 15 引入了 recovery_prefetch 选项,该选项允许在恢复过程中异步地预读数据,解决了这个“单进程恢复”的弱点,并可能使恢复速度比产生数据的原始业务负载更快。

此外,实例可能具有非常不同的工作负载 - 例如,在同一时间段内,处理写入密集型工作负载的实例,会比只读实例生成更多的 WAL。

应该清楚的是,更长的 checkpoint_timeout 意味着更多的 WAL,因此恢复时间更长,但“合理的”超时值是多少,仍然不是很清楚。不幸的是,没有简单的公式可以告诉你“最佳”值是多少。

然而,默认值(5 分钟)显然太低,生产系统通常使用 30 分钟到 1 小时之间的值。PostgreSQL 9.6 甚至将最长值增加到了 1 天(因此显然有高级专家认为,这对某些系统来说可能是个好主意)。偏低的值还可能导致由于全页写而导致写入放大(这是一个严重的问题,但为了简洁起见,就不打算在这里讨论它了)。

checkpoint_timeout 参数是一个很好的“收益递减”的例子 - 调高值的好处很快就会消失。例如,将超时从 5 分钟增加到 10 分钟可以产生明显的改进。从 10 分钟再次加倍到 20 分钟,可能会再次改善情况,但改进可能要小得多(而成本 - WAL 量和恢复时间 - 仍然翻倍)。然后 20 到 40 分钟,成本仍然翻倍,但相对收益再次不那么显著。您需要决定正确的权衡值。

假设我们决定使用 30 分钟,根据我们的经验,这是一个合理的值 - 既不太低,也不太高。

checkpoint_timeout = 30min

从现在开始,这就是我们的目标 - 我们希望检查点平均每半小时发生一次。但是,如果我们只这样做,数据库仍可能因为达到检查点之间的 WAL 量限制 - max_wal_size,更频繁地触发检查点。我们不希望这样 - 我们的目标是让检查点只由 checkpoint_timeout 触发。

这意味着我们需要估计,数据库在 30 分钟内会产生多少 WAL,以便我们可以将其用于 max_wal_size。有几种方法可以计算该估计值:

  • 使用 pg_current_wal_lsn() 查看实际的 WAL 位置(基本上是文件中的偏移量),并计算 30 分钟后测量的位置之间的差量。

    注意:该函数在 PG 9.6 之前,曾被称为 pg_current_xlog_location()。

  • 启用 log_checkpoints,然后从服务器日志中提取信息(每个完成的检查点都会有详细的统计信息,包括 WAL 的数量)。

  • 使用 pg_stat_bgwriter,其中包括有关检查点数量的信息(您可以将这些信息与当前 max_wal_size 值的知识相结合)。

例如,让我们使用第一种方法。在运行 pgbench 的测试机器上,会看到如下内容:

select pg_current_wal_lsn();
 pg_current_wal_lsn
--------------------
 C7/72C140D8

... after 5 minutes ...

select pg_current_wal_lsn();
 pg_current_wal_lsn
--------------------
 C7/E8A494CF

SELECT pg_wal_lsn_diff('C7/E8A494CF', 'C7/72C140D8');
 pg_wal_lsn_diff
-----------------
    1977832439

这表明,在 5 分钟内,数据库生成了 ~1.8GB 的 WAL,因此,对于 checkpoint_timeout = 30min 的情况,会有大约 6 * 1.8GB = 11 GB 的 WAL。但是,如前所述,max_wal_size 是 1 - 2 个检查点的配额,我们需要使用 22GB。

注意max_wal_size 覆盖的检查点数量取决于检查点目标值,这将在后面关于分散检查点的部分中讨论。可以肯定的是,更高的值很重要。此外,在 PG 11 之前,该数字曾经是 2 - 3 个检查点。

其他方法收集数据的方式不同,但总体思路是相同的,结果应该差别不大。

仅做估计

这些测量值本质上是不准确的,因为检查点的频率会影响我们需要进行的全页写次数,而全页写可能会成为 WAL 写入放大的主要来源。

在一次检查点后,对每个数据页(数据文件中的 8kB 数据块)的第一次更改会触发全页写,即将完整的 8kB 页写入 WAL。如果更改页面两次(例如,因为从不同的事务执行两次 UPDATE),这可能会导致一次或两次全页写,具体取决于它们之间是否发生了检查点。这意味着,减少执行检查点的频率可能会消除一些全页写,从而减少写入的 WAL 量。因此,当降低检查点的频率后,产生的 WAL 量明显低于更频繁的检查点产生的 WAL 总和时,请不要感到惊讶。

例如,假设检查点每 10 分钟发生一次,每次检查点写入 10GB 的 WAL。如果您希望每 30 分钟发生一次检查点,您可以设置

max_wal_size = 60GB

以覆盖 2 个检查点,每次检查点 30GB,这应该可以解决问题。但是,如果然后测量每次检查点生成的 WAL 量,您可能会发现平均只有 ~15GB 的 WAL(例如),而不是预期的 30GB。这可能是由于全页写的次数较少,最终的效果很好 - 减少了全页写,降低了 WAL 放大,减少了归档和/或复制的 WAL 等。

这意味着,较少的检查点可能会显著减少根据备份策略需要保留的 WAL 量。通常需要保留足够的备份和 WAL 来执行上个月的 PITR,即您需要保留过去 30 天左右的 WAL。如果降低检查点的频率,可以减少全页写的 WAL 量,那么归档的 WAL 量也会减少。

分散检查点

在前面建议你只需要调整 checkpoint_timeoutmax_wal_size 时,并没有指出全部真相。这两个参数当然是重要的两个参数,但还有第三个参数,称为 checkpoint_completion_target

你可能不需要调整它 - 默认值相当合理,如果你调整的是前两个参数,那就没问题了。但是,了解什么是“分散检查点”是件好事,这样您就可以在需要时进行调整。

CHECKPOINT 期间,数据库需要执行以下三个基本步骤:

  1. 识别共享缓冲区中的所有脏块(已修改)
  2. 将所有这些缓冲块写入磁盘(或者更确切地说是,写入文件系统缓存)
  3. 调用 fsync() 将所有修改过的文件刷写到磁盘上

只有当所有这些步骤都完成后,检查点才能被认为是完成的 - 最终,就是最后一个 “fsync” 步骤,以让数据持久化。

您可以“尽可能快地”执行这些步骤,即一次性写入所有脏的缓冲块,然后在所有受影响的文件上调用 fsync(),事实上,这正是 PostgreSQL 在 8.2 版本之前所做的。但是,由于滥用文件系统缓存、使存储设备 I/O 饱和并影响用户会话,这会导致主要的 I/O 活动发生停滞。想象一下,在共享缓冲区中有 8GB 的修改数据,您立即将所有这些数据写入操作系统,并调用 fsync。内核会竭尽所能地尽快完成您的请求,但这意味着其他进程的 I/O 将不得不等待更长时间。这是从后台启动的,但对用户会话的影响是巨大的。

为了解决这个问题,PostgreSQL 8.3 引入了 “分散检查点” 的概念 - 不是一次写入所有数据,而是在很长一段时间内分散写入。这让操作系统有时间在后台刷写脏数据,使最终的 fsync() 调用开销更低,并减少对用户会话的影响。

写入会根据到达下一个检查点的进度进行限制 - 数据库知道,在需要另一个检查点之前,我们还剩下多少时间或 WAL 量,因此它可以计算出,在给定时间点应该写出多少脏的缓冲块(到操作系统)。

然而,数据库必须直到最后一刻才发出写入 - 这意味着,最后一批写入仍将在文件系统缓存中,这使得最终的 fsync() 调用开销(在开始下一个检查点之前发出)再次变得很高。可能不像以前那么高(因为脏数据量会更少),但是如果我们能防止这种情况 ……

checkpoint_completion_target(默认值为 0.9,在 PG 14 之前为 0.5)表示在离下一个检查点还有多远前,所有写入都应该完成。例如,假设每 30 分钟触发一次检查点,则数据库将限制写入,以便在 30 * 0.9 = 27 分钟内完成最后一次写入。这意味着,操作系统还有另外 3 分钟的时间将数据刷写到磁盘,因此在检查点结束时发出的 fsync 调用既开销低又很快速。

在 PostgreSQL 9.6 之前,我们需要担心内核从页面缓存中换出脏数据的速度有多快,这是由 vm.dirty_expire_centisecs(默认设置为 30 秒)和 vm.dirty_background_bytes(开始写出数据前的脏数据量)确定的。在计算我们应该在检查点结束时,留出多少时间让操作系统写出数据时,需要考虑这一点。

然而,PostgreSQL 9.6 引入了一个选项 checkpoint_flush_after,这使得调整这些内核参数没有了太多必要性 - 该选项指示数据库在 CHECKPOINT 期间,在写入少量数据后(默认为 256kB),定期执行 fsync。这大大减少了 fsync 调用对其他进程的影响(不会有主要的 I/O 活动停滞)。这也意味着,我们不需要过多地担心检查点结束时的剩余时间,因为未刷写的数据量很小。

总结

因此,现在您应该已经了解了检查点的作用,以及调整检查点的基础知识。总结一下:

  • 大多数检查点应该按时间来触发(checkpoint_timeout
  • 实质上是吞吐量(不频繁的检查点)和恢复所需的时间(频繁的检查点)之间的一个折衷
  • 大多数生产系统可使用 30-60 分钟之间的超时值,请选择此范围内的值,除非您有数据支持其他选择
  • 在确定超时值后,可通过估计 WAL 日志量来选择 max_wal_size
  • 设置 checkpoint_completion_target 为 0.9(如果在旧版本上,则默认为 0.5)
  • 在没有 checkpoint_flush_after 的旧版本(9.6 之前)上,您可能需要调整内核参数(vm.dirty_expire_centisecs 和 vm.dirty_background_bytes)

了解更多

PostgreSQL 优化