RedLock:Redis 分布式锁的高可用方案
一、RedLock 要解决什么问题
单 Redis 节点锁的缺陷
时刻T1: 客户端A → 在 Master 加锁成功
时刻T2: Master 宕机(锁数据还没同步到 Slave)
时刻T3: Slave 提升为新 Master(没有锁信息)
时刻T4: 客户端B → 在新 Master 加锁成功
时刻T5: 客户端A 和 B 同时持有锁 → 互斥失效!
即使使用 Redis Sentinel 或 Cluster,也存在这个问题------Redis 主从复制是异步的,切换瞬间可能丢数据。
RedLock 的思路
不依赖单个 Redis 节点,而是同时在多个独立 Redis 节点 上加锁,多数成功才算加锁成功:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Redis-1 │ │ Redis-2 │ │ Redis-3 │ │ Redis-4 │ │ Redis-5 │
└────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘
│ │ │ │ │
✅ ✅ ❌ ✅ ❌
成功3个 ≥ 多数(3/5) → 加锁成功
即使 1-2 个节点宕机或网络不通,锁依然有效。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、RedLock 算法流程
加锁步骤
1. 记录开始时间 T1
2. 依次在 N 个独立 Redis 节点上执行加锁:
SET lock_key <random_value> NX PX <ttl>
- 每个节点设置一个较短的超时(如 5-50ms)
- 某个节点超时或失败则立即跳过,继续下一个
3. 记录结束时间 T2
4. 计算加锁耗时:elapsed = T2 - T1
5. 判断是否加锁成功:
- 成功节点数 ≥ N/2 + 1(多数)
- 且 elapsed < ttl(锁还没过期)
6. 如果成功:
- 锁的实际有效时间 = ttl - elapsed
7. 如果失败:
- 向所有节点发送 DEL 释放锁(包括加锁失败的节点)
解锁步骤
向所有 N 个节点发送释放命令(Lua 脚本验证 value):
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
end
return 0
时间线示意
├─── ttl = 30秒 ───────────────────────────┤
│ │
T1────────T2──────────────────────────────────────────→ 锁过期
│ 加锁耗时 │ 锁实际可用时间 │
│ elapsed │ = ttl - elapsed │
│ = 200ms │ = 29.8秒 │
三、为什么需要多数成功
多数派(Quorum)原理
这是分布式系统的经典思想,同样用于 Raft、Paxos、ZAB 等共识算法:
5个节点中任意 3 个构成多数派
客户端A 加锁成功:节点 1, 2, 3
客户端B 加锁尝试:
- 节点 1:已有锁 → 失败
- 节点 2:已有锁 → 失败
- 节点 3:已有锁 → 失败
- 节点 4:加锁成功
- 节点 5:加锁成功
成功 2 个 < 多数(3) → 加锁失败 ✅ 互斥保证
任何两个多数派至少有一个交集节点------这保证了两个客户端不可能同时获得多数派。
节点宕机的容错
5 个节点,允许 2 个同时宕机(N=5, 多数=3, 容错=2)
3 个节点,允许 1 个同时宕机(N=3, 多数=2, 容错=1)
公式:容错数 = N - (N/2 + 1) = (N-1)/2
| 节点数 N | 多数 | 容错 |
|---|---|---|
| 3 | 2 | 1 |
| 5 | 3 | 2 |
| 7 | 4 | 3 |
四、关键约束条件
1. 节点必须独立
✅ 正确:5 个独立的 Redis 实例(单机模式)
❌ 错误:1 个 Redis Cluster 的 5 个主节点
❌ 错误:5 个 Redis Sentinel 的 5 个主节点
RedLock 要求每个节点是完全独立的,没有主从复制关系。如果用 Cluster/Sentinel,它们内部的主从同步问题依然存在。
2. 时钟假设
RedLock 依赖一个假设:各节点的时钟漂移在可接受范围内。
如果某个节点时钟突然跳变(NTP 校时跳跃),可能导致锁提前过期:
节点3 时钟突然快了 20 秒
→ 锁在节点3 上提前 20 秒过期
→ 如果其他节点也到期了,锁可能失效
3. 加锁时间必须远小于 TTL
ttl = 30 秒
加锁耗时 elapsed = 200ms
锁有效时间 = 30 - 0.2 = 29.8秒 ✅ 充裕
如果网络延迟导致 elapsed = 28秒
锁有效时间 = 30 - 28 = 2秒 ❌ 几乎没用
→ 应该视为加锁失败
五、代码示例
Redisson 实现
java
@Configuration
public class RedLockConfig {
@Bean
public RedissonClient redisson1() {
Config config = new Config();
config.useSingleServer().setAddress("redis://redis-node1:6379");
return Redisson.create(config);
}
@Bean
public RedissonClient redisson2() {
Config config = new Config();
config.useSingleServer().setAddress("redis://redis-node2:6379");
return Redisson.create(config);
}
@Bean
public RedissonClient redisson3() {
Config config = new Config();
config.useSingleServer().setAddress("redis://redis-node3:6379");
return Redisson.create(config);
}
}
@Service
public class PaymentService {
@Resource private RedissonClient redisson1;
@Resource private RedissonClient redisson2;
@Resource private RedissonClient redisson3;
public void deductBalance(String accountId, BigDecimal amount) {
// 从 3 个独立 Redis 节点各获取一把锁
RLock lock1 = redisson1.getLock("lock:account:" + accountId);
RLock lock2 = redisson2.getLock("lock:account:" + accountId);
RLock lock3 = redisson3.getLock("lock:account:" + accountId);
// 组合为 RedLock
RLock redLock = redisson1.getRedLock(lock1, lock2, lock3);
try {
// 尝试加锁:等待10秒,持有30秒
boolean acquired = redLock.tryLock(10, 30, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("获取锁超时");
}
// 临界区
accountRepository.deduct(accountId, amount);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
redLock.unlock();
}
}
}
手动实现(理解原理)
java
public class SimpleRedLock {
private final List<RedisTemplate<String, String>> redisNodes;
private final int quorum; // 多数 = N/2 + 1
public SimpleRedLock(List<RedisTemplate<String, String>> nodes) {
this.redisNodes = nodes;
this.quorum = nodes.size() / 2 + 1;
}
public boolean tryLock(String lockKey, String requestId, long ttlMs) {
long startTime = System.currentTimeMillis();
int successCount = 0;
// 依次在每个节点加锁
for (RedisTemplate<String, String> node : redisNodes) {
try {
Boolean result = node.opsForValue()
.setIfAbsent(lockKey, requestId, ttlMs, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(result)) {
successCount++;
}
} catch (Exception e) {
// 节点不可用,跳过
}
}
long elapsed = System.currentTimeMillis() - startTime;
// 判断:多数成功 且 耗时未超过 TTL
if (successCount >= quorum && elapsed < ttlMs) {
return true; // 加锁成功
} else {
// 失败:释放所有节点的锁
unlock(lockKey, requestId);
return false;
}
}
public void unlock(String lockKey, String requestId) {
String script = "if redis.call('get',KEYS[1]) == ARGV[1] " +
"then return redis.call('del',KEYS[1]) else return 0 end";
for (RedisTemplate<String, String> node : redisNodes) {
try {
node.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), requestId);
} catch (Exception e) {
// 节点不可用,忽略
}
}
}
}
六、RedLock 争议
Martin Kleppmann 的批评
分布式系统专家 Martin Kleppmann 发表文章 "How to do distributed locking" 指出 RedLock 的问题:
问题1:GC 停顿导致锁失效
时刻T1: 客户端A 获得 RedLock,TTL=30秒
时刻T2: 客户端A 发生 Full GC,暂停 35 秒
时刻T3: 锁已过期(30秒到了)
时刻T4: 客户端B 获得同一把 RedLock
时刻T5: 客户端A GC 结束,以为自己还持有锁 → 两个客户端同时操作
问题2:时钟跳变
时刻T1: 客户端A 在节点 1,2,3 加锁成功(TTL=30秒)
时刻T2: 节点1 时钟跳变,NTP 向前调了 30 秒
时刻T3: 节点1 上的锁"过期"了(实际才过了几秒)
时刻T4: 客户端B 在节点 1,4,5 加锁成功 → 互斥失效
他的建议
如果锁仅用于效率优化(避免重复工作):单 Redis 节点就够了,偶尔失效可接受。
如果锁用于正确性保证 (数据一致性):应该用 fencing token(递增令牌):
客户端获取锁时同时获得一个递增 token
写入存储时携带 token
存储端拒绝 token 小于当前值的写入
客户端A: token=33,GC暂停...
客户端B: token=34,写入成功
客户端A: GC恢复,token=33 < 34 → 写入被拒绝 ✅
Antirez(Redis 作者)的回应
- GC 停顿问题:所有分布式锁都有这个问题,不是 RedLock 特有的
- 时钟跳变:合理的运维可以避免大幅跳变(渐进式 NTP 校时)
- RedLock 在合理假设下是正确的
争议总结
| 观点 | Martin Kleppmann | Antirez |
|---|---|---|
| RedLock 安全吗 | 不够安全,依赖时钟假设 | 在合理假设下安全 |
| 推荐方案 | 需要正确性用 ZooKeeper + fencing token | RedLock 足够大多数场景 |
| 适用性 | RedLock 不比单节点强多少 | RedLock 明显强于单节点 |
七、什么时候该用/不该用 RedLock
适合用 RedLock
- 金融级场景:支付扣款、余额操作
- 库存操作:防止超卖
- 对互斥性要求高,但能接受极端情况下的短暂失效
- 已有多个 Redis 实例的基础设施
不需要 RedLock
- 普通业务防重:单 Redis 节点 + 幂等兜底即可
- 效率型锁:避免重复计算,偶尔失效无业务影响
- 缓存击穿保护:单节点锁足够
应该用 ZooKeeper 而非 RedLock
- 强一致性要求:不允许任何双写的场景
- Leader 选举
- 分布式协调(配置分发、服务发现)
八、替代方案对比
| 方案 | 一致性 | 性能 | 复杂度 | 容错 |
|---|---|---|---|---|
| 单 Redis 锁 | 弱(主从切换可能丢) | 最高 | 最低 | 无 |
| RedLock | 中(依赖时钟假设) | 高 | 中 | N/2 节点宕机 |
| ZooKeeper 锁 | 强(ZAB 协议共识) | 中 | 中 | N/2 节点宕机 |
| etcd 锁 | 强(Raft 协议共识) | 中高 | 中 | N/2 节点宕机 |
| 数据库锁 | 强(事务保证) | 低 | 低 | 依赖数据库高可用 |
九、实际项目中的选择建议
┌─ 效率型(防重复工作)→ 单 Redis 节点
│
需要分布式锁 ───────┼─ 一般业务(订单、库存)→ 单 Redis + 幂等兜底
│ 或 Redisson 普通锁
│
└─ 强一致(资金、对账)→ ZooKeeper / etcd
或 RedLock + fencing token
大多数业务场景:Redisson 普通锁 + 业务层幂等 就足够了。RedLock 适合在已有多 Redis 节点基础设施、且对互斥性有更高要求时使用。
十、总结
| 概念 | 一句话 |
|---|---|
| RedLock | 在 N 个独立 Redis 节点上加锁,多数成功才算成功 |
| 多数派 | N/2+1 个节点同意 = 共识达成 |
| 为什么需要 | 单节点主从切换可能丢锁 |
| 核心假设 | 节点间时钟漂移在可接受范围内 |
| 争议点 | GC停顿和时钟跳变可能导致失效 |
| 实际建议 | 大多数场景单节点锁+幂等即可,极端场景考虑 ZooKeeper |