
零停机迁移最容易出的事故,不是复制断了,是复制看着还在跑,主键却已经开始撞车。逻辑复制不会替你处理序列,这一件事就足够让割接当晚回滚。
下面这份流程来自一次真实的单库迁移:PostgreSQL 16.4,一张 3.2 亿行的订单表,停机窗口要求控制在 90 秒内。它的重点不是”怎么开订阅”,是”怎么保证切换那一刻两边还是同一份数据”。
1. 先把参数改对,否则后面全是白工
逻辑复制依赖 WAL 的逻辑解码,发布端必须是 wal_level=logical。这一项改完要重启,不要在割接前一小时才想起来改。
# 发布端 postgresql.conf(PG 16.4)
wal_level = logical
max_replication_slots = 10 # 至少 = 订阅数 + 预留
max_wal_senders = 10
wal_sender_timeout = 60s
# 订阅端 postgresql.conf
max_logical_replication_workers = 8
max_parallel_apply_workers_per_subscription = 4 # PG 16 才有,决定大事务回放速度
max_sync_workers_per_subscription = 4
# 改完必须重启(reload 不生效)
pg_ctl restart -D /var/lib/pgsql/16/data
订阅端的 max_parallel_apply_workers_per_subscription 是 16 版才引入的。在老版本上照抄这份配置,参数会被直接忽略,你不会收到任何报错。
改完用一条 SQL 确认,别信配置文件:
SHOW wal_level; -- 必须 logical
SELECT name, setting FROM pg_settings
WHERE name IN ('max_replication_slots','max_wal_senders');
2. 发布端:只发布需要的表
不要图省事用 FOR ALL TABLES。它会把你不想迁的日志表、队列表一起带走,而队列表上的高频 UPDATE 会持续放大 WAL 解码压力。
-- 发布端
CREATE PUBLICATION mig_pub FOR TABLE
public.orders,
public.order_items,
public.customers
WITH (publish = 'insert, update, delete, truncate');
-- 确认发布内容
SELECT schemaname, tablename FROM pg_publication_tables
WHERE pubname = 'mig_pub';
没有主键的表会被拒绝同步。这是逻辑复制最有用的一道保险:它逼你在迁移前补上主键。补不上就先用 REPLICA IDENTITY FULL,代价是每次 UPDATE 都把整行写进 WAL。
3. 订阅端:先建结构,再拉数据
订阅只同步数据,不同步表结构。DDL 必须你自己在两边各跑一遍,这是逻辑复制和物理复制最大的区别。
-- 订阅端:先用 pg_dump 拉结构,不要拉数据
pg_dump -h old-db -U app -s -t public.orders -t public.order_items \
-t public.customers appdb | psql -h new-db -U app appdb
-- 建立订阅,初始快照会自动 COPY
CREATE SUBSCRIPTION mig_sub
CONNECTION 'host=old-db port=5432 dbname=appdb user=repl password=***'
PUBLICATION mig_pub
WITH (copy_data = true, create_slot = true, enabled = true);
-- 看进度
SELECT srsubstate, count(*) FROM pg_subscription_rel GROUP BY 1;
-- r = ready 才算初始同步完成
初始 COPY 期间,发布端的写入会排在快照之后回放,所以最终一致。但如果这期间有人执行了 TRUNCATE,快照里的数据会和你以为的不一样。
4. 四个必然踩到的坑
前三个坑我在这次迁移里全踩了,第四个是同事在上一家公司踩的。
坑一:序列不同步。逻辑复制不传序列。割接后第一笔 INSERT 会拿到新库从 1 开始的 nextval,直接撞上已有主键。这不是概率问题,是必然发生。
-- 割接前对每个序列执行(在订阅端)
SELECT setval('public.orders_id_seq',
(SELECT COALESCE(MAX(id), 1) FROM public.orders) + 1000,
true);
-- 留 1000 的余量,给切换瞬间还在旧库写入的连接
坑二:复制槽把磁盘吃满。订阅端一旦停机超过一天,发布端的 WAL 会为这个槽一直保留。3.2 亿行的库,一天能攒出几百 GB。监控要盯 pg_replication_slots 的 pg_wal_lsn_diff,不是盯订阅状态。
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
坑三:唯一键冲突让订阅静默停摆。两边数据只要有一行不一致,apply 进程就会报错退出,而 pg_stat_subscription 只显示”没有 worker”。PG 16 起可以查冲突统计:
SELECT * FROM pg_stat_subscription_stats;
-- confl_insert_exists / confl_update_exists 大于 0 就是撞了
坑四:大事务把延迟拉到几小时。一次批量 UPDATE 十万行,在订阅端是串行回放的。迁移期间要和生产约定:单事务不超过 5 万行。否则你会看到延迟曲线在割接前突然翘起来。
5. 切换:把 90 秒拆成四步
真正需要停写的时间只有最后两步。前面都是准备。
- 预演三次,每次记录各步骤耗时。第三次的波动应该小于 10 秒。
- 停应用写入,等待延迟归零:
SELECT * FROM pg_stat_subscription;直到received_lsn = latest_end_lsn。 - 同步序列(见坑一),然后
ALTER SUBSCRIPTION mig_sub DISABLE;断开复制。 - 切换连接串,放开写入,抽查三张表的
count(*)与最新 10 条记录。
目录结构建议这样放,切换时不用翻聊天记录:
migration/
├── 00-precheck.sql # wal_level、主键、磁盘余量
├── 01-schema.sh # pg_dump -s
├── 02-subscribe.sql # CREATE SUBSCRIPTION
├── 03-verify.sql # 行数、校验和、延迟
├── 04-cutover.sql # 序列对齐 + DISABLE
└── 05-rollback.md # 回滚步骤,含旧库保留时长
验收不要只看行数。行数相等而内容不同,是这类迁移最常见的假成功。用主键区间做抽样校验和,比全表 count 有用得多。
-- 两边各跑一次,比对结果
SELECT sum(hashtext(id::text || coalesce(amount::text,'') )) AS chk,
count(*) AS n
FROM public.orders WHERE id BETWEEN 1000000 AND 1100000;
6. 什么时候不该用这套方案
逻辑复制要求版本不低于 10,跨大版本迁移时类型差异会咬人。从 12 迁到 16,timestamptz 的时区处理、numeric 的精度声明都可能带来偏差。跨两个大版本以上,物理复制加升级检查更稳。
另一个边界是写入模式。如果业务依赖跨库事务或临时表,逻辑复制帮不上忙。它保证的是”最终一致的表数据”,不是”一致的事务边界”。
写在最后
这次迁移最后停机 71 秒,比预算少了 19 秒。省下来的时间不是因为脚本写得好,是因为预演时发现序列那一项要花 40 秒,提前改成了并行执行。
零停机迁移的真正难点,从来不是复制技术,是把”哪些东西不会被复制”列全。序列、DDL、大对象、权限、扩展,五个清单少一项,割接当晚就要补一项。
技术在文档里写得清清楚楚,事故都发生在文档没写的地方。
你做过的那次数据库迁移,最后卡在哪一步?序列、DDL,还是某个谁也没想到的扩展?

评论 (0)