Redis Sentinel 监督非 Cluster 的主从部署,并提供当前主节点地址。它不存储应用数据集,也不代理应用命令。主节点失败后自动提升副本,可以帮助恢复写入可用性,但服务发现、副本数据新鲜度、客户端重连以及阻止旧写者继续写入,仍然是不同的问题,不能由“有哨兵”三个字一并保证。
故障认定与选举授权是两道门
一个 Sentinel 在足够长时间收不到可接受回复后,会把实例标记为主观下线;达到配置的 quorum 都认为主节点不可达,才形成客观下线判断。这个故障判定阈值不等于故障转移领导者获得授权所需的多数。选举还必须达到配置的 quorum,因此将 quorum 设置得更高,也可能进一步提高授权门槛。
例如五个已知 Sentinel 配置 quorum 为二,两个观察者可以同意主节点发生故障,但授权提升仍需要三个构成多数。反过来,quorum 为一也不意味着三个已知观察者只剩一个时,它能独自授权。SENTINEL CKQUORUM 会检查这两项可达性条件;某个 Redis 进程看上去健康,并不能证明观察者满足条件。
稳健部署至少需要三个处在独立故障范围内的 Sentinel。同一台笔记本上的三个进程能测试协议行为,却会随整台机器一起失效。每个 Sentinel 还需要可写的配置文件,持久保存发现的拓扑与纪元状态;这些不是随时可以丢弃的普通静态配置,也应保护其中可能存放的认证材料。
提升副本不会阻止所有旧连接写入
Redis 使用异步复制,被选中的副本可能没有收到旧主已经确认的一条写入。网络分区时,留在旧主一侧的客户端甚至可能继续向它写入,而另一侧已经提升替代节点。Sentinel 协调选举,并不等于撤销所有旧连接的写权限,更不会把 Redis 自动变成强一致存储。
min-replicas-to-write、min-replicas-max-lag 等设置可以在复制健康度下降时限制写入,同时牺牲部分可用性;它们不是通用的隔离旧写者协议。仍需独立备份和明确的应用恢复规则。网络分区、复制落后和单纯杀死一个进程应分别验证,不能用其中一个结果替代其他情形。
客户端需要重新发现并建立连接
支持 Sentinel 的客户端先查询观察者拿到主节点地址,再直接连接 Redis。因此,它要处理暂时发现不到主节点、连接断开和角色改变。只认旧地址的客户端,不会因为某个哨兵已经更新答案就自动恢复;同样,写请求超时也不能证明操作从未执行。
下面的代码在发现新的主节点后,明确断开应用的旧连接池,再检查新连接。这样能够单独验证重连环节,而不是承诺故障转移对调用方完全不可见。切换窗口内不能盲目重复非幂等写操作,节点提升并没有替业务提供去重机制。
坏掉的样子
可达观察者不足、没有合格副本、Sentinel 状态文件不可写,或者通告的地址从客户端不可达,都可能阻止恢复。认证与网络规则需要分别允许观察者之间、观察者与 Redis、复制链路以及应用连接等必要路径。仅看到某个端口能回复 PING,并不说明这些链路都已配置正确。
应结合 CKQUORUM、副本健康、故障转移事件、发现结果和真实客户端操作监控。检测与选举都需要时间,过于激进的超时还可能把短暂停顿当成故障。应使用实际部署的客户端、拓扑和安全配置演练后,再对外承诺可接受的中断窗口。
快速验证
原有实验记录使用 macOS、Python 3.14.4、Redis 8.10.1 和 redis-py 8.1.0;本轮审阅没有重新执行服务实验,下面的结果说明保留原有观察范围。准备 POSIX 环境、C 编译器、make、curl、tar 与 Python 3.10 或更新版本。安装步骤仅用 libc 构建 Redis 核心,并把客户端装入临时虚拟环境。
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
在同一个 shell 中,用 "$redis_lab/venv/bin/python" 运行代码。它在预先占位分配的回环端口上创建一个主节点、一个副本和三个 Sentinel,以私有 Unix socket 控制,并生成临时可写配置。数据库持久化关闭;只杀死自己创建的子进程,结束时关闭客户端、停止其余进程并清理测试目录。很短的检测参数仅用于实验,不是生产建议。
from contextlib import contextmanager, ExitStack
import os
from pathlib import Path
import signal
import socket
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
from redis.sentinel import MasterNotFoundError, Sentinel
def until(check):
deadline = time.monotonic() + 20
while not check():
assert time.monotonic() < deadline, "Sentinel state did not converge"
time.sleep(0.05)
@contextmanager
def instance(directory, port, monitor=None):
directory.mkdir()
endpoint = str(directory / "redis.sock")
config = directory / "redis.conf"
lines = ["bind 127.0.0.1", f"port {port}", "protected-mode yes",
f"unixsocket {endpoint}", "unixsocketperm 700", f"dir {directory}"]
if monitor is None:
lines += ['save ""', "appendonly no"]
else:
lines += [f"sentinel monitor lab 127.0.0.1 {monitor} 2",
"sentinel down-after-milliseconds lab 500",
"sentinel failover-timeout lab 5000",
"sentinel parallel-syncs lab 1",
"sentinel announce-ip 127.0.0.1"]
config.write_text("\n".join(lines) + "\n")
command = [os.environ["REDIS_SERVER"], str(config)]
if monitor is not None:
command.append("--sentinel")
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
assert time.monotonic() < deadline, "Redis did not start"
time.sleep(0.02)
assert client.info("server")["process_id"] == process.pid
yield client, process
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode in (0, -signal.SIGKILL), errors.decode()
def crash(process):
assert process.poll() is None
process.kill()
assert process.wait(timeout=5) == -signal.SIGKILL
with TemporaryDirectory(prefix="redis-sentinel-", dir="/tmp") as directory, ExitStack() as stack:
leases = [stack.enter_context(socket.socket()) for _ in range(5)]
for lease in leases:
lease.bind(("127.0.0.1", 0))
ports = [lease.getsockname()[1] for lease in leases]
nodes = []
for index in range(5):
leases[index].close()
nodes.append(stack.enter_context(instance(
Path(directory) / str(index), ports[index], ports[0] if index >= 2 else None,
)))
primary, replica = nodes[0][0], nodes[1][0]
observers = [item[0] for item in nodes[2:]]
assert replica.replicaof("127.0.0.1", ports[0])
until(lambda: replica.info("replication")["master_link_status"] == "up")
until(lambda: all(observer.sentinel_master("lab")["num-other-sentinels"] == 2
and len(observer.execute_command("SENTINEL", "REPLICAS", "lab")) == 1
for observer in observers))
for observer in observers:
assert observer.execute_command("SENTINEL", "CKQUORUM", "lab").startswith(b"OK 3 usable")
with primary.client() as writer:
assert writer.set("marker", "replicated-before-failure")
assert writer.wait(1, 5000) == 1
assert replica.get("marker") == b"replicated-before-failure"
print("three Sentinels discover each other and a replica; WAIT confirms the fixture write")
manager = Sentinel([("127.0.0.1", port) for port in ports[2:]],
socket_timeout=1, retry=Retry(NoBackoff(), 0),
sentinel_kwargs={"socket_timeout": 1, "retry": Retry(NoBackoff(), 0)})
application = manager.master_for("lab")
try:
assert manager.discover_master("lab") == ("127.0.0.1", ports[0])
assert application.get("marker") == b"replicated-before-failure"
crash(nodes[0][1])
try:
primary.get("marker")
except redis.ConnectionError:
pass
else:
raise AssertionError("the original primary connection must fail")
def switched():
try:
return (manager.discover_master("lab") == ("127.0.0.1", ports[1])
and replica.info("replication")["role"] == "master")
except MasterNotFoundError:
return False
until(switched)
application.connection_pool.disconnect()
assert application.get("marker") == b"replicated-before-failure"
assert application.set("after-failover", "new-primary")
assert replica.get("after-failover") == b"new-primary"
print("automatic failover changes discovery; a reconnected client uses the promoted replica")
finally:
application.close()
for sentinel_client in manager.sentinels:
sentinel_client.close()
for item in nodes[3:]:
crash(item[1])
until(lambda: len(observers[0].sentinel_sentinels("lab")) == 2
and all(peer["is_sdown"] for peer in observers[0].sentinel_sentinels("lab")))
assert observers[0].execute_command("SENTINEL", "SET", "lab", "quorum", 1) == b"OK"
assert observers[0].sentinel_master("lab")["quorum"] == 1
try:
observers[0].execute_command("SENTINEL", "CKQUORUM", "lab")
except redis.ResponseError as error:
assert "majority" in str(error) and "specified quorum" not in str(error)
print("one of three known Sentinels meets quorum=1 but cannot authorize failover")
else:
raise AssertionError("one surviving observer is not a majority of three")
实验先等待观察者互相发现且看见副本,再写入标记,并在同一条连接上检查 WAIT。主节点被杀后,原连接失败;查询发现结果能找到被提升的副本,重新连接的应用可以读取原标记,也能写入新数据。
最后再停止两个观察者,并故意把幸存者的 quorum 降为一。CKQUORUM 仍拒绝授权,因为三个已知观察者中只有一个可用,不构成多数。这检查的是诊断门槛,而不是再次尝试提升节点。WAIT 只确认本实验标记已获得复制应答,不证明磁盘持久性或强一致性。网络分区、副本阻塞、独立主机故障和生产恢复延迟仍未执行验证。
来源
- Redis Sentinel,故障检测、授权、服务发现与分区风险,已核验 2026-09-08。
- Redis 复制,异步复制与写入限制,已核验 2026-09-08。
- Redis WAIT,连接范围内的复制应答与一致性边界,已核验 2026-09-08。
- Redis 8.10.1 Sentinel 源码,独立的 quorum 与多数检查,已核验 2026-09-08。
- redis-py 8.1.0 Sentinel 客户端,服务发现和主节点连接池,已核验 2026-09-08。
- Redis 8.10.1 构建说明,仅构建核心与选择分配器,已核验 2026-09-08。