

# 在 RDS for PostgreSQL 中避免 REPLICA IDENTITY FULL 的性能问题
<a name="PostgreSQL.ReplicaIdentityFull"></a>

PostgreSQL 逻辑复制要求每个已发布的表都具有*复制标识*，以便订阅用户可以找到正确的行进行更新或删除。默认情况下，主键用作复制标识。当表没有主键或合适的唯一索引时，您可以将复制标识设置为 `FULL`，这会导致 PostgreSQL 使用整行作为键。

虽然 `REPLICA IDENTITY FULL` 解决了复制没有主键的表的暂时问题，但它可能会给发布者和订阅用户带来严重的性能问题。对于任何使用 RDS for PostgreSQL 的逻辑复制的用户而言，包括蓝绿部署等内部依赖逻辑复制的功能，了解这些影响都非常重要。

## REPLICA IDENTITY FULL 会导致问题的原因
<a name="PostgreSQL.ReplicaIdentityFull.WhyProblems"></a>

### 发布者上的 WAL 量增加
<a name="PostgreSQL.ReplicaIdentityFull.WALVolume"></a>

`REPLICA IDENTITY` 设置控制 PostgreSQL 向预写日志（WAL）中写入哪些信息来标识更新或删除的行。使用默认复制标识（主键），只会将键列作为旧的行标识进行记录。使用 `FULL`，对于每个 `UPDATE` 和 `DELETE`，PostgreSQL 会记录*每个*列的旧值。这会带来几个后果：
+ **WAL 大小显著增加。**对于更新，每个 WAL 记录的大小大约翻了一番，因为每列的旧值和新值都会被记录。如果表中包含使用 [TOAST](https://www.postgresql.org/docs/current/storage-toast.html) 存储的较大值，则增量可能会大得多，因为 TOAST 值即使在更新过程中没有修改，也必须将其提取并写入 WAL。
+ **发布者上较高的 I/O 和 CPU 使用率。**额外的 WAL 写入会消耗更多的磁盘 I/O 带宽和 CPU 周期，对于写入密集型工作负载尤其如此。
+ **向订阅用户发送较多数据。**发布者必须通过网络向每个订阅用户传输较大的 WAL 记录，这会增加带宽消耗。

### 订阅用户的行查找速度缓慢
<a name="PostgreSQL.ReplicaIdentityFull.SlowLookups"></a>

当订阅用户收到 `UPDATE` 或 `DELETE` 日志记录时，必须在表的本地副本中查找匹配的行。使用 `REPLICA IDENTITY FULL` 时，订阅用户搜索与旧行映像中*所有*列值相匹配的行。

PostgreSQL 执行此搜索的方式因 PostgreSQL 主要版本而异：
+ **在 PostgreSQL 16 之前：**如果表没有主键，也没有明确配置的复制标识索引，对于每个 `UPDATE` 或 `DELETE` 操作，订阅用户对整个表执行顺序扫描。在大型表上，这使得应用时的性能极其缓慢。
+ **PostgreSQL 16 及更高版本**：订阅用户可以使用 btree 或哈希索引来查找行，即使该索引未明确设置为复制标识。但是，订阅用户不会评估哪个索引效率最高。[从版本 16 开始](https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=89e46da5e)，PostgreSQL 会选择找到的[第一个合适的索引](https://www.postgresql.org/docs/18/logical-replication-publication.html#LOGICAL-REPLICATION-PUBLICATION-REPLICA-IDENTITY)，用户无法控制这个选择。如果所选索引的选择性较低（例如，布尔值列或状态列上的索引），则行查找的速度可能会几乎与顺序扫描一样慢。因此，依靠 `REPLICA IDENTITY FULL` 的隐式索引选择并不可靠，应将其视为后备配置，而不是推荐的配置。

### REPLICA IDENTITY FULL 如何导致复制延迟
<a name="PostgreSQL.ReplicaIdentityFull.ReplicationLag"></a>

上面描述的两个问题（发布者上的 WAL 较大，订阅用户上的行查找速度较慢）共同导致了复制延迟。

默认情况下，PostgreSQL 逻辑复制在每个订阅中使用单个*应用工作线程*进程，用来接收来自发布者的更改并将其应用到订阅用户的表。应用工作线程按提交顺序连续处理更改，每次处理一行。这意味着订阅用户的吞吐量受限于应用每个单独更改的速度。

在没有适当索引的表上设置 `REPLICA IDENTITY FULL` 时，每个 `UPDATE` 和 `DELETE` 都需要对整个表进行顺序扫描来查找匹配的行。如果表有数百万行，则每个操作都可能需要几秒钟或更长时间。由此造成了一个连锁问题：

1. **发布者生成更改的速度，快于订阅用户应用更改的速度。**发布者的写入工作负载继续以正常速度运行，但是订阅用户的应用工作线程由于顺序扫描或者用于每个行查找的索引选择性不佳，遇到了瓶颈。

1. **WAL 会在发布者一端累积，并可能耗尽存储空间。**在订阅用户确认已应用 WAL 分段之前，PostgreSQL 无法回收这些分段。当订阅用户进一步落后时，发布者的磁盘上继续累积 WAL。在 RDS for PostgreSQL 上，这表现为 CloudWatch 中的 `OldestReplicationSlotLag` 持续增长。在严重的情况下，这可能会占用所有可用存储空间，并导致发布者停止接受写入。

1. **这种延迟会自行增加。**当订阅用户落后时，订阅者上的表会继续随着复制的插入而增长，从而使每次顺序扫描变得更慢。如果没有干预，延迟就会无限制地扩大。

对于经常接收 `UPDATE` 或 `DELETE` 操作的表，这个问题尤其严重。`INSERT` 操作不会受到影响，因为它们不需要订阅用户上的行查找。

**注意**  
从 PostgreSQL 16 开始，对于大型流式事务，应用工作线程可以使用并行应用，这有助于提高吞吐量。但是，对于没有索引的 `REPLICA IDENTITY FULL`，底层的行查找瓶颈仍然存在，因为每个单独的行仍然需要扫描才能定位。

### 对蓝绿部署的影响
<a name="PostgreSQL.ReplicaIdentityFull.BlueGreen"></a>

Amazon RDS 中的蓝绿部署在内部使用逻辑复制，通过为每个数据库设置一个订阅，使绿色环境与蓝色环境保持同步。绿色环境中的逻辑复制 *apply process* 是单线程的。单个应用工作线程接收来自蓝色环境的所有更改，并按提交顺序逐个应用这些更改。蓝/绿复制路径中没有并行应用。

这种单线程设计意味着绿色环境能否跟上蓝色环境，完全取决于一个应用工作线程处理每个单独更改的速度。当表在没有主键或适当索引的情况下使用 `REPLICA IDENTITY FULL` 时，对应用工作线程的影响取决于 PostgreSQL 版本。在版本 16 之前的版本中，这些表上的每个 `UPDATE` 和 `DELETE` 都会强制要求应用工作线程对整个表执行顺序扫描来查找匹配的行。在版本 16 及更高版本中，如果有合适的索引，PostgreSQL 将使用[合适的索引](https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=89e46da5e)，但是如果没有符合条件的索引，则应用工作线程仍会回退到顺序扫描。当应用工作线程扫描一个大表来查找一行时，所有表中的所有其他待处理更改都会排队等待。

对蓝绿部署造成的后果非常严重：
+ **复制延迟持续增加。**如果蓝色环境生成写入流量的速度超过了单个应用工作线程处理写入流量的速度，则绿色环境会越来越落后。由于应用工作线程是单线程的，因此无法通过并行处理来加速追赶。
+ **切换可能会处于阻塞状态。**蓝/绿切换要求绿色环境与蓝色环境完全同步。如果复制延迟过高，就无法在超时时限内完成切换。
+ **绿色环境可能永远无法与蓝色环境实现同步。**在使用 `REPLICA IDENTITY FULL` 且没有索引的大型表上，写入密集型工作负载的应用速率可能非常慢，导致绿色环境永久落后，因此如果不先解决复制标识配置的问题，就无法进行切换。
+ **WAL 在蓝色环境中积累。**当绿色环境落后时，蓝色环境就需要为复制槽保留 WAL 分段。这会增加蓝色（生产）环境的存储使用量，并可能影响生产性能。

为避免这些问题，在创建蓝绿部署*之前*，请确保已经使用 `ALTER TABLE ... REPLICA IDENTITY USING INDEX`，为所有表明确配置了主键或合适的唯一索引作为复制标识。不要依赖于 PostgreSQL 16\+ 中的 `REPLICA IDENTITY FULL` 和隐式索引选择，因为订阅用户选择的索引可能选择性较差，或者会回退到顺序扫描。使用具有代表性的写入工作负载测试部署，确认绿色环境可以跟上。

有关蓝绿部署限制的更多信息，请参阅 [Amazon RDS 蓝绿部署的限制和注意事项](blue-green-deployments-considerations.md)。有关最佳实践，请参阅[RDS for PostgreSQL 蓝绿部署的最佳实践](blue-green-deployments-best-practices.md#blue-green-deployments-best-practices-postgres)。

## 如何使用 REPLICA IDENTITY FULL 来标识表
<a name="PostgreSQL.ReplicaIdentityFull.Identify"></a>

运行以下查询，查找所有具有 `REPLICA IDENTITY FULL` 的表：

```
SELECT n.nspname AS schema, c.relname AS table_name, c.relreplident
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
  AND c.relreplident = 'f'
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
ORDER BY n.nspname, c.relname;
```

`relreplident` 列值为：
+ `d`：默认（主键）
+ `n`：无
+ `f`：完整（整行）
+ `i`：特定索引

## 解决方法和最佳实践
<a name="PostgreSQL.ReplicaIdentityFull.Workarounds"></a>

### 只要可能就添加主键
<a name="PostgreSQL.ReplicaIdentityFull.AddPrimaryKey"></a>

最有效的解决方案是为缺少主键的表添加主键。存在主键时，PostgreSQL 会将其用作默认复制标识，从而在订阅用户上提供高效的行查找，并最大限度地减少发布者的 WAL 开销。

```
ALTER TABLE my_table ADD COLUMN id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY;
```

**重要**  
此语句获取 `ACCESS EXCLUSIVE` 锁并重写整个表，因为默认值表达式使用的 `nextval()` 是易失的。在重写期间，对表的所有读取和写入都将被阻止。在大型表上，这可能会导致大量的停机时间。将此更改规划在维护时段内进行，或者考虑其他方法，例如先将列创建为可为空，然后回填列并在单独的步骤中添加约束条件。

如果由于应用程序限制而无法添加主键，请考虑在一组 `NOT NULL` 列上添加唯一索引并将其设置为复制标识：

```
CREATE UNIQUE INDEX my_table_replica_idx ON my_table (col1, col2);
ALTER TABLE my_table REPLICA IDENTITY USING INDEX my_table_replica_idx;
```

**注意**  
为避免在构建索引时阻塞写入，请使用 [https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY](https://www.postgresql.org/docs/current/sql-createindex.html#SQL-CREATEINDEX-CONCURRENTLY) 子句：`CREATE UNIQUE INDEX CONCURRENTLY my_table_replica_idx ON my_table (col1, col2);`

**注意**  
用于复制标识的索引必须唯一，不能是部分索引，也不能是可延迟的，并且必须仅包含具有 `NOT NULL` 约束的列。

### 不要依赖隐式索引选择（PostgreSQL 16\+）
<a name="PostgreSQL.ReplicaIdentityFull.SubscriberIndexes"></a>

从 PostgreSQL 16 开始，当复制标识设置为 `FULL` 时，订阅用户的应用工作线程可以使用 btree 或哈希索引来查找行，即使这些索引没有明确配置为复制标识。在某些情况下这会阻止顺序扫描，依赖这种隐式行为是一种反面模式，原因如下：
+ **您无法控制选择哪个索引。**PostgreSQL 按目录顺序选择找到的第一个符合条件的索引，而不是最具选择性或效率最高的索引。如果表中有多个符合条件的索引，则选定索引的选择性可能较低，从而导致查找性能不佳。
+ **这种行为很不可靠。**添加、删除或重建索引可能会更改应用工作线程使用的索引，可能导致复制过程出现意外的性能下降。
+ **这会掩盖底层的问题。**没有主键或显式复制标识的表，本质上存在逻辑复制风险。依靠隐式索引选择可以推迟问题发生，但不能解决问题。

相反，您应为每个复制的表显式配置复制标识：
+ **最佳选项：**添加主键。这是最可靠、最高效的复制标识。
+ **备选方案：**使用 `ALTER TABLE ... REPLICA IDENTITY USING INDEX` 来指定仅包含 `NOT NULL` 列的唯一、非部分、不可延迟的特定索引。这使您可以明确控制哪些列用于行标识。

只有在这些选项都不可行时，才为表保留 `REPLICA IDENTITY FULL`，并且您需要知道，此时性能取决于您无法直接控制的因素。

### 监控复制延迟
<a name="PostgreSQL.ReplicaIdentityFull.MonitorLag"></a>

使用 `REPLICA IDENTITY FULL` 时，请密切监控复制延迟，以便在订阅用户应用速度缓慢的情况变得严重之前检测到问题。

**在发布者一端**，检查当前 WAL 位置与订阅用户确认的位置之间的延迟：

```
SELECT slot_name, confirmed_flush_lsn, pg_current_wal_lsn(),
       (pg_current_wal_lsn() - confirmed_flush_lsn) AS lag_bytes
FROM pg_replication_slots
WHERE slot_type = 'logical';
```

稳步增长的 `lag_bytes` 值表明订阅用户正在落后。`pg_stat_replication_slots` 视图提供了有关每个复制槽使用情况的更多统计信息。

**在订阅用户一端**，`pg_stat_subscription` 视图显示每个应用工作线程的状态，包括上次收到和报告的 WAL 位置：

```
SELECT subname, received_lsn, latest_end_lsn,
       last_msg_send_time, last_msg_receipt_time
FROM pg_stat_subscription;
```

**注意**  
在 PostgreSQL 16 及更高版本中，您还可以选择 `worker_type` 来区分主应用工作线程和并行应用工作线程。

`received_lsn` 和 `latest_end_lsn` 之间的巨大差距或者 `last_msg_send_time` 中存在过时的时间戳，可能表明应用工作线程正在努力跟上节奏。`pg_stat_subscription_stats` 视图还会跟踪可能导致延迟的应用错误和冲突。

**对于 RDS for PostgreSQL**，您还可以监控 `OldestReplicationSlotLag` CloudWatch 指标，该指标以字节为单位，跟踪最落后于复制槽的延迟。值上升是复制延迟的警告信号。

**检查哪些表在应用期间可能使用了次优索引**

在订阅用户一端，您可以识别应用工作线程在执行过多堆读取的表，这可能表明该表在应用期间没有高效的索引用于行查找。在订阅用户一端运行以下查询：

```
SELECT relname, heap_blks_read, heap_blks_hit,
       idx_blks_read, idx_blks_hit,
       heap_blks_read + heap_blks_hit AS total_heap_access
FROM pg_statio_user_tables
WHERE heap_blks_read > 0
ORDER BY heap_blks_read DESC
LIMIT 10;
```

如果表的 `heap_blks_read` 值相对于 `idx_blks_read` 较高，则表明对于 `UPDATE` 和 `DELETE` 操作，应用工作线程没有使用有效的索引来查找行。这是使用 `REPLICA IDENTITY FULL` 时造成复制延迟的常见原因。

**注意**  
此查询要求在订阅用户上启用 [https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-COUNTS](https://www.postgresql.org/docs/current/runtime-config-statistics.html#GUC-TRACK-COUNTS) 参数。默认情况下，该参数为 on。

### 评估是否需要 REPLICA IDENTITY FULL
<a name="PostgreSQL.ReplicaIdentityFull.Evaluate"></a>

在设置 `REPLICA IDENTITY FULL` 之前，请考虑您是否真的需要它。使用它的常见原因包括：
+ 表没有主键或唯一索引。
+ 对于更改数据捕获（CDC）使用方，您需要行的完整前映像。
+ 对于不修改这些列的更新，您需要在复制事件中包含 TOAST 列值。

如果唯一的原因是缺少主键，那么添加一个主键几乎总是更好的方法。如果您需要 CDC 的完整前映像，请考虑您的 CDC 使用方是否可以通过在外部维护状态来重建整行，这样可以避免 `REPLICA IDENTITY FULL` 的 WAL 和订阅用户开销。

## 建议总结
<a name="PostgreSQL.ReplicaIdentityFull.Summary"></a>


| 场景 | 建议 | 
| --- | --- | 
| 表具有主键 | 使用默认复制标识（无需执行任何操作） | 
| 表具有唯一的 NOT NULL 索引 | 使用 ALTER TABLE ... REPLICA IDENTITY USING INDEX 将索引设置为复制标识 | 
| 表没有合适的键（PostgreSQL 16\+） | 添加主键或唯一索引。将 REPLICA IDENTITY FULL 与隐式索引选择一起使用并不可靠，应作为最后的手段 | 
| 表没有合适的键（PostgreSQL 16 之前） | 添加主键或唯一索引；尽可能避免 REPLICA IDENTITY FULL | 
| 写入密集型工作负载，具有较大/TOAST 列 | 避免由于 WAL 量放大造成的 REPLICA IDENTITY FULL | 