Redis: 主从、Sentinel、Cluster 与 Redlock

Redis高可用架构与分布式锁

基本模型

  • Redis常见的部署形态可以分为单节点、primary-replicaSentinelCluster
  • 复制和锁解决的是两类不同问题:
    • 复制把数据从主节点同步到副本节点,用于冗余、故障转移和读扩展
    • 分布式锁让多个客户端在一个时间窗口内竞争某个资源的使用权
  • “高可用”不等于“强一致”:Redis复制默认是异步的,故障转移可能丢失尚未复制的写入,副本读也可能读到旧数据
  • 官方文档:

架构总览

单节点

  • 单节点拓扑只有一个 Redis实例:

    1
    Client ──> Redis
  • 适合本地开发、测试环境和允许缓存丢失的场景

  • 通过 RDBAOF可以恢复部分数据,但持久化不能替代在线故障转移

  • 实例故障时,客户端连接、内存数据和正在执行的请求都会受到影响

Primary-Replica

  • 主节点负责写入,副本节点从主节点复制数据:

    1
    2
    3
    Client ──> Primary
    ├── Replica A
    └── Replica B
  • 副本连接主节点后,可以在配置文件中声明:

    1
    replicaof 192.168.1.1 6379
  • 复制连接通常是异步的,因此主节点返回成功时,副本节点不一定已经收到该写入

  • 副本节点适合承担读请求,但读到的数据可能落后于主节点

  • 主节点故障后,需要人工或其它协调组件提升副本节点,否则副本只会继续提供已有数据

  • 所有副本通常保存完整数据集,这种架构能增加冗余和读能力,但不能把一个大数据集水平拆到多个实例

复制连接与命令流

  • 复制连接通常由副本节点主动向主节点建立,复制方向是单向的:主节点把写命令按顺序发送给副本,副本依次执行这些命令;客户端的读请求不会进入复制流
  • 副本节点不会把自己的写入自动转发回主节点,因此这不是双主复制,也不能通过同时向两个节点写入来获得冲突合并能力
  • 副本节点可以再作为其它副本的上游,形成级联复制。这样可以减少主节点的网络发送压力,但会增加复制延迟,并且中间副本故障后需要重新连接或由协调组件重新配置下游节点
  • 可以通过INFO replication观察role、复制偏移量、上游连接状态和副本数量;这些状态反映的是复制进度,不代表主节点与副本已经形成共识

首次同步与断线重连

  • 副本第一次连接,或者主节点无法提供副本所需的历史命令时,会进行全量同步:
    1. 主节点生成一个数据快照,通常是RDB文件,也可以根据配置采用无盘方式传输
    2. 生成快照期间产生的新写入会暂存在主节点的复制缓冲区
    3. 主节点把快照传给副本,副本加载快照并替换自己的数据集
    4. 主节点再发送快照期间缓存的写命令,之后切换到持续命令流
  • 短暂断线后,副本会使用PSYNC携带自己保存的复制ID和偏移量请求继续同步。只要主节点的复制积压缓冲区仍保留这段命令历史,就可以进行部分重同步,不必重新传输完整数据集
  • 复制积压缓冲区是有限大小的环形缓冲区,不是永久日志。副本离线时间过长、缓冲区太小,或者副本保存的复制历史与主节点不匹配时,PSYNC会退化为全量同步
  • 复制链路中断时,副本是否继续响应读请求由replica-serve-stale-data等配置决定;即使继续响应,返回的也只是最近一次成功应用的旧数据

副本的读写行为

  • replica-read-only yes通常用于防止误写副本。关闭该选项后可以向副本写入,但这些写入不会回传主节点,后续重新同步时也可能被主节点的数据覆盖,因此不能把它当作独立写副本
  • 主节点写入成功时,副本可能尚未收到或执行该命令。WAIT可以等待指定数量的副本确认某个复制偏移量,但它仍然不是共识协议,也不能保证故障转移时绝对不丢数据
  • 可以使用min-replicas-to-writemin-replicas-max-lag让主节点在可用副本不足或副本延迟过大时拒绝部分写入,以缩小潜在丢失窗口;代价是网络故障时写入可用性下降,而且这仍不是零数据丢失保证

主节点故障与副本提升

  • 发生故障时,副本不会因为连接断开就自动互相选举主节点。没有Sentinel或其它协调组件时,需要人工执行:

    1
    REPLICAOF NO ONE
  • 被提升的副本只能提供它已经收到并执行的数据。旧主节点尚未复制出去的写入可能丢失,多个副本之间的复制偏移量也可能不同,因此提升前通常要根据复制进度选择更合适的副本

  • 副本提升后,其它副本仍可能指向旧主节点,客户端也可能仍然连接旧地址;新主节点、其它副本和客户端都需要重新配置或重新发现

  • 提升后的节点会保留旧复制历史作为辅助复制ID,使部分下游副本在条件满足时可以继续部分重同步;这能减少恢复成本,但不能恢复已经丢失的写入

Sentinel

  • Sentinel是运行在数据节点之外的控制平面进程,本身不保存业务数据,也不代理客户端的读写流量

  • Sentinel在主从复制之上提供监控、通知、自动故障转移和客户端配置发现

  • 典型拓扑是多个 Sentinel共同监控一组主从节点:

    1
    2
    3
    4
    5
    6
    7
    8
    Application

    ├── Sentinel A
    ├── Sentinel B
    └── Sentinel C

    └── Primary ── Replica A
    └── Replica B
  • 一个 Sentinel配置示例:

    1
    2
    3
    4
    5
    port 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作决定
  • 某个Sentineldown-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
2
3
4
5
6
7
8
9
10
11
12
13
PING超时

本地 SDOWN
↓ 其它 Sentinel 投票达到 quorum
客观 ODOWN
↓ 竞选领导者并取得授权
选择合适的 Replica

REPLICAOF NO ONE,提升为新的 Primary

重新配置其它 Replica 和恢复后的旧 Primary

发布 switch-master,客户端重新发现地址
  • 领导者会先筛选副本:排除断开时间过长或状态不合格的副本,跳过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
    4
    Cluster-aware Client
    ├── Shard 1: Primary ── Replica
    ├── Shard 2: Primary ── Replica
    └── Shard 3: Primary ── Replica
  • Redis Cluster固定使用 16384个哈希槽,键通过 CRC16(key) % 16384映射到槽位,再由负责该槽位的主节点处理

  • 每个分片可以配置一个或多个副本,主节点故障时副本可以被提升为新的主节点

  • 添加或移除节点的本质是迁移哈希槽,迁移过程可以在服务运行时进行

  • 客户端需要理解集群拓扑并维护槽位到节点的映射

  • 客户端访问错误节点时,节点会返回 MOVEDASK重定向:

    1
    2
    GET x
    -MOVED 3999 127.0.0.1:6381
  • 多键命令、事务和脚本通常要求相关键位于同一个槽位,可以使用哈希标签把相关键放进同一槽位:

    1
    2
    SET user:{42}:profile Alice
    SET user:{42}:quota 100
  • 如果某个主节点及其全部副本都不可用,负责这些槽位的服务可能不可用,即使其它分片仍然健康

  • Cluster解决的是水平分片和分片级故障转移,不会自动提供跨槽位事务、全局强一致或业务级幂等

复制与一致性边界

  • Primary-ReplicaSentinelCluster都应先按异步复制来理解:
    • 主节点先处理写入,再把复制流发送给副本
    • 主节点在副本确认之前就返回成功时,主节点突然故障可能丢失这部分写入
    • 副本读适合对短暂旧数据不敏感的查询,不适合直接承担强一致读
  • Sentinel的自动故障转移缩短了恢复时间,但不能撤销已经发生的网络分区和异步复制窗口
  • Cluster的副本只负责对应分片,不能把一个分片的副本当成另一个分片的独立数据源
  • 需要强一致的业务事实时,应把数据库事务、唯一约束、版本号或共识系统作为最终裁决,Redis更适合作为缓存、会话、计数器、短期状态和协调加速层

分布式锁

单实例锁

  • 单实例锁的获取需要把“仅当键不存在时写入”和“设置过期时间”放在一个原子命令中:

    1
    SET lock:order:1001 lock-token-<unique> NX PX 30000
  • 锁值必须是当前持有者生成的唯一随机值,不能只写固定字符串,否则释放锁时无法确认所有者

  • 释放锁不能直接执行 DEL,因为客户端可能在锁过期后恢复,此时它删除的已经是另一个客户端持有的新锁

  • Redis 8.4及更新版本可以使用按值删除的 DELEX,旧版本可以用 Lua 脚本原子地比较并删除:

    1
    2
    3
    4
    5
    if 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
    • 选择支持槽位映射、MOVEDASK的集群客户端
    • 设计键名时提前规划哈希标签和跨槽位操作
    • 为每个分片配置副本,并演练主节点、全副本和网络分区故障
  • 不要因为启用了 SentinelCluster就默认获得强一致锁;数据高可用拓扑和锁的安全证明需要分别设计

可重复工作的互斥

  • 对缓存击穿保护、重复计算抑制和短时任务协调,可以使用单实例锁
  • 获取锁时使用唯一值、NX和有限 PX租约
  • 释放锁时使用 DELEX或比较并删除的 Lua 脚本
  • 临界区应短于租约,并为获取失败配置有限重试、指数退避和随机抖动
  • 锁只作为减少并发的优化手段,任务本身仍应支持重复执行、超时恢复和幂等提交

需要严格所有权的资源

  • 库存扣减、资金变更、同一文件写入和不可重复的外部副作用不应只依赖 Redlock

  • 更稳妥的做法是让最终资源验证一个单调递增的 fencing token

    1
    2
    3
    4
    5
    获取租约并分配 fencing_token

    携带 fencing_token 写入下游资源

    下游只接受大于已记录令牌的请求
  • 数据库可以通过事务、唯一约束和乐观锁版本号拒绝重复或过期写入,外部服务则需要提供版本条件或幂等键

  • 如果下游资源不能验证 fencing token,就不能把锁的成功响应当成防止旧客户端写入的充分证明

必须使用 Redlock

  • 使用 35个相互独立的主实例,并把实例放在不同故障域,不要把一个复制组拆成多个“独立节点”计票
  • 并行请求各实例,并为单个实例设置远小于总租约的连接和命令超时
  • 只有在多数派成功且扣除耗时后仍有足够租约时才进入临界区,失败时释放所有已尝试的锁
  • 使用可靠的唯一令牌和安全释放逻辑,记录获取耗时、成功节点数、剩余租约、续租失败和清理失败
  • Redlock定位为有明确时间假设的租约机制;当正确性依赖绝对互斥和旧客户端不可写时,应增加 fencing token或改用带共识和租约语义的协调系统