3293 字
16 分钟

Pod 都是 Ready,PostgreSQL 却没有主备复制

2026-09-27 凌晨,检查集群里的 PostgreSQL、MariaDB 和 Redis 主备状态。第一眼看上去没有明显问题:三台节点 Ready,七套 PostgreSQL 都是 2/2 Ready,CloudNativePG 的状态也都是 Cluster in healthy state。

真正进入数据库检查后,发现其中四套 PostgreSQL 的主备复制已经断开。备库还在运行,也能接受只读连接,却一直拿不到需要的 WAL;主库则有一批会话卡在同步提交确认上。

最后保留原主库和旧备库数据卷,为四套数据库分别重建了新备库。到 01:04,七套 PostgreSQL 全部恢复为 streaming / quorum,采样回放差和提交等待都归零。

这篇记录从发现到恢复的过程。环境为 PostgreSQL 17.6、CloudNativePG 1.29.1,时间均为北京时间。命令里的名称使用示例值,节点地址、备份目录和凭据没有放进正文。

Ready 的范围比想象中窄#

这次一共检查了七套 PostgreSQL、五套 MariaDB、九套 Redis,以及 Redis 的 27 个 Sentinel。没有只看控制器状态,而是把声明角色、实际角色、复制状态和访问入口逐项对上。

PostgreSQL 的第一轮结果如下:

应用Kubernetes / CNPG 状态数据库实际状态主库等待同步确认的会话
AnyLink2/2 Ready无主备复制连接1
Harbor2/2 Ready无主备复制连接24
JumpServer2/2 Ready无主备复制连接7
KF2 查询服务2/2 Ready无主备复制连接1
GitLab、OSSPilot、CyxcCloud均 2/2 Readystreaming / quorum,回放差 0 B均为 0

这是 00:22–00:29 的检查快照。四套异常库的 Pod 都正常运行,备库也确实处于 recovery、事务只读;问题出在它们没有继续接收主库的变化。

在这次部署里,Ready 并没有表达“备库已追平”。更容易忽略的是,四个落后备库仍然是 -ro Service 中 Ready 的端点。如果客户端通过这些只读入口访问,就存在读到旧数据的风险。这次没有逐一核实每个应用是否使用了只读入口,因此没有把风险写成已经发生的业务事实。

从数据库两端确认复制链路#

我先分别在主、备库确认实际角色:

SELECT pg_is_in_recovery() AS in_recovery,
current_setting('transaction_read_only') AS transaction_read_only;

主库应为 false / off,备库应为 true / on。角色正确只是第一步,还需要看两端是否存在复制连接。

在主库查询发送端状态:

SELECT application_name,
state,
sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS replay_lag_bytes
FROM pg_stat_replication;

在备库查询接收端状态:

SELECT status,
sender_host,
received_tli,
pg_wal_lsn_diff(
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn()
) AS received_not_replayed_bytes
FROM pg_stat_wal_receiver;

正常的三套数据库,主库能看到一个 streaming / quorum 的备库,备库也能看到 streaming 的 WAL receiver。四套异常库则在多轮检查中都没有持续的复制连接。

这些视图表达的是正在运行的 WAL 发送和接收进程,可以直接与 Pod 的角色、IP 和 Service 端点交叉核对。PostgreSQL 17 统计视图说明

回放差是采样值。活跃写入时,一次查询出现少量差值或短暂等待不一定异常,需要结合连接状态和后续采样判断;也不能把 NULL 当成零延迟。

备库需要的 WAL 已经被回收#

四个备库的日志不断重复同一种错误:

started streaming WAL from primary ...
could not receive data from WAL stream:
ERROR: requested WAL segment ... has already been removed
waiting for WAL to become available ...

备库能够连接主库,但开始请求旧 WAL 后,主库已经无法提供对应文件。备库随后退出流复制、继续等待,再重试同一过程。

这解释了为什么节点和进程都恢复运行后,复制却没有自行恢复。单纯再重启一次 Pod,仍会使用原来的数据目录和回放位置,缺失的那段 WAL 不会因此重新出现。

PostgreSQL 的流复制说明也明确指出:如果所需 WAL 已经被回收,又没有可用归档供备库追赶,就需要从新的基础备份重新初始化备库。流复制与 WAL 保留

有复制槽,不代表当时确实保留了所需 WAL#

进一步检查了主库的物理复制槽:

SELECT slot_name,
active,
restart_lsn,
wal_status,
invalidation_reason
FROM pg_replication_slots;

四个旧备库对应的槽都是:

active = false
restart_lsn = NULL
wal_status = NULL
invalidation_reason = NULL

所以本次只能确认“存在槽对象,但没有有效的保留起点”,不能把它描述成已经观察到 wal_status=lost。同样,max_slot_wal_keep_size=-1 也不能单独证明所需 WAL 一直受到了保护。

当时 wal_keep_size=512MB,四套 Cluster 没有配置 backup 或备份插件。虽然 archive_mode=on、archive command 非空,控制器还显示 ContinuousArchiving=True,这些信息仍不足以证明存在一份能取回缺失段的外部归档。本次没有找到可用于补齐该缺口的归档路径,也没有据此断言所有外部备份都不存在。

四个异常备库还集中在同一台此前发生过运行时故障的节点上,当前主库的切换时间也相近。这是明确的时间与节点关联;WAL 保留、切主和归档的完整因果链还没有全部还原,不能直接把某一个配置值认定为这次事故的唯一根因。

主库为什么也卡住了#

四套数据库都采用两实例同步复制,声明配置为:

minSyncReplicas: 1
maxSyncReplicas: 1

故障时主库的实际运行参数仍要求一个备库确认:

synchronous_commit = on
synchronous_standby_names = ANY 1 ("对应备库")

我用下面的查询检查等待情况,限定 IPC 类型,避免与同名的内部锁等待混淆:

SELECT count(*) AS waiting_sessions,
max(clock_timestamp() - query_start) AS max_statement_age
FROM pg_stat_activity
WHERE wait_event_type = 'IPC'
AND wait_event = 'SyncRep';

IPC / SyncRep 表示正在等待远端同步复制确认。等待事件定义

00:28 左右,四套合计有 33 个等待会话;到 00:48 开始恢复前,Harbor 自己就已经增加到 33 个,四套合计 42 个。相关最长语句已运行约一小时。这里的时长来自 query_start,是语句耗时,并不是精确的等待事件起始时间。

到这一步,已经有充分证据说明问题影响了提交。只检查数据库能否连接、执行 SELECT 1 或返回健康页,都不足以覆盖这个故障。

先保留恢复资料,再串行重建备库#

四个主库仍然存活,这次没有切主,也没有升级 PostgreSQL 或 CNPG。恢复范围限定为四个无法追赶的备库。

操作前重新确认了 API、节点、etcd 和 Ceph 状态。存储仍有回填任务,因此按 Harbor、JumpServer、AnyLink、KF2 的顺序串行处理。

同时生成了新的恢复资料:

资料数量及用途实际校验
etcd 快照1 份,保护 Kubernetes API 状态原生 snapshot status、第二节点大小及 SHA256
PostgreSQL custom archive8 份,覆盖四套主库中可连接的非模板数据库同版本 pg_restore 完整解析、双份 SHA256
PostgreSQL globals4 份,保存角色等全局对象使用 --no-role-passwords,双份 SHA256
资源基线Cluster、Pod、PVC 的身份与配置摘要用于操作前后逐项比较

十二份数据库恢复文件合计约 231 MB,保存在两台节点的受限目录。它们不是四个应用统一停写后的业务快照,格式校验也不等于完成了隔离恢复演练。

保留旧 PVC,让控制器创建新的副本#

这次使用已有的 CNPG 1.29.1 官方插件,并核对了同版本实现:

Terminal window
# 生产变更示意。先确认目标确实是故障备库,并完成备份。
# 三个变量必须替换为已核对的 namespace、Cluster 和备库 Pod 名称。
: "${PG_NS:?}" "${PG_CLUSTER:?}" "${PG_REPLICA:?}"
kubectl cnpg -n "$PG_NS" destroy \
"$PG_CLUSTER" "$PG_REPLICA" --keep-pvc

--keep-pvc 在这里很关键:指定备库 Pod 会退出,PVC/PV 保留,PVC 的 Cluster owner 被移除并标记为 detached。控制器再按原来的两实例声明创建新实例,由 join Job 从当前主库制作基础副本。CNPG 1.29.1 实现

这个命令会改变生产状态,不能仅凭 Pod 编号执行。每次操作前,我都重新核对 currentPrimary、targetPrimary、主备实际 recovery 状态、目标 Pod UID,以及 PVC UID 和 PV 绑定。它不是一个替操作者确认“绝不会选中主库”的保护开关。

四套都创建出了编号为 -3 的新备库:

应用开始替换确认同步复制主库是否变化
Harbor00:54:0500:55:19未变化
JumpServer00:56:0600:56:58未变化
AnyLink00:57:4300:58:17未变化
KF2 查询服务00:58:5501:00:33未变化

KF2 的主业务数据库约 2.14 GB,基础复制比另外三套耗时更长。每一套都等到新备库进入同步复制、采样回放差和提交等待归零,再继续下一套。

四个旧备库卷都没有删除。它们保留了故障现场,但数据已经落后,不能当成当前可直接提升的副本。

必须记录的过渡:提交恢复早于同步冗余恢复#

这次最需要如实保留的细节,是重建期间的同步行为。

四个 Cluster 的 spec 没有修改,minSyncReplicas、maxSyncReplicas 也没有手工降低。但当旧备库退出、只剩一个活动实例时,控制器曾把运行时的 synchronous_standby_names 暂时置空。

Harbor 的一次采样是:

ready instances = 1
synchronous_standby_names = ""
new replica state = streaming
sync_state = async
SyncRep waiters = 0

随后新备库 Ready,运行时配置才恢复成:

synchronous_standby_names = ANY 1 ("新的备库")
state = streaming
sync_state = quorum
replay_lag_bytes = 0

KF2 重建期间也观察到了同样的空同步目标和 async 过渡。JumpServer、AnyLink 没有连续采样整个过渡阶段,因此不能声称它们全过程始终保持同步确认。

这意味着,提交等待清零本身不能作为恢复完成的判据。需要继续确认新的同步目标生效、备库真正追平。配置文件没有变化,也不等于整个恢复过程里的运行行为没有变化。

旧备库退出到新备库就绪之间,只读入口也有短时无后端的窗口。本文记录这些实际边界,不把最后的健康状态扩展成“整个过程零中断”或“已经证明零数据丢失”。

验收要落到复制、提交和入口#

四套恢复后,我又检查了一遍七套 PostgreSQL,而不是只检查新创建的 Pod。

最终验收包含:

  1. 每套实际一主一备,主库不在 recovery,备库在 recovery 且只读。
  2. 主库看到一个 streaming / quorum 备库,备库有正常 WAL receiver。
  3. 采样回放差为 0 B,没有持续的 SyncRep 堵塞;最终采样等待会话为 0。
  4. 物理复制槽 active、restart_lsn 非空、wal_status=reserved。
  5. -rw Service 指向原主库,-ro 指向新备库,不再把落后实例作为就绪端点。

此外,每套修复库执行了一次只涉及临时表的事务:

BEGIN;
CREATE TEMP TABLE cnpg_recovery_probe (ok integer) ON COMMIT DROP;
INSERT INTO cnpg_recovery_probe VALUES (1);
COMMIT;
SELECT to_regclass('pg_temp.cnpg_recovery_probe') IS NULL
AS temp_table_removed;

测试连接限制了语句和锁等待时间,保留原同步提交设置。四次事务都正常提交,临时表随后消失,没有写业务表。包含 kubectl 连接开销的单次耗时约 325–343 ms;这个数字只用于说明测试没有再卡住,不作为数据库性能结果。

到 01:04,七套 PostgreSQL 都通过上述检查。四个新备库均 Ready、重启数为 0,日志中没有再出现本次 WAL 缺失错误。四个原主库 Pod UID 和 Cluster spec 摘要没有变化,原八个数据库 PVC 的 UID 与 PV 绑定全部保留。

业务侧补查了 Harbor 八组件健康状态、JumpServer 数据库健康接口、KF2 查询入口和应用副本数。这些检查通过,但没有代替登录后的完整用户工作流验证。同期 etcd 和节点正常,Ceph 仍有原先的回填与告警,没有把数据库恢复写成整个集群已经完全无告警。

这次修复之后还留下什么#

当晚修复的是已经失去追赶能力的四个 PostgreSQL 备库。持续 WAL 归档、自动备份、故障副本恢复策略,以及复制状态告警,还需要继续完善。不能指望下一次也靠人工看到日志后再重建。

最值得补的监控,是“预期副本数与实际复制连接数不一致”、持续的 SyncRep 等待、复制槽的有效保留状态,以及只读入口是否还指向明显落后的副本。

同一轮巡检还发现了 MariaDB 备库只读保护和部分 Redis Sentinel 节点分布的问题,它们没有混进这次 PostgreSQL 恢复窗口。四个 detached 旧卷也继续保留,等观察期和备份保留要求确认后,再单独安排清理。

这次最有用的检查顺序,是从 Ready 再向下走一步:确认角色,确认复制,确认提交,最后确认客户端实际连接的入口。