PostgreSQL 中的二进制只读副本,第一部分
第一部分:只读副本是什么,以及它的代价
添加只读副本是大多数 PostgreSQL 安装所做的第一个扩展动作,而且通常是正确的。这也是团队学到副本是一个永久处于恢复中的数据库、恰好能回答查询的地方。副本几乎所有令人惊讶的行为都源于这个事实。
副本是什么
二进制副本是一个第二个 PostgreSQL 服务器,它开始于主节点数据目录的物理拷贝(一个基础备份,通常用 pg_basebackup 获取),然后通过按主节点做出更改的顺序应用主节点所做的每一个更改来保持最新。结果是逐块物理拷贝。它有相同的数据库、相同的表和索引、相同的角色,以及相同的膨胀。副本上的查询在任何方面都不能与主节点不同,除了它落后多少。
术语不一致,所以最好尽早确定。接受写入的服务器是主节点。通过应用主节点更改来保持自己的服务器是备库,而在这样做时接受只读查询的备库是热备;这些是 PostgreSQL 文档使用的术语。"只读副本"、"二进制副本"、"物理副本"、"流副本"和"从库"在实践中都意味着热备。我在这里使用副本,在必须与另一种区分时使用"二进制"。
另一种是逻辑复制,它把相同的更改流解码为行级插入、更新和删除,并应用到独立、可写的数据库。逻辑复制可以复制表的一个子集,可以跨主版本。二进制复制两者都不能。作为交换,二进制复制运维更简单、无需被告知就复制一切(包括 schema 更改),并对主节点施加很少的负载。在常规环境中扩展读取,二进制复制是默认选择,也是这里的主题。
预写日志,简要
PostgreSQL 对表或索引所做的每个更改首先在追加到预写日志(WAL)的记录中描述。规则是 WAL 记录必须在更改的数据页到达持久存储之前到达持久存储。提交是一次 WAL 刷新;数据文件本身稍后更新。如果服务器崩溃,它从最近的检查点开始向前重放 WAL 直到用完,此时数据文件反映每个已提交事务。(无日志表是例外,按设计。它们的内容不写入 WAL,它们不被复制,对副本上的一个查询会失败。)
WAL 重放不在乎 WAL 来自哪里。给一个 PostgreSQL 服务器一个基础备份和来自另一个服务器的 WAL 供应,它就会在供应持续期间重放那个 WAL。副本就是这个。数据目录中一个名为 standby.signal 的文件告诉服务器,在启动时到达它已有 WAL 的末尾时留在恢复中并等待更多。
WAL 的三个属性解释了副本行为的大部分。
WAL 是物理的。 一条记录实际上说"更改这个文件第 1,204 块上的这些字节"。它不说 UPDATE orders SET ...。因此副本只能把 WAL 应用到与主节点完全一样布局的文件。这就是为什么主节点及其副本必须在相同硬件架构上运行相同主版本的 PostgreSQL,以及为什么副本总是整个集群的副本。(在 PostgreSQL 的词汇中,"集群"是一个服务器实例及其所有数据库,不是一组服务器。)没有办法只复制一个数据库、一张表,或除审计日志之外的一切。
WAL 有位置。 每条记录有一个日志序列号(LSN),WAL 流中的 64 位字节偏移。复制进度总是以 LSN 表示:主节点写了多远、副本收到了多远、副本重放了多远。复制延迟是其中两者之间的距离。
WAL 以两种方式移动。 它存储在段文件中,默认每个 16MB,较旧的交付机制是把每个完成的段复制到归档,副本用 restore_command 从中获取。较新的是流复制:副本上的 walreceiver 进程连接到主节点上的 walsender 进程,记录在生成时发送,无需等待段填满。使用流复制,健康的副本通常落后不到一秒。大多数生产安装两者都用,流复制用于保持最新,归档作为副本断开太久、主节点不再有它需要的 WAL 时的回退。
在副本上,两个进程分工。walreceiver 把传入的 WAL 写到副本自己的磁盘。启动进程(执行崩溃恢复的同一进程)读取那个 WAL 并把它应用到数据文件。接收和重放彼此独立:副本可以已接收并安全存储了它尚未应用的 WAL。当重放停顿时,这个区别很重要。
这一切依赖的两个服务器设置在标准安装中已经是正确值。wal_level 默认为 replica,它向 WAL 写入足够信息以支持副本,而 hot_standby 默认为 on,允许副本在重放时接受查询。
主节点必须保留 WAL 直到每个副本都有它。归档处理这一点。复制槽也处理这一点,它是主节点上某个特定副本进展到哪里的记录,主节点不会丢弃它后面的 WAL。槽同样方便和危险:一个属于已离开副本的槽会保留 WAL 直到主节点磁盘满,除非 max_slot_wal_keep_size 设置限制。
流复制默认是异步的。主节点在向客户端报告提交之前不等待任何副本。PostgreSQL 可以配置为等待(同步复制),代价是提交延迟以及使主节点上的提交依赖副本的健康。这里的一切假设异步默认,除非另有说明。
副本擅长什么
读取扩展是明显的用途。不写入的查询可以发送到副本,而副本可以有很多。副本还可以为其他副本供数(级联复制),这减少了到主节点的直接连接数。
高可用是另一个。副本是数据库的完整、几乎最新的拷贝,可以在片刻内提升为主节点,使用 pg_ctl promote 或 pg_promote()。大多数拥有副本的安装首先为这个原因拥有它,其次为读取流量。因为复制是异步的,被提升的副本可能缺少旧主节点提交的最后一些事务;多少取决于主节点失败时它落后多远。
副本也是放置会伤害主节点的工作的地方:报告查询、导出、pg_dump、基础备份。而副本可以用 recovery_min_apply_delay 故意保持在主节点之后,这给操作员一个窗口,在副本也应用之前从错误的 DELETE 中恢复。
副本不能做什么
它不能写入。 副本上的会话永远不会被分配事务 ID,也不能生成 WAL,所以禁令远远超出 INSERT、UPDATE 和 DELETE。不能创建临时表。不能用 nextval() 推进序列。SELECT ... FOR UPDATE 和 FOR SHARE 失败,因为拿行锁修改行。LISTEN 和 NOTIFY 不可用,可序列化隔离级别也不可用。被其所有者描述为只读的应用程序经常结果做了其中一件事:一个把结果暂存在临时表中的报告工具,或一个在每个请求上更新最后看到时间戳的 Web 框架。在路由流量之前弄清楚。
它不能有自己的索引或自己的统计信息。 只帮助副本上报告查询的索引必须在主节点上创建,在那里它会在每次写入时被维护并复制到每个副本。ANALYZE 不能在副本上运行;它使用的规划器统计信息是主节点的。Autovacuum 也不在那里运行。所有维护发生在主节点并通过 WAL 到达。
它不能扩展写入。 每个副本都必须应用主节点所做的每个更改,所以添加副本增加读取容量,仅此而已。重放也由单个进程执行,而主节点用它拥有的尽可能多的并发会话生成那个 WAL。主节点在持续重写入负载下产生 WAL 的速度可能快于副本应用它的速度,而处于那种状态的副本会进一步落后,直到负载缓解。
它不能运行不同的主版本,这排除了二进制副本作为执行主版本升级的手段。(次版本可以短暂不同。应用次版本时,先更新副本。)
在一些特定方面,它的配置必须至少与主节点一样慷慨。 max_connections、max_prepared_transactions、max_locks_per_transaction、max_wal_senders 和 max_worker_processes 调整重放依赖的共享内存结构。任何一项低于主节点的副本将暂停重放,直到设置被纠正且副本重启。提高其中一项时,先改副本。
它落后。 通常远低于一秒,有时多得多,而应用程序不能假设是哪种。标准失败是读己之写:用户保存一个更改,写入到主节点,页面从尚未重放提交的副本重新加载,而更改看起来丢失了。有三种回应。应用程序可以把必须看到最近写入的读取发送到主节点。主节点可以配置为同步复制,synchronous_commit = remote_apply,这样提交直到同步副本重放了它才返回;这很昂贵,而且它只覆盖主节点正在等待的副本。或者,从 PostgreSQL 19(撰写时处于 beta)开始,副本上的会话可以发出 WAIT FOR LSN,带一个提交后在主节点上取得的 LSN,并在副本重放到那么远后继续。
最后一个限制是最不明显的。副本上运行的查询可以被复制取消,不是它自己的错。
查询冲突
PostgreSQL 通过保留行的多个版本(MVCC)实现并发。UPDATE 不覆盖行;它写一个新版本并把旧版本标记为被取代。DELETE 只标记。旧版本留在表中,以便在更改之前开始的事务可以继续看到数据当时的样子。一旦没有运行中的事务可能需要旧版本,它就是死的,VACUUM(或普通查询沿途执行的增量页面清理)移除它。
"没有运行中的事务"是副本进入的地方。主节点通过查看自己的运行中事务来决定什么是死的。默认情况下它对副本上运行的查询一无所知。
考虑一个在副本上开始并取得快照的查询。当它运行时,它能看到的行为在主节点上被更新,旧版本就主节点所能判断的变成死的,而 vacuum 移除它们。描述该移除的 WAL 记录到达副本。副本的查询仍然需要那些行版本。在单个服务器上这种情况不可能发生,因为 vacuum 会看到查询并留下行不动。在副本上它已经发生了:WAL 记录描述主节点所做的更改,而副本不被允许拒绝它。
副本有两个选项。它可以停止重放 WAL 直到查询完成,或者它可以取消查询。PostgreSQL 在有界时间内做第一个,然后做第二个。
行清理是最常见的冲突来源。它不是唯一的:
- 快照冲突。 重放一个清理记录,移除副本查询可能仍能看到的行版本。测试是粗糙的。它把查询快照的年龄与被移除行的年龄比较,对同一数据库中的每个查询,而它不问查询是否读取被清理的表。一个长报告可以被它从未碰过的表的清理取消。在可见性映射中把页面标记为全可见也算,所以 VACUUM 即使在没有东西要移除的表上也能导致这些。
- 锁冲突。 重放主节点上取得的 ACCESS EXCLUSIVE 锁,在副本查询正在使用的表上。DROP TABLE、TRUNCATE 和许多形式的 ALTER TABLE 拿一个。不那么明显的是,VACUUM 在从表尾截断空页面时也拿一个。
- 缓冲区 pin 冲突。 重放一个清理记录需要对副本查询持有的一页的独占访问,无论查询是否能看见被移除的行。
- 被删除的对象。 在主节点上删除的数据库立即断开副本上连接到它的每个会话,无论延迟设置说什么。副本查询在其中有临时文件的表空间被删除时,取消副本上每个活动查询,因为重放不会弄清文件属于它们中的哪一个。
副本按类型、按数据库在 pg_stat_database_conflicts 中计数取消(confl_snapshot、confl_lock、confl_bufferpin 等)。在更改任何设置之前,在副本上查看那个视图。正确的补救取决于哪个计数器在动。
被取消的查询收到这个:
ERROR: canceling statement due to conflict with recovery
DETAIL: User query might have needed to see row versions that must be removed.
DETAIL 行随冲突类型变化(而这一个中的"might"是字面意思)。SQLSTATE 是 40001,PostgreSQL 在其他地方用来表示"请求没有错;再试一次"的序列化失败代码,而再试一次是正确的。查询可以在新事务中立即重跑,并且常常成功。从副本读取的应用程序需要为此错误的重试逻辑,并限制尝试次数。
大多数情况下只有语句被取消。一些会话被直接断开:一个在事务内空闲同时持有冲突锁或快照的会话、一个在冲突解决时处于子事务中的会话(一个保存点、一个带 EXCEPTION 子句的 PL/pgSQL 块,或大多数 ORM 所称的嵌套事务),以及连接到已被删除数据库的每个会话。应用程序必须准备好重连以及重试。
延迟设置
重放在取消之前等待多久在副本上设置:max_standby_streaming_delay 用于通过流复制到达的 WAL,max_standby_archive_delay 用于从归档读取的 WAL(或从副本自己的 pg_wal 目录,如重启后)。两者默认 30 秒。值 -1 意味着永远等待。值 0 意味着立即取消冲突查询。
这些设置被广泛误读为"副本上的查询得到 30 秒"。那不是它们的意思。时钟不在查询开始时启动,也不在发现冲突时启动。它在副本收到它试图应用的 WAL 时启动。对于流复制,它只在重放赶上已收到的一切时重置;对于归档 WAL,它随每个段文件重启。因此流复制设置限制冲突可以把重放整体拖后多远,而它是副本上每个查询共享的一份配额。
假设一个副本完全赶上。查询 A 与一个 WAL 记录冲突,重放停下来等它。三十秒后 A 还没完成,所以它被取消,重放恢复,落后 30 秒。片刻后查询 B 与另一个记录冲突。B 没有宽限期。重放没有赶上,所以时钟没有重置,配额已经花掉。B 立即被取消,之后每个冲突查询也是如此,直到重放赶上。因无关原因落后的副本(例如主节点上的写入突发)处于同样位置。
文档自己的总结是正确的:当主节点上的表被定期且重度更新时,有限的 max_standby_streaming_delay 表现得像副本上长查询的 statement_timeout。
提高延迟保护查询,代价是最新性,而代价比最初看起来更大。当启动进程在冲突上等待时,它什么都不应用。重放是串行的,所以一张表上的一个冲突停止该副本上每个数据库中每张表的重放。副本上的每个其他会话看到的数据一秒比一秒更陈旧。用 max_standby_streaming_delay = -1 和一个运行六小时的报告,报告完成时副本可能落后六小时,而在此期间查询它的其他每个人得到最多六小时旧的答案,却没有被告知。
等待期间 WAL 继续到达。walreceiver 继续把它写到副本的磁盘,所以没有东西丢失,主节点也不被拖住。它也在那里累积,因为副本不能回收它尚未重放的 WAL,而足够长的停顿可以填满副本的磁盘。积压仍然必须被重放,而被提升的副本在成为主节点之前重放它已有的 WAL。本应花片刻的故障转移花与积压一样长的时间。
这就是权衡:复制延迟对查询取消。因为设置住在副本上,权衡可以对每个副本不同地做,而且应该如此。为故障转移保留的副本想要短延迟,以便它保持接近主节点。为长报告保留的副本可以容忍长延迟。要求一个副本两者兼顾,要么产生被取消的报告,要么产生陈旧的故障转移目标。
要看到重放何时被拖住,在副本上设置 log_recovery_conflict_waits = on。它在启动进程因冲突等待超过 deadlock_timeout 时记录每次。在主节点上,pg_stat_replication 的 replay_lag 列近似最近事务在每个副本上变得可见花了多长时间。
hot_standby_feedback
冲突的产生是因为主节点不知道副本的查询需要什么。hot_standby_feedback 告诉它。打开该设置,副本定期向主节点报告其任何查询正在使用的最旧快照(它的 xmin),而主节点上的 vacuum 把那个当作主节点本身上有相同年龄的事务打开一样对待。副本查询可能需要的行版本不被移除,不产生冲突的清理记录,而且只要副本保持连接,快照冲突就停止。
该设置默认关闭,在副本上打开。报告通过副本到主节点的现有连接传输,发送频率不超过每 wal_receiver_status_interval(默认 10 秒;把该间隔设为 0 会悄悄禁用反馈)。在级联中,每个打开反馈的副本向上游转发报告,所以只有当沿途每个副本都打开反馈时它才到达主节点。每个副本报告的 xmin 可以在主节点上的 pg_stat_replication.backend_xmin 中看到,或者如果副本使用槽,在 pg_replication_slots.xmin 中看到。
支持它的理由正是文档提出的:主节点并不比那些查询直接在主节点上运行更糟,而执行它们的工作仍然被移到别处。对服务短查询的副本,那是一笔好交易,打开它使最常见的一类取消消失。
反对它的理由需要更长时间陈述。
成本转移到主节点
一个在打开反馈的副本上长运行的查询,就主节点上的 vacuum 而言,是主节点上的一个长运行事务。死行在它后面累积。它们在每个数据库的每张表中累积,不仅在查询读取的表中,因为报告的 xmin 拖住整个服务器的清理。(在这一点上,文档的安慰太慷慨了。主节点上的事务只在其自己的数据库中拖住清理。)副本上一个六小时的报告是主节点上六小时量的死行,vacuum 无法移除。表和索引增长,主节点上扫描它们的查询变慢,而主节点正是副本本应保护的服务器。
这也很难看到。检查什么拖住 vacuum 的通常做法是在主节点上的 pg_stat_activity 中寻找旧事务,而那个检查结果为空,因为事务在另一台机器上。要看的地方是上面命名的两个列:
sql
SELECT application_name, backend_xmin, age(backend_xmin) AS xmin_age
FROM pg_stat_replication;
SELECT slot_name, active, xmin, age(xmin) AS xmin_age
FROM pg_replication_slots;
一个大且增长的 xmin_age 意味着一个副本在拖住清理,下一步是在那个副本上找到负责的查询。
它不防止每个冲突
反馈处理快照冲突,仅此而已。锁冲突不受影响:主节点上的 DDL 仍然会在延迟到期后取消受影响表上的副本查询。vacuum 的截断步骤也是如此。当 VACUUM 在表尾找到足够多的空页面(1,000 个,或表的十六分之一)时,它拿一个短暂的 ACCESS EXCLUSIVE 锁以便把它们归还给操作系统;那个锁在副本上被重放,并与任何读取该表的查询冲突。这是打开 hot_standby_feedback 后取消继续时的通常解释,而 confl_lock 将是上升的计数器。补救是禁用截断,要么用 vacuum_truncate 存储参数按表,要么从 PostgreSQL 18 起用 vacuum_truncate 设置对整个服务器。缓冲区 pin 冲突同样不受反馈影响。
它只在副本连接时有效
反馈是活动连接的属性。如果副本断开(网络中断、主节点重启),主节点停止代表它持有行,vacuum 在没有它的情况下继续。副本重连时在途的查询暴露于反馈本要防止的完全相同的冲突。复制槽关闭那个缺口,因为槽在副本离开时在主节点上保留副本的 xmin。
然后槽造成相反的危险。一个退役的副本,或一个失败且从未被带回来的副本,留下一个槽,继续持有它的 WAL 位置和它的 xmin。主节点累积 WAL 直到磁盘满,以及死行直到有人注意到。max_slot_wal_keep_size 限制一个槽可以保留的 WAL,而 PostgreSQL 18 添加了 idle_replication_slot_timeout,使不活跃太久的槽无效。设置一个或两个,并无论如何对不活跃槽告警。
选择
对复制数据运行长查询的成本必须落在某处,而 hot_standby_feedback 选择哪里。关闭它,副本付,以被取消的查询或延迟的形式,根据延迟设置。打开它,主节点付,以膨胀的形式。对每个副本要问的问题是它能最好地负担哪一个,而答案来自副本是为了什么。
一个为被提升而存在的副本应该不运行任何长东西。 完全让报告查询远离它,并给它一个短的 max_standby_streaming_delay,以便它保持最新。
一个服务应用程序普通读取流量、查询在毫秒或秒内完成的副本应该有 hot_standby_feedback = on 和默认延迟。 在那个副本的配置中设置 statement_timeout 和 idle_in_transaction_session_timeout,这样单个失控查询或被遗弃的会话不能拖住主节点上的清理超过你决定允许的时间。这是我推荐作为大多数安装起点的配置。
一个用于报告和分析、查询运行数分钟到数小时的副本应该有 hot_standby_feedback = off 和一个长的 max_standby_streaming_delay,最多到 -1。 成本然后作为延迟落在那个副本上,唯一受影响的人是运行报告的人。它不应该是一个故障转移候选,而任何需要当前数据的东西都不应该从它读取。主节点上的膨胀由数据库的每个用户支付。报告副本上的延迟由报告支付。
这些是三台不同的服务器。一个被要求做全部三份工作的副本会把每一份都做得很糟,而故障转移那份会在最糟糕的时刻受损。
(我通过 PGX Inc. 提供 PostgreSQL 咨询服务。)
相关
- PostgreSQL 中的二进制只读副本,第二部分
- 你的所有 GUC 排成一行:max_slot_wal_keep_size
- 你的所有 GUC 排成一行:hot_standby