过去要在 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 组成复合主键,避免不同节点争用同一个序列。
如果发布表需要复制 UPDATE 或 DELETE,必须提供可用的副本标识;主键通常是最直接的选择。事件表保持追加写,会让故障恢复、重放和审计简单很多。
什么时候核心能力已经够用
Hub—Worker 架构适合满足以下条件的系统:
- 每张表或每类数据有明确的单一写入方;
- 可以接受异步复制与短暂延迟;
- 应用能够生成跨节点不冲突的键;
- 团队愿意监控复制槽、WAL 保留和订阅状态;
- DDL 由独立迁移流程管理。
如果需求是多地同时修改同一行、自动解决冲突、跨区域低延迟写入,或者需要完整的模式变更流,那么 Postgres 核心逻辑复制不是完整答案,还需要额外的协调层、CDC 工具或专门的多活方案。
十个大版本带来的实际价值,是让越来越多原本需要专用复制框架的架构可以用表、发布、订阅和几条 SQL 表达出来。组件变少之后,系统并不会自动变简单;但数据所有权、复制方向和故障边界会更容易被看见,也更容易被团队长期维护。