Postgres 逻辑复制十年:用核心能力搭建 Hub—Worker 写入架构

2026-09-22 30 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

过去要在 Postgres 上实现一个中心节点加多个写入节点的系统,往往需要触发器、消息队列、定时器和常驻同步进程共同配合。Londiste 与 PgQ 能完成任务,但每增加一个节点,就多出一组需要部署、监控和向新同事解释的组件。

Postgres 10 在 2017 年引入内置逻辑复制。按 Postgres 10 到 19 的发布序列计算,这项能力已经走过十个大版本。它带来的关键变化不是简单地让复制更快,而是把过去由外部中间件承担的一部分数据管道,逐步收进了 Postgres 自身:发布哪些表、订阅哪个数据源、初始数据是否复制,都可以通过 SQL 表达。

Hub—Worker 的关键不是复制,而是数据所有权

考虑一个典型的计费系统:

  • Hub 保存客户、套餐和价格等参考数据;
  • 多个 Worker 分担应用写流量,在本地记录消费或计费事件;
  • Hub 把参考数据下发给所有 Worker;
  • Hub 再从每个 Worker 汇总事件,生成账单。

这个拓扑包含两个方向的数据流:

Hub reference tables  ──────>  Worker A / Worker B / Worker C
Hub invoice events    <──────  Worker A / Worker B / Worker C

真正让它稳定的原则是:每类数据只能有一个权威写入方

参考表只允许 Hub 修改,Worker 将其视为只读副本;事件表由各 Worker 写入,Hub 只负责汇总。这样可以避开双向写入冲突,也不需要引入多主系统中的冲突检测与合并规则。

表的选择同样重要。Hub 上的发布只包含参考表,不包含汇总后的事件表;Worker 上的发布只包含本地事件表。即使数据在两个方向流动,也不会形成事件被反复发布的复制环路。

一套可以改造的最小实现

下面是一个简化示例。假设有一台 Hub 和两个 Worker,所有节点都运行支持内置逻辑复制的 Postgres 版本,并且节点间网络可达。

在每个需要作为发布端的节点上启用逻辑复制。以下数值只是小型环境的起点,修改后需要重启 Postgres:

ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_wal_senders = 20;
ALTER SYSTEM SET max_replication_slots = 20;

可以用下面的命令确认配置:

psql -d postgres -c 'SHOW wal_level;'
psql -d postgres -c 'SHOW max_wal_senders;'
psql -d postgres -c 'SHOW max_replication_slots;'

1. 在 Hub 创建参考表和事件汇总表

CREATE TABLE customer (
    customer_id bigint PRIMARY KEY,
    name text NOT NULL
);

CREATE TABLE plan (
    plan_id bigint PRIMARY KEY,
    name text NOT NULL,
    currency text NOT NULL
);

CREATE TABLE price (
    plan_id bigint PRIMARY KEY,
    amount_cents bigint NOT NULL CHECK (amount_cents >= 0)
);

CREATE TABLE invoice_event (
    worker_id text NOT NULL,
    event_id bigint NOT NULL,
    customer_id bigint NOT NULL,
    plan_id bigint NOT NULL,
    quantity bigint NOT NULL CHECK (quantity > 0),
    occurred_at timestamptz NOT NULL,
    payload jsonb NOT NULL DEFAULT '{}'::jsonb,
    PRIMARY KEY (worker_id, event_id)
);

CREATE PUBLICATION hub_reference_pub
FOR TABLE customer, plan, price;

invoice_event 使用 (worker_id, event_id) 作为主键。因为不同 Worker 的本地序列都可能生成 event_id = 1,仅使用事件编号会在 Hub 上发生主键冲突。

2. 在每个 Worker 创建同名表

逻辑复制不会自动替你完成全部 DDL 管理,因此订阅前要先创建目标表。下面以 worker-a 为例;部署到其他节点时,需要修改默认的 Worker 名称。

CREATE TABLE customer (
    customer_id bigint PRIMARY KEY,
    name text NOT NULL
);

CREATE TABLE plan (
    plan_id bigint PRIMARY KEY,
    name text NOT NULL,
    currency text NOT NULL
);

CREATE TABLE price (
    plan_id bigint PRIMARY KEY,
    amount_cents bigint NOT NULL CHECK (amount_cents >= 0)
);

CREATE TABLE invoice_event (
    worker_id text NOT NULL DEFAULT 'worker-a',
    event_id bigint GENERATED ALWAYS AS IDENTITY,
    customer_id bigint NOT NULL,
    plan_id bigint NOT NULL,
    quantity bigint NOT NULL CHECK (quantity > 0),
    occurred_at timestamptz NOT NULL DEFAULT now(),
    payload jsonb NOT NULL DEFAULT '{}'::jsonb,
    PRIMARY KEY (worker_id, event_id),
    CHECK (worker_id = 'worker-a')
);

CREATE PUBLICATION worker_events_pub
FOR TABLE invoice_event;

随后让 Worker 订阅 Hub 的参考数据。运行前请替换主机名、数据库名、用户和密码,并在 Hub 的 pg_hba.conf 中只放行所需来源地址:

CREATE SUBSCRIPTION hub_reference_sub
CONNECTION 'host=hub.internal port=5432 dbname=billing user=repl password=change-me sslmode=require'
PUBLICATION hub_reference_pub
WITH (copy_data = true);

生产环境不要把密码直接写进版本库或共享脚本,可以改用受控的密钥注入方式。复制连接用户还需要合适的登录、复制和表读取权限。

3. 让 Hub 分别订阅每个 Worker

在 Hub 上为每个 Worker 建立独立订阅,订阅名称和复制槽也应保持唯一:

CREATE SUBSCRIPTION worker_a_events_sub
CONNECTION 'host=worker-a.internal port=5432 dbname=billing user=repl password=change-me sslmode=require'
PUBLICATION worker_events_pub
WITH (copy_data = true);

CREATE SUBSCRIPTION worker_b_events_sub
CONNECTION 'host=worker-b.internal port=5432 dbname=billing user=repl password=change-me sslmode=require'
PUBLICATION worker_events_pub
WITH (copy_data = true);

如果 Hub 的目标表已经预装过历史数据,需要先确认不会重复,再决定使用 copy_data = false。这不是一个应该凭感觉选择的开关:设置为 true 可能产生主键冲突,设置为 false 则可能漏掉订阅创建前的数据。

应用写入 Worker 时不必自己生成全局事件编号:

INSERT INTO invoice_event (
    customer_id,
    plan_id,
    quantity,
    payload
)
VALUES (
    1001,
    20,
    3,
    '{"source":"api","request_id":"req-8f31"}'::jsonb
)
RETURNING worker_id, event_id, occurred_at;

这条记录会保留 Worker 身份,并通过逻辑复制进入 Hub 的同名汇总表。

从外部管道变成 SQL,并不意味着零运维

内置逻辑复制减少了触发器、队列和 Python 守护进程,但没有消除分布式系统中的边界条件。

复制是异步的

Worker 刚写入的事件不保证立即出现在 Hub。如果账单接口要求写后立刻可见,应明确等待策略,或者直接从写入节点读取;不要把异步副本当作强一致数据库使用。

订阅停机可能导致 WAL 堆积

每个订阅通常对应发布端的复制槽。订阅端长时间不可用时,发布端可能持续保留 WAL,最终占满磁盘。可以在发布端检查槽状态和大致保留量:

SELECT
    slot_name,
    active,
    pg_size_pretty(
        pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)
    ) AS retained_wal
FROM pg_replication_slots
ORDER BY slot_name;

订阅端则可以查看:

SELECT * FROM pg_stat_subscription;

监控系统至少应覆盖复制槽是否活跃、订阅错误、延迟趋势和磁盘空间。

DDL、序列和冲突仍需设计

逻辑复制主要传递数据变化,不能假设一次 ALTER TABLE 会自动、安全地传播到所有节点。推荐先在订阅端部署兼容 DDL,再修改发布端,并把整个过程纳入迁移工具。

序列状态也不能当作自动同步的全局编号服务。本例通过 worker_id 与本地 event_id 组成复合主键,避免不同节点争用同一个序列。

如果发布表需要复制 UPDATEDELETE,必须提供可用的副本标识;主键通常是最直接的选择。事件表保持追加写,会让故障恢复、重放和审计简单很多。

什么时候核心能力已经够用

Hub—Worker 架构适合满足以下条件的系统:

  • 每张表或每类数据有明确的单一写入方;
  • 可以接受异步复制与短暂延迟;
  • 应用能够生成跨节点不冲突的键;
  • 团队愿意监控复制槽、WAL 保留和订阅状态;
  • DDL 由独立迁移流程管理。

如果需求是多地同时修改同一行、自动解决冲突、跨区域低延迟写入,或者需要完整的模式变更流,那么 Postgres 核心逻辑复制不是完整答案,还需要额外的协调层、CDC 工具或专门的多活方案。

十个大版本带来的实际价值,是让越来越多原本需要专用复制框架的架构可以用表、发布、订阅和几条 SQL 表达出来。组件变少之后,系统并不会自动变简单;但数据所有权、复制方向和故障边界会更容易被看见,也更容易被团队长期维护。


相关推荐