新集群已经部署好了,数据同步也跑起来了,接下来就是切换(cutover)——把流量从旧集群切到新集群。很多人把切换当成迁移的最后一步,到了那个节点才去想怎么做。但实际上,切换策略决定了你怎么迁移,而不是反过来。它影响你怎么配复制、预留多少 replication slot、怎么处理 schema 变更、以及回滚路径长什么样。所以在搭复制之前,先把切换策略定下来。
本文对比两种最常见的切换策略:逐库切换和整体切换,看看各自付出什么代价、换来什么收益。
逐库切换:一个库稳了再动下一个
想象搬办公室——先搬财务部,下周再搬工程部。每个部门安顿好了再搬下一个,出了问题也只影响一个部门。逐库切换就是这个思路:挑一个库,迁移、测试、切换、确认没问题,再动下一个。
为什么这样更稳
管理负担轻。 盯一个库的切换,出了问题你知道往哪看。团队不用同时操心十个库。
第一个库就是消防演习。 你会发现 runbook 漏了什么、监控没覆盖什么、回滚步骤实际跑起来什么样——但影响范围有限。到第五个库的时候,团队已经驾轻就熟。
Replication slot 不堆积。 逻辑复制期间,发布端要为每个活跃的订阅端保持一个 replication slot,slot 持有 WAL 数据防止订阅端遗漏。同时同步很多库,slot 就堆起来了——磁盘占用上升、内存压力增大、风险升高。逐库切换的好处是:一个库切完,slot 立刻释放,发布端喘口气。
回滚简单。 一个库出了问题,只需要回退这一个库。反向复制路径短而干净,不用同时撤销十几个切换。
CPU 保持平稳。 同时管理很多库的复制会给发布端带来可观负载:WAL sender、复制 worker、跨多个 slot 的变更追踪,可能在最需要系统稳定的时刻把 CPU 打满。逐库推进让负载可预测。
停机时间可以只有几秒。 单库切换流程很短:暂停写入 → 确认订阅端 lag 为零 → 更新连接字符串 → 恢复写入。
人为错误空间小。 切换是高压操作,同时做的事越少出错越少。一个库意味着一份检查清单、一次确认、一个回滚方案,而不是十份并行。
迁移周期可控。 每个库是独立的,不用等所有库都准备好才能开始切换。即使某个库很大,迁移窗口也只限于这一个库。
整体切换:所有库一起上卡车
回到搬办公室的比喻——所有部门同时装车、一起出发、希望新大楼对每个部门都准备好了。纸面上看起来更快,实际操作难度大得多。
你会面对的问题
参数值随库数量膨胀。 同时复制几百个库,max_logical_replication_workers 和 max_worker_processes 要设到几百再加预留。每个 worker 进程消耗内存和 CPU,值越高,PostgreSQL 启动的后台进程越多,在最需要稳定的时刻给系统施加更大压力。
停机时间随集群规模增长。 逻辑复制不同步 sequence(序列),切换前要把每个库的每个 sequence 在新集群上快进到安全值。一个库 30 秒的事,整个集群可能要 20 分钟。
一个问题可以卡住全部。 一个库遇到 schema 不匹配、卡住的事务、或复制失同步,整个切换都受影响。没法轻松跳过有问题的库继续其他的。
中间的 schema 变更很痛苦。 迁移要时间,逻辑复制跑着的时候开发还在改 schema。发布端建好复制之后的任何 schema 变更必须手动应用到订阅端,否则那个库的复制会断。跨很多库、长窗口追踪这些变更,遗漏几乎不可避免,补漏通常是在压力下手工修复。
新库可能悄无声息地出现。 其他团队在迁移期间可能建了新库,如果没人通知数据库团队,这些库在订阅端就不存在,被跳过。直到该在新集群上的东西还在旧集群上悄悄跑着,才有人发现。
回滚慢且痛苦。 切完很多库之后要回滚,得给所有库建反向逻辑复制。迁移窗口越长,新集群上变化的数据越多,回滚越难。
迁移窗口可能拖成几周。 所有库都得准备好才能切换,schema 变更还在不断进来,准备阶段越来越长。本来几天的事变成几周的同步工作,业务还在持续变化。
一次重启可能让你从零开始。 如果发布端跑在 Patroni 上,一次 PostgreSQL 重启(比如 OOM kill 触发的),Patroni 会丢弃它不认识的 replication slot——你为迁移创建的逻辑复制 slot 正属于这类。整体迁移意味着所有正在复制的库都丢 slot,从头开始初始数据同步,不是从断点续传。几百个库的情况下,这不是 setback,是重新开始整个迁移。
实操:逐库切换的关键步骤与命令
下面给一个逐库切换的实操流程,包含可以直接运行的命令。
1. 建立逻辑复制(发布端)
-- 在旧集群(发布端)上,为单个库创建 publication
-- 先连到目标库
\c myapp_production
-- 发布所有表(也可以指定表名列表)
CREATE PUBLICATION myapp_prod_pub FOR ALL TABLES;
2. 建立逻辑复制(订阅端)
-- 在新集群(订阅端)上,连到对应库
\c myapp_production
-- 创建订阅,连接信息指向旧集群
CREATE SUBSCRIPTION myapp_prod_sub
CONNECTION 'host=old-cluster.internal port=5432 dbname=myapp_production user=replicator password=SECRET'
PUBLICATION myapp_prod_pub;
订阅创建后,PostgreSQL 自动开始初始数据同步,然后进入持续复制模式。
3. 切换前检查复制延迟
# 在订阅端查看复制延迟,确认 lag 为 0 再继续
psql -h new-cluster.internal -U postgres -d myapp_production \
-c "SELECT subname, pid, received_lsn, latest_end_lsn,
latest_end_time, now() - latest_end_time AS lag
FROM pg_stat_subscription
WHERE subname = 'myapp_prod_sub';"
lag 应为 0 或接近 0。如果还有延迟,等它追上。
4. 快进 sequence 到安全值
逻辑复制不同步 sequence,切换前必须手动处理。下面是一个脚本,把旧集群上所有 sequence 的当前值查出来,在新集群上设到更大的安全值:
#!/usr/bin/env bash
# fast_forward_sequences.sh
# 用法: ./fast_forward_sequences.sh <旧集群连接串> <新集群连接串> <库名> <增量偏移>
# 示例: ./fast_forward_sequences.sh \
# "host=old-cluster.internal port=5432 user=postgres dbname=myapp_production" \
# "host=new-cluster.internal port=5432 user=postgres dbname=myapp_production" \
# myapp_production 1000
OLD_CONN="$1"
NEW_CONN="$2"
DBNAME="$3"
OFFSET="${4:-1000}" # 默认偏移 1000,防止切换期间旧集群还在用旧值
# 从旧集群取所有 sequence 的当前值
SEQ_VALUES=$(psql "$OLD_CONN" -t -A -c \
"SELECT sequencename, last_value FROM pg_sequences WHERE schemaname = 'public';")
# 在新集群上快进每个 sequence
while IFS='|' read -r seq_name last_val; do
new_val=$((last_val + OFFSET))
echo "Setting $seq_name to $new_val on new cluster..."
psql "$NEW_CONN" -c \
"SELECT setval(pg_get_serial_sequence('public.${seq_name}', 'id'), $new_val, false);"
done <<< "$SEQ_VALUES"
echo "Sequence fast-forward complete for $DBNAME."
注意:上面的
pg_get_serial_sequence假设 sequence 与表的id列关联。如果你的 sequence 是独立的或命名不同,需要调整查询逻辑,直接用SELECT setval('public.${seq_name}', $new_val, false);。
5. 暂停写入、切换连接字符串
# 暂停应用写入(具体方式取决于你的应用架构)
# 例如,通过 PgBouncer 切换后端:
# 1. 在应用层暂停写入(或设 maintenance 模式)
# 2. 确认 lag 为 0(步骤 3)
# 3. 更新 PgBouncer 配置指向新集群
# PgBouncer 配置片段示例 (databases.ini):
# 切换前:
myapp_production = host=old-cluster.internal port=5432 dbname=myapp_production
# 切换后:
myapp_production = host=new-cluster.internal port=5432 dbname=myapp_production
# 重载 PgBouncer
pgbouncer -R databases.ini
6. 清理:释放 replication slot
-- 在新集群上删除订阅(这会通知发布端释放 slot)
\c myapp_production
DROP SUBSCRIPTION myapp_prod_sub;
-- 在旧集群上确认 publication 和 slot 已清理
\c myapp_production
DROP PUBLICATION myapp_prod_pub;
slot 释放后,旧集群的 WAL 积压和磁盘压力立刻下降。
7. 保护你的 replication slot(Patroni 环境)
如果你的旧集群跑在 Patroni 上,Patroni 重启时会丢弃它不认识的 slot。整体迁移中这是灾难,逐库迁移中也值得防护——毕竟一个库的 slot 丢了也要重新同步。在 patroni.yml 中明确告诉 Patroni 不要清理你的迁移 slot:
# patroni.yml — 防止 Patroni 重启时丢弃迁移用的逻辑复制 slot
postgresql:
parameters:
# 确保参数足够支撑你的复制 worker 数量
max_logical_replication_workers: 4
max_worker_processes: 8
# 关键配置:告诉 Patroni 忽略这些 slot
use_slots: true
bootstrap:
# Patroni 0.9+ 支持在 DCS 配置中指定 ignore slots
# 也可以在 patroni.yml 中设置:
# ignore_slots:
# - name: myapp_prod_sub # 与订阅名对应的 slot 名
# 更推荐的方式:在 Patroni DCS (etcd/consul) 配置中添加 ignore_slots
# 这样所有节点都能读到,避免单节点配置遗漏
不同 Patroni 版本的
ignore_slots配置位置可能不同,请查阅你所用版本的文档确认具体写法。核心原则是:在迁移开始前就把 slot 保护配置到位,不要等重启发生了再补救。
决策清单
逐库切换几乎在所有维度占优:更易管理监控、风险更低、回滚更快、停机更短、发布端负载更小、团队对状况更清楚。整体切换只在特定约束下才合理。
选择前问自己这几个问题:
| 问题 | 逐库切换 | 整体切换 |
|---|---|---|
| 业务是否允许不同库暂时在不同集群? | ✅ 允许 → 逐库 | ❌ 不允许 → 整体 |
| 应用架构能否拆分流量,部分指向新集群? | ✅ 能 → 逐库 | ❌ 不能 → 整体 |
| 库数量是否超过几十个? | 逐库周期更长但仍然可控 | 参数和 slot 管理压力极大 |
| 团队是否有整体切换的演练经验? | 没经验 → 更应逐库 | 有经验且约束硬 → 可整体 |
| 回滚时间要求? | 单库秒级回滚 | 可能需要小时级反向复制 |
| 发布端是否跑 Patroni? | 逐库 + slot 保护 | 整体 + slot 保护,但一次重启代价巨大 |
如果你有选择,逐库切换。 一个库一个库地搬,让每个库在新集群上安顿好了再动下一个。你的 on-call 排班会感谢你。