PostgreSQL 复制槽记录某个消费者需要保留的材料。一旦槽已经预留日志位置,消费者连接消失,并不会自动解除这份要求。主库可能继续生成日志,而槽的保留起点一直不动。实际风险是磁盘保留量增长,以及追赶还是重建越来越难取舍。槽是否可用,取决于必需日志是否仍然存在,以及实际配置的失效规则。

坏掉的样子

新建物理复制槽不一定立即保留 WAL。如果没有请求立即预留,在槽第一次使用之前,重启位置可能始终为空。因此,仅凭不活跃标志不能量化保留量。需要结合槽类型、预留位置、可用状态和消费者归属来理解。对于逻辑槽,已确认刷新位置描述消费者确认的进度,也不能替代对更早重启位置及其保留义务的分析。

删除槽解除的是这个槽的要求,不一定会在同一瞬间删除文件。检查点清理和其他保留条件仍然有效。别的复制槽、归档积压或日志预留,都可能继续保留文件;回收还可能只是重命名供将来复用,并不把全部已分配字节交还文件系统。多个消费者需要的日志区间可以重叠,所以把每个槽的滞后量简单相加也会重复计算。

保留限制是在两种风险之间做交换。槽的最大日志保留量在检查点附近执行,不是瞬间限制磁盘大小的硬配额。落后足够多的槽可能失去必需日志并变得不可用。这样减少无限保留风险,代价是放弃该消费者现有的追赶路径。仅仅静默时间长,不足以证明可以删除一个仍有真实消费者依赖的槽。

快速验证

在 Linux shell 环境里,以非超级操作系统账户运行脚本,准备 PostgreSQL 18 服务端和客户端程序。本次实际执行版本是 PostgreSQL 18.6。私有临时集群不打开 TCP 监听,客户端通过私有 Unix 套接字连接,里面只有实验用复制槽。少量有日志写入配合强制切段,使保留现象快速可见;这不是对真实业务日志生成量的性能基准。

#!/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 目标,以及按日志位置差计算落后超过六十四兆二进制字节的状态。

脚本先检查最初需要的日志段仍然存在,再删除这个测试槽,并确认文件随即仍在。后续检查点之后,旧日志段名字才消失。这些结果把解除保留要求与立即完成文件系统清理区分开来,并不声称整个日志目录一定变空,也不声称原空间被回收后就一定从文件系统占用中消失。

最后一阶段设置三十二兆二进制字节的槽保留限制,核对配置重新加载已经生效,再预留一个新槽。继续写入和切段,使它超过限制。检查点之后,必须得到日志已经丢失、失效原因是必要日志被移除的结果;断言核对可用状态与原因。这里观察到的是槽的恢复路径丢失,不是某个备库成功追赶到了当前位置。

脚本只删除自己的测试槽,退出时停止临时服务,并打印保留日志与数据的目录,便于复核。整个演练不会断开或重建真实消费者。需要清理的是实验专属资源,生产里该不该放弃某条复制链路,仍然要由消费者归属与恢复承诺决定,不能机械照抄测试中的删除动作。

根据归属与可恢复性作决定

监控重启位置、日志状态、安全余量、不活跃时间、实际保留字节以及剩余空间。安全余量为空,可能表示没有设置保留上限,也可能表示槽已经失效,所以必须结合其他字段解释。PostgreSQL 18 还提供明确的失效原因,可以区分日志被移除与其他原因,避免单纯根据静默时长猜测。

退役一个槽之前,要找到它的所有者,并确认消费者是否还会恢复。何时应重新建立副本,何时应继续保留历史,应当明确选择。若必要日志已经消失,事后调大限制并不会重新生成它。只有替代消费者从一致起点建立起来,并且持续向前推进,才能把这次事件视为恢复。单纯看到磁盘曲线下降,不能证明复制服务也恢复了正常。

来源