首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >烦人,逻辑复制又被写坏了?PostgreSQL 19双保险来了

烦人,逻辑复制又被写坏了?PostgreSQL 19双保险来了

作者头像
用户4035096
发布2026-07-09 19:09:03
发布2026-07-09 19:09:03
60
举报

PostgreSQL 19 - 逻辑订阅端冲突检测双保险

在 PostgreSQL 逻辑复制中,流式传输的事务(或更改)通常都会带着它们的提交时间戳(commit timestamp)和提交顺序。接收端(订阅者)在应用这些更改时,会根据提交时间戳来排序,并按顺序来应用事务。

在数据库复制环境中,特别是在 PostgreSQL 的逻辑复制中,处理延迟事务时会出现冲突,主要是在订阅端(Subscriber)尝试应用发布端(Publisher)的更改时。

当一个事务从发布端复制到订阅端时,它可能会遇到以下几种情况,导致冲突:

1. 主键(或唯一约束)冲突

这是最常见的情况。一个事务在发布端插入了一条记录,但在这条记录到达订阅端并被应用之前,订阅端的一个本地事务也插入了拥有相同主键值的记录。当复制事务尝试插入时,就会发生唯一约束冲突。

2. 外键约束冲突

当发布端的事务以某种顺序操作了多张表(比如先插入父表,再插入子表),但这些操作到达订阅端时,如果订阅端的本地事务修改了相关数据,可能导致外键约束被违反。例如,发布端先删除了父表记录,然后删除了子表记录。但在复制到订阅端时,如果子表记录还存在,而父表记录已经被删除,就可能导致外键约束冲突。

3. 数据冲突

当发布端和订阅端的本地事务同时修改同一行数据时,就会发生数据冲突。

  • 更新冲突: 发布端的事务更新了某一行,但订阅端的本地事务也更新了同一行。当复制事务到达时,它会发现这行数据已经被修改,从而无法应用它的更新。
  • 删除冲突: 发布端的事务尝试删除某一行,但订阅端的本地事务已经删除了这行。当复制事务到达时,它会发现要删除的行不存在,这通常不会导致硬错误,但在某些复制场景下可能被视为冲突。反之,如果发布端更新了一行,而订阅端删除了这一行,也会导致更新冲突。

创建订阅时, 可指定一个选项: 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):

  • 在一个更晚的时间点 T2T2 > T1),由于某些应用逻辑,订阅端本地的一个事务也对同一行进行了操作。
  • DELETE FROM products WHERE id = 101;

3. 复制滞后:

  • 此时,来自发布者端的 Tx-A(更新操作)由于网络或系统繁忙而发生了复制滞后。它在订阅端本地的 Tx-Local 事务提交之后才到达。

在订阅者端,实际发生的操作顺序是:

  1. 订阅者应用 Tx-Local,删除 id=101 的行。
  2. 来自发布者的 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)。
  • 它会发现,虽然当前表里没有这个行,但是通过查看死元组,它可以找到这条记录的旧版本,并且知道来自发布者的更新是在本地删除之前发生的(通过事务 ID 或提交时间戳)。
  • 基于这个历史信息,复制机制可以更智能地处理这个情况。它可以忽略这个更新,或者在某些更复杂的场景下,进行更高级的冲突解决。关键是,它能够利用历史数据来判断这个操作的合法性,而不是简单地因为找不到行就报错。

总结:

你的观点完全正确,复制流的顺序是有保证的。retain_dead_tuples 解决的不是网络传输中的乱序问题,而是由于复制滞后,导致发布端一个更早的事务订阅端一个更晚的本地事务之间发生数据不一致时产生的冲突。它通过保留死元组,提供了一个“时间窗口”,让系统有足够的信息来处理这些“时间差”带来的数据冲突。

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 命令来修改已有的订阅。

创建订阅时使用:

代码语言:javascript
复制
CREATE SUBSCRIPTION my_sub CONNECTION 'dbname=pub host=... user=...' PUBLICATION pub_name WITH (retain_dead_tuples = true, max_retention_duration = '1 day');  

修改订阅时使用:

代码语言:javascript
复制
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),它指示一个订阅当前是否正在保留死元组用于冲突检测。
  • 这个字段的值由 PostgreSQL 内部管理,当 retain_dead_tuples = true 且复制正常工作时,它的值为 true
  • 当复制滞后超过了你设置的 max_retention_duration 时,PostgreSQL 会停止保留死元组,并将 subretentionactive 字段设置为 false

防止重启后“误恢复”

这就是这个字段最重要的作用。

设想以下场景:

  1. 你有一个订阅,启用了 retain_dead_tuplesmax_retention_duration
  2. 复制出现严重滞后,超过了 max_retention_duration 的限制。
  3. PostgreSQL 停止保留死元组,并将 subretentionactive 设置为 false
  4. 此时,数据库服务器因为某些原因(例如维护、宕机)重启了。

如果没有 subretentionactive 字段:

在重启之后,订阅的状态可能会被默认地重置为“正在保留死元组”。这意味着系统会错误地认为它有足够的信息进行冲突检测,但实际上,之前由于滞后而被丢弃的历史信息并没有恢复。如果此时有非常延迟的事务到达,系统可能在缺乏必要历史数据的情况下进行冲突检测,导致不可预知的错误或冲突。

有了 subretentionactive 字段:

在服务器重启时,PostgreSQL 会读取 pg_subscription 表中的 subretentionactive 字段。如果该字段为 false,它就会知道这个订阅已经因为超过 max_retention_duration 而停止保留死元组,并继续保持这种状态。这能防止系统误以为自己有完整的历史记录,从而避免在不具备完整冲突检测能力的情况下处理延迟事务,确保系统的行为是可预测和安全的。

简单来说,subretentionactive 字段提供了一种持久化(persistent)的方式来记录订阅的“健康状态”或“保留能力”是否已经被“降级”,确保即使在服务器重启后,这种状态也能被正确地识别和维护。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-09-14,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 digoal德哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • PostgreSQL 19 - 逻辑订阅端冲突检测双保险
    • 1. 主键(或唯一约束)冲突
    • 2. 外键约束冲突
    • 3. 数据冲突
  • retain_dead_tuples 的问题
  • 状态字段
    • 字段的含义
    • 防止重启后“误恢复”
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档