
主库挂掉的那一刻,最难受的不是数据丢了,是应用还在往一个已经不存在的地址写。连接池里的连接还没超时,请求一个个失败,日志里全是”connection refused”,而备库安静地站在旁边,谁也不知道它该接班。
Redis 主从复制本身不带故障切换。哨兵(Sentinel)就是补这一块的:它不存数据,只负责盯着、投票、改写配置,然后告诉客户端”现在该连谁”。
这篇用一个能在一台机器上跑完的最小拓扑,把整条链路搭起来,最后拿一次真实的杀进程演练验证。版本:Redis 8.0.2,redis-py 5.0.8,Docker 27.3。
1. 拓扑:一主两从,三个哨兵
三个 Redis 实例(6380 主、6381/6382 从),三个哨兵(26380/26381/26382)。哨兵必须是奇数个,且至少三个——两个哨兵的情况下,网络一分区就谁也不敢做主。
目录结构长这样:
redis-sentinel-lab/
├── docker-compose.yml
├── conf/
│ ├── master.conf
│ ├── replica.conf
│ ├── sentinel-1.conf
│ ├── sentinel-2.conf
│ └── sentinel-3.conf
└── data/
├── 6380/ 6381/ 6382/
└── s1/ s2/ s3/
单机跑三个实例只是省机器,不代表它能扛住机器级故障。生产上三个哨兵必须落在三台不同的物理机或可用区,否则断电一次,投票机制跟着一起消失。
2. 主库配置:密码、持久化、复制积压
conf/master.conf,完整内容如下。注意 requirepass 和 masterauth 都写了同一个密码——这一行是后面切换能不能成功的关键,先记住它。
port 6380
bind 0.0.0.0
protected-mode no
daemonize no
dir /data
requirepass Er7pLq2mSx
masterauth Er7pLq2mSx
appendonly yes
appendfsync everysec
save 900 1
save 300 10
repl-backlog-size 64mb
repl-diskless-sync yes
repl-diskless-sync-delay 5
maxmemory 2gb
maxmemory-policy noeviction
repl-backlog-size 默认只有 1MB,主库写入一密集,从库断线重连就得全量同步。把它调到 64MB,是为了让”短暂抖动”不至于演变成一次全量拷贝。
maxmemory-policy 用 noeviction 是有意的:当 Redis 既是缓存又是队列时,静默淘汰比报错更难查。
3. 从库配置:只差一行
conf/replica.conf 和主库几乎一样,只改端口,加一行指向主库。其余保持一致,是为了让任何一个从库被提升为主库之后,配置不需要再改。
port 6381
bind 0.0.0.0
protected-mode no
daemonize no
dir /data
requirepass Er7pLq2mSx
masterauth Er7pLq2mSx
replicaof 127.0.0.1 6380
replica-read-only yes
replica-serve-stale-data yes
appendonly yes
appendfsync everysec
repl-backlog-size 64mb
maxmemory 2gb
maxmemory-policy noeviction
6382 那份把 port 改成 6382 即可。replica-serve-stale-data yes 意味着从库和主库失联时仍然返回旧数据——这个设置适合读多写少的场景,但它会让”读到过期数据”变成一件安静发生的事。
4. 哨兵配置:三份只差端口
conf/sentinel-1.conf。哨兵的配置文件必须可写,运行时会自己往里追加状态。不要用 ConfigMap 挂只读文件,否则哨兵启动就报错退出。
port 26380
bind 0.0.0.0
protected-mode no
daemonize no
dir /data
sentinel monitor mymaster 127.0.0.1 6380 2
sentinel auth-pass mymaster Er7pLq2mSx
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1
sentinel deny-scripts-reconfig yes
sentinel resolve-hostnames no
sentinel announce-ip 127.0.0.1
sentinel announce-port 26380
几个数字要说清:2 是法定票数,三个哨兵里两个同意才判定主库下线;5000 是主观下线的时间窗,5 秒没回应就先标记为可疑;60000 是一次切换的总超时;parallel-syncs 1 让从库一个一个同步,避免切换瞬间主库被全量同步打满。
announce-ip 和 announce-port 这两行,在单机实验里看着多余,但它们是容器和 NAT 环境里最容易出事的地方,下面第三个坑专门讲。
sentinel-2 和 sentinel-3 把 port 与 announce-port 改成 26381、26382,其余不变。
5. 起服务与第一轮验收
docker-compose.yml 用同一个镜像起六个容器,把配置和数据目录挂进去:
services:
redis-master:
image: redis:8.0.2
command: ["redis-server", "/conf/master.conf"]
ports: ["6380:6380"]
volumes:
- ./conf:/conf:ro
- ./data/6380:/data
redis-replica-1:
image: redis:8.0.2
command: ["redis-server", "/conf/replica.conf"]
ports: ["6381:6381"]
volumes:
- ./conf:/conf:ro
- ./data/6381:/data
sentinel-1:
image: redis:8.0.2
command: ["redis-sentinel", "/conf/sentinel-1.conf"]
ports: ["26380:26380"]
depends_on: [redis-master]
volumes:
- ./conf:/conf
- ./data/s1:/data
(6382 与 sentinel-2/3 按同样结构补齐。sentinel 的 conf 目录不能挂 :ro,理由同上。)
docker compose up -d
docker compose ps
起来之后先验三件事,顺序不要乱:
- 主库能写:
redis-cli -p 6380 -a Er7pLq2mSx SET k v - 从库同步正常:
redis-cli -p 6381 -a Er7pLq2mSx INFO replication,看到master_link_status:up,且master_last_io_seconds_ago小于 2 - 哨兵认得主库:
redis-cli -p 26380 SENTINEL get-master-addr-by-name mymaster,返回 127.0.0.1 和 6380
三步都过,再看哨兵之间的互相发现:SENTINEL sentinels mymaster 应该列出另外两个哨兵的地址。如果这里是空的,说明哨兵之间没握上手,后面永远凑不齐两票。
6. 演练:把主库杀掉
用 docker compose stop redis-master,然后盯住任意一个哨兵的日志:
docker compose logs -f --tail=50 sentinel-1
5 秒后会看到 +sdown master mymaster,几秒后 +odown(客观下线,说明拿到两票),接着 +try-failover、+elected-leader、+failover-state-send-slaveof-noone,最后 +switch-master mymaster 127.0.0.1 6380 127.0.0.1 6381。
从杀掉进程到完成切换,这个实验里是 8 到 12 秒。这个数字就是你的写入中断窗口,不是”几毫秒”。
客户端侧,用 redis-py 的 Sentinel 连接,让它自己去问:
from redis.sentinel import Sentinel
sentinel = Sentinel(
[("127.0.0.1", 26380), ("127.0.0.1", 26381), ("127.0.0.1", 26382)],
socket_timeout=0.5,
password="Er7pLq2mSx",
sentinel_kwargs={"password": ""},
)
master = sentinel.master_for("mymaster", db=0)
print(master.set("probe", "1"))
print(sentinel.discover_master("mymaster"))
master_for 返回的不是一条连接,而是一个会在失败时重新问哨兵的代理。不要把它缓存成全局变量再自己拆出 connection pool,那样切换后旧池里的地址还是旧的。
把旧主重新拉起来,它会以从库身份回归,日志里出现 +convert-to-slave。这一步经常被人忽略:重启的旧主不会自动变回主库,除非哨兵再次切换。
7. 三个必然踩到的坑
坑一:哨兵广播的是容器内网 IP。 客户端拿到 172.18.0.4:6380,在宿主机上根本连不上。表现是”哨兵说主库在 6380,客户端连 6380 一直超时”。解法是显式写 sentinel announce-ip 和 announce-port,把对外可达的地址钉死。
坑二:切换成功,但新主拒绝从库。 日志里是 NOAUTH Authentication required 或 master_link_status:down。原因几乎总是新主的 masterauth 没配——它当从库时用不上这一行,被提升为主库后,别的从库来连它就需要。所有实例都写 requirepass + masterauth,是让提升后的节点立刻可用的唯一办法。
坑三:客户端根本不问哨兵。 应用里写死 host=redis-master,靠 DNS 或负载均衡指向主库。哨兵切换完了,应用还在原地重试。哨兵只解决”谁来当主”,不解决”你怎么知道”。要么用哨兵感知的客户端,要么在切换后触发一次配置刷新。
还有一个不算坑但会咬人的参数:min-replicas-to-write 1 配 min-replicas-max-lag 10。它能让主库在”没有健康从库”时拒绝写入,防脑裂丢数据;但网络抖一下,写入就会失败。开不开,取决于你更能接受丢数据还是更能接受写入报错。
8. 验收清单
- 三个哨兵互认,
SENTINEL sentinels mymaster返回 2 条 INFO replication里从库master_link_status:up,延迟小于 2 秒- 杀主后 15 秒内
discover_master返回新地址 - 切换后新主可写、可读、可被从库同步
- 旧主重启后自动以从库身份回归
sentinel.conf被自动改写,末尾出现sentinel known-replica记录
这六条全过,这套哨兵才算能用。只过前三条的,通常是没演练过一次真实恢复。
写在最后
哨兵这套东西的设计思路挺旧:定期 ping、主观下线、客观下线、投票选主。没有共识算法,没有日志复制,靠的是”多数派看得见”这个朴素假设。
它管用,但它的边界很清楚——它保证的是”有人接班”,不是”一条数据都不丢”。异步复制下,主库在挂掉前最后几百毫秒的写入,如果还没传到从库,切换后就真的没了。哨兵不会告诉你这件事,它只会在日志里打一行 +switch-master。
所以真正该问的问题不是”我的哨兵配好了吗”,而是”我能接受丢多少秒的写入”。前一个问题有标准答案,后一个没有。
你那套主从切换,最近一次演练是什么时候?切换窗口是几秒,有没有量过?

评论 (0)