用 pg_upgrade 和 pg_createsubscriber 组合做 PostgreSQL 19 集群升级,思路很诱人:先把物理只读副本转换成逻辑订阅端,让新集群追上旧集群,再在很短的窗口里切流量。这个流程把停机时间压得很低,但也暴露了一个长期存在的坑:逻辑复制不会复制 sequence 的当前状态。
这不是一个边角问题。只要表里有 serial、bigserial、GENERATED ... AS IDENTITY,背后通常都有 sequence。数据行复制过去了,如果 sequence 没跟上,应用切到新集群后,下一次插入可能生成已经存在的主键。
为什么表数据同步了,主键还会撞
PostgreSQL 的逻辑复制主要关注表数据变化:INSERT、UPDATE、DELETE。sequence 的 nextval() 状态并不是普通表行变化,它维护的是一个独立对象的计数状态。
因此在近零停机升级流程中,可能出现这样的时间线:
- 旧集群继续服务写入请求,表数据通过逻辑复制进入新集群。
- 业务不断调用
nextval(),旧集群 sequence 持续前进。 - 新集群收到了新行,但本地 sequence 仍停在较旧的位置。
- 切流量后,新集群执行
nextval(),生成一个已经存在的 ID。
这个问题在低写入量系统里可能很久才暴露;在高并发写入系统里,可能切换后第一批请求就报错。
升级前后应该把 sequence 当成独立资产
做这类升级时,不要只问“表追平了吗”,还要问“sequence 也追平了吗”。尤其是以下对象:
- 使用
serial或bigserial的主键列。 - 使用
GENERATED BY DEFAULT AS IDENTITY的列。 - 应用直接调用
nextval('some_sequence')的业务编号。 - 多租户、分片或批量导入任务中自定义维护的 sequence。
更稳妥的做法是:在最终切流量窗口里,暂停旧集群写入,确认逻辑复制延迟归零,然后从旧集群抽取 sequence 状态,并在新集群执行 setval()。这样不会依赖逻辑复制去做它本来不做的事情。
可以这样实践:导出并同步所有 sequence
下面示例假设你有两个连接串:
OLD_DSN指向升级前仍在服务的源集群。NEW_DSN指向升级后的目标集群或逻辑订阅端。
切换前先暂停应用写入,然后运行:
#!/usr/bin/env bash
set -euo pipefail
: "${OLD_DSN:?set OLD_DSN first}"
: "${NEW_DSN:?set NEW_DSN first}"
TMP_SQL="/tmp/sync_sequences.sql"
psql "$OLD_DSN" -At <<'SQL' > "$TMP_SQL"
SELECT format(
'SELECT setval(%L, %s, %s);',
quote_ident(sequence_schema) || '.' || quote_ident(sequence_name),
last_value,
is_called
)
FROM information_schema.sequences s
JOIN LATERAL (
SELECT last_value, is_called
FROM pg_catalog.pg_sequences
WHERE schemaname = s.sequence_schema
AND sequencename = s.sequence_name
) g ON true
WHERE sequence_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY sequence_schema, sequence_name;
SQL
psql "$NEW_DSN" -v ON_ERROR_STOP=1 -f "$TMP_SQL"
运行前需要根据你的权限模型确认两点:执行用户能读取源端 sequence 状态,也能在目标端执行 setval()。如果环境里有非常老的 PostgreSQL 版本,pg_sequences 视图的可用性也需要单独确认;可以改用逐个 SELECT last_value, is_called FROM schema.seq 的方式生成 SQL。
同步后,可以用下面的查询快速检查目标端 sequence 是否小于表内最大 ID。这里以 public.orders(id) 为例:
SELECT
max(id) AS max_table_id,
last_value AS sequence_last_value,
last_value >= max(id) AS sequence_is_safe
FROM public.orders,
public.orders_id_seq;
如果 sequence_is_safe 是 false,就应该在目标端修正:
SELECT setval('public.orders_id_seq', (SELECT max(id) FROM public.orders), true);
把检查放进切换清单,而不是靠记忆
真正危险的不是 sequence 不复制,而是团队以为“逻辑复制已经覆盖所有状态”。建议把下面几项写进升级 runbook:
- 切换前冻结旧集群写入,避免同步过程中又产生新的
nextval()。 - 等待逻辑订阅追平,确认表数据没有复制延迟。
- 从源集群导出所有业务 schema 下的 sequence 状态。
- 在目标集群执行
setval(),并抽样验证关键表的max(id)。 - 切流量后监控唯一键冲突、主键冲突和应用插入失败。
近零停机升级的关键,不只是让数据行过去,还要让数据库对象的“隐含状态”过去。sequence 正是这种隐含状态里最容易被忽略、也最容易造成生产事故的一类。