只读副本允许一份数据库服务查询,另一份数据库处理写入。报表、搜索或其他适合的读取,可以由此离开写入节点,但把连接路由到某个地址只是设计的一部分。应用还必须说明返回结果需要包含哪些已经提交的变更。本文采用 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"

初始余额是一百。备库上的可重复读事务先执行一次查询,再保持打开。脚本核对这个后端确实处于事务内空闲状态并持有快照,随后请求暂停日志回放,并等待实际暂停。主库把余额提交为八十,捕获提交之后的日志位置,作为这次读取需要达到的进度目标。

预期输出分别显示主库余额八十、暂停副本余额一百。五次回放检查之间进行短暂等待,结果都为假,于是输出新鲜读取门槛等待失败的标记。这表示必要变更尚未应用,不能确认读取足够新。实验不会悄悄接受旧结果,也不把这些轮询间隔解释成精确的端到端延迟保证,因为每次连接与查询本身同样需要时间。

回放恢复之后,脚本等待其位置达到目标,然后新建的备库查询必须读到八十。原有可重复读会话此时再次查询,却仍然看到一百。保存的会话输出必须连续包含两次一百,证明旧快照在回放进度变化之后仍然有效。因此,“服务端已经追上”和“这个事务仍然读取旧数据”可以同时成立,不能把它们误诊成相互矛盾的统计。

脚本提交保留的读事务,关闭管道,停止两个实验服务,并打印日志目录。断言检查真实数据库值和实际回放状态,不依赖应用缓存推测。这次演练不会修改任何生产路由或连接池;其完成条件是把不同读取边界分别观察清楚,而不是宣称已经完成业务系统的副本路由改造。

明确路由所承诺的结果

进度标识必须属于目标副本所在的同一条数据库历史。日志位置不是在无关集群或已经分叉的时间线之间全局唯一的业务版本。生产环境中的进度门槛需要绑定相关拓扑,并明确处理故障切换。这个夹具使用固定主库及其直接副本,所以身份关系由建立实验的过程保证,不能把这一前提从生产方案里省略。

如果请求预算内无法确认新鲜度,应明确返回应用能够解释的状态,或在已授权且容量允许时从写入节点读取。不能因为达到截止时间,就暗中降低结果含义。门槛满足后再取得新快照,并保持事务足够短,避免旧快照长期保留使后续进度检查失去意义。业务需要的是确定的读取契约,而不只是尽量快地返回一个数字。

备库上的长查询还可能与恢复工作冲突。PostgreSQL 可以取消它们以推进回放;反馈设置则会在减少取消与增加主库旧版本保留之间交换代价。应联合观察回放延迟、查询取消与事务年龄。评估副本收益时,也要测量真正从写入节点移走了多少读取工作,并把复制开销及维护副本的运行成本一起纳入判断。

来源