Language:Chinese VersionEnglish Version

数据库复制比你想象的更有趣

复制是数据库在硬件故障时保持可用、在多个服务器间分配读取负载以及从灾难中恢复的方式。大多数开发者了解基础知识 — 主/从复制、只读副本、故障转移。但这个领域已经显著发展。逻辑复制、变更数据捕获(CDC)和主动-主动配置已经从仅限企业使用的功能变成了每月50美元数据库实例上可用的功能。全面了解整个情况有助于你做出更好的架构决策并避免昂贵的错误。

物理复制:基础

物理(流式)复制将原始二进制数据更改从主库复制到从库 — 它在存储级别复制所有内容,无论数据库对象如何。在PostgreSQL中,这是WAL(预写日志)传输。

# 主库上的postgresql.conf
wal_level = replica
max_wal_senders = 10
wal_keep_size = 1GB
hot_standby = on

# 主库上的pg_hba.conf — 允许从库连接
host  replication  replicator  10.0.1.5/32  md5
# 在从库上:从主库创建基础备份
pg_basebackup 
  -h primary-db 
  -U replicator 
  -D /var/lib/postgresql/16/main 
  -P 
  -Xs 
  -R  # --write-recovery-conf: 自动创建standby.signal

然后从库会持续流式传输WAL更改。借助PostgreSQL的热备功能,从库可以在跟随主库的同时处理读取查询。

物理复制限制

  • 从库必须运行与主库相同的PostgreSQL主版本
  • 从库默认是只读的(热备模式)
  • 无法复制到不同的数据库引擎
  • 无法复制表的子集
  • 整个集群都被复制 — 无法排除高频率变更的表

逻辑复制:精确与灵活

逻辑复制将WAL更改解码为行级操作(INSERT、UPDATE、DELETE)并在订阅者上重放它们。这为你提供了更大的灵活性:

  • 复制特定的表,而不是整个集群
  • 复制到不同的 PostgreSQL 主版本
  • 过滤正在复制的行
  • 在复制过程中转换数据
  • 复制到非 PostgreSQL 目标(通过 CDC 消费者)
-- 在发布者(主库)上
-- postgresql.conf: wal_level = logical

-- 为特定表创建发布
CREATE PUBLICATION user_data_pub
  FOR TABLE users, orders, payments
  WITH (publish = 'insert, update, delete');

-- 为所有表创建发布
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
-- 在订阅者(副本)上
CREATE SUBSCRIPTION user_data_sub
  CONNECTION 'host=primary-db port=5432 dbname=myapp user=replicator password=secret'
  PUBLICATION user_data_pub
  WITH (
    connect = true,
    enabled = true,
    copy_data = true,  -- 初始数据同步
    create_slot = true
  );

-- 检查复制状态
SELECT * FROM pg_stat_subscription;
SELECT * FROM pg_replication_slots;

几乎零停机的主版本升级

逻辑复制为 PostgreSQL 主版本之间提供了一条实用的迁移路径,且停机时间极短:

  1. 设置一个运行 PG 17 的新集群,作为你的 PG 16 主库的逻辑订阅者
  2. 让它同步并追上(根据数据量可能需要数小时/天)
  3. 在维护窗口期间:停止向 PG 16 写入,验证 PG 17 已完全追上,提升 PG 17,更新应用连接字符串
  4. 停机时间:通常不到 1 分钟

变更数据捕获:将您的数据库转变为事件流

CDC 通过将每个数据库更改视为其他系统可以消费的事件,进一步扩展了逻辑复制的功能。与应用程序在数据库写入后显式发布事件到 Kafka 不同,数据库本身成为了事件的真相来源。

标准设置使用 Debezium,这是一个开源的 CDC 工具,它读取 PostgreSQL WAL 并发布到 Kafka:

-- postgresql.conf: wal_level = logical
-- 为 Debezium 创建复制槽
SELECT pg_create_logical_replication_slot(
  'debezium_slot',
  'pgoutput'
);
# Debezium 连接器配置(Kafka Connect)
{
  "name": "postgres-source-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "primary-db",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "secret",
    "database.dbname": "myapp",
    "database.server.name": "myapp",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_slot",
    "table.include.list": "public.orders,public.payments",
    "transforms": "unwrap",
    "transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
    "transforms.unwrap.delete.handling.mode": "rewrite",
    "topic.prefix": "myapp"
  }
}

orders 表中的每一行变更都会成为 myapp.public.orders 主题上的 Kafka 消息:

{
  "before": null,
  "after": {
    "id": 9821,
    "user_id": 4821,
    "status": "completed",
    "total_cents": 4999,
    "updated_at": 1742041234000000
  },
  "op": "c",  // c=create, u=update, d=delete
  "ts_ms": 1742041234123,
  "transaction": {
    "id": "7:45982012",
    "total_order": 1,
    "data_collection_order": 1
  }
}

证明设置复杂性的 CDC 用例

  • 缓存失效:当底层数据库行发生变化时使 Redis 缓存条目失效 — 而无需在应用程序代码中混入缓存逻辑
  • 搜索索引更新:自动将变更流式传输到 Elasticsearch 或 Typesense
  • 事件溯源而无事件溯源开销:使用现有数据库作为事实来源,从变更中派生事件
  • 审计日志:捕获敏感表的每次变更,包含完整的前后值
  • 跨服务数据同步:将特定表复制到单独的分析数据库,而无需在应用程序代码中进行双写

主动-主动复制:难题所在

物理复制和逻辑复制都是主动-被动的:一个主节点接受写入,副本节点跟随。主动-主动(多主)复制允许多个节点进行写入,变更双向复制。这听起来很有吸引力,但存在一个根本问题:写入冲突。

当节点 A 和节点 B 同时更新同一行时会发生什么?冲突解决策略:

  • 最后写入获胜:具有最新时间戳的变更获胜。简单,但可能会静默丢弃写入。
  • 最先写入获胜:最先到达的变更获胜。在某些用例中更安全。
  • 应用程序级解决:您的应用程序定义合并逻辑。复杂但正确。

Citus(用于水平分片)和 CockroachDB(用于地理分布式主动-主动)通过不同的权衡解决了这个问题:

-- CockroachDB: 跨区域的主动-主动复制
-- 多区域无需配置 — 内置于 SQL 层中

-- 设置表的主区域并容忍区域故障
ALTER TABLE orders SET LOCALITY REGIONAL BY ROW;

-- 查询自动路由到最近的区域
-- 涉及"us-east"中行的写入会发送到 us-east 节点

监控复制延迟:关键指标

复制延迟是指在主库上写入操作与其在副本上出现之间的延迟。对于面向用户查询的只读副本,过时数据是一个正确性问题。请监控它:

-- 在主库上:检查每个副本的复制延迟
SELECT
  application_name,
  client_addr,
  state,
  sent_lsn,
  write_lsn,
  flush_lsn,
  replay_lsn,
  write_lag,
  flush_lag,
  replay_lag,
  sync_state
FROM pg_stat_replication;

-- 在副本上:检查它落后多远
SELECT
  now() - pg_last_xact_replay_timestamp() AS replication_lag,
  pg_is_in_recovery() AS is_replica;
# 用于复制延迟的 Prometheus 告警规则
- alert: PostgresReplicationLagHigh
  expr: pg_replication_lag_seconds > 30
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "在 {{ $labels.instance }} 上复制延迟为 {{ $value }}s"

选择合适的复制策略

基于您需求的决策框架:

  • 读取扩展 + 高可用故障转移:物理流复制。简单、成熟,内置在 Postgres 中。
  • 主版本升级且最小停机时间:版本间的逻辑复制。
  • 选择性表复制:使用发布物的逻辑复制。
  • 数据库作为事件源:使用 Debezium + Kafka 的 CDC。
  • 多区域主动-主动:如果需要,可以使用 CockroachDB 或 Spanner;大多数团队不需要。
  • 分析工作负载分离:CDC 到读取优化的存储(Redshift、BigQuery、ClickHouse)。

大多数中小型应用程序只需要一件事:流式物理复制到热备用服务器以实现高可用性。当您有特定的多系统一致性要求时,逻辑复制和 CDC 的复杂性才是合理的。从简单开始,只有当您有更简单方法无法解决的具体问题时,才增加复杂性。

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *

You missed