Redis 红锁 Redlock
红锁 Redlock 是 Redis 分布式锁的一种增强算法。它不是“另一种 Redis 命令”,而是一套在多个相互独立的 Redis 节点上同时尝试加锁,并用多数派结果判断是否拿到锁的客户端算法。
学习红锁前,先把一句话记住:
红锁想解决的是“单个 Redis 节点故障或主从切换导致锁丢失”的风险,但它不能让 Redis 锁变成强一致事务,也不能替代数据库唯一约束、业务状态机和幂等。
学习目标
学完本页,你应该能回答:
- 为什么普通 Redis 分布式锁在主从切换时可能失效。
- 红锁为什么要用多个独立 Redis 节点,而不是一个 Redis Cluster。
- 红锁的加锁、失败回滚、释放锁完整流程是什么。
- 为什么红锁也不是绝对安全。
- 商业项目里什么时候可以用红锁,什么时候不要用。
- 面试时如何把“会用锁”和“懂锁的边界”说清楚。
先复习普通 Redis 锁
普通 Redis 锁的正确写法是:
SET lock:order:1001 request-uuid NX PX 30000释放时不能直接 DEL key,而要用 Lua 判断 value 是否还是自己:
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。
极端情况下会出现下面的流程:
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 主从架构无法保证“锁写入已经被多数节点确认”。所以红锁引入了多数派思想。
红锁是什么
红锁的核心思想是:
- 准备多个独立 Redis Master,常见是 5 个。
- 客户端给每个 Redis 节点都尝试加同一把锁。
- 只要超过半数节点加锁成功,例如 5 个节点成功 3 个,并且总耗时小于锁有效期,就认为拿锁成功。
- 如果没有达到多数派,或者耗时已经超过锁有效期,就认为失败,并释放已经加上的锁。
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["获得红锁"]多数派计算公式:
quorum = N / 2 + 1例如:
| Redis 节点数 | 多数派数量 |
|---|---|
| 3 | 2 |
| 5 | 3 |
| 7 | 4 |
为什么必须是独立 Redis 节点
红锁要求多个 Redis 节点相互独立,意思是它们不应该是同一套主从复制关系里的 Master 和 Slave,也不应该共享同一个单点故障。
如果 5 个节点只是同一个 Redis Cluster 里的分片,或者只是一个 Master 的多个副本,就不能简单等同于红锁。因为红锁要的是“多个互不复制、互不依赖的锁裁判”,不是“一个 Redis 系统里的多个数据分片”。
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如果多个节点其实依赖同一个机房、同一个宿主机、同一个网络设备,那么故障相关性仍然很高,红锁的收益会下降。
加锁完整流程
一次标准红锁加锁流程如下:
- 客户端生成一个全局唯一 token,例如 UUID。
- 记录当前开始时间。
- 逐个向所有 Redis 节点执行
SET key token NX PX ttl。 - 每个节点请求要设置很短的超时时间,不能因为某个坏节点卡住太久。
- 统计成功节点数量。
- 计算总耗时。
- 如果成功数量达到多数派,并且总耗时小于 TTL,才算加锁成功。
- 否则释放所有已经成功加锁的节点。
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,而是:
有效时间 = TTL - 加锁总耗时 - 时钟漂移预留时间例如:
| 项目 | 数值 |
|---|---|
| TTL | 10000ms |
| 加锁耗时 | 800ms |
| 漂移预留 | 100ms |
| 实际可用时间 | 9100ms |
如果业务逻辑可能超过这个有效时间,就不能继续假设自己仍然独占资源。
flowchart TD
A["锁 TTL 10 秒"] --> B["加锁过程耗时 0.8 秒"]
B --> C["扣除时钟漂移 0.1 秒"]
C --> D["业务最多应在约 9.1 秒内完成"]
D --> E{"业务是否可能超时"}
E -- "是" --> F["不要只依赖红锁"]
E -- "否" --> G["可以进入临界区"]释放锁流程
红锁释放锁时,要对所有 Redis 节点执行释放脚本,而不只是释放多数派成功的节点。原因是网络超时可能让客户端不知道某个节点是否写成功。
释放脚本仍然是“比较 token 后删除”:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endflowchart 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 暂停、业务补偿等问题。
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;
}
}
}业务使用方式:
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 项目可以使用 Duration、Optional、lambda 等语法,但锁算法本身和 JDK 版本无关。
商业项目怎么用
红锁比较适合“需要降低重复执行概率,但最终正确性还有兜底”的场景。
| 场景 | 是否适合 | 原因 |
|---|---|---|
| 热点缓存重建 | 适合 | 即使重复重建一次,通常只是浪费资源 |
| 多实例定时任务抢占 | 适合 | 任务表状态可以兜底 |
| 报表批处理防重复 | 可用 | 要配合批次状态机 |
| 库存扣减最终正确性 | 不应只靠红锁 | 必须以数据库条件更新、库存流水、幂等为准 |
| 支付回调处理 | 不应只靠红锁 | 必须以支付流水唯一约束和状态机为准 |
| 账户余额变更 | 不适合只靠红锁 | 属于强一致资金场景 |
以采集任务为例,红锁只能防止多个实例同时抢同一个批次,真正兜底要靠数据库状态:
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 必须带状态条件:
update t_collect_batch
set status = 'RUNNING'
where batch_no = ?
and status = 'WAITING';如果没有这层状态机,即使红锁大多数时候正常,也无法证明业务一定不会重复执行。
红锁没有解决什么
红锁经常被误解成“Redis 版本的强一致锁”。这是危险的。
| 没解决的问题 | 为什么 |
|---|---|
| 长时间 GC 暂停 | 客户端拿到锁后可能暂停,锁过期后别的客户端继续执行 |
| 业务执行超时 | 红锁只保证一段有效时间,不保证业务一定在时间内完成 |
| 业务幂等 | 锁失败、超时、网络抖动后仍可能重复进入 |
| 强一致事务 | Redlock 不是 Paxos/Raft,也没有日志复制和一致性提交 |
| 下游写入乱序 | 旧锁持有者恢复后可能继续写,需要 fencing token 或状态版本控制 |
典型风险流程:
flowchart TD
A["客户端 A 获得红锁"] --> B["A 发生长时间 GC"]
B --> C["锁 TTL 过期"]
C --> D["客户端 B 获得红锁"]
D --> E["B 完成业务写入"]
B --> F["A 恢复继续执行"]
F --> G["A 可能覆盖 B 的结果"]解决思路不是“再把锁时间加得无限长”,而是引入业务侧保护:
flowchart TD
A["拿锁"] --> B["检查业务状态"]
B --> C{"状态是否允许执行"}
C -- "否" --> D["幂等返回"]
C -- "是" --> E["条件更新状态"]
E --> F{"更新是否成功"}
F -- "否" --> D
F -- "是" --> G["执行业务"]
G --> H["写结果和状态"]Fencing Token 和红锁
Fencing Token 是锁常见的增强手段。每次拿锁都生成一个递增版本号,下游资源只接受更大的版本号。
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 写入"]数据库条件更新示例:
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,吞吐不适合热点业务锁 |
| 数据库状态机 | 条件更新、唯一约束 | 最终正确性强 | 不能单独解决所有等待和吞吐问题 |
实际商业项目里,更常见的是组合使用:
flowchart TD
A["Redis 锁降低并发"] --> B["数据库唯一约束防重复"]
B --> C["状态机控制流转"]
C --> D["幂等表识别重复请求"]
D --> E["补偿任务修复异常状态"]常见坑
| 坑 | 后果 | 正确做法 |
|---|---|---|
| 把 Redis 主从节点当作红锁节点 | 主从仍可能一起丢锁或延迟复制 | 使用相互独立的 Redis Master |
| 节点超时时间太长 | 一个坏节点拖垮整个加锁过程 | 每个节点设置短连接和命令超时 |
| 只判断成功数量,不判断耗时 | 锁可能已经接近过期 | 必须计算有效时间 |
| 失败后不释放部分锁 | 其他客户端长时间拿不到锁 | 失败必须遍历释放 |
| 锁里放远程调用和大事务 | 锁持有时间不可控 | 锁内只放必要短逻辑 |
| 认为红锁绝对安全 | 核心业务缺少兜底 | 加状态机、幂等、唯一约束、补偿 |
排查方法
线上怀疑红锁或 Redis 锁有问题时,不要只看“有没有加锁成功日志”,要按链路排查:
- 查锁 key 的命名粒度,确认是否按业务资源隔离。
- 查 Redis 节点是否真的独立,是否共用同一套主从或同一故障域。
- 查客户端连接超时和命令超时,确认坏节点不会拖慢加锁。
- 查加锁耗时和业务耗时,确认业务没有超过有效时间。
- 查是否存在长 GC、线程池阻塞、远程接口卡住。
- 查释放锁脚本是否校验 token。
- 查数据库是否有状态条件、唯一约束或幂等表。
- 查重复执行后的补偿任务和告警是否存在。
排查图:
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 分布式锁基础:先掌握
SET NX PX、Lua 释放、Redisson 看门狗。 - 分布式锁扩展点:掌握公平锁、读写锁、联锁、信号量、Fencing Token、ZooKeeper/etcd 和选型。
- Redisson 分布式锁:理解 Redisson 的可重入、续期、等待通知。
- 幂等设计:理解为什么锁不能替代幂等。
- 数据一致性:理解最终一致、强一致和业务兜底。
- 分布式事务:理解 XA、TCC、Saga、可靠消息和补偿。
本章小结
红锁的价值是让 Redis 锁从“单点判断”变成“多数派判断”,降低单个 Redis 节点故障带来的并发风险。它适合做工程上的互斥增强,但不适合单独承担核心业务正确性。真正可靠的商业系统,一定是“锁减少并发 + 状态机保证流转 + 幂等防重复 + 数据库约束兜底 + 补偿任务修复异常”的组合设计。
