由 WAL 流持续喂入、近乎实时的主库副本。

Postgres 流式复制让备用节点保持温热

物理流复制把 PostgreSQL WAL 发送到备库,备库通过重放记录维持集群的另一份副本。发送不必等待整个日志段结束。这与选择部分表变更进行传送的逻辑复制不同。接收进程正在运行、备库可以连接,都是有价值的健康信号,但任何一个信号都不能单独证明某条已经提交的业务记录此刻在备库可见。 坏掉的样子 流复制默认是异步的。主库可以先向客户端确认提交,备库之后才收到或应用相关记录。若主库恰好在这个窗口丢失,切换到备库时可能丢掉最近已经确认的工作。备库也可能保持连接并持续接收日志,却由于暂停回放而一直返回旧行。因此网络交付在推进,并不能证明查询新鲜度也在推进。 应区分不同进度标记。备库的接收位置函数报告通过流复制已经接收并同步到磁盘的位置,回放位置函数则报告已经应用的位置,两者差值可以暴露回放积压。最后一次重放事务的时间戳,即使在完全空闲的系统上也会不断变旧;单纯用当前时间减去它,不能可靠判断究竟有多少尚未处理的工作。 快速验证 使用非超级操作系统账户,在 Linux 环境中准备 PostgreSQL 18 服务端与客户端程序。本脚本已经在 PostgreSQL 18.6 上执行。它创建私有临时集群及 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" 脚本通过带恢复配置选项的物理基础备份建立副本,这个选项会写入备库连接信息并创建备库信号文件。副本在另一个套接字端口启动后,先请求暂停回放,再等待状态确实变成已经暂停。随后主库提交第二行,并捕获提交之后的日志位置,作为接收与回放比较的明确目标。 ...

八月 10, 2027 · 3 分钟 · XB Field Notes
主库喂给一个承接查询的只读副本。

只读副本服务查询需求

只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 PostgreSQL 物理热备库,它在正常恢复模式下可以读取,但不能接受普通业务写入。 坏掉的样子 异步复制下,成功写入之后立刻查询副本,仍然可能读到旧值。用户刚修改账户并刷新页面,之前的内容却又出现了。此时副本可能连接健康、日志接收持续推进,只是相关提交还没有被重放。如果新鲜度检查只看接收位置、不看回放位置,就检查了错误的完成边界。 即使回放已经推进,旧事务快照仍然可能影响结果。可重复读事务本来就需要在恢复继续进行时保留自己的视图。等待副本应用某次写入,不会顺便刷新这个事务。因此,要实现写后读到本次修改,既需要足够的回放进度,也需要查询使用在这个进度条件满足之后取得的新快照,两个条件缺一不可。 新鲜度承诺也需要限定范围。包含这个用户较早的一次写入,不等于保证其他地方不存在更新的事务。若读取余额是为了批准下一笔扣款,仍然必须在写入路径中进行事务性核对。副本提供的查询容量,不能替代保护业务动作的并发控制,也不能使一个只读结果天然变成后续写入的有效授权。 快速验证 在 Linux 环境中以非超级操作系统账户运行脚本,并准备 PostgreSQL 18 服务端和客户端程序。本次使用 PostgreSQL 18.6。脚本建立私有主备集群,通过 Unix 套接字连接,不打开网络监听。一个命名管道保持读会话存活,让实验能够明确控制事务快照边界,无需额外安装数据库驱动。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-read-replica.XXXXXX) mkdir "$lab/socket" primary=$lab/primary standby=$lab/standby reader_pid='' reader_open=0 cleanup() { if test "$reader_open" = 1; then exec 3>&-; fi if test -n "$reader_pid"; then wait "$reader_pid" || :; fi 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 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 account (id integer PRIMARY KEY,balance integer); INSERT INTO account VALUES (1,100); 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 mkfifo "$lab/reader.sql" PGPORT=56433 PGAPPNAME=replica_snapshot psql -X -v ON_ERROR_STOP=1 -qAt <"$lab/reader.sql" >"$lab/reader.log" 2>&1 & reader_pid=$! exec 3>"$lab/reader.sql" reader_open=1 printf '%s\n' 'BEGIN ISOLATION LEVEL REPEATABLE READ;' 'SELECT balance FROM account WHERE id=1;' >&3 attempt=0 while test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT EXISTS (SELECT FROM pg_stat_activity WHERE application_name='replica_snapshot' AND state='idle in transaction' AND backend_xmin IS NOT NULL)")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done 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 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt -c 'UPDATE account SET balance=80 WHERE id=1' target=$(psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT pg_current_wal_lsn()') printf 'primary_balance ' psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' printf 'paused_replica_balance ' PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1' for attempt in 1 2 3 4 5; do test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_last_wal_replay_lsn()>='$target'::pg_lsn")" = f sleep 0.1 done echo fresh_read_gate_timed_out 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 || exit 1 sleep 0.1 done test "$(PGPORT=56433 psql -X -v ON_ERROR_STOP=1 -qAt -c 'SELECT balance FROM account WHERE id=1')" = 80 echo fresh_snapshot_after_replay_80 printf '%s\n' 'SELECT balance FROM account WHERE id=1;' 'COMMIT;' >&3 exec 3>&- reader_open=0 wait "$reader_pid" reader_pid='' test "$(cat "$lab/reader.log")" = "$(printf '100\n100')" echo old_repeatable_read_snapshot_still_100 printf 'lab_directory %s\n' "$lab" 初始余额是一百。备库上的可重复读事务先执行一次查询,再保持打开。脚本核对这个后端确实处于事务内空闲状态并持有快照,随后请求暂停日志回放,并等待实际暂停。主库把余额提交为八十,捕获提交之后的日志位置,作为这次读取需要达到的进度目标。 ...

七月 12, 2027 · 3 分钟 · XB Field Notes
断开的备库迫使主库保留一长段 WAL。

停滞的复制槽会继续保留 WAL

PostgreSQL 复制槽记录某个消费者需要保留的材料。一旦槽已经预留日志位置,消费者连接消失,并不会自动解除这份要求。主库可能继续生成日志,而槽的保留起点一直不动。实际风险是磁盘保留量增长,以及追赶还是重建越来越难取舍。槽是否可用,取决于必需日志是否仍然存在,以及实际配置的失效规则。 坏掉的样子 新建物理复制槽不一定立即保留 WAL。如果没有请求立即预留,在槽第一次使用之前,重启位置可能始终为空。因此,仅凭不活跃标志不能量化保留量。需要结合槽类型、预留位置、可用状态和消费者归属来理解。对于逻辑槽,已确认刷新位置描述消费者确认的进度,也不能替代对更早重启位置及其保留义务的分析。 删除槽解除的是这个槽的要求,不一定会在同一瞬间删除文件。检查点清理和其他保留条件仍然有效。别的复制槽、归档积压或日志预留,都可能继续保留文件;回收还可能只是重命名供将来复用,并不把全部已分配字节交还文件系统。多个消费者需要的日志区间可以重叠,所以把每个槽的滞后量简单相加也会重复计算。 保留限制是在两种风险之间做交换。槽的最大日志保留量在检查点附近执行,不是瞬间限制磁盘大小的硬配额。落后足够多的槽可能失去必需日志并变得不可用。这样减少无限保留风险,代价是放弃该消费者现有的追赶路径。仅仅静默时间长,不足以证明可以删除一个仍有真实消费者依赖的槽。 快速验证 在 Linux shell 环境里,以非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端和客户端程序。本次实际执行版本是 PostgreSQL 18.6。私有临时集群不打开网络监听,里面只有实验用复制槽。少量有日志写入配合强制切段,使保留现象快速可见;这不是对真实业务日志生成量的性能基准。 #!/bin/sh set -eu umask 077 lab=$(mktemp -d /tmp/xbcatm-slots.XXXXXX) mkdir "$lab/socket" cluster=$lab/data cleanup() { if test -f "$cluster/postmaster.pid"; then pg_ctl -D "$cluster" -m fast -w stop >/dev/null fi } trap cleanup EXIT initdb -D "$cluster" --no-locale --encoding=UTF8 --auth-local=trust --auth-host=reject >/dev/null cat >> "$cluster/postgresql.conf" <<CONF listen_addresses = '' unix_socket_directories = '$lab/socket' port = 56432 wal_level = replica max_replication_slots = 4 max_slot_wal_keep_size = -1 min_wal_size = '32MB' max_wal_size = '64MB' checkpoint_timeout = '1h' CONF pg_ctl -D "$cluster" -l "$lab/server.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' SELECT * FROM pg_create_physical_replication_slot('unused_slot'); SELECT active,restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot'; DO $$ BEGIN IF (SELECT restart_lsn IS NULL FROM pg_replication_slots WHERE slot_name='unused_slot') IS NOT TRUE THEN RAISE EXCEPTION 'unexpected immediate reservation'; END IF; END $$; SELECT pg_drop_replication_slot('unused_slot'); CREATE TABLE churn (value text); SELECT * FROM pg_create_physical_replication_slot('held_slot',true); SQL segment=$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_walfile_name(restart_lsn) FROM pg_replication_slots WHERE slot_name='held_slot'") psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT active,wal_status,pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot'; DO $$ BEGIN IF (SELECT NOT active AND wal_status='extended' AND pg_wal_lsn_diff(pg_current_wal_lsn(),restart_lsn)>64*1024*1024 FROM pg_replication_slots WHERE slot_name='held_slot') IS NOT TRUE THEN RAISE EXCEPTION 'inactive slot did not retain the expected WAL range'; END IF; END $$; SQL test -f "$cluster/pg_wal/$segment" echo inactive_slot_retains_old_segment psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT pg_drop_replication_slot('held_slot')" >/dev/null test -f "$cluster/pg_wal/$segment" echo drop_did_not_immediately_remove_segment psql -X -v ON_ERROR_STOP=1 -qAt -c CHECKPOINT test ! -f "$cluster/pg_wal/$segment" echo checkpoint_released_old_segment_name psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' ALTER SYSTEM SET max_slot_wal_keep_size='32MB'; SELECT pg_reload_conf(); SQL attempt=0 while test "$(psql -X -v ON_ERROR_STOP=1 -qAt -c "SELECT current_setting('max_slot_wal_keep_size')='32MB'")" != t; do attempt=$((attempt+1)) test "$attempt" -lt 100 || exit 1 sleep 0.1 done psql -X -v ON_ERROR_STOP=1 -qAt <<'SQL' SELECT * FROM pg_create_physical_replication_slot('limited_slot',true); DO $$ BEGIN FOR n IN 1..8 LOOP INSERT INTO churn SELECT md5(g::text) FROM generate_series(1,5000) g; PERFORM pg_switch_wal(); END LOOP; END $$; CHECKPOINT; SELECT wal_status,invalidation_reason FROM pg_replication_slots WHERE slot_name='limited_slot'; DO $$ BEGIN IF (SELECT wal_status='lost' AND invalidation_reason='wal_removed' FROM pg_replication_slots WHERE slot_name='limited_slot') IS NOT TRUE THEN RAISE EXCEPTION 'slot limit did not invalidate the test slot'; END IF; END $$; SELECT pg_drop_replication_slot('limited_slot'); SQL printf 'lab_directory %s\n' "$lab" 第一个物理槽创建时不立即预留。输出中的假与真,分别表示槽不活跃、重启位置为空。脚本对此作出断言,再删除这个尚未使用的夹具。下一个槽明确请求预留,而且始终没有消费者连接。经过写入、切段和检查点后,观察到不活跃、保留范围超出最大 WAL 目标,以及按日志位置差计算落后超过六十四兆二进制字节的状态。 ...

十月 19, 2026 · 2 分钟 · XB Field Notes