物理流复制把 PostgreSQL WAL 发送到备库,备库通过重放记录维持集群的另一份副本。发送不必等待整个日志段结束。这与选择部分表变更进行传送的逻辑复制不同。接收进程正在运行、备库可以连接,都是有价值的健康信号,但任何一个信号都不能单独证明某条已经提交的业务记录此刻在备库可见。
坏掉的样子
流复制默认是异步的。主库可以先向客户端确认提交,备库之后才收到或应用相关记录。若主库恰好在这个窗口丢失,切换到备库时可能丢掉最近已经确认的工作。备库也可能保持连接并持续接收日志,却由于暂停回放而一直返回旧行。因此网络交付在推进,并不能证明查询新鲜度也在推进。
应区分不同进度标记。备库的接收位置函数报告通过流复制已经接收并同步到磁盘的位置,回放位置函数则报告已经应用的位置,两者差值可以暴露回放积压。最后一次重放事务的时间戳,即使在完全空闲的系统上也会不断变旧;单纯用当前时间减去它,不能可靠判断究竟有多少尚未处理的工作。
快速验证
使用非超级操作系统账户,在 Linux 环境中准备 PostgreSQL 18 服务端与客户端程序。原文记录了 PostgreSQL 18.6 执行结果,本轮未重跑。它创建私有临时集群及 Unix 套接字目录,不打开 TCP 监听,但 Unix 套接字仍是监听端点,必须得到执行环境允许。本地信任认证仅用于这些可丢弃的实验实例。少量日志预留支持这个有界演练,脚本没有配置永久复制槽。
#!/bin/sh
set -eu
umask 077
lab=$(mktemp -d /tmp/xbcatm-streaming.XXXXXX)
mkdir "$lab/socket"
primary=$lab/primary
standby=$lab/standby
cleanup() {
for cluster in "$standby" "$primary"; do
if test -f "$cluster/postmaster.pid"; then
pg_ctl -D "$cluster" -m fast -w stop >/dev/null
fi
done
}
trap cleanup EXIT
initdb -D "$primary" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null
cat >> "$primary/postgresql.conf" <<CONF
listen_addresses = ''
unix_socket_directories = '$lab/socket'
port = 56432
wal_level = replica
max_wal_senders = 4
wal_keep_size = '64MB'
CONF
pg_ctl -D "$primary" -l "$lab/primary.log" -w -t 15 start >/dev/null
export PGHOST=$lab/socket PGPORT=56432 PGDATABASE=postgres PGUSER=$(id -un)
export PGCONNECT_TIMEOUT=2
psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
CREATE TABLE marker (id integer PRIMARY KEY,value text);
INSERT INTO marker VALUES (1,'base');
SQL
pg_basebackup -D "$standby" -R -X stream -c fast -w
cat >> "$standby/postgresql.conf" <<CONF
port = 56433
hot_standby = on
CONF
pg_ctl -D "$standby" -l "$lab/standby.log" -w -t 15 start >/dev/null
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_pause()' >/dev/null
attempt=0
while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_get_wal_replay_pause_state()='paused'")" != t; do
attempt=$((attempt+1))
test "$attempt" -lt 100 || { echo 'replay did not pause' >&2; exit 1; }
sleep 0.1
done
psql -X -v ON_ERROR_STOP=1 -qAt -c "INSERT INTO marker VALUES (2,'later')"
target=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()')
attempt=0
while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_receive_lsn()>='$target'::pg_lsn")" != t; do
attempt=$((attempt+1))
test "$attempt" -lt 150 || { echo 'receiver did not catch up' >&2; exit 1; }
sleep 0.1
done
psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT state,sync_state FROM pg_stat_replication'
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<SQL
SELECT pg_last_wal_receive_lsn()>='$target'::pg_lsn,
pg_last_wal_replay_lsn()<'$target'::pg_lsn,
(SELECT count(*) FROM marker);
DO \$\$ BEGIN
IF (pg_last_wal_receive_lsn()>='$target'::pg_lsn
AND pg_last_wal_replay_lsn()<'$target'::pg_lsn
AND (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker)='1:base')
IS NOT TRUE THEN
RAISE EXCEPTION 'paused replay result mismatch';
END IF;
END \$\$;
SQL
echo stale_read_while_receiving
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_wal_replay_resume()' >/dev/null
attempt=0
while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" != t; do
attempt=$((attempt+1))
test "$attempt" -lt 150 || { echo 'replay did not catch up' >&2; exit 1; }
sleep 0.1
done
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
SELECT id,value FROM marker ORDER BY id;
DO $$ BEGIN
IF (SELECT string_agg(id||':'||value,',' ORDER BY id) FROM marker)
IS DISTINCT FROM '1:base,2:later' THEN
RAISE EXCEPTION 'resumed replay result mismatch';
END IF;
END $$;
SQL
pg_ctl -D "$primary" -m fast -w stop >/dev/null
echo old_primary_stopped
pg_ctl -D "$standby" -w -t 15 promote >/dev/null
PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL'
SELECT pg_is_in_recovery();
INSERT INTO marker VALUES (3,'promoted');
SELECT id,value FROM marker ORDER BY id;
DO $$ BEGIN
IF pg_is_in_recovery() OR (SELECT count(*) FROM marker)<>3 THEN
RAISE EXCEPTION 'promotion result mismatch';
END IF;
END $$;
SQL
printf 'lab_directory %s\n' "$lab"
脚本通过带恢复配置选项的物理基础备份建立副本,这个选项会写入备库连接信息并创建备库信号文件。副本在另一个套接字端口启动后,先请求暂停回放,再等待状态确实变成已经暂停。随后主库提交第二行,并捕获提交之后的日志位置,作为接收与回放比较的明确目标。
主库的预期状态是正在流复制且采用异步确认。备库输出的两个真值与行数一,分别表示接收已经到达目标、回放仍未到达、表里只有原始一行。断言还会检查这一行的实际内容。因此“仍在接收时读到旧数据”的标记来自数据库真实查询结果,并不是在应用缓存中人为伪造一段延迟。
恢复回放之后,脚本等待回放位置达到目标,再核对第一行为 base、第二行为 later。每次观察都使用新连接,排除了先前打开的事务快照导致旧结果的干扰。真实应用中的长事务如果继续持有旧快照,即使备库已经回放到新位置,也可能还需要结束那个事务才能读到新版本;这是另一条需要单独识别的可见性边界。
接管步骤先停止旧主库,再提升备库并等待提升完成。恢复状态变为假,第三行写入后得到 promoted,断言验证新主库确实接受了这次写入。所有实验服务都会在退出时停止,打印目录保留日志。这验证的是隔离环境里的有序提升,没有验证自动故障探测、网络分区处理或客户端地址迁移,不能据此宣称整套高可用切换已经完成。
明确确认与保留契约
同步复制既需要选择同步备库,也需要相关事务同步提交设置。仅把事务选项设为开启,而没有选择任何同步备库,不会凭空增加远端确认要求。配置好同步备库之后,普通开启模式等待它们持久化日志;远端写入模式等待写入对方操作系统;远端应用模式等待对方重放。这些选择改变的不只是持久性与读取可见性,也包括提交延迟以及备库不可用时的服务可用性。远端应用确认只覆盖参与确认的备库,不覆盖负载均衡器后面的所有副本;随后把读取送到别处,仍可能返回旧数据。
日志保留是另一项独立义务。复制槽可以保留断线接收者仍需要的日志,但消费者长期停滞时,若没有有意识地设置限制与监控,也可能耗尽磁盘。没有足够保留日志或可用归档时,过旧的备库可能只能重建。应联合观察日志生成、接收、回放、保留字节数与剩余空间,不要只看某一个复制进程是否还活着。
提升本身不会隔离另一个仍然写入的主库。生产故障切换必须阻止旧写入者继续服务,选择可接受的恢复位置,重定向客户端,并通过受支持的重建或回绕流程让原主库重新加入。否则两个独立写入者可能产生分叉。一个健康的流复制连接只证明其中一部分机制正在工作,不能替代完整接管流程及其业务数据核验。
来源
- PostgreSQL 18 温备与流复制,异步交付、同步确认、日志保留与故障切换,已核验 2026-10-15。
- PostgreSQL 18 管理函数,接收和回放位置、实际暂停状态及提升语义,已核验 2026-10-15。
- PostgreSQL 18 pg_basebackup,使用恢复配置选项创建备库,已核验 2026-10-15。