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

版本信息最后核对:2026-10-25

Lab 状态:实跑通过2026-10-25 · Docker Compose

故障切换、双主隔离与 Sentinel 收敛通过;计划内回切按实际主节点判断

在 Docker Compose 的 Redis 主从加 Sentinel 拓扑中演练只读副本提升、客户端重连、偏移对比与双主隔离

演练目标

安全边界:本 Lab 只允许在一次性虚拟机或本地 Docker Compose 中进行。不要在生产 Redis 上执行 REPLICAOF NO ONE、SENTINEL FAILOVER、SHUTDOWN 或任何摘除节点的命令。真实切换涉及连接池、Sentinel quorum、DNS/VIP、CDC 和业务写入方,必须走变更流程并明确 RPO。实验数据卷在结束后整体删除。

环境准备

准备 Docker 与 Docker Compose v2,预留约 1 GB 内存。所有容器只挂在实验专用网络 lab-redis-net,不映射端口到宿主机。macOS 若没有 timeout,可用 coreutils 的 gtimeout 替代,不要省略超时。

timeout 10 mkdir -p /tmp/lab-redis/{backup,sentinel-1,sentinel-2,sentinel-3}
timeout 30 docker version --format '{{.ServerVersion}}'
timeout 30 docker compose version

三个 Sentinel 各用一份配置。resolve-hostnames 和 announce-hostnames 让哨兵在容器网络中保存服务名而不是易变 IP;quorum 为 2,三哨兵中允许一台失联。下面的 chmod 777 只针对一次性实验目录,生产配置必须使用最小权限:

for i in 1 2 3; do
cat > /tmp/lab-redis/sentinel-$i/sentinel.conf <<'EOF'
port 26379
sentinel resolve-hostnames yes
sentinel announce-hostnames yes
sentinel monitor labmaster redis-master 6379 2
sentinel down-after-milliseconds labmaster 5000
sentinel failover-timeout labmaster 60000
sentinel parallel-syncs labmaster 1
EOF
done
timeout 30 chmod -R 777 /tmp/lab-redis/sentinel-1 /tmp/lab-redis/sentinel-2 /tmp/lab-redis/sentinel-3

编写 Compose 文件。主库开启 AOF 便于停止后恢复;副本显式只读;client 容器只作为模拟应用,用来验证静态地址与 Sentinel 发现两种连接方式:

cat > /tmp/lab-redis/docker-compose.yml <<'EOF'
name: lab-redis
services:
  redis-master:
    image: redis:7.2
    container_name: lab-redis-master
    command: ["redis-server", "--appendonly", "yes", "--save", ""]
  redis-replica-1:
    image: redis:7.2
    container_name: lab-redis-replica-1
    depends_on: [redis-master]
    command: ["redis-server", "--replicaof", "redis-master", "6379"]
  redis-replica-2:
    image: redis:7.2
    container_name: lab-redis-replica-2
    depends_on: [redis-master]
    command: ["redis-server", "--replicaof", "redis-master", "6379"]
  sentinel-1:
    image: redis:7.2
    container_name: lab-redis-sentinel-1
    depends_on: [redis-master]
    command: ["redis-server", "/etc/redis/sentinel.conf", "--sentinel"]
    volumes: ["./sentinel-1:/etc/redis"]
  sentinel-2:
    image: redis:7.2
    container_name: lab-redis-sentinel-2
    depends_on: [redis-master]
    command: ["redis-server", "/etc/redis/sentinel.conf", "--sentinel"]
    volumes: ["./sentinel-2:/etc/redis"]
  sentinel-3:
    image: redis:7.2
    container_name: lab-redis-sentinel-3
    depends_on: [redis-master]
    command: ["redis-server", "/etc/redis/sentinel.conf", "--sentinel"]
    volumes: ["./sentinel-3:/etc/redis"]
  client:
    image: redis:7.2
    container_name: lab-redis-client
    command: ["sleep", "infinity"]
networks:
  default:
    name: lab-redis-net
EOF
timeout 240 docker compose -f /tmp/lab-redis/docker-compose.yml up -d
timeout 180 bash -c 'until docker exec lab-redis-replica-1 redis-cli INFO replication | grep -q "master_link_status:up"; do sleep 2; done'
timeout 180 bash -c 'until docker exec lab-redis-replica-2 redis-cli INFO replication | grep -q "master_link_status:up"; do sleep 2; done'
timeout 60 docker exec lab-redis-sentinel-1 redis-cli -p 26379 SENTINEL masters

写入基线并确认副本可读。复制是异步的,写入主库成功不代表副本已经收到,后续偏移对比才有意义:

timeout 20 docker exec lab-redis-master redis-cli SET lab:order:1 '{"amount":100}'
timeout 20 docker exec lab-redis-master redis-cli HSET lab:session:1 user alice
timeout 20 docker exec lab-redis-master redis-cli EXPIRE lab:session:1 600
timeout 20 docker exec lab-redis-master redis-cli INCR lab:counter
timeout 60 bash -c 'until [ "$(docker exec lab-redis-replica-1 redis-cli GET lab:counter)" = "1" ]; do sleep 1; done'
timeout 20 docker exec lab-redis-client redis-cli -h redis-replica-1 GET lab:counter
READONLY_OUT=$(timeout 20 docker exec lab-redis-client redis-cli -h redis-replica-1 SET lab:readonly test 2>&1); echo "replica_write_output=$READONLY_OUT"

最后一条应返回 READONLY。redis-cli 对服务端错误通常仍以退出码 0 结束,判据是输出中的 READONLY 文本,而不是 $?。在注入故障前做一次可验证快照,作为回滚和取证材料,文件放到宿主机实验目录:

timeout 60 docker exec lab-redis-master redis-cli SAVE
timeout 30 docker cp lab-redis-master:/data/dump.rdb /tmp/lab-redis/backup/before-failover.rdb
timeout 30 shasum -a 256 /tmp/lab-redis/backup/before-failover.rdb | tee /tmp/lab-redis/backup/before-failover.sha256

注入故障

先固定偏移基线,再停止主节点。docker stop 是受控停止,不能代表断电;真实宕机可能丢失尚未传播的写入:

timeout 30 docker exec lab-redis-master redis-cli INFO replication > /tmp/lab-redis/backup/master-before.txt
timeout 30 docker exec lab-redis-replica-1 redis-cli INFO replication > /tmp/lab-redis/backup/replica-1-before.txt
timeout 30 docker exec lab-redis-replica-2 redis-cli INFO replication > /tmp/lab-redis/backup/replica-2-before.txt
# 以下变量和后续切换命令应在同一个 shell 会话中执行;跨终端会丢失 OLD_OFFSET/NEW_MASTER。
OLD_OFFSET=$(awk -F: '/^master_repl_offset:/{print $2+0}' /tmp/lab-redis/backup/master-before.txt)
echo "old_master_offset=$OLD_OFFSET"
timeout 60 docker stop lab-redis-master
timeout 30 docker ps -a --filter name=lab-redis-master --format '{{.Names}} {{.Status}}'

等待 Sentinel 完成提升。默认 down-after-milliseconds 为 5 秒,多数派满足后才会客观下线并发起故障转移,不要在这段时间内重复手工提权:

timeout 120 bash -c 'until for c in lab-redis-replica-1 lab-redis-replica-2; do docker exec "$c" redis-cli --raw ROLE | head -1; done | grep -qx master; do sleep 3; done'
timeout 30 docker logs --since 10m lab-redis-sentinel-1 | grep -E '\+sdown|\+odown|\+try-failover|\+promoted-slave|\+switch-master' | tail -20
NEW_MASTER=$(for c in lab-redis-replica-1 lab-redis-replica-2; do
  if [ "$(timeout 20 docker exec "$c" redis-cli --raw ROLE | head -1)" = master ]; then echo "$c"; fi
done | head -1)
echo "new_master=$NEW_MASTER"
timeout 30 docker exec "$NEW_MASTER" redis-cli INFO replication | head -12

对比旧主最后记录的偏移与新主当前偏移。注意两者不一定同一量纲:Redis 7 默认的 diskless 全量同步会把 RDB 字节计入新主的 master_repl_offset,因此差值可能为负,也可能明显大于实际丢失量。差值只能在同一次同步上下文里做方向性参考,不能单独量化 RPO:

NEW_OFFSET=$(timeout 30 docker exec "$NEW_MASTER" redis-cli INFO replication | awk -F: '/^master_repl_offset:/{print $2+0}')
echo "old=$OLD_OFFSET new=$NEW_OFFSET delta=$((OLD_OFFSET-NEW_OFFSET))"
timeout 20 docker exec "$NEW_MASTER" redis-cli INFO replication | grep -E 'sync_full|sync_partial_ok|sync_partial_err'

timeout 20 docker exec "$NEW_MASTER" redis-cli SET lab:order:2 '{"amount":200}' for c in lab-redis-replica-1 lab-redis-replica-2; do echo "== $c"; timeout 20 docker exec "$c" redis-cli ROLE | head -1 timeout 20 docker exec "$c" redis-cli GET lab:order:2 done


验证客户端重连。静态地址仍指向已停止的旧主,必然失败;Sentinel 感知路径会重新解析主节点并重建连接,这也是应用连接池在切换后应实现的行为:

```shell
timeout 20 docker exec lab-redis-client redis-cli -h redis-master GET lab:counter; echo "static_exit=$?"
timeout 20 docker exec lab-redis-client sh -c 'H=$(redis-cli -h lab-redis-sentinel-1 -p 26379 --raw SENTINEL get-master-addr-by-name labmaster | head -1); echo "resolved=$H"; redis-cli -h "$H" INCR lab:counter'

现在制造双主窗口。暂停全部 Sentinel,阻止它们把旧主降级;重新启动旧主,它会按本地 AOF 恢复并以主节点身份接受写入。此时新主和旧主都可写,正是最危险的脑裂状态:

timeout 30 docker pause lab-redis-sentinel-1 lab-redis-sentinel-2 lab-redis-sentinel-3
timeout 60 docker start lab-redis-master
timeout 30 bash -c 'until docker exec lab-redis-master redis-cli PING | grep -q PONG; do sleep 1; done'
timeout 20 docker exec lab-redis-master redis-cli ROLE | head -2
timeout 20 docker exec lab-redis-master redis-cli SET lab:rogue checkout-2026
timeout 20 docker exec "$NEW_MASTER" redis-cli SET lab:good verified

恢复 Sentinel 后,它们会把旧主重新配置为新主的副本。旧主偏移领先或复制历史不匹配时只能全量同步,分叉写入会被覆盖。生产中不能依赖这一步“自动修好”,必须先把旧主隔离并评估损失:

timeout 30 docker unpause lab-redis-sentinel-1 lab-redis-sentinel-2 lab-redis-sentinel-3
timeout 120 bash -c 'until [ "$(docker exec lab-redis-master redis-cli --raw ROLE | head -1)" = slave ]; do sleep 3; done'
timeout 120 bash -c 'until docker exec lab-redis-master redis-cli INFO replication | grep -q "master_link_status:up"; do sleep 3; done'
timeout 20 docker exec lab-redis-master redis-cli EXISTS lab:rogue
timeout 20 docker exec lab-redis-master redis-cli INFO replication | grep -E 'role|master_link_status|slave_repl_offset'

观察现象

主节点停止后,副本的 master_link_status 变为 down,Sentinel 日志依次出现 +sdown、+odown、+try-failover、+promoted-slave 和 +switch-master。被提升的副本 role 变为 master,另一个副本自动改指向新主并恢复 master_link_status:up。旧主重新启动后先以 role:master 出现,说明它的本地持久化数据仍在,Sentinel 恢复后才被降为副本。

成功的判据是:Sentinel 多数派健康且只发生一次切换;新主可写、副本重新指向新主并追平;旧主最终只读;lab:rogue 在权威数据中不存在;静态客户端失败后能通过 Sentinel 重新发现新主;切换前快照的校验和可验证。失败的判据是:两个节点长期同时可写;Sentinel 反复 +switch-master;新主写入报错;副本长期 master_link_status:down;关键 key 数量、值或 TTL 与基线无法解释;旧主的分叉写入在新主上仍然存在。任意一条成立都应停止流量切换,先隔离写入方。

判断与定位

  1. 先确认实际角色,不要只看容器是否 Running。INFO replication 的 role、master_link_status、master_last_io_seconds_ago 和 slave_repl_offset 能区分主、从、断链和延迟;字段名随版本可能显示 slave 或 replica,以当前输出为准。
  2. 偏移差值是风险窗口,不是精确丢失量。旧主停止前的最后采样可能早于最后一次写入,差值会低估损失;新主若经历 diskless 全量同步,RDB 字节又会抬高偏移,使差值为负或失真。要结合业务 key 对比、AOF 时间点、sync_full 计数和监控写入量共同判断。
  3. Sentinel 切换依赖多数派和 quorum。三哨兵中两台失联时不会安全提升;此时应先恢复网络和 Sentinel,而不是在副本上手工执行 REPLICAOF NO ONE。
  4. 客户端重连是独立故障面。DNS TTL、VIP、连接池、直连地址和代理都可能仍指向旧主;只把 Sentinel 指向新主,不代表应用已经切换。
  5. 旧主回归时,role:master 只代表进程角色,不代表它仍是权威数据源。它可能在隔离期间接受过写入,必须比较偏移、key 和业务日志后再决定。
  6. 双主不能靠手工合并修复。应先停止一侧写入、保存双方快照、确定唯一权威主,另一侧通过全量同步或备份重建;WAIT 只影响当前连接此前的写入,不提供事务级强一致。

恢复与回滚

若新主尚未接受业务写入,回滚相对简单:停止新主,确认旧主数据完整后启动旧主,再让 Sentinel 重新收敛,最后把新主按副本重建。若新主已经接受写入,绝不能直接把旧主拉回读写入口,否则旧主上的分叉数据会与新主冲突。

演练中的收敛路径已经演示:旧主作为副本重新加入,全量同步覆盖分叉写入。真实事故中要先隔离旧主、保留快照,再决定是否让它以副本身份回归。若数据有业务价值,只能从快照或 AOF 中提取并做幂等补偿,不能直接把旧数据写回新主。

需要计划内回切时,等旧主追平并保持健康,再用受控切换把主节点切回。回切是一次新变更,必须重新验证客户端、监控和备份,不要在故障刚结束时立即执行:

# SENTINEL FAILOVER 按 replica-priority/offset 选择副本,返回 OK 只代表命令被接受,不保证选中旧主。
# 如需回切到旧主,先把旧主优先级调低(数值越小越优先),并等待 Sentinel 刷新 INFO:
timeout 20 docker exec lab-redis-master redis-cli CONFIG SET replica-priority 10
timeout 20 docker exec lab-redis-replica-1 redis-cli CONFIG SET replica-priority 100
timeout 20 docker exec lab-redis-replica-2 redis-cli CONFIG SET replica-priority 100
CURRENT_MASTER=$(timeout 30 docker exec lab-redis-sentinel-1 redis-cli -p 26379 --raw SENTINEL get-master-addr-by-name labmaster | head -1)
echo "current_master=$CURRENT_MASTER"
timeout 60 docker exec lab-redis-sentinel-1 redis-cli -p 26379 SENTINEL FAILOVER labmaster
# 等 Sentinel 报出的主地址变化并连续稳定 15 秒;旧主短暂成为主又切走不能算成功。
timeout 240 bash -c '
  stable=0; last=""
  while [ "$stable" -lt 3 ]; do
    a=$(docker exec lab-redis-sentinel-1 redis-cli -p 26379 --raw SENTINEL get-master-addr-by-name labmaster | head -1)
    if [ -n "$a" ] && [ "$a" = "$last" ]; then stable=$((stable+1)); else stable=0; last="$a"; fi
    sleep 5
  done
  echo "stable_master=$last"
'
RESOLVED_MASTER=$(timeout 30 docker exec lab-redis-sentinel-1 redis-cli -p 26379 --raw SENTINEL get-master-addr-by-name labmaster | head -1)
echo "resolved_master=$RESOLVED_MASTER"
for c in lab-redis-master lab-redis-replica-1 lab-redis-replica-2; do
  ip=$(timeout 20 docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$c")
  echo "$c ip=$ip role=$(timeout 20 docker exec "$c" redis-cli --raw ROLE | head -1)"
done

把 resolved_master 与上面的容器 IP 对照:只有它指向 lab-redis-master 且角色为 master,才说明回切真的完成。若 Sentinel 选了另一个副本,SENTINEL FAILOVER 依然返回 OK,但“旧主已切回”的判断是错的;按实际结果记录,不要继续套用后续验证里“新主”的假设。

验证

MASTER_ADDR=$(timeout 30 docker exec lab-redis-sentinel-1 redis-cli -p 26379 --raw SENTINEL get-master-addr-by-name labmaster | head -1)
echo "authoritative_master=$MASTER_ADDR"
timeout 30 docker exec lab-redis-client redis-cli -h "$MASTER_ADDR" INFO replication | grep -E 'role|connected_slaves|master_repl_offset'
for c in lab-redis-master lab-redis-replica-1 lab-redis-replica-2; do
  echo "== $c"; timeout 20 docker exec "$c" redis-cli ROLE | head -1
  timeout 20 docker exec "$c" redis-cli DBSIZE
  timeout 20 docker exec "$c" redis-cli EXISTS lab:rogue
done
timeout 20 docker exec lab-redis-client redis-cli -h "$MASTER_ADDR" --scan --pattern 'lab:*' | sort
timeout 20 docker exec lab-redis-client redis-cli -h "$MASTER_ADDR" TTL lab:session:1
timeout 20 docker exec lab-redis-client redis-cli -h "$MASTER_ADDR" INCR lab:counter

清理

全部验证通过后再清理本项目的容器、网络和卷,不要对任何生产项目执行 down -v。清理前确认快照、Sentinel 日志和偏移记录已经复制到宿主机:

timeout 120 docker compose -f /tmp/lab-redis/docker-compose.yml down -v --remove-orphans
timeout 30 docker volume ls --filter name=lab-redis
rm -rf /tmp/lab-redis

复盘

相关命令

参考链接

读完自测

  1. 主节点停止后,Sentinel 已经提升了一个副本,为什么另一个副本仍可能返回 READONLY?客户端需要做什么?
  2. 怎样用复制偏移判断是否可能丢数据?为什么差值为 0 仍不能承诺零丢失?
  3. 旧主重新启动后为什么不能直接接回流量?发现双主后应怎样收口?
答案
  1. 未被提升的副本仍然是从节点,只读属性没有变化,直接连它的客户端写入会得到 READONLY。客户端必须通过 Sentinel 重新解析当前主节点、重建连接池;Cluster 客户端还要处理 MOVED/ASK 并刷新槽位,不能依赖固定地址。
  2. 比较旧主最后记录的 master_repl_offset 与新主提升后的偏移:差值大于 0 可能意味着有未传播写入,但 diskless 全量同步会把 RDB 字节计入新主偏移,差值为负同样正常。基线是停止前的采样,最后一条写入也可能发生在采样之后;复制是异步的,差值只能说明风险窗口,必须结合业务 key、AOF 和监控写入量核算 RPO。
  3. 旧主本地仍有数据,重启后会先以可写主节点身份出现,与新主形成双主;隔离期间的分叉写入无法简单合并。正确做法是先隔离旧主、保存快照、比较偏移和业务数据,确定新主为唯一权威,再让旧主作为副本全量同步,分叉数据按幂等规则从快照补偿。
微信小程序码:扫码打开命令速查小程序

小程序版

微信扫码,随时查命令