Redis: 主从、Sentinel、Cluster 与 Redlock
Redis高可用架构与分布式锁
基本模型
Redis常见的部署形态可以分为单节点、primary-replica、Sentinel和Cluster- 复制和锁解决的是两类不同问题:
- 复制把数据从主节点同步到副本节点,用于冗余、故障转移和读扩展
- 分布式锁让多个客户端在一个时间窗口内竞争某个资源的使用权
- “高可用”不等于“强一致”:
Redis复制默认是异步的,故障转移可能丢失尚未复制的写入,副本读也可能读到旧数据 - 官方文档:
架构总览
单节点
单节点拓扑只有一个
Redis实例:1
Client ──> Redis
适合本地开发、测试环境和允许缓存丢失的场景
通过
RDB或AOF可以恢复部分数据,但持久化不能替代在线故障转移实例故障时,客户端连接、内存数据和正在执行的请求都会受到影响
Primary-Replica
主节点负责写入,副本节点从主节点复制数据:
1
2
3Client ──> Primary
├── Replica A
└── Replica B副本连接主节点后,可以在配置文件中声明:
1
replicaof 192.168.1.1 6379
复制连接通常是异步的,因此主节点返回成功时,副本节点不一定已经收到该写入
副本节点适合承担读请求,但读到的数据可能落后于主节点
主节点故障后,需要人工或其它协调组件提升副本节点,否则副本只会继续提供已有数据
所有副本通常保存完整数据集,这种架构能增加冗余和读能力,但不能把一个大数据集水平拆到多个实例
复制连接与命令流
- 复制连接通常由副本节点主动向主节点建立,复制方向是单向的:主节点把写命令按顺序发送给副本,副本依次执行这些命令;客户端的读请求不会进入复制流
- 副本节点不会把自己的写入自动转发回主节点,因此这不是双主复制,也不能通过同时向两个节点写入来获得冲突合并能力
- 副本节点可以再作为其它副本的上游,形成级联复制。这样可以减少主节点的网络发送压力,但会增加复制延迟,并且中间副本故障后需要重新连接或由协调组件重新配置下游节点
- 可以通过
INFO replication观察role、复制偏移量、上游连接状态和副本数量;这些状态反映的是复制进度,不代表主节点与副本已经形成共识
首次同步与断线重连
- 副本第一次连接,或者主节点无法提供副本所需的历史命令时,会进行全量同步:
- 主节点生成一个数据快照,通常是
RDB文件,也可以根据配置采用无盘方式传输 - 生成快照期间产生的新写入会暂存在主节点的复制缓冲区
- 主节点把快照传给副本,副本加载快照并替换自己的数据集
- 主节点再发送快照期间缓存的写命令,之后切换到持续命令流
- 主节点生成一个数据快照,通常是
- 短暂断线后,副本会使用
PSYNC携带自己保存的复制ID和偏移量请求继续同步。只要主节点的复制积压缓冲区仍保留这段命令历史,就可以进行部分重同步,不必重新传输完整数据集 - 复制积压缓冲区是有限大小的环形缓冲区,不是永久日志。副本离线时间过长、缓冲区太小,或者副本保存的复制历史与主节点不匹配时,
PSYNC会退化为全量同步 - 复制链路中断时,副本是否继续响应读请求由
replica-serve-stale-data等配置决定;即使继续响应,返回的也只是最近一次成功应用的旧数据
副本的读写行为
replica-read-only yes通常用于防止误写副本。关闭该选项后可以向副本写入,但这些写入不会回传主节点,后续重新同步时也可能被主节点的数据覆盖,因此不能把它当作独立写副本- 主节点写入成功时,副本可能尚未收到或执行该命令。
WAIT可以等待指定数量的副本确认某个复制偏移量,但它仍然不是共识协议,也不能保证故障转移时绝对不丢数据 - 可以使用
min-replicas-to-write和min-replicas-max-lag让主节点在可用副本不足或副本延迟过大时拒绝部分写入,以缩小潜在丢失窗口;代价是网络故障时写入可用性下降,而且这仍不是零数据丢失保证
主节点故障与副本提升
发生故障时,副本不会因为连接断开就自动互相选举主节点。没有
Sentinel或其它协调组件时,需要人工执行:1
REPLICAOF NO ONE
被提升的副本只能提供它已经收到并执行的数据。旧主节点尚未复制出去的写入可能丢失,多个副本之间的复制偏移量也可能不同,因此提升前通常要根据复制进度选择更合适的副本
副本提升后,其它副本仍可能指向旧主节点,客户端也可能仍然连接旧地址;新主节点、其它副本和客户端都需要重新配置或重新发现
提升后的节点会保留旧复制历史作为辅助复制
ID,使部分下游副本在条件满足时可以继续部分重同步;这能减少恢复成本,但不能恢复已经丢失的写入
Sentinel
Sentinel是运行在数据节点之外的控制平面进程,本身不保存业务数据,也不代理客户端的读写流量Sentinel在主从复制之上提供监控、通知、自动故障转移和客户端配置发现典型拓扑是多个
Sentinel共同监控一组主从节点:1
2
3
4
5
6
7
8Application
│
├── Sentinel A
├── Sentinel B
└── Sentinel C
│
└── Primary ── Replica A
└── Replica B一个
Sentinel配置示例:1
2
3
4
5port 5000
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1
监控循环与节点发现
- 每个
Sentinel会周期性向主节点和副本节点发送PING,并通过INFO replication了解角色、复制偏移量和副本列表;它还会通过__sentinel__:hello等机制发现同一监控组中的其它Sentinel Sentinel会记录每个节点的最后响应时间、连接状态、复制进度和配置版本。它自己判断某个节点不可达时,只能形成一个本地观点,不会直接替其它Sentinel作决定- 某个
Sentinel在down-after-milliseconds时间内没有得到主节点的有效响应,会把主节点标记为主观下线(SDOWN) - 发现
SDOWN后,它会向其它Sentinel询问主节点是否可达。达到sentinel monitor配置的quorum后,主节点才被标记为客观下线(ODOWN),才具备发起自动故障转移的条件
主观下线、客观下线与投票
SDOWN是单个Sentinel的本地判断,可能由网络抖动、进程暂停或该Sentinel自身的网络故障造成;ODOWN是多个Sentinel对同一主节点不可达的共同判断quorum只表示客观下线判断需要多少个Sentinel同意,不等于所有Sentinel的多数,也不表示写入已经复制到多少个数据节点- 发起故障转移的
Sentinel还需要在一个配置纪元中竞选领导者,并取得足够多的Sentinel投票和故障转移授权。即使达到了quorum,如果没有足够的Sentinel可通信,故障转移仍可能无法开始 - 一个常见的三节点部署中,
quorum可以设为2,同时把三个Sentinel分散到不同故障域;只部署两个Sentinel时,任意一个故障都可能使剩余节点无法取得多数授权
故障转移期间的节点行为
一次典型故障转移可以概括为:
1 | PING超时 |
- 领导者会先筛选副本:排除断开时间过长或状态不合格的副本,跳过
replica-priority 0的副本,再优先考虑复制偏移量更靠前的副本;复制偏移量越新,通常越可能包含旧主节点最近的写入 - “断开时间过长”不是简单看当前是否在线,
Sentinel还会结合主节点进入SDOWN的时间和down-after-milliseconds估算副本是否过于落后。这个筛选只能降低数据丢失概率,不能把异步复制变成同步复制 - 选中的副本会被要求执行
REPLICAOF NO ONE,确认其角色变成主节点后,Sentinel让其它副本执行类似REPLICAOF <new-primary> <port>的重配置 sentinel parallel-syncs限制同一故障转移中可以同时向新主节点同步的副本数量:数值较小对新主节点压力较低,但整个拓扑恢复为完整副本集所需的时间更长- 旧主节点重新上线后,
Sentinel通常会把它改配置为新主节点的副本,而不是把两个主节点的数据自动合并;分区期间只写入旧主节点的数据可能在重新同步时被覆盖或丢失
配置传播与客户端行为
Sentinel之间会交换主从拓扑、节点状态和配置纪元,并把最新的监控配置重写到本地配置文件;因此配置最终会收敛,但网络分区期间不同Sentinel可能暂时持有不同观点Sentinel不在应用的数据路径上。客户端应连接任意可用的Sentinel,通过以下命令按服务名查询当前主节点,然后直接连接返回的host:port:1
SENTINEL get-master-addr-by-name mymaster
客户端可以订阅
+switch-master等事件作为快速提示,但不能只依赖通知;连接失败时仍应重新查询主节点并建立连接,因为通知可能丢失Sentinel不负责分片,所有主从节点仍然共同承载同一份数据集。客户端切换连接也不会撤销已经发给旧主节点的请求,因此超时命令必须结合幂等键、事务或版本检查处理网络分区期间,旧主节点可能仍然被一部分客户端访问;分区恢复后它通常会被降级为副本,期间产生的写入可能丢失。这是异步复制和最终一致配置共同造成的结果
生产环境通常应把多个
Sentinel和数据节点分散到不同故障域,并为客户端配置连接重试、主节点重新发现和命令失败后的幂等处理
Redis Cluster
Cluster把数据分布到多个主节点,每个主节点负责一个或多个哈希槽:1
2
3
4Cluster-aware Client
├── Shard 1: Primary ── Replica
├── Shard 2: Primary ── Replica
└── Shard 3: Primary ── ReplicaRedis Cluster固定使用16384个哈希槽,键通过CRC16(key) % 16384映射到槽位,再由负责该槽位的主节点处理每个分片可以配置一个或多个副本,主节点故障时副本可以被提升为新的主节点
添加或移除节点的本质是迁移哈希槽,迁移过程可以在服务运行时进行
客户端需要理解集群拓扑并维护槽位到节点的映射
客户端访问错误节点时,节点会返回
MOVED或ASK重定向:1
2GET x
-MOVED 3999 127.0.0.1:6381多键命令、事务和脚本通常要求相关键位于同一个槽位,可以使用哈希标签把相关键放进同一槽位:
1
2SET user:{42}:profile Alice
SET user:{42}:quota 100如果某个主节点及其全部副本都不可用,负责这些槽位的服务可能不可用,即使其它分片仍然健康
Cluster解决的是水平分片和分片级故障转移,不会自动提供跨槽位事务、全局强一致或业务级幂等
复制与一致性边界
Primary-Replica、Sentinel和Cluster都应先按异步复制来理解:- 主节点先处理写入,再把复制流发送给副本
- 主节点在副本确认之前就返回成功时,主节点突然故障可能丢失这部分写入
- 副本读适合对短暂旧数据不敏感的查询,不适合直接承担强一致读
Sentinel的自动故障转移缩短了恢复时间,但不能撤销已经发生的网络分区和异步复制窗口Cluster的副本只负责对应分片,不能把一个分片的副本当成另一个分片的独立数据源- 需要强一致的业务事实时,应把数据库事务、唯一约束、版本号或共识系统作为最终裁决,
Redis更适合作为缓存、会话、计数器、短期状态和协调加速层
分布式锁
单实例锁
单实例锁的获取需要把“仅当键不存在时写入”和“设置过期时间”放在一个原子命令中:
1
SET lock:order:1001 lock-token-<unique> NX PX 30000
锁值必须是当前持有者生成的唯一随机值,不能只写固定字符串,否则释放锁时无法确认所有者
释放锁不能直接执行
DEL,因为客户端可能在锁过期后恢复,此时它删除的已经是另一个客户端持有的新锁Redis 8.4及更新版本可以使用按值删除的DELEX,旧版本可以用 Lua 脚本原子地比较并删除:1
2
3
4
5if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end锁的过期时间是租约上限,不是业务完成保证;业务执行时间超过租约后,其他客户端可能重新获得同一把锁
续租也必须比较唯一值并原子执行,而且无法阻止进程暂停期间租约已经过期的问题
Redlock
Redlock假设存在N个相互独立的Redis主实例,而不是同一主从组中的主节点和副本节点客户端获取锁的过程可以抽象为:
1
2
3
4
5
6
7
8
9记录开始时间
↓
使用较短超时并行请求 N 个独立主实例的 SET NX PX
↓
统计成功实例数量和已经消耗的时间
↓
成功数达到 N / 2 + 1 且剩余租约仍大于零
├── 成功:进入临界区
└── 失败:释放所有已经写入的锁并重试或返回失败获取成功后的有效租约需要扣除获取过程耗时、网络延迟和时钟漂移余量,不能直接把初始
TTL当成剩余可用时间例如
N = 5时,至少需要3个实例在有效时间内成功加锁,少于多数派就应释放已经获取的锁Redlock把互斥性建立在多个实例相互独立、时钟漂移受控、客户端能在租约内完成工作的假设之上Redlock不是共识协议,也不会向业务资源发放自动递增的所有权版本,因此“成功获得锁”不能单独证明后续写入一定安全
Redlock的缺点
- 租约过期后旧客户端仍可能继续执行
- 客户端获得锁后发生长时间的进程暂停、虚拟机暂停或调度延迟
- 锁在
Redis中已经过期,另一个客户端获得新锁并开始执行 - 旧客户端恢复后并不知道自己已经失去所有权,仍可能向数据库、文件系统或第三方服务提交操作
- 异步故障转移可能丢失锁状态
- 在普通主从加
Sentinel的拓扑中,锁先写入主节点 - 主节点在复制锁值之前故障,副本被提升后可能没有这把锁
- 新客户端于是可以获得同一资源的锁,所以不能把一个主从组的主节点、副本节点和
Sentinel当作多个独立投票者
- 在普通主从加
- 时钟和网络假设会压缩安全窗口
- 网络延迟、实例响应慢和时钟漂移都会减少实际临界区时间
- 如果客户端只按墙上时钟判断租约而忽略耗时和漂移,计算出的有效时间会过于乐观
- 缺少
Fencing token- 锁服务只能告诉客户端“当前尝试获得了租约”,不能强制下游资源拒绝旧客户端
- 没有围栏令牌时,即使锁算法在大多数时间工作正常,也无法从协议层阻止迟到的旧写入
- 可用性和运维成本更高
- 客户端要同时管理多个实例、超时、重试、部分成功和清理逻辑
- 网络分区时可能无法取得多数派,多个实例的监控、升级和故障域规划也会增加成本
- 锁不能代替业务幂等和事务
- 客户端超时并不能证明加锁失败,重试可能与第一次请求并存
- 业务写入仍应使用幂等键、唯一约束、版本检查或事务保护
选型与最佳实践
按数据规模和可用性选拓扑
- 数据量小、业务允许缓存丢失:使用单节点,重点做好监控和重启恢复
- 数据集需要完整复制但不需要水平分片:使用
Primary-Replica + Sentinel- 使用多个
Sentinel并分散故障域 - 客户端通过主节点名称发现主节点
- 把故障转移看成恢复机制,而不是零数据丢失机制
- 使用多个
- 数据量或吞吐量超过单个主节点:使用
Redis Cluster- 选择支持槽位映射、
MOVED和ASK的集群客户端 - 设计键名时提前规划哈希标签和跨槽位操作
- 为每个分片配置副本,并演练主节点、全副本和网络分区故障
- 选择支持槽位映射、
- 不要因为启用了
Sentinel或Cluster就默认获得强一致锁;数据高可用拓扑和锁的安全证明需要分别设计
可重复工作的互斥
- 对缓存击穿保护、重复计算抑制和短时任务协调,可以使用单实例锁
- 获取锁时使用唯一值、
NX和有限PX租约 - 释放锁时使用
DELEX或比较并删除的 Lua 脚本 - 临界区应短于租约,并为获取失败配置有限重试、指数退避和随机抖动
- 锁只作为减少并发的优化手段,任务本身仍应支持重复执行、超时恢复和幂等提交
需要严格所有权的资源
库存扣减、资金变更、同一文件写入和不可重复的外部副作用不应只依赖
Redlock更稳妥的做法是让最终资源验证一个单调递增的
fencing token:1
2
3
4
5获取租约并分配 fencing_token
↓
携带 fencing_token 写入下游资源
↓
下游只接受大于已记录令牌的请求数据库可以通过事务、唯一约束和乐观锁版本号拒绝重复或过期写入,外部服务则需要提供版本条件或幂等键
如果下游资源不能验证
fencing token,就不能把锁的成功响应当成防止旧客户端写入的充分证明
必须使用 Redlock时
- 使用
3或5个相互独立的主实例,并把实例放在不同故障域,不要把一个复制组拆成多个“独立节点”计票 - 并行请求各实例,并为单个实例设置远小于总租约的连接和命令超时
- 只有在多数派成功且扣除耗时后仍有足够租约时才进入临界区,失败时释放所有已尝试的锁
- 使用可靠的唯一令牌和安全释放逻辑,记录获取耗时、成功节点数、剩余租约、续租失败和清理失败
- 把
Redlock定位为有明确时间假设的租约机制;当正确性依赖绝对互斥和旧客户端不可写时,应增加fencing token或改用带共识和租约语义的协调系统