Skip to content

Redis 红锁 Redlock

红锁 Redlock 是 Redis 分布式锁的一种增强算法。它不是“另一种 Redis 命令”,而是一套在多个相互独立的 Redis 节点上同时尝试加锁,并用多数派结果判断是否拿到锁的客户端算法。

学习红锁前,先把一句话记住:

红锁想解决的是“单个 Redis 节点故障或主从切换导致锁丢失”的风险,但它不能让 Redis 锁变成强一致事务,也不能替代数据库唯一约束、业务状态机和幂等。

学习目标

学完本页,你应该能回答:

  1. 为什么普通 Redis 分布式锁在主从切换时可能失效。
  2. 红锁为什么要用多个独立 Redis 节点,而不是一个 Redis Cluster。
  3. 红锁的加锁、失败回滚、释放锁完整流程是什么。
  4. 为什么红锁也不是绝对安全。
  5. 商业项目里什么时候可以用红锁,什么时候不要用。
  6. 面试时如何把“会用锁”和“懂锁的边界”说清楚。

先复习普通 Redis 锁

普通 Redis 锁的正确写法是:

bash
SET lock:order:1001 request-uuid NX PX 30000

释放时不能直接 DEL key,而要用 Lua 判断 value 是否还是自己:

lua
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end

这套方案解决了三个基础问题:

问题解决方式
多个实例同时加锁NX 保证 key 不存在才写入
服务宕机后死锁PX 设置过期时间
误删别人锁value 唯一标识 + Lua 原子释放

但它仍有一个很重要的边界:如果 Redis 发生主从切换,锁可能还没复制到从节点,新的 Master 上就没有这把锁。

普通 Redis 锁为什么可能失效

Redis 主从复制默认是异步复制。异步复制的含义是:Master 写成功后可以先返回客户端,之后再慢慢复制给 Slave。

极端情况下会出现下面的流程:

mermaid
flowchart TD
    A["客户端 A 请求加锁"] --> B["旧 Master 写入锁成功"]
    B --> C["Master 返回 OK"]
    C --> D["锁还没有复制到 Slave"]
    D --> E["旧 Master 宕机"]
    E --> F["Slave 晋升为新 Master"]
    F --> G["客户端 B 请求加锁"]
    G --> H["新 Master 没有旧锁"]
    H --> I["客户端 B 也加锁成功"]
    I --> J["A 和 B 可能同时处理同一资源"]

这个问题的本质不是 SET NX PX 写错了,而是单 Redis 主从架构无法保证“锁写入已经被多数节点确认”。所以红锁引入了多数派思想。

红锁是什么

红锁的核心思想是:

  1. 准备多个独立 Redis Master,常见是 5 个。
  2. 客户端给每个 Redis 节点都尝试加同一把锁。
  3. 只要超过半数节点加锁成功,例如 5 个节点成功 3 个,并且总耗时小于锁有效期,就认为拿锁成功。
  4. 如果没有达到多数派,或者耗时已经超过锁有效期,就认为失败,并释放已经加上的锁。
mermaid
flowchart TD
    A["客户端生成唯一 token"] --> B["依次请求多个独立 Redis 节点"]
    B --> C["节点 1 SET NX PX"]
    C --> D["节点 2 SET NX PX"]
    D --> E["节点 3 SET NX PX"]
    E --> F["节点 4 SET NX PX"]
    F --> G["节点 5 SET NX PX"]
    G --> H{"成功数量 >= 多数派"}
    H -- "否" --> I["释放已加锁节点"]
    H -- "是" --> J{"总耗时 < TTL"}
    J -- "否" --> I
    J -- "是" --> K["获得红锁"]

多数派计算公式:

text
quorum = N / 2 + 1

例如:

Redis 节点数多数派数量
32
53
74

为什么必须是独立 Redis 节点

红锁要求多个 Redis 节点相互独立,意思是它们不应该是同一套主从复制关系里的 Master 和 Slave,也不应该共享同一个单点故障。

如果 5 个节点只是同一个 Redis Cluster 里的分片,或者只是一个 Master 的多个副本,就不能简单等同于红锁。因为红锁要的是“多个互不复制、互不依赖的锁裁判”,不是“一个 Redis 系统里的多个数据分片”。

mermaid
flowchart TD
    A["红锁需要的节点"] --> B["Redis Master 1"]
    A --> C["Redis Master 2"]
    A --> D["Redis Master 3"]
    A --> E["Redis Master 4"]
    A --> F["Redis Master 5"]
    B --> G["彼此独立"]
    C --> G
    D --> G
    E --> G
    F --> G

如果多个节点其实依赖同一个机房、同一个宿主机、同一个网络设备,那么故障相关性仍然很高,红锁的收益会下降。

加锁完整流程

一次标准红锁加锁流程如下:

  1. 客户端生成一个全局唯一 token,例如 UUID。
  2. 记录当前开始时间。
  3. 逐个向所有 Redis 节点执行 SET key token NX PX ttl
  4. 每个节点请求要设置很短的超时时间,不能因为某个坏节点卡住太久。
  5. 统计成功节点数量。
  6. 计算总耗时。
  7. 如果成功数量达到多数派,并且总耗时小于 TTL,才算加锁成功。
  8. 否则释放所有已经成功加锁的节点。
mermaid
flowchart TD
    A["开始加锁"] --> B["生成 token"]
    B --> C["记录 startTime"]
    C --> D["请求 Redis 节点"]
    D --> E["统计成功数量"]
    E --> F["计算 elapsed"]
    F --> G{"成功数量是否达到多数派"}
    G -- "否" --> H["释放部分锁"]
    G -- "是" --> I{"elapsed 是否小于 TTL"}
    I -- "否" --> H
    I -- "是" --> J["计算剩余有效时间"]
    J --> K["业务进入临界区"]

这里有一个容易被忽略的点:不是“成功 3 个节点就一定成功”,还要看加锁耗时。

假设锁有效期是 10 秒,但请求 5 个节点用了 12 秒,即使最后有 3 个成功,也不能认为拿到了有效锁,因为最早加上的节点可能已经过期。

为什么要计算剩余有效时间

红锁成功后,真正可用的锁时间不是原始 TTL,而是:

text
有效时间 = TTL - 加锁总耗时 - 时钟漂移预留时间

例如:

项目数值
TTL10000ms
加锁耗时800ms
漂移预留100ms
实际可用时间9100ms

如果业务逻辑可能超过这个有效时间,就不能继续假设自己仍然独占资源。

mermaid
flowchart TD
    A["锁 TTL 10 秒"] --> B["加锁过程耗时 0.8 秒"]
    B --> C["扣除时钟漂移 0.1 秒"]
    C --> D["业务最多应在约 9.1 秒内完成"]
    D --> E{"业务是否可能超时"}
    E -- "是" --> F["不要只依赖红锁"]
    E -- "否" --> G["可以进入临界区"]

释放锁流程

红锁释放锁时,要对所有 Redis 节点执行释放脚本,而不只是释放多数派成功的节点。原因是网络超时可能让客户端不知道某个节点是否写成功。

释放脚本仍然是“比较 token 后删除”:

lua
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
else
  return 0
end
mermaid
flowchart TD
    A["业务结束或加锁失败"] --> B["遍历所有 Redis 节点"]
    B --> C["执行 Lua 校验 token"]
    C --> D{"token 是否匹配"}
    D -- "匹配" --> E["删除该节点锁"]
    D -- "不匹配" --> F["不删除"]
    E --> G["继续下一个节点"]
    F --> G

最小 Java Demo

下面是一个教学版 Redlock。它展示算法关键点:唯一 token、多节点加锁、多数派、耗时判断、失败回滚、Lua 安全释放。

生产环境不建议直接复制这段代码替代成熟组件,因为还要处理连接超时、监控、重试退避、节点隔离、GC 暂停、业务补偿等问题。

java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

public class SimpleRedlock {

    private static final String UNLOCK_LUA =
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "return redis.call('del', KEYS[1]) else return 0 end";

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT =
            new DefaultRedisScript<Long>(UNLOCK_LUA, Long.class);

    private final List<StringRedisTemplate> redisNodes;

    public SimpleRedlock(List<StringRedisTemplate> redisNodes) {
        if (redisNodes == null || redisNodes.size() < 3 || redisNodes.size() % 2 == 0) {
            throw new IllegalArgumentException("Redlock 建议使用 3 个以上奇数个独立 Redis 节点");
        }
        this.redisNodes = redisNodes;
    }

    public LockResult tryLock(String key, Duration ttl) {
        String token = UUID.randomUUID().toString();
        long startNanos = System.nanoTime();
        List<StringRedisTemplate> lockedNodes = new ArrayList<StringRedisTemplate>();

        for (StringRedisTemplate redis : redisNodes) {
            try {
                Boolean ok = redis.opsForValue().setIfAbsent(key, token, ttl);
                if (Boolean.TRUE.equals(ok)) {
                    lockedNodes.add(redis);
                }
            } catch (Exception ex) {
                // 单个节点失败不能让整个加锁过程长时间阻塞。
                // 生产环境应记录日志和指标,这里为了突出算法流程只跳过该节点。
            }
        }

        long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
        long ttlMs = ttl.toMillis();
        long driftMs = Math.max(2L, ttlMs / 100L);
        long validityMs = ttlMs - elapsedMs - driftMs;
        int quorum = redisNodes.size() / 2 + 1;

        if (lockedNodes.size() >= quorum && validityMs > 0) {
            return LockResult.success(key, token, validityMs, lockedNodes);
        }

        unlock(key, token, redisNodes);
        return LockResult.failed();
    }

    public void unlock(LockResult result) {
        if (result == null || !result.locked) {
            return;
        }
        unlock(result.key, result.token, redisNodes);
    }

    private void unlock(String key, String token, List<StringRedisTemplate> nodes) {
        for (StringRedisTemplate redis : nodes) {
            try {
                redis.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token);
            } catch (Exception ex) {
                // 释放失败要记录日志,依赖 TTL 最终释放,并由补偿任务兜底。
            }
        }
    }

    public static class LockResult {
        private final boolean locked;
        private final String key;
        private final String token;
        private final long validityMs;
        private final List<StringRedisTemplate> lockedNodes;

        private LockResult(boolean locked, String key, String token,
                           long validityMs, List<StringRedisTemplate> lockedNodes) {
            this.locked = locked;
            this.key = key;
            this.token = token;
            this.validityMs = validityMs;
            this.lockedNodes = lockedNodes;
        }

        public static LockResult success(String key, String token, long validityMs,
                                         List<StringRedisTemplate> lockedNodes) {
            return new LockResult(true, key, token, validityMs, lockedNodes);
        }

        public static LockResult failed() {
            return new LockResult(false, null, null, 0L,
                    Collections.<StringRedisTemplate>emptyList());
        }

        public boolean isLocked() {
            return locked;
        }

        public long getValidityMs() {
            return validityMs;
        }
    }
}

业务使用方式:

java
public void rebuildProductCache(Long productId) {
    String key = "lock:cache:product:" + productId;
    SimpleRedlock.LockResult lock = redlock.tryLock(key, Duration.ofSeconds(10));

    if (!lock.isLocked()) {
        return;
    }

    try {
        // 只放必须互斥的短逻辑
        Product product = productRepository.findById(productId);
        redisTemplate.opsForValue().set("product:" + productId, product);
    } finally {
        redlock.unlock(lock);
    }
}

JDK 7 项目没有 Duration,可以改成 long ttlMillis;JDK 8 项目可以使用 DurationOptional、lambda 等语法,但锁算法本身和 JDK 版本无关。

商业项目怎么用

红锁比较适合“需要降低重复执行概率,但最终正确性还有兜底”的场景。

场景是否适合原因
热点缓存重建适合即使重复重建一次,通常只是浪费资源
多实例定时任务抢占适合任务表状态可以兜底
报表批处理防重复可用要配合批次状态机
库存扣减最终正确性不应只靠红锁必须以数据库条件更新、库存流水、幂等为准
支付回调处理不应只靠红锁必须以支付流水唯一约束和状态机为准
账户余额变更不适合只靠红锁属于强一致资金场景

以采集任务为例,红锁只能防止多个实例同时抢同一个批次,真正兜底要靠数据库状态:

java
public void runCollectBatch(String batchNo) {
    SimpleRedlock.LockResult lock = redlock.tryLock(
            "lock:collect:batch:" + batchNo,
            Duration.ofSeconds(30)
    );

    if (!lock.isLocked()) {
        return;
    }

    try {
        int updated = collectBatchMapper.markRunning(batchNo);
        if (updated == 0) {
            return;
        }

        doCollect(batchNo);
        collectBatchMapper.markSuccess(batchNo);
    } catch (Exception ex) {
        collectBatchMapper.markFailed(batchNo, ex.getMessage());
        throw ex;
    } finally {
        redlock.unlock(lock);
    }
}

SQL 必须带状态条件:

sql
update t_collect_batch
set status = 'RUNNING'
where batch_no = ?
  and status = 'WAITING';

如果没有这层状态机,即使红锁大多数时候正常,也无法证明业务一定不会重复执行。

红锁没有解决什么

红锁经常被误解成“Redis 版本的强一致锁”。这是危险的。

没解决的问题为什么
长时间 GC 暂停客户端拿到锁后可能暂停,锁过期后别的客户端继续执行
业务执行超时红锁只保证一段有效时间,不保证业务一定在时间内完成
业务幂等锁失败、超时、网络抖动后仍可能重复进入
强一致事务Redlock 不是 Paxos/Raft,也没有日志复制和一致性提交
下游写入乱序旧锁持有者恢复后可能继续写,需要 fencing token 或状态版本控制

典型风险流程:

mermaid
flowchart TD
    A["客户端 A 获得红锁"] --> B["A 发生长时间 GC"]
    B --> C["锁 TTL 过期"]
    C --> D["客户端 B 获得红锁"]
    D --> E["B 完成业务写入"]
    B --> F["A 恢复继续执行"]
    F --> G["A 可能覆盖 B 的结果"]

解决思路不是“再把锁时间加得无限长”,而是引入业务侧保护:

mermaid
flowchart TD
    A["拿锁"] --> B["检查业务状态"]
    B --> C{"状态是否允许执行"}
    C -- "否" --> D["幂等返回"]
    C -- "是" --> E["条件更新状态"]
    E --> F{"更新是否成功"}
    F -- "否" --> D
    F -- "是" --> G["执行业务"]
    G --> H["写结果和状态"]

Fencing Token 和红锁

Fencing Token 是锁常见的增强手段。每次拿锁都生成一个递增版本号,下游资源只接受更大的版本号。

mermaid
flowchart TD
    A["A 获得锁 token=10"] --> B["A 卡住"]
    B --> C["锁过期"]
    C --> D["B 获得锁 token=11"]
    D --> E["B 写入资源 lastToken=11"]
    B --> F["A 恢复后写 token=10"]
    F --> G["资源拒绝旧 token 写入"]

数据库条件更新示例:

sql
update t_resource
set value = ?,
    lock_token = ?
where id = ?
  and lock_token < ?;

红锁负责减少并发进入,Fencing Token 负责防止过期持有者继续写旧数据。两者关注点不同。

和其他方案对比

方案核心能力优点风险和边界
单 Redis 锁单节点 SET NX PX简单、性能高主从切换可能丢锁
Redisson RLock可重入、续期、等待通知Java 项目易用仍依赖 Redis 可用性和业务幂等
Redlock多独立节点多数派降低单节点故障风险运维复杂,不是强一致共识
ZooKeeper 锁临时顺序节点一致性更强,适合协调性能和使用成本更高
etcd 锁lease + revision适合云原生协调需要维护 etcd,吞吐不适合热点业务锁
数据库状态机条件更新、唯一约束最终正确性强不能单独解决所有等待和吞吐问题

实际商业项目里,更常见的是组合使用:

mermaid
flowchart TD
    A["Redis 锁降低并发"] --> B["数据库唯一约束防重复"]
    B --> C["状态机控制流转"]
    C --> D["幂等表识别重复请求"]
    D --> E["补偿任务修复异常状态"]

常见坑

后果正确做法
把 Redis 主从节点当作红锁节点主从仍可能一起丢锁或延迟复制使用相互独立的 Redis Master
节点超时时间太长一个坏节点拖垮整个加锁过程每个节点设置短连接和命令超时
只判断成功数量,不判断耗时锁可能已经接近过期必须计算有效时间
失败后不释放部分锁其他客户端长时间拿不到锁失败必须遍历释放
锁里放远程调用和大事务锁持有时间不可控锁内只放必要短逻辑
认为红锁绝对安全核心业务缺少兜底加状态机、幂等、唯一约束、补偿

排查方法

线上怀疑红锁或 Redis 锁有问题时,不要只看“有没有加锁成功日志”,要按链路排查:

  1. 查锁 key 的命名粒度,确认是否按业务资源隔离。
  2. 查 Redis 节点是否真的独立,是否共用同一套主从或同一故障域。
  3. 查客户端连接超时和命令超时,确认坏节点不会拖慢加锁。
  4. 查加锁耗时和业务耗时,确认业务没有超过有效时间。
  5. 查是否存在长 GC、线程池阻塞、远程接口卡住。
  6. 查释放锁脚本是否校验 token。
  7. 查数据库是否有状态条件、唯一约束或幂等表。
  8. 查重复执行后的补偿任务和告警是否存在。

排查图:

mermaid
flowchart TD
    A["发现重复执行"] --> B["查业务状态机"]
    B --> C{"状态条件是否兜底"}
    C -- "否" --> D["先补数据库约束和幂等"]
    C -- "是" --> E["查锁有效时间"]
    E --> F{"业务是否超时"}
    F -- "是" --> G["缩短锁内逻辑或延长并监控 TTL"]
    F -- "否" --> H["查 Redis 故障和客户端超时"]
    H --> I["查 GC 和线程池阻塞"]

刷题复习入口

本页负责讲清楚 Redlock 的原理、边界、Demo 和商业选型。需要快速复习答题结构时,跳到 Redis 面试知识点 的红锁与分布式锁部分。

关联知识点

本章小结

红锁的价值是让 Redis 锁从“单点判断”变成“多数派判断”,降低单个 Redis 节点故障带来的并发风险。它适合做工程上的互斥增强,但不适合单独承担核心业务正确性。真正可靠的商业系统,一定是“锁减少并发 + 状态机保证流转 + 幂等防重复 + 数据库约束兜底 + 补偿任务修复异常”的组合设计。