系统学习 演练学习路径:35 个 lab 的难度排序、4 周基础计划与复盘方法

Lab 状态:实跑通过2026-10-25 · Docker Compose / 测试数据库

切换、连接恢复与一致性检查通过

在本地 PostgreSQL 主从容器中模拟主库停止,演练摘流量、提升从库、重建旧主与数据一致性核对

演练目标

安全边界:本 Lab 只允许在一次性虚拟机、kind 或本地 Docker Compose 中进行。不要在生产数据库上执行 pg_ctl promote、STOP REPLICA、SET GLOBAL read_only=OFF 或任何切换命令。真实故障转移涉及 RPO/RTO、连接入口、连接池、CDC 和业务写入方,必须按变更流程执行,并由业务方确认可丢失窗口。

环境准备

准备 Docker 与 Docker Compose v2,预留约 1 GB 内存。实验使用两个本地容器、独立网络和临时数据卷,不映射数据库端口到宿主机,避免被其他进程误连。镜像标签可换成本地已缓存的主版本镜像,不要为了实验随意使用不明来源的镜像。

mkdir -p /tmp/lab-db/backup
docker version --format '{{.ServerVersion}}'
docker compose version

创建实验专用的认证配置和复制账号初始化脚本。这里的 trust 只用于隔离的 Docker 网络,生产环境必须使用强密码、TLS 和最小权限:

cat > /tmp/lab-db/pg_hba.conf <<'EOF'
local   all             all                                     trust
host    all             all             0.0.0.0/0               scram-sha-256
host    replication     replicator      0.0.0.0/0               trust
EOF
cat > /tmp/lab-db/init-replication.sql <<'EOF'
CREATE ROLE replicator WITH REPLICATION LOGIN PASSWORD 'labpass';
EOF

编写 Compose 文件。主库显式开启 WAL 归档所需的复制级别;从库先用 pg_basebackup 做基础备份,再由 -R 生成 standby 配置并启动:

cat > /tmp/lab-db/docker-compose.yml <<'EOF'
name: lab-db
services:
  pg-primary:
    image: postgres:16
    container_name: lab-pg-primary
    environment:
      POSTGRES_USER: lab
      POSTGRES_PASSWORD: labpass
      POSTGRES_DB: app
    command: >
      postgres -c wal_level=replica -c max_wal_senders=5
      -c hot_standby=on -c hba_file=/etc/postgresql/pg_hba.conf
    volumes:
      - pgprimary:/var/lib/postgresql/data
      - ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
      - ./init-replication.sql:/docker-entrypoint-initdb.d/10-replication.sql:ro
  pg-replica:
    image: postgres:16
    container_name: lab-pg-replica
    # PostgreSQL 拒绝以 root 运行;自定义 bash 命令不会触发镜像的重启降权
    user: postgres
    environment:
      PGDATA: /var/lib/postgresql/replica
    depends_on:
      - pg-primary
    command: >
      bash -c "until pg_isready -h pg-primary -U lab; do sleep 1; done;
      pg_basebackup -h pg-primary -U replicator -D /var/lib/postgresql/replica -Fp -Xs -R -P;
      exec postgres -c hba_file=/etc/postgresql/pg_hba.conf -c hot_standby=on"
    volumes:
      - ./pg_hba.conf:/etc/postgresql/pg_hba.conf:ro
volumes:
  pgprimary:
networks:
  default:
    name: lab-db-net
EOF
timeout 240 docker compose -f /tmp/lab-db/docker-compose.yml up -d
timeout 180 bash -c 'until docker exec lab-pg-replica pg_isready -U lab -d app; do sleep 2; done'

确认主从关系、复制状态和从库只读。pg_is_in_recovery() 为 true 表示从库仍处于恢复模式,只能读不能写:

timeout 30 docker exec lab-pg-primary psql -U lab -d app -c \
  "SELECT application_name, state, sync_state, sent_lsn, replay_lsn, write_lag, flush_lag, replay_lag FROM pg_stat_replication;"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT pg_is_in_recovery(), pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn();"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT now() - pg_last_xact_replay_timestamp() AS replay_delay;"

创建业务表、写入基线数据,并做一次可验证的逻辑备份。备份文件要复制到容器外,且用 pg_restore --list 验证可读:

timeout 30 docker exec lab-pg-primary psql -U lab -d app -c \
  "CREATE TABLE accounts (id bigserial PRIMARY KEY, owner text NOT NULL, balance numeric(12,2) NOT NULL);"
timeout 30 docker exec lab-pg-primary psql -U lab -d app -c \
  "INSERT INTO accounts (owner, balance) VALUES ('alice', 100), ('bob', 200);"
timeout 30 docker exec lab-pg-primary psql -U lab -d app -Atc \
  "SELECT count(*), coalesce(sum(balance),0), max(id) FROM accounts;" \
  | tee /tmp/lab-db/backup/baseline.txt
timeout 30 docker exec lab-pg-primary psql -U lab -d app -Atc \
  "SELECT pg_current_wal_lsn();" | tee /tmp/lab-db/backup/lsn-before.txt
timeout 120 docker exec lab-pg-primary pg_dump -U lab -d app -Fc -f /tmp/app-before.dump
timeout 60 docker cp lab-pg-primary:/tmp/app-before.dump /tmp/lab-db/backup/app-before.dump
timeout 60 docker exec lab-pg-primary pg_restore --list /tmp/app-before.dump | head

等待从库追平后,用从库上的查询结果与主库基线对比。复制延迟为 0 只代表采样时刻没有延迟,不代表故障时不会丢失最后一段事务。

timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc \
  "SELECT count(*), coalesce(sum(balance),0), max(id) FROM accounts;"

注入故障

把主库容器停掉,模拟主库进程不可用。docker stop 是受控停止,不等同于断电;真实宕机可能丢失尚未发送或尚未回放的事务,因此故障转移必须明确 RPO:

timeout 60 docker stop lab-pg-primary
timeout 30 docker ps -a --filter name=lab-pg-primary --format '{{.Names}} {{.Status}}'
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT pg_is_in_recovery(), pg_last_wal_replay_lsn();"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "INSERT INTO accounts (owner, balance) VALUES ('blocked', 1);"; echo "write_before_promote_exit=$?"

提升前从库应保持只读,写入会失败。确认旧主已经停止、不会被应用继续写入后,再提升从库。提升命令必须使用容器内实际的数据目录,并加超时:

timeout 60 docker exec -u postgres lab-pg-replica pg_ctl promote -D /var/lib/postgresql/replica
sleep 3
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT pg_is_in_recovery(), pg_current_wal_lsn();"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "INSERT INTO accounts (owner, balance) VALUES ('carol', 50);"

提升成功后,新主库 pg_is_in_recovery() 返回 false,写入成功。此时旧主仍处于停止状态,绝不能直接 docker start 后把它接回读写入口;那会形成两个可写主库。

观察现象

主库停止后,从库的 WAL receiver 会断开,日志中出现连接失败或复制中断;从库仍可接受只读查询,但写入报 cannot execute INSERT in a read-only transaction,这是热备的正常行为,不是从库损坏。提升后,从库退出恢复模式,时间线推进,新写入可以提交;旧主容器仍是 Exited 状态,没有被应用连接。

故障转移成功的判据是:旧主已隔离;新主 pg_is_in_recovery() 为 false 且能提交写入;新主 WAL 位置持续增长;剩余从库能重新指向新主并追平;应用完成写入、读取和事务回滚验证;行数、汇总值和最大 ID 与切换前基线及故障后写入量能对上。

失败的判据同样要提前写死:提升命令失败或新主仍为只读;旧主仍在接受写入;两个节点同时可写;从库复制中断且无法追平;应用仍连旧主;关键表数据缺失、重复或约束失效;GTID/LSN 出现无法解释的缺口。任意一条成立都应停止流量切换,先隔离写入方。

判断与定位

  1. 先确认拓扑和角色。pg_is_in_recovery()、pg_stat_replication、pg_stat_wal_receiver 和 pg_last_wal_replay_lsn() 能区分主库、从库、复制中断和回放延迟;不要只看容器是否 Running。
  2. 提升前必须确认从库已经回放完 relay/WAL,且没有长时间运行的事务阻塞回放。故障转移要选择 LSN 最靠前、磁盘和网络健康的从库,不要选延迟最大的节点。
  3. 摘流量和隔离旧主是两步。仅把 DNS 或 VIP 指向新主不够,旧主上的直连客户端、批处理、CDC 和运维脚本仍可能写入;必须让旧主停止、只读或从网络隔离。
  4. 容易误判:从库能读就以为可以切主;pg_is_in_recovery() 为 false 就以为应用已经切换;复制延迟为 0 就以为没有数据丢失;docker start 旧主就以为完成了回切。
  5. 序列和自增值不能简单用“完全相等”判断一致性。序列可能有缓存和跳号,重点是最大 ID、唯一性、行数、金额汇总和约束;切换后新写入的 ID 不能与旧数据冲突。
  6. MySQL 的对应概念是 SHOW REPLICA STATUS、read_only/super_read_only、GTID 和 CHANGE REPLICATION SOURCE TO。不要把 PostgreSQL 的 LSN、复制槽命令与 MySQL 的 GTID 命令混用;字段名和语法随版本变化,以官方文档为准。

恢复与回滚

先区分两种情形。若新主还没有接受任何业务写入,回滚相对简单:停止新主,重新启动旧主,确认旧主数据完整后再恢复原入口,然后把新主按从库重建。若新主已经接受写入,就不能直接把旧主拉起来当主库,否则会形成双主和数据分叉。

下面演示“已发生写入后”的向前恢复:把旧主重建为指向新主的从库。旧容器先保留,不删除其数据卷,便于回滚和取证;新容器使用新的临时数据目录,通过基础备份重新加入:

timeout 30 docker rm -f lab-pg-old-rebuilt 2>/dev/null || true
timeout 240 docker run -d --name lab-pg-old-rebuilt --network lab-db-net \
  --user postgres \
  -e PGDATA=/var/lib/postgresql/replica \
  -v /tmp/lab-db/pg_hba.conf:/etc/postgresql/pg_hba.conf:ro \
  postgres:16 \
  bash -c "until pg_isready -h lab-pg-replica -U lab; do sleep 1; done;
  pg_basebackup -h lab-pg-replica -U replicator -D /var/lib/postgresql/replica -Fp -Xs -R -P;
  exec postgres -c hba_file=/etc/postgresql/pg_hba.conf -c hot_standby=on"

重建容器需要先完成 pg_basebackup 才会启动 PostgreSQL;pg_isready 只代表实例可连接,pg_is_in_recovery() 才代表已进入恢复模式。查询前先等实例就绪:

timeout 180 bash -c 'until docker exec lab-pg-old-rebuilt pg_isready -U lab -d app; do sleep 2; done'

确认旧主已作为从库运行,并在新主上看到复制连接:

timeout 30 docker exec lab-pg-old-rebuilt psql -U lab -d app -c \
  "SELECT pg_is_in_recovery();"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT application_name, state, sent_lsn, replay_lsn, replay_lag FROM pg_stat_replication;"

如果提升失败或新主不可用,停止新主并从剩余从库中选择数据最完整的一台重新提升;不要回到旧主直接写入。如果出现双写,立即停止一侧写入,比较 LSN、WAL 和业务数据,确定唯一权威主库;另一侧必须重建,不能靠手工合并猜测。若数据损坏且逻辑备份可用,可在独立实例上用 pg_restore 恢复,再按业务确认的时间点补数据;需要精确到时间点时使用 WAL 归档和 PITR,参考 runbook-postgres-pitr。

回切应在业务稳定、复制追平、数据核对完成后按计划执行,不要在新主刚接管时立即回切。回切步骤与切换对称:冻结写入、确认追平、摘流量、提升原主、切换入口、重建从库。

实验结束只清理本实验的容器、网络和卷,不要对任何生产容器执行 down -v:

timeout 60 docker rm -f lab-pg-old-rebuilt 2>/dev/null || true
timeout 120 docker compose -f /tmp/lab-db/docker-compose.yml down -v --remove-orphans
rm -rf /tmp/lab-db

恢复后的数据一致性检查包括:在新主和重建从库上分别统计行数、金额汇总、最大 ID 和重复 ID;确认故障前基线加上故障后成功写入能够解释当前结果;检查主键、唯一约束和外键没有被破坏;在新主写入一条带唯一标记的记录,等待复制后在从库读到;确认旧主没有保留故障后的冲突写入;记录可能丢失的事务范围和 RPO 违约情况。不要用“复制正常”掩盖已经丢失的数据。

验证

最终验证要同时覆盖数据库角色、复制、应用和数据四个层面:

timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT pg_is_in_recovery(), pg_current_wal_lsn();"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "SELECT application_name, state, sent_lsn, replay_lsn, replay_lag FROM pg_stat_replication;"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc \
  "SELECT count(*), coalesce(sum(balance),0), max(id) FROM accounts;"
timeout 30 docker exec lab-pg-old-rebuilt psql -U lab -d app -Atc \
  "SELECT count(*), coalesce(sum(balance),0), max(id) FROM accounts;"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -c \
  "INSERT INTO accounts (owner, balance) VALUES ('verify', 1) RETURNING id;"
sleep 2
timeout 30 docker exec lab-pg-old-rebuilt psql -U lab -d app -Atc \
  "SELECT count(*) FROM accounts WHERE owner = 'verify';"
timeout 30 docker exec lab-pg-replica psql -U lab -d app -Atc \
  "SELECT count(*) FROM (SELECT id FROM accounts GROUP BY id HAVING count(*) > 1) AS dup;"

复盘

相关命令

参考链接

读完自测

  1. 停掉主库后、提升从库前,从库为什么还能读但不能写?此时直接写从库会发生什么?
  2. 为什么提升新主之前必须先隔离旧主?如果新主已经接受写入,为什么不能直接把旧主重新启动为可写主库?
  3. 重建旧主为从库后,应该用哪些检查确认数据一致?为什么复制延迟为 0 还不够?
答案
  1. 从库处于 hot standby 恢复模式,可以接受只读查询,但事务系统不允许写入;直接写会报只读事务错误。只有执行提升、退出恢复模式后,从库才能接受写入。
  2. 旧主仍可写会与新主形成双写和脑裂,之后的数据分叉无法靠简单合并修复。新主一旦接受写入,旧主的数据时间线已经落后,必须通过基础备份或 pg_rewind 重建为从库,不能直接启动后接回读写入口。
  3. 要比较行数、金额汇总、最大 ID、重复 ID、约束和故障后标记记录,确认旧主没有冲突写入,并量化故障窗口内可能丢失的事务。延迟为 0 只说明采样时刻复制追平,不能证明故障发生时没有未复制事务,也不能证明应用已经切到新主。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令