Redis高可用与分布式架构

Redis 高可用与分布式架构

一、Redis 高可用的三个层次

Redis 的高可用体系是层层递进的,每一层解决不同的问题:

层次 机制 解决的问题
第一层 持久化(Persistence) 数据不丢 --- 进程重启后数据可恢复
第二层 复制(Replication) 读扩展 + 数据冗余 --- 多副本提供读能力和数据安全
第三层 哨兵(Sentinel) 自动故障转移 --- 主节点宕机后自动切换
第四层 集群(Cluster) 写扩展 + 数据分片 --- 突破单机写瓶颈和内存上限
  • 持久化是最基础的高可用手段,保证数据落盘,但无法解决单点故障。
  • 复制提供了数据冗余,从节点可以接管读请求,但主节点故障时需要手动切换。
  • 哨兵在复制的基础上实现了自动故障转移,无需人工干预。
  • 集群在哨兵的基础上进一步解决了水平扩展问题,支持数据分片(Sharding),将数据分布到多个节点上。

四层关系:持久化是基础,复制是前提,哨兵依赖复制,集群是最完整的方案。


二、复制(Replication)

2.1 主从复制模型

Redis 采用的是 异步主从复制(Asynchronous Master-Replica Replication) 模型:

  • Master(主节点):负责读写操作,接收所有写请求。

  • Replica(从节点):从主节点同步数据,只提供读服务(默认配置)。

  • 拓扑结构:一主多从,也支持链式复制(从节点再作为其他节点的主节点)。

    复制代码
          ┌── Replica-1

    Master ─┼── Replica-2
    └── Replica-3 ── Replica-4(链式复制)

核心作用:

  1. 读扩展(Read Scaling):读请求分散到多个从节点,提升读吞吐量。
  2. 数据冗余(Data Redundancy):多副本存储,防止数据丢失。
  3. 高可用基础(HA Foundation):为哨兵和集群的故障转移提供前提条件。

配置方式:

在从节点的 redis.conf 中配置:

conf 复制代码
replicaof <master-ip> <master-port>
# 或运行时执行命令
REPLICAOF <master-ip> <master-port>
# 取消复制
REPLICAOF NO ONE

从节点默认为只读模式(replica-read-only yes),这是推荐配置,防止数据不一致。

2.2 全量同步(Full Sync)

触发条件:

  1. 从节点首次连接主节点(无复制历史)。
  2. 增量同步失败(从节点的复制偏移量不在复制积压缓冲区范围内)。
  3. 从节点发送的 Replication ID 与主节点不匹配。

全量同步流程:

  1. 从节点发送 PSYNC ? -1 请求全量同步。
  2. 主节点执行 BGSAVE,在后台生成 RDB 快照。
  3. 主节点将 RDB 文件发送给从节点。
  4. 主节点同时将生成 RDB 期间的新写命令缓存在复制积压缓冲区中。
  5. 从节点清空旧数据,加载 RDB 文件。
  6. 主节点将积压缓冲区中的增量命令发送给从节点。
  7. 此后进入命令传播(Command Propagation)阶段,主节点持续将写命令发送给从节点。

伪代码:全量同步流程

pseudocode 复制代码
// 从节点发起全量同步
function fullSync(replica):
    // 步骤1:从节点请求全量同步
    replica.sendPSYNC(replication_id="?", offset=-1)

    // 步骤2:主节点后台生成RDB
    master.startBGSAVE()
    new_commands = []  // 缓存BGSAVE期间的新写命令

    // 步骤3:主节点继续接收写命令,同时缓存
    while not master.bgsaveComplete():
        cmd = master.receiveCommand()
        new_commands.append(cmd)
        master.executeCommand(cmd)  // 主节点正常执行

    // 步骤4:发送RDB文件给从节点
    rdb_file = master.getRDB()
    replica.sendRDB(rdb_file)

    // 步骤5:从节点清空旧数据并加载RDB
    replica.flushAllData()
    replica.loadRDB(rdb_file)

    // 步骤6:发送积压缓冲区中的增量命令
    for cmd in new_commands:
        replica.sendCommand(cmd)
        replica.executeCommand(cmd)

    // 步骤7:进入命令传播阶段
    enterCommandPropagation(master, replica)

注意事项:

  • 全量同步是一个重操作:主节点执行 BGSAVE 消耗 CPU 和内存,RDB 传输消耗网络带宽,从节点加载 RDB 期间会阻塞服务。
  • 生产环境应尽量避免频繁触发全量同步,合理配置 repl-backlog-size 是关键。

2.3 增量同步(Partial Sync / PSYNC)

触发条件:

从节点短暂断连后重连,且所需的数据仍在主节点的复制积压缓冲区(Replication Backlog)中。

关键概念:

  1. Replication ID(复制ID):每个 Redis 主节点有一个唯一的 Replication ID,用于标识数据集的版本。从节点需要匹配主节点的 Replication ID 才能进行增量同步。

  2. Offset(偏移量):主节点每产生一个写命令,偏移量递增。每个从节点记录自己已同步到的偏移量。主节点也记录每个从节点的偏移量,用于判断是否可以增量同步。

  3. Replication Backlog(复制积压缓冲区):主节点维护的一个固定大小的环形缓冲区(FIFO),默认 1MB。存储最近的写命令及其偏移量。当缓冲区满时,旧数据被覆盖。

PSYNC 判断逻辑:

从节点重连时发送 PSYNC <replication_id> <offset>,主节点根据以下条件判断:

  • Replication ID 是否匹配?
  • 请求的 offset 是否在复制积压缓冲区的范围内?

如果两个条件都满足,则进行增量同步;否则进行全量同步。

伪代码:PSYNC 判断逻辑

pseudocode 复制代码
function handlePSYNC(replication_id, offset):
    // 条件1:Replication ID是否匹配
    if replication_id != master.replication_id:
        log("Replication ID不匹配,需要全量同步")
        return FULL_SYNC

    // 条件2:offset是否在积压缓冲区范围内
    backlog_start = master.backlog_offset  // 积压缓冲区起始offset
    backlog_end = master.current_offset    // 主节点当前offset

    if offset >= backlog_start and offset <= backlog_end:
        // 增量同步:发送offset之后的命令
        pending_commands = master.backlog.getCommandsFrom(offset)
        replica.sendPartialData(pending_commands)
        replica.updateOffset(backlog_end)
        log("增量同步成功,发送积压缓冲区数据")
        return PARTIAL_SYNC
    else:
        log("Offset不在积压缓冲区范围内,需要全量同步")
        return FULL_SYNC

PSYNC2(Redis 4.0+):

Redis 4.0 引入了 PSYNC2,支持更复杂的增量同步场景:

  • 场景:从节点切换主节点后(如 Sentinel 故障转移),新主节点可能是旧主节点的从节点,两者有共同的复制历史。
  • 实现:每个主节点维护两个 Replication ID(当前 ID 和之前的 ID),切换后如果新主节点的历史 ID 匹配,仍可增量同步。
  • 意义:减少了故障转移后的全量同步次数,降低了故障恢复的开销。

2.4 复制积压缓冲区调优

默认值问题:

repl-backlog-size 默认为 1MB,这在生产环境中通常远远不够。如果从节点断连时间稍长,积压缓冲区就会被覆盖,导致重连时必须进行全量同步。

计算公式:

复制代码
repl-backlog-size ≈ 写入速率 × 断连最大时间 × 安全系数

示例计算:

  • 写入速率:每秒产生 10MB 的写命令数据。

  • 断连最大时间:60 秒(网络抖动恢复时间)。

  • 安全系数:2(留有余量)。

    repl-backlog-size = 10MB/s × 60s × 2 = 1200MB ≈ 1.2GB

生产建议:

  • 常规场景:至少 100MB~256MB。
  • 高写入场景:根据公式计算,可能需要 1GB 以上。
  • repl-backlog-ttl:当没有从节点连接时,积压缓冲区的保留时间,默认 3600 秒。设为 0 表示永不释放。
conf 复制代码
# redis.conf 配置示例
repl-backlog-size 256mb
repl-backlog-ttl 3600

2.5 异步复制的数据安全

默认行为:异步复制

Redis 的复制默认是异步的:主节点执行写命令后立即返回客户端,不等待从节点确认同步完成。

复制代码
客户端 → 主节点写入成功 → 返回OK给客户端
                ↓(异步)
           从节点同步数据

风险:

如果主节点在写命令同步到从节点之前宕机,这部分数据会丢失。在主从切换后,丢失的数据无法恢复。

配置保障:

Redis 提供了两个配置项来增强数据安全:

conf 复制代码
# 至少有N个从节点同步成功,主节点才允许写入
min-replicas-to-write 1

# 从节点的延迟不超过N秒,主节点才允许写入
min-replicas-max-lag 10

工作原理:

pseudocode 复制代码
function checkWritePermission():
    // 统计满足条件的从节点数量
    healthy_replicas = 0
    for replica in master.replicas:
        if replica.lag <= min_replicas_max_lag:
            healthy_replicas += 1

    // 判断是否满足写入条件
    if healthy_replicas >= min_replicas_to_write:
        return ALLOW_WRITE
    else:
        return DENY_WRITE  // 返回错误:NOREPLICAS

注意:

  • 这两个配置会降低可用性:如果从节点全部断连,主节点将拒绝写入。
  • 需要在数据安全和可用性之间权衡。
  • 对于要求强一致性的场景,Redis 并非最佳选择,应考虑关系型数据库。

三、Sentinel(哨兵)

3.1 Sentinel 的四大功能

Redis Sentinel 是 Redis 官方提供的高可用(High Availability)解决方案,其核心功能包括:

  1. 监控(Monitoring):Sentinel 持续向主节点、从节点和其他 Sentinel 发送心跳命令(PING),检测节点是否正常运行。

  2. 通知(Notification):当 Sentinel 检测到 Redis 实例出现故障时,可以通过 API、Pub/Sub 等方式通知管理员或应用程序。

  3. 自动故障转移(Automatic Failover):当主节点故障时,Sentinel 自动将一个从节点提升为新的主节点,并重新配置其他从节点复制新主节点,同时通知客户端新主节点的地址。

  4. 配置提供者(Configuration Provider):客户端不直接连接主节点,而是通过 Sentinel 查询当前主节点的地址。当故障转移发生后,Sentinel 会提供新主节点的地址。

Sentinel 的部署架构:

复制代码
┌──────────┐   ┌──────────┐   ┌──────────┐
│ Sentinel1│   │ Sentinel2│   │ Sentinel3│
└────┬─────┘   └────┬─────┘   └────┬─────┘
     │              │              │
     └──────────────┼──────────────┘
                    │
        ┌───────────┼───────────┐
        │           │           │
   ┌────┴────┐ ┌────┴────┐ ┌────┴────┐
   │  Master │ │Replica-1│ │Replica-2│
   └─────────┘ └─────────┘ └─────────┘

3.2 Sentinel 集群

为什么需要多个 Sentinel:

单个 Sentinel 存在单点故障风险。如果唯一的 Sentinel 宕机,则无法进行故障转移。因此 Sentinel 必须以集群方式部署。

部署要求:

  • 至少 3 个 Sentinel 实例(保证多数派选举)。
  • 建议部署在不同的物理机或可用区上,避免共因故障。
  • Sentinel 实例数量建议为奇数(3、5、7),便于多数派判断。

通信机制:

  1. Sentinel 与 Redis 节点:Sentinel 每秒向主从节点发送 INFO 命令,获取拓扑信息;每秒发送 PING 命令检测存活。
  2. Sentinel 之间 :通过 Redis 主节点的 Pub/Sub 机制通信。每个 Sentinel 每两秒在 __sentinel__:hello 频道发布自身信息(IP、端口、runid、配置纪元等),同时订阅该频道获取其他 Sentinel 的信息。
  3. Sentinel 与客户端 :客户端通过 Sentinel 获取主节点地址,并订阅 +switch-master 等事件获取主节点变更通知。

伪代码:Sentinel 监控流程

pseudocode 复制代码
function sentinelMainLoop(sentinel):
    while true:
        // 1. 向所有被监控的Redis节点发送PING
        for instance in sentinel.monitored_instances:
            reply = instance.sendPING()
            if reply.isHealthy():
                instance.last_ok_ping = now()
            else:
                instance.last_fail_ping = now()

        // 2. 向主节点发送INFO,获取从节点列表
        for master in sentinel.masters:
            info = master.sendINFO()
            sentinel.updateReplicaList(master, info.replicas)

        // 3. 通过Pub/Sub与其他Sentinel交换信息
        sentinel.publishHello()  // 发布自身状态
        // 自动通过订阅回调接收其他Sentinel信息

        // 4. 检查是否需要故障转移
        for master in sentinel.masters:
            if sentinel.checkObjectivelyDown(master):
                sentinel.startFailoverIfNeeded(master)

        sleep(1_second)  // 每秒执行一次

Sentinel 配置示例:

conf 复制代码
# sentinel.conf
port 26379

# 监控名为mymaster的主节点
# 最后一个2是quorum:至少2个Sentinel同意才判定客观下线
sentinel monitor mymaster 127.0.0.1 6379 2

# 主节点无响应超过30秒则判定为主观下线
sentinel down-after-milliseconds mymaster 30000

# 故障转移超时时间
sentinel failover-timeout mymaster 180000

# 同时可以有几个从节点与新主节点同步
sentinel parallel-syncs mymaster 1

3.3 故障检测

Sentinel 的故障检测采用两阶段机制,从主观判断升级到客观共识:

主观下线(SDOWN,Subjective Down):

  • 单个 Sentinel 认为某个节点不可达。
  • 判断条件:节点在 down-after-milliseconds 时间内没有响应 PING 命令。
  • 单个 Sentinel 的主观判断可能不准确(如网络分区),不能直接触发故障转移。

客观下线(ODOWN,Objective Down):

  • 超过 quorum 个 Sentinel 都认为主节点主观下线。
  • 只有主节点的 ODOWN 才会触发故障转移(从节点和 Sentinel 的下线不需要 quorum)。
  • 这是分布式共识的体现,避免了误判。

伪代码:SDOWN → ODOWN 的判断逻辑

pseudocode 复制代码
function checkSubjectiveDown(sentinel, master):
    // 判断主观下线
    elapsed = now() - master.last_ok_ping
    if elapsed > sentinel.down_after_milliseconds:
        master.flags |= SDOWN
        log("Master {} is SDOWN".format(master.name))
    else:
        master.flags &= ~SDOWN

function checkObjectiveDown(sentinel, master):
    // 判断客观下线
    if not (master.flags & SDOWN):
        return  // 未主观下线,不可能客观下线

    // 统计认为该主节点SDOWN的Sentinel数量
    down_count = 1  // 包含自己
    for other_sentinel in sentinel.known_sentinels:
        if other_sentinel thinks master is SDOWN:
            down_count += 1

    // 超过quorum则标记为ODOWN
    if down_count >= master.quorum:
        if not (master.flags & ODOWN):
            log("Master {} is ODOWN (quorum={}/{})".format(
                master.name, down_count, master.quorum))
            master.flags |= ODOWN
            // 触发故障转移流程
            sentinel.startFailover(master)
    else:
        master.flags &= ~ODOWN

注意: quorum 的值应设置为 Sentinel 总数的一半以上(多数派)。例如 3 个 Sentinel 时 quorum 设为 2,5 个时设为 3。

3.4 故障转移流程

当主节点被判定为 ODOWN 后,Sentinel 集群执行以下故障转移流程:

步骤 1:选出新的主节点

选择条件按优先级从高到低:

  1. 优先级(replica-priority) :从节点的 replica-priority 配置值越小优先级越高(默认 100)。设为 0 表示永不被选为主节点。
  2. 复制偏移量(Replication Offset):偏移量最大的从节点拥有最新的数据,优先选择。
  3. Run ID 最小:如果以上都相同,选择 Run ID 字典序最小的从节点,保证确定性结果。

步骤 2:通知其他从节点复制新主节点

Sentinel 向所有从节点发送 REPLICAOF <new_master_ip> <new_master_port> 命令,使它们开始复制新主节点。

步骤 3:通知客户端新主节点地址

Sentinel 更新自身维护的主节点映射,客户端下次查询时获取新地址。同时通过 Pub/Sub 发布 +switch-master 事件。

步骤 4:继续监控旧主节点

Sentinel 持续监控旧主节点。如果旧主节点恢复上线,Sentinel 将其设置为新主节点的从节点,避免其重新成为主节点造成脑裂(Split Brain)。

伪代码:Sentinel 故障转移完整流程

pseudocode 复制代码
function executeFailover(sentinel, master):
    log("开始故障转移: {}".format(master.name))

    // 步骤1:选举新的主节点
    new_master = selectNewMaster(master)
    if new_master == null:
        log("没有可用的从节点,故障转移失败")
        return

    log("选中新主节点: {}".format(new_master.name))

    // 步骤1.1:向新主节点发送SLAVEOF NO ONE
    new_master.sendCommand("SLAVEOF NO ONE")
    // 等待新主节点就绪
    waitUntil(new_master.isMaster())

    // 步骤2:通知其他从节点复制新主节点
    for replica in master.replicas:
        if replica != new_master:
            replica.sendCommand("REPLICAOF {} {}".format(
                new_master.ip, new_master.port))
            // 控制同时同步的从节点数量(parallel-syncs)
            if current_syncing >= master.parallel_syncs:
                waitUntil(sync_slot_available)

    // 步骤3:更新主节点映射并通知客户端
    sentinel.updateMasterMapping(master.name, new_master)
    sentinel.publishEvent("+switch-master", {
        "name": master.name,
        "old_host": master.ip,
        "old_port": master.port,
        "new_host": new_master.ip,
        "new_port": new_master.port
    })

    // 步骤4:继续监控旧主节点
    sentinel.monitorOldMaster(master)
    // 当旧主节点恢复后,将其设为新主节点的从节点
    // 在监控循环中处理

    log("故障转移完成")

function selectNewMaster(master):
    candidates = []
    for replica in master.replicas:
        // 排除不可用的从节点
        if replica.isDown() or replica.disconnected():
            continue
        // 排除5秒内未回复INFO的从节点
        if now() - replica.last_info_time > 5_seconds:
            continue
        // 排除断开主节点连接过久的从节点
        if replica.down_after_master:
            continue
        candidates.append(replica)

    if candidates.is_empty():
        return null

    // 按优先级排序:replica-priority → offset → run_id
    candidates.sort(by=[
        replica_priority ASC,      // 优先级越小越好
        replication_offset DESC,    // 偏移量越大越好(数据越新)
        run_id ASC                  // Run ID越小越好(确定性)
    ])

    return candidates[0]

3.5 领导者选举

为什么需要领导者:

如果多个 Sentinel 同时发起故障转移,会导致混乱:多个从节点可能同时被提升为主节点,造成脑裂。因此必须选举一个 Sentinel 领导者来负责执行故障转移。

选举规则:

  1. 最先发现主节点 ODOWN 的 Sentinel 向其他所有 Sentinel 发送 SENTINEL IS-MASTER-DOWN-BY-ADDR 命令,申请成为领导者。
  2. 每个 Sentinel 在同一个配置纪元(Configuration Epoch)中只能投票一次,先到先得。
  3. 获得多数票 (大于等于 quorum,且大于 Sentinel 总数的一半)的 Sentinel 成为领导者。
  4. 如果在 failover-timeout 时间内没有选出领导者,将在下一个纪元重新选举。

伪代码:领导者选举

pseudocode 复制代码
function electLeader(sentinel, master):
    // 增加配置纪元
    sentinel.current_epoch += 1

    // 向其他Sentinel请求投票
    votes_received = 1  // 投给自己
    for other in sentinel.known_sentinels:
        response = other.requestVote(
            epoch=sentinel.current_epoch,
            candidate_id=sentinel.run_id,
            master_name=master.name
        )
        if response.vote_granted:
            votes_received += 1

    // 判断是否获得多数票
    majority = ceil(sentinel.total_count / 2) + 1
    if votes_received >= majority:
        log("成为领导者,获得{}票".format(votes_received))
        return true
    else:
        log("未获得足够票数({}/{})".format(votes_received, majority))
        return false

// Sentinel接收投票请求的逻辑
function handleVoteRequest(request):
    // 每个纪元只能投票一次
    if request.epoch > sentinel.voted_epoch:
        // 先到先得
        sentinel.voted_epoch = request.epoch
        sentinel.voted_for = request.candidate_id
        return {vote_granted: true}
    else:
        return {vote_granted: false}

3.6 客户端连接 Sentinel

客户端连接流程:

  1. 客户端连接 Sentinel 集群(可以连接任一 Sentinel 实例)。
  2. 客户端发送 SENTINEL GET-MASTER-ADDR-BY-NAME <master_name> 获取当前主节点地址。
  3. 客户端连接主节点进行正常读写操作。
  4. 客户端订阅 Sentinel 的 +switch-master 事件,当主节点变更时收到通知并重新连接。

伪代码:客户端连接 Sentinel

pseudocode 复制代码
function connectViaSentinel(sentinel_addresses, master_name):
    // 步骤1:连接Sentinel
    sentinel = connectToSentinel(sentinel_addresses)

    // 步骤2:获取主节点地址
    master_addr = sentinel.sendCommand(
        "SENTINEL GET-MASTER-ADDR-BY-NAME", master_name)

    // 步骤3:连接主节点
    master = connectToRedis(master_addr.ip, master_addr.port)

    // 步骤4:订阅主节点变更事件
    sentinel.subscribe("+switch-master", function(event):
        if event.master_name == master_name:
            log("主节点变更: {}:{} → {}:{}".format(
                event.old_ip, event.old_port,
                event.new_ip, event.new_port))
            // 重新连接新主节点
            master.close()
            master = connectToRedis(event.new_ip, event.new_port)
    )

    return master

Sentinel 常用命令:

conf 复制代码
# 获取主节点地址
SENTINEL GET-MASTER-ADDR-BY-NAME mymaster

# 获取主节点的所有从节点信息
SENTINEL SLAVES mymaster

# 获取所有Sentinel信息
SENTINEL SENTINELS mymaster

# 手动触发故障转移
SENTINEL FAILOVER mymaster

# 检查Sentinel配置是否一致
SENTINEL CKQUORUM mymaster

# 重置主节点状态
SENTINEL RESET mymaster

四、Cluster(集群)

4.1 为什么需要 Cluster

单机 Redis 的瓶颈:

  1. 写瓶颈:单主节点无法承受高并发写入。
  2. 内存上限:单机内存有限(通常建议不超过 20~30GB),超过后 RDB 持久化和恢复耗时长。
  3. 单点故障:即使有 Sentinel,同一时刻只有一个主节点处理写请求。

Sentinel 的局限:

Sentinel 解决了高可用问题(主节点故障自动切换),但不解决水平扩展问题。所有数据仍然在同一个主节点上,写吞吐和内存容量仍然是单机上限。

Cluster 的定位:

Redis Cluster 同时解决了高可用和水平扩展两个问题:

  • 数据分片(Sharding):将数据分散到多个主节点,突破单机写瓶颈和内存上限。
  • 自动故障转移:内置故障检测和转移机制,不需要单独部署 Sentinel。

4.2 Hash Slot(哈希槽)

Redis Cluster 将整个键空间划分为 16384 个 Hash Slot(哈希槽),每个主节点负责一部分 Slot。

Key 到 Slot 的映射算法:

复制代码
slot = CRC16(key) % 16384
  • CRC16 是一种循环冗余校验算法,输出 16 位值(0~65535)。
  • 对 16384 取模,将结果映射到 0~16383 的 Slot 编号。

伪代码:Key 到 Slot 的映射过程

pseudocode 复制代码
function keyToSlot(key):
    // 处理Hash Tag:如果key中包含{},只取{}内的部分计算
    hash_key = extractHashTag(key)

    // CRC16算法计算哈希值
    crc_value = CRC16(hash_key)

    // 对16384取模得到Slot编号
    slot = crc_value % 16384

    return slot  // 返回0~16383之间的整数

function extractHashTag(key):
    // Hash Tag规则:取第一个{和其后第一个}之间的内容
    start = key.indexOf('{')
    if start == -1:
        return key  // 没有{},使用整个key

    end = key.indexOf('}', start + 1)
    if end == -1 or end == start + 1:
        return key  // {}不完整或为空,使用整个key

    return key.substring(start + 1, end)  // 只用{}内的部分

// 示例
// "user:1001" → CRC16("user:1001") % 16384 = 12578
// "{user}:1001" → CRC16("user") % 16384 = 5474
// "{user}:1002" → CRC16("user") % 16384 = 5474(同一个Slot)

Slot 到 Node 的映射:

每个节点维护一个 Slot 映射表,记录每个 Slot 属于哪个节点。集群中的所有节点共享同一份 Slot 映射信息,通过 Gossip 协议同步。

4.3 为什么是 16384 个槽

面试高频题:为什么不是 65536 个槽?

Redis 作者 antirez 在 GitHub Issue 中给出了详细解释,核心原因如下:

1. 节点间通信开销

Redis 节点间通过 Gossip 协议交换信息。每个节点需要将自身的 Slot 分布信息发送给其他节点。Slot 分布用位图(Bitmap)表示:

  • 16384 个 Slot 的位图大小:16384 / 8 = 2048 字节(2KB)
  • 65536 个 Slot 的位图大小:65536 / 8 = 8192 字节(8KB)

Gossip 消息中,位图会频繁在节点间传输。8KB 的位图在 1000 个节点的集群中,心跳消息的网络开销显著增大。

2. 实际规模足够

Redis Cluster 的设计目标是最多支持约 1000 个节点。16384 个 Slot 分配给 1000 个节点,每个节点平均负责约 16 个 Slot,已经足够均匀。即使只有 3 个节点,每个节点约 5461 个 Slot,也不会因为 Slot 太少导致数据倾斜。

3. Slot 数量与迁移粒度的权衡

Slot 越多,每个 Slot 包含的 Key 越少,迁移粒度越细,灵活性更高。但如前所述,更多 Slot 意味着更大的通信开销。16384 是在两者之间的合理折中。

4. 65536 的实际收益有限

即便将 Slot 数增加到 65536,对于 1000 个节点以内的集群,数据分布的均匀程度提升有限,但通信开销增加了 4 倍。

总结:

对比项 16384 槽 65536 槽
位图大小 2KB 8KB
支持 1000 节点 每节点约 16 槽 每节点约 65 槽
心跳消息开销 高 4 倍
迁移粒度 合理 更细但收益有限

4.4 集群拓扑

基本结构:

  • Redis Cluster 由多个节点(Node)组成,至少需要 6 个节点(3 主 3 从)才能保证高可用。

  • 每个主节点负责一部分 Hash Slot。

  • 每个主节点可以有一个或多个从节点,推荐至少 1 个从节点。

    ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
    │ Master-A │ │ Master-B │ │ Master-C │
    │ Slot 0~5460│ │5461~10922 │ │10923~16383 │
    └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
    │ │ │
    ┌────┴────┐ ┌────┴────┐ ┌────┴────┐
    │Replica-A│ │Replica-B│ │Replica-C│
    └─────────┘ └─────────┘ └─────────┘

节点间通信:Gossip 协议

  • 每个节点每秒向集群中随机选择的少量节点发送 Ping 消息。
  • Ping 消息包含自身状态和部分其他节点的状态信息。
  • 接收节点用这些信息更新自身的集群状态视图。
  • Pong 消息作为 Ping 的响应,也携带状态信息。
  • Meet 消息用于新节点加入集群时的握手。

Gossip 协议的优势:

  • 去中心化:不需要中心协调节点。
  • 最终一致性:集群状态在有限时间内收敛。
  • 容错性:部分节点故障不影响协议运行。

Gossip 协议的劣势:

  • 状态收敛有延迟:集群规模越大,延迟越明显。
  • 消息冗余:每个节点可能收到重复的状态信息。

4.5 请求路由

当客户端向集群中的某个节点发送命令时,可能出现以下情况:

1. Key 所在 Slot 在当前节点: 直接执行命令并返回结果。

2. Key 所在 Slot 不在当前节点: 返回 MOVED 重定向错误,告知客户端正确的节点地址。

复制代码
-MOVED 3999 127.0.0.1:6380

含义:Slot 3999 在 127.0.0.1:6380 节点上,客户端应该重新发送命令到该节点。

3. Slot 正在迁移中: 返回 ASK 重定向错误,告知客户端临时迁移到目标节点。

复制代码
-ASK 3999 127.0.0.1:6380

含义:Slot 3999 正在迁移,请求 127.0.0.1:6380 节点处理。

MOVED vs ASK 的区别:

  • MOVED:永久性重定向,客户端应更新本地的 Slot 映射缓存,后续请求直接发到新节点。
  • ASK:临时性重定向,客户端仅本次请求发到新节点,不更新本地缓存。下次请求仍然先尝试原节点。

伪代码:MOVED vs ASK 的处理逻辑

pseudocode 复制代码
function sendCommand(client, key, command):
    slot = keyToSlot(key)

    // 首先查询本地Slot映射缓存
    target_node = client.slot_cache.get(slot)

    if target_node == null:
        // 随机选择一个节点发送
        target_node = client.getRandomNode()

    // 发送命令
    response = target_node.send(command)

    if response.isMovedError():
        // MOVED:永久重定向,更新本地缓存
        new_node = parseMovedResponse(response)
        client.slot_cache.update(slot, new_node)
        // 重新发送命令到新节点
        return new_node.send(command)

    else if response.isAskError():
        // ASK:临时重定向,不更新本地缓存
        new_node = parseAskResponse(response)
        // 先发送ASKING命令(标识接下来是ASK重定向请求)
        new_node.send("ASKING")
        // 发送实际命令
        return new_node.send(command)

    else:
        return response

ASKING 命令的作用:

目标节点在 Slot 迁移期间,对于不属于自己负责的 Slot,默认会返回 MOVED 错误。发送 ASKING 命令后,目标节点会临时接受该请求(仅一次),这就是 ASK 重定向的工作方式。

4.6 Slot 迁移

Slot 迁移是 Redis Cluster 在线扩容和缩容的核心操作。迁移期间集群仍可正常提供服务。

迁移过程:

  1. 向目标节点发送 CLUSTER SETSLOT <slot> IMPORTING <source_node_id>,标记目标节点准备接收该 Slot。
  2. 向源节点发送 CLUSTER SETSLOT <slot> MIGRATING <target_node_id>,标记源节点准备迁出该 Slot。
  3. 逐个将源节点的 Key 迁移到目标节点:使用 MIGRATE 命令。
  4. 迁移完成后,向所有节点发送 CLUSTER SETSLOT <slot> NODE <target_node_id>,更新 Slot 映射。

迁移期间的请求处理:

  • 客户端请求先到达源节点。
  • 如果 Key 仍在源节点上,源节点正常处理。
  • 如果 Key 已迁移到目标节点,源节点返回 ASK 重定向。
  • 客户端发送 ASKING + 实际命令到目标节点,目标节点临时处理。

伪代码:Slot 迁移流程

pseudocode 复制代码
function migrateSlot(source_node, target_node, slot):
    // 步骤1:目标节点标记为IMPORTING
    target_node.sendCommand(
        "CLUSTER SETSLOT {} IMPORTING {}".format(
            slot, source_node.node_id))

    // 步骤2:源节点标记为MIGRATING
    source_node.sendCommand(
        "CLUSTER SETSLOT {} MIGRATING {}".format(
            slot, target_node.node_id))

    // 步骤3:逐个迁移Key
    while true:
        // 获取源节点上该Slot的一小批Key
        keys = source_node.sendCommand(
            "CLUSTER GETKEYSINSLOT {} {}".format(slot, 100))

        if keys.is_empty():
            break  // 该Slot的所有Key已迁移完毕

        // 将这批Key迁移到目标节点
        // MIGRATE命令是原子的:GET+DEL+RESTORE
        for key in keys:
            source_node.sendCommand(
                "MIGRATE {} {} {} 0 5000".format(
                    target_node.ip, target_node.port,
                    key))

    // 步骤4:通知所有节点更新Slot映射
    for node in cluster.all_nodes:
        node.sendCommand(
            "CLUSTER SETSLOT {} NODE {}".format(
                slot, target_node.node_id))

    log("Slot {} 迁移完成:{} → {}".format(
        slot, source_node.node_id, target_node.node_id))

4.7 故障检测与转移

Redis Cluster 内置了类似 Sentinel 的故障检测和转移机制,不需要额外部署 Sentinel 进程。

故障检测:

  1. 主观下线(PFAIL,Possibly Fail) :节点 A 向节点 B 发送 Ping,如果在 cluster-node-timeout(默认 15 秒)内未收到 Pong 响应,A 将 B 标记为 PFAIL。
  2. 客观下线(FAIL):当超过半数的主节点将某个主节点标记为 PFAIL 时,发现该情况的节点将其标记为 FAIL,并广播给集群中所有节点。

故障转移:

  1. 从节点发现主节点 FAIL 后,开始竞选成为新主节点。
  2. 竞选条件:复制偏移量(Replication Offset)最大的从节点优先,因为它拥有最新的数据。
  3. 竞选过程:从节点向其他主节点请求投票,获得多数主节点同意后成为新主节点。
  4. 新主节点执行 SLAVEOF NO ONE,更新 Slot 映射。
  5. 其他从节点开始复制新主节点。

伪代码:Cluster 故障转移

pseudocode 复制代码
function clusterFailover(failed_master, replica):
    // 步骤1:从节点检测到主节点FAIL
    if not replica.master.flags & FAIL:
        return

    // 步骤2:竞选准备
    // 更新配置纪元(每次故障转移递增)
    replica.current_config_epoch += 1

    // 步骤3:向其他主节点请求投票
    votes_received = 0
    for master in cluster.masters:
        if master == failed_master:
            continue  // 跳过故障主节点
        vote = master.requestVote(
            config_epoch=replica.current_config_epoch,
            candidate=replica.node_id
        )
        if vote.granted:
            votes_received += 1

    // 步骤4:判断是否获得多数票
    majority = ceil(cluster.master_count / 2)
    if votes_received < majority:
        log("未获得足够票数")
        return

    // 步骤5:成为新主节点
    replica.sendCommand("SLAVEOF NO ONE")
    replica.role = MASTER

    // 步骤6:接管故障主节点的Slot
    for slot in failed_master.slots:
        replica.addSlot(slot)
        // 广播Slot映射更新
        cluster.broadcastSlotUpdate(slot, replica.node_id)

    // 步骤7:其他从节点开始复制新主节点
    for other_replica in failed_master.replicas:
        if other_replica != replica:
            other_replica.sendCommand(
                "CLUSTER REPLICATE {}".format(replica.node_id))

    log("故障转移完成:{} 成为新主节点".format(replica.node_id))

与 Sentinel 故障转移的区别:

对比项 Sentinel Cluster
部署方式 独立进程 集成在 Redis 节点中
检测机制 Sentinel 投票(quorum) 主节点投票(多数派)
选主依据 优先级 → 偏移量 → Run ID 偏移量优先
Slot 感知 有,更新 Slot 映射

4.8 集群限制

1. 不支持多 Key 操作

当多个 Key 不在同一个 Slot 时,MGET、MSET、SUNION 等多 Key 操作不可用。

解决方案:Hash Tag

Hash Tag 是一种机制,通过在 Key 中使用 {} 来指定用于计算 Slot 的部分。{} 内的内容相同,Key 就会映射到同一个 Slot。

伪代码:Hash Tag 的工作原理

pseudocode 复制代码
function extractHashTag(key):
    // 查找第一个 {
    start = key.indexOf('{')
    if start == -1:
        return key  // 没有{,使用完整key

    // 查找{之后的第一个}
    end = key.indexOf('}', start + 1)
    if end == -1:
        return key  // 没有匹配的},使用完整key

    if end == start + 1:
        return key  // {}为空,使用完整key

    // 使用{}内的内容计算Slot
    return key.substring(start + 1, end)

// 示例:
// "user:{1001}:name" → hash_tag="1001" → CRC16("1001") % 16384 = Slot X
// "user:{1001}:age"  → hash_tag="1001" → CRC16("1001") % 16384 = Slot X
// 两个Key在同一个Slot,可以执行MGET等操作

// 注意:Hash Tag会影响数据分布
// 如果大量Key使用相同的Hash Tag,会导致Slot数据倾斜

2. 不支持 SELECT 命令

Redis Cluster 只使用 0 号数据库(db0),不支持多数据库。如果客户端发送 SELECT 命令,会返回错误。

3. 其他限制

  • Lua 脚本中访问的 Key 必须在同一个 Slot(除非使用 Hash Tag)。
  • 事务中涉及的 Key 必须在同一个 Slot(除非使用 Hash Tag)。
  • 集群不支持 KEYS * 等全局命令(只能在每个节点上单独执行)。

五、分布式锁(Distributed Lock)

5.1 基本实现

在分布式系统中,多个进程可能同时访问共享资源,需要通过分布式锁来保证互斥访问。

Redis 实现分布式锁的基本命令:

conf 复制代码
SET lock_key unique_value NX PX 30000

参数解析:

  • lock_key:锁的名称,即 Redis 中的 Key。
  • unique_value:锁的唯一标识,通常使用 UUID 或线程 ID + 时间戳。必须全局唯一,防止误删其他客户端的锁。
  • NX:Only set the key if it does not already exist,即 Key 不存在时才设置。这保证了互斥性:只有第一个请求能成功获取锁。
  • PX 30000:设置 Key 的过期时间为 30 秒(30000 毫秒)。这防止了死锁:即使持有锁的客户端崩溃未能主动释放锁,锁也会在超时后自动过期。

伪代码:获取锁

pseudocode 复制代码
function acquireLock(lock_name, acquire_timeout, lock_timeout):
    unique_value = generateUUID()
    lock_key = "lock:" + lock_name
    end_time = now() + acquire_timeout

    while now() < end_time:
        // 尝试设置锁(NX + PX原子操作)
        result = redis.set(lock_key, unique_value,
                          "NX", "PX", lock_timeout)

        if result == "OK":
            log("获取锁成功: {}".format(lock_key))
            return unique_value  // 返回锁的唯一标识

        // 短暂等待后重试
        sleep(10_ms)

    log("获取锁超时: {}".format(lock_key))
    return null  // 获取失败

5.2 释放锁的原子性

为什么不能直接 DEL:

考虑以下场景:

  1. 客户端 A 获取锁(value=A),过期时间 30 秒。
  2. 客户端 A 业务执行缓慢,锁过期自动释放。
  3. 客户端 B 获取锁(value=B)。
  4. 客户端 A 执行完毕,发送 DEL lock_key误删了客户端 B 的锁
  5. 客户端 C 也能获取锁,导致 A、B、C 三个客户端同时持有"锁",互斥性被破坏。

正确做法:使用 Lua 脚本

先检查 value 是否匹配,匹配则删除。Lua 脚本在 Redis 中以原子方式执行,不会被其他命令打断,保证了检查和删除的原子性。

伪代码:释放锁的 Lua 脚本

lua 复制代码
-- 释放锁的Lua脚本
-- KEYS[1] = 锁的Key
-- ARGV[1] = 客户端的唯一标识

if redis.call("GET", KEYS[1]) == ARGV[1] then
    -- value匹配,说明是自己的锁,可以删除
    return redis.call("DEL", KEYS[1])
else
    -- value不匹配,说明锁已被其他人持有,不能删除
    return 0
end

伪代码:释放锁的完整流程

pseudocode 复制代码
function releaseLock(lock_name, unique_value):
    lock_key = "lock:" + lock_name

    // 使用Lua脚本原子地检查并删除
    lua_script = """
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    """

    result = redis.eval(lua_script, 1, lock_key, unique_value)

    if result == 1:
        log("释放锁成功: {}".format(lock_key))
        return true
    else:
        log("释放锁失败(锁不属于当前客户端): {}".format(lock_key))
        return false

5.3 分布式锁的边界问题

问题 1:锁过期但业务未完成

如果客户端的业务执行时间超过了锁的过期时间,锁会自动释放,其他客户端可以获取锁,导致互斥性被破坏。

解决方案:看门狗(Watchdog)自动续期

Redisson(Redis 的 Java 客户端)实现了看门狗机制:

  • 锁的默认过期时间为 30 秒(lockWatchdogTimeout)。
  • 每隔 10 秒(过期时间的 1/3),看门狗检查锁是否仍被持有,如果是,则将过期时间重置为 30 秒。
  • 如果持有锁的客户端宕机,看门狗停止续期,锁在 30 秒后自动过期。

伪代码:看门狗自动续期

pseudocode 复制代码
function startWatchdog(lock_key, unique_value, lock_timeout):
    renewal_interval = lock_timeout / 3  // 续期间隔

    while true:
        sleep(renewal_interval)

        // 检查锁是否仍属于当前客户端
        lua_script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("PEXPIRE", KEYS[1], ARGV[2])
        else
            return 0
        end
        """
        result = redis.eval(lua_script, 1,
                           lock_key, unique_value, lock_timeout)

        if result == 0:
            // 锁不再属于当前客户端,停止续期
            log("锁已不属于当前客户端,停止续期")
            break
        else:
            log("锁续期成功")

问题 2:Redis 主从切换导致锁丢失

考虑以下场景(主从异步复制导致的锁丢失问题):

  1. 客户端 A 在主节点获取锁成功。
  2. 主节点将锁信息异步复制到从节点(尚未完成)。
  3. 主节点宕机,从节点被提升为新主节点。
  4. 新主节点没有客户端 A 的锁信息,客户端 B 可以获取锁。
  5. 客户端 A 和 B 同时持有"锁"。

解决方案:Redlock 算法

Redlock 由 Redis 作者 antirez 提出,使用多个独立的 Redis 实例(非主从关系)来保证锁的安全性。

伪代码:Redlock 算法的核心逻辑

pseudocode 复制代码
function redlockAcquire(lock_name, unique_value, lock_timeout, retry_count):
    // N个独立的Redis实例(通常为5个)
    instances = [redis1, redis2, redis3, redis4, redis5]
    quorum = len(instances) / 2 + 1  // 多数派,5个实例需要3个同意

    for attempt in range(retry_count):
        acquired_count = 0
        start_time = now()

        // 向所有实例依次请求锁
        for instance in instances:
            try:
                result = instance.set(
                    "lock:" + lock_name, unique_value,
                    "NX", "PX", lock_timeout)
                if result == "OK":
                    acquired_count += 1
            except:
                continue  // 实例不可用,跳过

        // 计算获取锁的实际有效时间
        elapsed = now() - start_time
        validity_time = lock_timeout - elapsed

        // 判断是否获得多数派,且锁仍有效
        if acquired_count >= quorum and validity_time > 0:
            log("Redlock获取锁成功,有效时间: {}ms".format(validity_time))
            return {
                success: true,
                validity_time: validity_time
            }

        // 获取失败,向所有实例释放锁
        for instance in instances:
            try:
                releaseLockOnInstance(instance, lock_name, unique_value)
            except:
                continue

        // 随机延迟后重试(避免多个客户端同时重试导致活锁)
        sleep(random(1_ms, 200_ms))

    log("Redlock获取锁失败")
    return {success: false}

function redlockRelease(lock_name, unique_value):
    // 向所有实例释放锁
    for instance in instances:
        try:
            releaseLockOnInstance(instance, lock_name, unique_value)
        except:
            continue

Redlock 的核心思想:

  • 使用 N 个独立 Redis 实例(推荐 5 个),不是主从关系。
  • 获取锁时需要多数派(≥ N/2+1)实例同意。
  • 考虑了获取锁消耗的时间,计算锁的实际有效时间。
  • 释放锁时向所有实例发送释放请求。

5.4 Redlock 争议

Martin Kleppmann 的批评(2016 年):

分布式系统专家 Martin Kleppmann 发表文章《How to do distributed locking》,指出 Redlock 存在以下问题:

  1. 时钟漂移(Clock Drift):Redlock 依赖各实例的系统时钟来计算锁的有效时间。如果某个实例的时钟发生跳变(如 NTP 同步),可能导致锁提前过期或延迟过期,影响正确性。

  2. 进程暂停(Process Pause):持有锁的进程可能因为 GC(Garbage Collection)暂停而长时间不响应。暂停结束后,进程可能不知道锁已过期,继续操作共享资源。

  3. 网络分区:在复杂的网络分区场景下,Redlock 可能无法保证互斥性。

Martin 的建议:对于需要正确性保证的场景,应使用基于一致性协议(如 Raft)的分布式协调服务(如 ZooKeeper、etcd),而非 Redlock。

Antirez 的回应:

Redis 作者 antirez 发表了回应文章《Is Redlock safe?》:

  1. 时钟问题:Redlock 不依赖绝对时间,只依赖相对时间(过期计时)。合理的时钟同步(如 NTP)足以保证正确性。
  2. 进程暂停:这个问题对所有分布式锁方案都存在,不是 Redlock 特有的问题。使用 fencing token(递增的令牌)可以缓解此问题。
  3. 网络分区:Redlock 的多数派机制已经考虑了分区容错。

结论与生产实践建议:

  • 大多数场景:单实例 Redis 锁 + 看门狗续期已经足够。大部分业务场景可以容忍极端情况下的偶尔锁失效。
  • 极高安全要求:如金融交易、库存核心扣减等,应使用基于 Raft 的协调服务(etcd、ZooKeeper、Consul),它们提供更强的一致性保证。
  • Redlock 适用于:需要比单实例锁更高的安全性,但又不想引入 ZooKeeper/etcd 等额外组件的场景。

六、事务(Transaction)

6.1 Redis 事务的基本操作

Redis 事务通过 MULTI、EXEC、DISCARD、WATCH 四个命令实现:

  • MULTI:标记事务开始,之后的命令进入队列而非立即执行。
  • EXEC:执行事务中的所有命令。
  • DISCARD:放弃事务,清空命令队列。
  • WATCH:乐观锁,监视一个或多个 Key,如果在 EXEC 执行前这些 Key 被其他客户端修改,则事务自动放弃。

伪代码:事务执行流程

pseudocode 复制代码
function redisTransaction(watch_keys, commands):
    // 步骤1:WATCH监视Key(可选)
    if watch_keys:
        for key in watch_keys:
            redis.watch(key)
            // 记录Key的当前版本
            watched_versions[key] = redis.get_version(key)

    // 步骤2:MULTI开启事务
    redis.multi()

    // 步骤3:命令入队
    for cmd in commands:
        result = redis.queue_command(cmd)
        // 注意:入队时返回QUEUED,不执行命令
        assert result == "QUEUED"

    // 步骤4:EXEC执行事务
    results = redis.exec()

    if results == null:
        // WATCH的Key被修改,事务被放弃
        log("事务放弃:WATCH的Key已被修改")
        return TRANSACTION_ABORTED
    else:
        // 事务执行成功,返回每条命令的结果
        return results

// 示例:银行转账
// 从账户A转100元到账户B
function transfer(account_a, account_b, amount):
    return redisTransaction(
        watch_keys=[account_a, account_b],
        commands=[
            "DECRBY {} {}".format(account_a, amount),
            "INCRBY {} {}".format(account_b, amount)
        ]
    )

6.2 Redis 事务不是 ACID

Redis 的事务与传统关系型数据库的 ACID 事务有显著差异:

原子性(Atomicity)------ 有限支持

  • Redis 事务保证命令要么全部执行(EXEC 后),要么都不执行(EXEC 前 DISCARD 或 WATCH 失败)。
  • 但是,如果事务中的某条命令执行失败(如类型错误),其他命令仍会继续执行,不会回滚。Redis 不支持回滚(Rollback)。
conf 复制代码
MULTI
SET key1 "value1"
INCR key1        // key1是字符串,INCR会失败
SET key2 "value2"  // 仍会执行
EXEC
// 结果:key1="value1"(SET成功),INCR报错,key2="value2"(SET成功)

为什么 Redis 不支持回滚:

Redis 作者认为:Redis 命令失败只可能由编程错误引起(如对字符串执行 INCR),这类错误应该在开发阶段被发现和修复。不支持回滚可以保持 Redis 的简单和高效。

一致性(Consistency)------ 基本保证

  • Redis 事务不会破坏数据的一致性约束(如唯一性、类型约束等)。
  • 但 Redis 本身没有严格的一致性约束(无外键、无约束检查),这个特性意义不大。

隔离性(Isolation)------ 部分保证

  • EXEC 前的命令对其他客户端不可见(命令在队列中)。
  • EXEC 后的命令一次性执行,不会被其他客户端的命令穿插。
  • 但没有隔离级别的概念,无法防止脏读、不可重复读等问题。

持久性(Durability)------ 不保证

  • 事务的持久性取决于 Redis 的持久化配置(RDB/AOF)。
  • 如果未开启持久化或使用 RDB 且未到保存时间点,事务执行后的数据可能在宕机后丢失。

6.3 WATCH 机制

WATCH 实现了乐观锁(Optimistic Locking),基于 CAS(Compare And Set)的思想。

工作原理:

  1. WATCH 命令监视一个或多个 Key,记录 Key 的当前修改版本。
  2. 客户端执行 MULTI 并入队命令。
  3. 当客户端执行 EXEC 时,Redis 检查所有被 WATCH 的 Key 是否被修改过:
    • 如果没有被修改:正常执行事务中的所有命令。
    • 如果被修改:放弃事务,返回 nil。

伪代码:WATCH 的实现逻辑

pseudocode 复制代码
// Redis服务端WATCH的实现
function watchKey(client, key):
    client.watched_keys.add(key)
    // 记录Key的当前修改版本号
    client.watched_versions[key] = redis.getModificationVersion(key)

// 判断WATCH的Key是否被修改
function isWatchedKeyModified(client):
    for key in client.watched_keys:
        current_version = redis.getModificationVersion(key)
        if current_version != client.watched_versions[key]:
            return true  // Key已被修改
    return false

// EXEC时检查WATCH
function execTransaction(client):
    // 检查WATCH的Key是否被修改
    if isWatchedKeyModified(client):
        // 放弃事务
        client.discardTransaction()
        client.unwatchAll()
        return null

    // 执行事务中的所有命令
    results = []
    for cmd in client.queued_commands:
        result = executeCommand(cmd)
        results.append(result)

    // 执行完毕,清除WATCH
    client.unwatchAll()
    return results

// 客户端使用WATCH实现乐观锁
function optimisticLockExample(key, new_value):
    max_retries = 100
    for i in range(max_retries):
        // 读取当前值
        current_value = redis.get(key)

        // WATCH监视Key
        redis.watch(key)

        // 开启事务
        redis.multi()
        redis.set(key, new_value)

        // 尝试执行
        result = redis.exec()

        if result != null:
            return true  // 成功
        // 否则重试

    return false  // 重试次数耗尽

6.4 事务 vs Lua 脚本

对比项 事务(Transaction) Lua 脚本
原子性 有限(不支持回滚) 天然原子(整体执行)
复杂逻辑 不支持(只有命令队列) 支持(条件判断、循环等)
WATCH 需要手动 WATCH 不需要(脚本执行期间不插入其他命令)
错误处理 某条失败不影响其他 可在脚本中处理错误
复用性 每次需重新发送 EVALSHA 可复用
性能 一般 更好(减少网络往返)

Lua 脚本更推荐的原因:

  1. 天然原子:Lua 脚本在 Redis 中以原子方式执行,期间不会执行其他客户端的命令,无需 WATCH。
  2. 更灵活:可以在脚本中编写条件判断、循环等复杂逻辑。
  3. 更高效:减少客户端与服务器之间的网络往返次数。

事务适用场景:

  • 简单的批量操作,不涉及条件判断。
  • 需要监视 Key 变化的简单乐观锁场景。

七、Lua 脚本

7.1 基本用法

Redis 内置了 Lua 解释器,可以在服务器端执行 Lua 脚本。脚本以原子方式执行,期间不会执行其他客户端的命令。

相关命令:

  • EVAL:执行 Lua 脚本。
  • EVALSHA:根据脚本的 SHA1 校验和执行已缓存的脚本。
  • SCRIPT LOAD:将脚本加载到缓存中,返回 SHA1。
  • SCRIPT EXISTS:检查脚本是否在缓存中。
  • SCRIPT FLUSH:清空脚本缓存。

EVAL 命令格式:

conf 复制代码
EVAL script numkeys key [key ...] arg [arg ...]
  • script:Lua 脚本代码。
  • numkeys:Key 的数量。
  • key:Key 参数(在脚本中通过 KEYS1、KEYS2 访问)。
  • arg:附加参数(在脚本中通过 ARGV1、ARGV2 访问)。

伪代码:EVAL 执行流程

pseudocode 复制代码
function evalScript(script, keys, args):
    // 步骤1:计算脚本的SHA1
    sha1 = SHA1(script)

    // 步骤2:检查脚本是否已缓存
    if not scriptCache.exists(sha1):
        // 编译并缓存脚本
        compiled_script = lua.compile(script)
        scriptCache.set(sha1, compiled_script)

    // 步骤3:在Lua环境中设置参数
    lua_env.setGlobal("KEYS", keys)
    lua_env.setGlobal("ARGV", args)
    lua_env.setGlobal("redis", redis_api)  // 提供redis.call等API

    // 步骤4:原子执行脚本(期间不执行其他客户端命令)
    redis.blockOtherClients()
    try:
        result = lua_env.execute(scriptCache.get(sha1))
    finally:
        redis.unblockOtherClients()

    // 步骤5:将Lua返回值转换为Redis回复
    return convertLuaToRedisReply(result)

Lua 脚本中可用的 Redis API:

lua 复制代码
-- 调用Redis命令
redis.call('SET', KEYS[1], ARGV[1])
redis.call('GET', KEYS[1])

-- 安全调用(命令出错返回错误对象,不抛异常)
local result = redis.pcall('GET', KEYS[1])

-- 返回日志
redis.log(redis.LOG_WARNING, "something is wrong")

-- 获取当前数据库中匹配的Key
-- 注意:生产环境中慎用KEYS命令,会阻塞服务器

7.2 秒杀场景示例

秒杀(Flash Sale)场景的核心需求是:库存扣减和订单创建必须原子执行,不能超卖。

问题分析:

  • 普通命令无法保证原子性:先检查库存再扣减,两步之间可能被其他请求插入。
  • 使用事务 + WATCH 可以实现,但并发冲突多时重试开销大。
  • Lua 脚本是最佳选择:原子执行,无需 WATCH。

伪代码:秒杀 Lua 脚本

lua 复制代码
-- 秒杀Lua脚本
-- KEYS[1] = 库存Key(如:seckill:stock:1001)
-- KEYS[2] = 已购用户集合Key(如:seckill:bought:1001)
-- ARGV[1] = 用户ID
-- ARGV[2] = 购买数量

-- 步骤1:检查用户是否已购买(防止重复购买)
if redis.call("SISMEMBER", KEYS[2], ARGV[1]) == 1 then
    return -1  -- 用户已购买
end

-- 步骤2:检查库存是否充足
local stock = tonumber(redis.call("GET", KEYS[1]))
if stock == nil then
    return -2  -- 商品不存在
end
if stock < tonumber(ARGV[2]) then
    return 0  -- 库存不足
end

-- 步骤3:扣减库存
redis.call("DECRBY", KEYS[1], ARGV[2])

-- 步骤4:记录已购用户
redis.call("SADD", KEYS[2], ARGV[1])

return 1  -- 秒杀成功

伪代码:客户端调用秒杀脚本

pseudocode 复制代码
function seckill(product_id, user_id, quantity):
    stock_key = "seckill:stock:" + product_id
    bought_key = "seckill:bought:" + product_id

    result = redis.eval(seckill_script, 2,
                        stock_key, bought_key,
                        user_id, str(quantity))

    switch result:
        case 1:  return "秒杀成功"
        case 0:  return "库存不足"
        case -1: return "请勿重复购买"
        case -2: return "商品不存在"

7.3 Lua 脚本注意事项

1. 不能执行耗时操作

Lua 脚本在 Redis 中以原子方式执行,意味着脚本执行期间,其他所有客户端的命令都会被阻塞。因此:

  • 禁止在脚本中执行耗时操作(如大循环、复杂计算)。
  • 禁止使用 KEYS * 等扫描全库的命令。
  • 脚本执行时间默认限制为 5 秒(lua-time-limit 配置),超时后 Redis 只能通过 SCRIPT KILL 或shutdown 来中断。
conf 复制代码
# 设置Lua脚本执行超时时间(毫秒),0表示无限制
lua-time-limit 5000

2. 脚本缓存与 EVALSHA

Redis 会缓存已执行过的 Lua 脚本(基于 SHA1 校验和)。EVALSHA 命令允许通过 SHA1 直接执行已缓存的脚本,避免每次都传输完整的脚本代码,节省网络带宽。

伪代码:EVALSHA 优化流程

pseudocode 复制代码
// 客户端使用EVALSHA的优化流程
function executeLuaScript(redis, script, keys, args):
    // 步骤1:计算脚本的SHA1
    sha1 = SHA1(script)

    // 步骤2:尝试使用EVALSHA执行
    try:
        result = redis.evalsha(sha1, len(keys), keys, args)
        return result
    except NOSCRIPT:
        // 步骤3:脚本不在缓存中,使用EVAL执行
        // EVAL会自动将脚本加入缓存
        result = redis.eval(script, len(keys), keys, args)
        return result

// 更优化的方式:启动时预加载所有脚本
function preloadScripts(redis, scripts):
    sha_map = {}
    for name, script in scripts:
        sha1 = redis.script_load(script)  // 加载到缓存
        sha_map[name] = sha1
    return sha_map

// 执行时直接用SHA1
function executePreloaded(redis, sha_map, name, keys, args):
    return redis.evalsha(sha_map[name], len(keys), keys, args)

3. 确定性要求

Redis Cluster 要求 Lua 脚本中的所有操作必须针对同一个 Slot 的 Key,除非脚本被标记为允许非确定性(通过 redis.replicate_commands() 在 Redis 7.0 之前)。从 Redis 7.0 开始,所有脚本默认在 effect replication 模式下运行,不再需要显式调用 redis.replicate_commands()


八、发布订阅(Pub/Sub)

8.1 基本用法

Redis 的发布订阅(Publish/Subscribe)是一种消息通信模式,发送者(Publisher)发送消息,接收者(Subscriber)接收消息。

核心命令:

  • PUBLISH channel message:向指定频道发送消息。
  • SUBSCRIBE channel channel ...:订阅一个或多个频道。
  • PSUBSCRIBE pattern pattern ...:订阅匹配模式的频道(支持通配符)。
  • UNSUBSCRIBE channel \[channel ...]:取消订阅。
  • PUNSUBSCRIBE pattern \[pattern ...]:取消模式订阅。

示例:

conf 复制代码
// 终端1:订阅者
SUBSCRIBE news.sports       // 精确订阅
PSUBSCRIBE news.*           // 模式订阅

// 终端2:发布者
PUBLISH news.sports "Redis wins the race"   // 精确发布
PUBLISH news.tech "Redis 8.0 released"     // 模式匹配也会收到

模式匹配规则:

  • * 匹配任意数量字符。
  • ? 匹配单个字符。
  • [...] 匹配字符集合。

8.2 特点与局限

特点:

  • 实时推送:消息在发布时立即推送给所有订阅者,延迟极低。
  • 多对多:一个消息可以有多个订阅者,一个订阅者可以订阅多个频道。
  • 轻量级:无需额外中间件,直接使用 Redis 即可实现。

局限:

  1. 不持久化消息:发布订阅是"即发即忘"(Fire and Forget)模式。消息发出后,如果没有订阅者在线,消息直接丢弃,不会保存。

  2. 消费者离线则消息丢失:如果订阅者断开连接,断开期间的消息无法补收。

  3. 不支持消费确认:没有 ACK 机制,无法保证消息被成功处理。

  4. 不支持消息堆积:消息不会在 Redis 中堆积等待消费,如果消费速度慢于生产速度,消息直接丢失。

  5. 可能阻塞 Redis:如果订阅者处理缓慢,Redis 的输出缓冲区可能积压大量消息,甚至导致内存问题。

与 Stream 的对比:

特性 Pub/Sub Stream
消息持久化 不持久化 持久化
消费者离线 消息丢失 可补收
消费确认 不支持 支持(ACK)
消费者组 不支持 支持(Consumer Group)
消息堆积 不支持 支持
适用场景 实时通知、事件广播 可靠消息队列

8.3 应用场景

1. 实时通知

如实时聊天、在线状态变更通知等。这些场景对消息丢失不太敏感,更看重实时性。

2. Sentinel 节点间通信

Sentinel 利用 Redis 主节点的 __sentinel__:hello 频道进行节点间信息交换,这是 Pub/Sub 在 Redis 内部的典型应用。

3. 配置变更推送

当配置变更时,发布配置变更消息,所有订阅的客户端收到通知后重新加载配置。

4. 跨进程事件通知

如一个进程完成数据预热后,通知其他进程可以开始读取数据。

注意: 对于需要可靠消息传递的场景,应优先考虑 Redis Stream 或专业的消息队列(如 Kafka、RabbitMQ)。


相关推荐
嘘神秘用10 小时前
布:AI 驱动的 Redis 客户端,更快、更直观
数据库·人工智能·redis
alxraves11 小时前
医用超声远程会诊系统:会诊平台的核心架构与功能解析
java·人工智能·架构
东北小狐狸-Hellxz12 小时前
Redis 哨兵搭建+ACL权限控制
数据库·redis
幻风_huanfeng13 小时前
软考:高级软件架构师学习笔记----软件架构设计
架构·系统架构·软考·高级系统架构师
ACP广源盛1392462567313 小时前
IX8024 PCIe4.0 交换芯片@ACP# RK3588:嵌入式 AI PC 标准化扩展方案
大数据·人工智能·分布式·单片机·嵌入式硬件
OCR_1337162127514 小时前
技术拆解:国产GPU两大核心路线GPGPU与NPU的架构差异与落地现状
架构
跨境数据猎手15 小时前
反向海淘实战|独立站搭建+国内电商API对接
开发语言·爬虫·架构
xfhuangfu16 小时前
19c CDB架构下的数据库启动和停止
数据库·架构
爱折腾的小黑牛16 小时前
小程序云开发数据模型设计:礼账系统的三表结构
数据库·架构