在 PostgreSQL 逻辑复制中,流式传输的事务(或更改)通常都会带着它们的提交时间戳(commit timestamp)和提交顺序。接收端(订阅者)在应用这些更改时,会根据提交时间戳来排序,并按顺序来应用事务。
在数据库复制环境中,特别是在 PostgreSQL 的逻辑复制中,处理延迟事务时会出现冲突,主要是在订阅端(Subscriber)尝试应用发布端(Publisher)的更改时。
当一个事务从发布端复制到订阅端时,它可能会遇到以下几种情况,导致冲突:
这是最常见的情况。一个事务在发布端插入了一条记录,但在这条记录到达订阅端并被应用之前,订阅端的一个本地事务也插入了拥有相同主键值的记录。当复制事务尝试插入时,就会发生唯一约束冲突。
当发布端的事务以某种顺序操作了多张表(比如先插入父表,再插入子表),但这些操作到达订阅端时,如果订阅端的本地事务修改了相关数据,可能导致外键约束被违反。例如,发布端先删除了父表记录,然后删除了子表记录。但在复制到订阅端时,如果子表记录还存在,而父表记录已经被删除,就可能导致外键约束冲突。
当发布端和订阅端的本地事务同时修改同一行数据时,就会发生数据冲突。
创建订阅时, 可指定一个选项: retain_dead_tuples 正是为了解决这类冲突而设计的。它让订阅端保留旧版本的死元组(dead tuple),以便在应用来自发布端的事务时,可以利用这些旧数据进行更精确的冲突检测,甚至尝试自动解决某些冲突。
这个功能相关的patch如下:
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=228c370868
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=fd5a1a0c3e566f7fc860838084466a1c25944281
来看一下这个例子:
假设我们有一个发布者和一个订阅者,在 products 表上进行复制。
products 表 | id | name | price |
|---|---|---|---|
初始状态 | 101 | 'Laptop' | 1200 |
1. 发布者端的事务 (Tx-A):
T1 提交:UPDATE products SET price = 1500 WHERE id = 101;2. 订阅者端的本地事务 (Tx-Local):
T2(T2 > T1),由于某些应用逻辑,订阅端本地的一个事务也对同一行进行了操作。DELETE FROM products WHERE id = 101;3. 复制滞后:
Tx-A(更新操作)由于网络或系统繁忙而发生了复制滞后。它在订阅端本地的 Tx-Local 事务提交之后才到达。在订阅者端,实际发生的操作顺序是:
Tx-Local,删除 id=101 的行。Tx-A 终于到达了。复制机制尝试应用 UPDATE products SET price = 1500 WHERE id = 101;。如果没有 retain_dead_tuples:
id=101 的行已经不存在了。它无法执行更新,因为目标行已不存在。这通常会导致一个冲突。有了 retain_dead_tuples:
Tx-Local 在订阅端删除 id=101 的行时,由于启用了 retain_dead_tuples,这个被删除的元组不会被立即清理,而是作为“死元组”保留下来。Tx-A 到达时,复制机制会首先检查这个更新操作所依赖的行(即 id=101)。总结:
你的观点完全正确,复制流的顺序是有保证的。retain_dead_tuples 解决的不是网络传输中的乱序问题,而是由于复制滞后,导致发布端一个更早的事务与订阅端一个更晚的本地事务之间发生数据不一致时产生的冲突。它通过保留死元组,提供了一个“时间窗口”,让系统有足够的信息来处理这些“时间差”带来的数据冲突。
在启用 retain_dead_tuples 选项时,如果应用工作进程(apply worker)落后于发布者,会导致“死元组”(dead tuples)过度累积的问题。这会消耗大量的存储空间,并可能导致性能问题。
为了解决这个问题, PostgreSQL 又提供了一个patch:
https://git.postgresql.org/gitweb/?p=postgresql.git;a=commit;h=a850be2fe653b3b529969946c1cefe0fd9e34a8d
这个补丁在 PostgreSQL 的订阅中增加了一个新的 max_retention_duration 选项。
解决了什么问题?
它解决了由于复制滞后而导致的死元组在订阅端过度积累的问题。当 retain_dead_tuples 启用时,订阅端会保留旧的元组版本以供冲突检测,但在复制滞后严重时,这些旧元组会不断堆积,占用大量磁盘空间。max_retention_duration 选项提供了一个机制来自动清理这些旧元组,从而防止订阅端磁盘空间被耗尽、也能防止性能问题(autovacuum 触发垃圾回收, 空转, CPU飙高)。 类似: 《PostgreSQL物理"备库"的哪些操作或配置(例如hot_standby_feedback),可能影响"主库"的性能、垃圾回收、IO波动》
如何使用这个功能?
这个功能可以在创建订阅时使用 CREATE SUBSCRIPTION 命令来启用,或者通过 ALTER SUBSCRIPTION 命令来修改已有的订阅。
创建订阅时使用:
CREATE SUBSCRIPTION my_sub CONNECTION 'dbname=pub host=... user=...' PUBLICATION pub_name WITH (retain_dead_tuples = true, max_retention_duration = '1 day');
修改订阅时使用:
ALTER SUBSCRIPTION my_sub SET (max_retention_duration = '24 hours');
当 max_retention_duration 被设置为一个非零值时,它会限制死元组的保留时长。如果时间超过这个值,订阅端将停止保留冲突检测信息。
最佳实践
retain_dead_tuples: 只有在启用了 retain_dead_tuples 选项后,max_retention_duration 选项才会有作用。max_retention_duration 的值应该根据你的工作负载和磁盘空间来设置。如果你的系统对复制滞后很敏感,或者磁盘空间有限,可以设置一个较短的时间。max_retention_duration,冲突检测信息将不再保留。这可能导致在处理延迟事务时出现冲突(也许需要人工解决冲突)。如果需要重新启用完整的冲突检测,你或许需要重新创建一个新的订阅(需要全量同步发布端的数据, 可能丢失本地事务对订阅表产生的修改(可能需要先保留旧的订阅表, 然后需要一些merge操作合并新订阅表的数据, 以保留本地事务产生的修改或插入或删除!))。 或者手工设置retain_dead_tuples = true, 重新启用冲突检测(但dead tuple已不完整/连续).max_retention_duration 的设置是有效的。pg_subscription.subretentionactive 是在 PostgreSQL 16 中为逻辑复制新增的一个字段。它的作用是跟踪订阅的死元组保留状态,以确保服务器重启后,这个状态不会被错误地重置。
subretentionactive 是一个布尔值(boolean),它指示一个订阅当前是否正在保留死元组用于冲突检测。retain_dead_tuples = true 且复制正常工作时,它的值为 true。max_retention_duration 时,PostgreSQL 会停止保留死元组,并将 subretentionactive 字段设置为 false。这就是这个字段最重要的作用。
设想以下场景:
retain_dead_tuples 和 max_retention_duration。max_retention_duration 的限制。subretentionactive 设置为 false。如果没有 subretentionactive 字段:
在重启之后,订阅的状态可能会被默认地重置为“正在保留死元组”。这意味着系统会错误地认为它有足够的信息进行冲突检测,但实际上,之前由于滞后而被丢弃的历史信息并没有恢复。如果此时有非常延迟的事务到达,系统可能在缺乏必要历史数据的情况下进行冲突检测,导致不可预知的错误或冲突。
有了 subretentionactive 字段:
在服务器重启时,PostgreSQL 会读取 pg_subscription 表中的 subretentionactive 字段。如果该字段为 false,它就会知道这个订阅已经因为超过 max_retention_duration 而停止保留死元组,并继续保持这种状态。这能防止系统误以为自己有完整的历史记录,从而避免在不具备完整冲突检测能力的情况下处理延迟事务,确保系统的行为是可预测和安全的。
简单来说,subretentionactive 字段提供了一种持久化(persistent)的方式来记录订阅的“健康状态”或“保留能力”是否已经被“降级”,确保即使在服务器重启后,这种状态也能被正确地识别和维护。