Redis 高可用:哨兵、集群与故障转移
深入 Redis 高可用体系:哨兵模式的故障检测与选主流程、Cluster 集群的分片机制、Codis vs Cluster 选型对比,以及分布式锁的正确实现(SET NX + Lua 脚本)与 Redlock 方案。
目录
| 章节 | 说明 |
|---|---|
| 哨兵模式(Sentinel) | 故障检测、领导者选举、故障转移流程 |
| 哨兵集群的建立 | 哨兵间如何互相发现 |
| Redis Cluster | 数据分片、故障切换、客户端重定向 |
| Codis vs Redis Cluster | 两种集群方案的对比与选型 |
| 数据倾斜与通信开销 | Cluster 的两大限制因素 |
| 分布式锁 | SET NX + Lua 脚本,正确实现与常见错误 |
| Redlock(多节点分布式锁) | 单节点锁的局限与 Redlock 方案 |
| Redis 事务 | MULTI/EXEC、WATCH、与数据库事务的区别 |
哨兵模式(Sentinel)
哨兵解决主库宕机后的自动故障转移问题,通常部署奇数个(≥3)。
主观下线 vs 客观下线
主观下线(SDOWN):
单个哨兵在 down-after-milliseconds 时间内收不到主库回复
→ 标记主库"主观下线"
客观下线(ODOWN):
超过 quorum 数量的哨兵都认为主库下线
→ 确认主库"客观下线",启动故障转移
# sentinel.conf
sentinel monitor mymaster 192.168.1.100 6379 2
# 2 = quorum,至少 2 个哨兵同意才算客观下线
领导者哨兵选举(Raft)
确认客观下线后,哨兵们通过 Raft 协议选出 Leader 哨兵来执行故障转移:
1. 候选者:任意一个哨兵(通常是发现主观下线的那个)
2. 发出投票请求:SENTINEL is-master-down-by-addr
3. 其他哨兵投票(先到先得,每轮只投一票)
4. 获得 majority(超过半数)投票者成为 Leader
5. Leader 执行故障转移
故障转移:如何选新主库
Leader 哨兵从从库中选出新主库,筛选标准(按优先级):
1. 过滤掉长时间与主库网络断连的从库
(断连时长 > down-after-milliseconds × 10 + 主从复制延迟)
2. 按以下优先级选择:
a. slave-priority 值越小优先(默认 100,0 表示永不成为主库)
b. 与原主库的 replication offset 越大优先(数据最新)
c. Run ID 字典序最小(决胜局)
故障转移流程
1. Leader 哨兵向新主库发送 SLAVEOF NO ONE(升级为主库)
2. 监控新主库每秒同步进度,直到 master_replication_offset 追上
3. 向其他从库发送 SLAVEOF 新主库IP PORT(改换门庭)
4. 向原主库(如果恢复)发送 SLAVEOF,降级为从库
5. 更新配置文件,通知客户端新主库地址
哨兵集群的建立
哨兵之间如何互相发现?借助主库的 Pub/Sub:
1. 每个哨兵定期向主库的频道 __sentinel__:hello 发布自己的 IP/端口
2. 所有哨兵都订阅该频道
3. 哨兵收到消息 → 发现新哨兵 → 与之建立连接
客户端如何找到主库?
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
→ 客户端通过哨兵获取当前主库地址(故障转移后自动更新)
Redis Cluster
数据分片:16384 个哈希槽
16384 个 slot 均匀分配给各主节点
key 的 slot = CRC16(key) & 16383
例:3 主节点
Node A:slot 0 - 5460
Node B:slot 5461 - 10922
Node C:slot 10923 - 16383
为什么是 16384 个槽? 作者 antirez 的解释:16384 位 = 2KB,心跳包中发送节点的 slot 信息只需 2KB;16K 个槽在 1000 个节点的集群中每个节点分配约 16 个槽,粒度合理。
客户端重定向(MOVED / ASK)
客户端访问 Node A,但 key 属于 Node B:
Node A 返回:MOVED 1234 Node_B_IP:6379
客户端重定向到 Node B
迁移中(MIGRATING / IMPORTING 状态):
Node A 返回:ASK Node_B_IP:6379
客户端需要先发 ASKING 命令,再执行操作(临时重定向,不更新路由表)
客户端优化:维护一份
slot → 节点的路由表(Smart Client),直接路由到正确节点,避免每次都被重定向。
Cluster 的故障转移
类似哨兵,但是集群内的从节点自己发起选举(不需要单独的哨兵进程):
1. 从节点发现主节点下线 → 发起 FAILOVER 广播
2. 其他主节点投票(类 Raft)
3. 获得 majority 投票的从节点升为新主节点
4. 广播更新路由表
Cluster 的限制
- 不支持跨节点的 mget/mset(除非所有 key 在同一个 slot)
- 使用 hash tag {} 保证相关 key 落在同一个节点
如:{user:1001}:name 和 {user:1001}:age 都在 user:1001 的 slot
- 不支持 MULTI/EXEC 跨 slot 的事务
- Lua 脚本内的所有 key 必须在同一个 slot
# 强制相关 key 落同一 slot
SET {order:1001}:status "paid"
SET {order:1001}:amount "99.9"
MGET {order:1001}:status {order:1001}:amount # ✅ 同一 slot,可以 mget
Codis vs Redis Cluster
| 对比 | Codis | Redis Cluster |
|---|---|---|
| 数据分片 | 1024 个 slot(proxy 路由) | 16384 个 slot(smart client/重定向) |
| 代理层 | ✅ 有 codis-proxy(无状态,可扩展) | ❌ 无代理,需智能客户端 |
| 原生 Redis 兼容性 | 高(client 无需改造) | 需要支持 cluster 的客户端 |
| 运维复杂度 | 高(proxy + zookeeper) | 低(全内置) |
| 扩容方式 | 手动迁移槽 | 支持 reshard,但仍需手动触发 |
| 跨节点 mget | ✅ proxy 层聚合 | ❌ 需要 hash tag |
| 多语言支持 | ✅ 标准协议,任意语言 | 需要 cluster 客户端库 |
选型建议:
- 新项目:优先 Redis Cluster(原生支持,社区活跃,维护成本低)
- 已有大量非 cluster 代码:Codis 迁移成本低
- 需要跨节点命令(如 mget 混合 key):Codis 更合适
数据倾斜与通信开销
数据倾斜
原因1:某些 slot 的 key 数量远多于其他(如热点业务集中在某个 hash tag)
原因2:bigkey 导致某个节点内存远高于其他节点
解决方案:
- 避免超大 hash tag(将一个大 hash tag 拆分为多个)
- 对 bigkey 进行拆分
- 监控各节点的内存使用,手动迁移不均衡的 slot
Gossip 通信开销
Cluster 节点间通过 Gossip 协议交换节点状态信息:
每秒:每个节点随机 ping 部分节点
消息大小:每个消息包含 1/10 的节点信息
节点数越多,Gossip 开销越大
建议:单集群节点数 ≤ 1000
分布式锁
单 Redis 节点的正确实现
# 加锁:原子操作(SET NX EX 一条命令)
SET lock_key unique_value NX EX 30
# NX:不存在才设置
# EX 30:30 秒自动释放(防止持有锁的进程崩溃后锁永不释放)
# unique_value:唯一标识(UUID),防止误删其他线程的锁
# 解锁:Lua 脚本保证"判断 + 删除"的原子性
EVAL """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""" 1 lock_key unique_value
为什么解锁要用 Lua 脚本?
错误的非原子写法:
1. GET lock_key → "uuid-1" (验证是自己的锁)
2. ← 线程被挂起,锁过期,另一个线程获取了锁 →
3. DEL lock_key ← 误删了别人的锁!
Lua 脚本将 GET + DEL 变为原子操作,中间不会被其他命令插入。
锁超时续期(看门狗)
持有锁的业务未完成,但锁已超时,需要续期:
# Redisson(Java)的看门狗机制
# 自动每 10 秒检测,如果业务未完成则续期 30 秒
lock = redisson.getLock("lock_key")
lock.lock() # 自动开启看门狗
# Python 手动实现
def watchdog(redis_client, key, value, ttl, stop_event):
while not stop_event.is_set():
time.sleep(ttl / 3)
# 只有值匹配才续期
script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('expire', KEYS[1], ARGV[2])
else
return 0
end
"""
redis_client.eval(script, 1, key, value, ttl)
Redlock(多节点分布式锁)
单 Redis 节点的局限
场景:主库宕机,从库还未同步锁信息
1. 客户端 A 在主库加锁成功
2. 主库宕机,从库升为新主库(但未同步锁)
3. 客户端 B 在新主库加锁成功
→ 同时有两个客户端持有"同一把锁"
Redlock 算法
基于 N 个独立 Redis 实例(通常 5 个,互相不复制):
1. 记录加锁开始时间 T1
2. 依次向 N 个 Redis 实例请求加锁
(每个请求设置较短超时时间,避免某个实例慢导致整体阻塞)
3. 统计成功加锁的实例数:
- 成功数 > N/2 + 1(多数派)
- 且加锁总耗时 < 锁的有效期
→ 加锁成功,有效期 = 初始有效期 - 加锁耗时
4. 如果条件不满足 → 向所有实例发送解锁请求
# 示例:5 个独立 Redis 实例
instances = [redis1, redis2, redis3, redis4, redis5]
n = len(instances)
quorum = n // 2 + 1 # = 3
lock_value = str(uuid.uuid4())
lock_ttl = 10000 # 10 秒
acquired = 0
start = time.time_ms()
for r in instances:
if r.set(key, lock_value, nx=True, px=lock_ttl):
acquired += 1
elapsed = time.time_ms() - start
if acquired >= quorum and elapsed < lock_ttl:
# 加锁成功,实际有效期 = lock_ttl - elapsed
pass
else:
# 加锁失败,释放已加的锁
for r in instances:
release_lock(r, key, lock_value)
Redlock 的争议
Martin Kleppmann 指出:即使使用 Redlock,在进程 GC 暂停(Stop-the-World GC)的极端情况下,仍然可能出现两个客户端同时持有锁。
实践建议:
- 大多数业务场景:单 Redis 主从 + 合理的锁超时 + 幂等设计 就足够了
- 需要强一致性的场景(如银行转账):使用 etcd/ZooKeeper 的分布式锁(基于 Raft,更强一致性保证)
Redis 事务
MULTI / EXEC
MULTI # 开始事务(后续命令入队,不立即执行)
SET key1 v1
INCR counter
SET key2 v2
EXEC # 原子执行所有入队命令(顺序保证,但不回滚)
DISCARD # 放弃事务,清空命令队列
Redis 事务 ≠ 数据库事务
| 特性 | 数据库事务(ACID) | Redis MULTI/EXEC |
|---|---|---|
| 原子性 | ✅ 全部成功或全部回滚 | ⚠️ 部分成功(运行时错误不回滚) |
| 一致性 | ✅ | ⚠️ 弱 |
| 隔离性 | ✅ 多级别隔离 | ✅ 事务执行期间不被其他命令打断 |
| 持久性 | ✅ | 依赖 AOF/RDB 配置 |
Redis 不支持事务回滚:如果 EXEC 执行期间某条命令失败(如对 String 执行 INCR),只有该命令失败,其他命令继续执行。
WATCH(乐观锁)
WATCH key1 # 监控 key1,如果在 EXEC 之前被修改,事务取消
MULTI
INCR key1
SET key2 v2
EXEC # 如果 key1 已被其他客户端修改,返回 nil(事务取消)
UNWATCH # 取消所有 WATCH
WATCH 实现了 CAS(Compare-And-Swap)语义,适合乐观锁场景。
参考资料
- 《Redis 核心技术与实战》— 第 7-10、29-32、35-38 讲(蒋德钧)
- 《Redis 源码剖析与实战》— 第 14、21-22 讲(蒋德钧)
- Redis Sentinel
- Redis Cluster Specification
- Distributed Locks with Redis
评论 (0)