分布式锁面试精讲:Redis、Zookeeper实现的对比与Redlock争议
分布式锁面试精讲:Redis、Zookeeper实现的对比与Redlock争议
适读人群:Java后端开发 | 难度:★★★★★ | 出现频率:极高
开篇故事
分布式锁是大厂面试必问的分布式系统话题,我被问过很多次,也问过很多人。
有一次面试候选人,他说Redis分布式锁用SET key value NX EX就够了。
我问:锁超时了但业务还没执行完,怎么办?
他说:续期。
我问:续期失败了或者续期期间进程崩了,其他进程获取了锁,两个进程同时在执行,怎么处理?
他沉默了。
这个问题指向分布式锁最难解决的问题:时钟漂移、进程暂停(GC停顿)、网络分区导致的安全性问题。连Redis作者antirez都亲自下场写文章讨论这个问题。
今天把分布式锁从入门到争议全部讲清楚。
一、高频考点拆解
这道题考察:
第一层:知道为什么需要分布式锁(分布式环境下JVM锁失效,多进程间需要互斥) 第二层:知道Redis和Zookeeper实现分布式锁的方案和差异 第三层:知道Redlock算法和它的争议(antirez vs Martin Kleppmann的论战)
二、深度原理分析
2.1 为什么需要分布式锁
单机环境:synchronized/ReentrantLock → JVM内部互斥,多线程安全
分布式环境:多个JVM进程,各自的JVM锁互相独立,无法互斥
2.2 分布式锁的三个要求
- 互斥性:任意时刻只有一个客户端持有锁
- 可靠性:持锁方崩溃,锁能自动释放(避免死锁)
- 安全性:只有加锁的客户端才能释放锁,不能释放别人的锁
2.3 Redis实现分布式锁
基础实现:SET key value NX EX seconds
// NX: key不存在才设置(原子操作,相当于加锁)
// EX: 过期时间(防止死锁)
// value: 唯一标识(用于判断是否是自己的锁)
SET lock:order:123 unique-client-id NX EX 30为什么value要用唯一标识?
防止误删别人的锁。场景:进程A获取锁,执行时间超过了锁的TTL,锁自动过期;进程B获取了锁;进程A执行完要释放锁,但此时锁是B的,A释放了B的锁!
// 错误:直接删除,可能删除了别人的锁
redisTemplate.delete("lock:order:123");
// 正确:先验证value是自己的,再删除(必须用Lua保证原子性)
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";锁续期(Watchdog机制):Redisson的实现
2.4 Zookeeper实现分布式锁
ZK基于临时有序节点实现分布式锁:
获取锁过程:
- 在
/lock下创建临时有序节点,如/lock/lock-0000002 - 查看所有子节点,如果自己是序号最小的,则获取到锁
- 否则,监听序号比自己小1的节点
- 当被监听的节点删除时(持锁方释放锁或断线),重新判断自己是否是最小的
ZK锁的优势:
- 不需要设置超时时间,ZK会自动检测会话超时,Session失效时删除临时节点
- 天然公平(有序节点按顺序获得锁)
2.5 Redis vs ZK 对比
| 特性 | Redis分布式锁 | ZK分布式锁 |
|---|---|---|
| 性能 | 极高(单机10万+/s) | 一般(万级/s) |
| 可靠性 | 依赖Redis主从,主从切换可能丢锁 | 基于ZAB协议,强一致 |
| 实现复杂度 | 简单(SET NX EX) | 复杂(临时有序节点+监听) |
| 锁超时 | 必须设置,存在锁超期风险 | 基于Session,进程断线自动释放 |
| 公平性 | 非公平(谁抢到是谁的) | 公平(有序节点顺序获取) |
| 适用场景 | 高性能、容忍少量不一致 | 强一致、不容忍安全性问题 |
2.6 Redlock的争议
Redlock是什么:antirez(Redis作者)提出的多Redis节点分布式锁算法。
问题背景:单Redis节点主从切换时,主节点写入锁成功但还未同步到从节点就宕机了,从节点升主后锁丢失,其他客户端可以重新获取,安全性无法保证。
Redlock算法:客户端向N个(通常5个)独立的Redis节点分别获取锁,至少获得超过半数(3个)成功,才认为锁获取成功。单个节点故障不影响锁的安全性。
Martin Kleppmann的反驳:
分布式系统领域的大牛Martin在他的博客中指出,Redlock在以下场景下仍然不安全:
GC停顿:客户端A获取了Redlock,然后JVM发生了长时间GC停顿(数秒),此间锁超期,客户端B获取了锁,两者同时持有锁。
时钟漂移:某个Redis节点的系统时钟跳变(NTP更新),导致锁提前过期。
Martin认为,如果对锁的安全性有严格要求,应该使用基于强一致性协议(Paxos/Raft)的分布式锁(如ZK、etcd),而不是Redlock。
antirez的回应:
antirez认为Martin的假设过于极端,在正常的系统中(不发生极端的GC停顿、时钟跳变),Redlock是安全的,适合高性能场景。
结论(务实角度):
- 大多数业务场景,单Redis节点锁(或主从Redis)+ Redisson看门狗续期,已经足够
- 极严格的安全要求(金融、分布式事务核心节点),考虑ZK或etcd
- Redlock理论有瑕疵,生产中用的不多
三、标准答案 + 代码验证
3.1 Redisson分布式锁(生产推荐)
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void processOrder(String orderId) {
// 获取锁(key对应一个锁对象)
RLock lock = redissonClient.getLock("lock:order:" + orderId);
boolean locked = false;
try {
// tryLock: 最多等待5秒,锁持有30秒后自动过期
// Redisson的Watchdog会自动续期,如果业务超过30秒仍会续期
locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("获取锁失败,稍后重试");
}
// 业务逻辑
doProcessOrder(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("等待锁被中断");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock(); // 只有持锁线程才能释放
}
}
}
}3.2 手写Redis分布式锁(理解原理)
@Component
public class RedisDistributedLock {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String UNLOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
/**
* 加锁
* @param key 锁的key
* @param value 锁的值(唯一标识,通常是UUID)
* @param expireSeconds 过期时间(秒)
* @return 加锁是否成功
*/
public boolean lock(String key, String value, long expireSeconds) {
return Boolean.TRUE.equals(
redisTemplate.opsForValue().setIfAbsent(key, value, expireSeconds, TimeUnit.SECONDS)
);
}
/**
* 释放锁(原子操作:验证+删除)
* @param key 锁的key
* @param value 加锁时的唯一标识
* @return 释放是否成功(false表示锁不是自己的,或已过期)
*/
public boolean unlock(String key, String value) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key),
value
);
return result != null && result == 1;
}
// 使用示例
public void doWithLock(String lockKey, Runnable task) {
String lockValue = UUID.randomUUID().toString();
boolean locked = lock(lockKey, lockValue, 30);
if (!locked) {
throw new RuntimeException("获取锁失败");
}
try {
task.run();
} finally {
boolean released = unlock(lockKey, lockValue);
if (!released) {
log.warn("锁释放失败,可能已超期或被其他进程释放: {}", lockKey);
}
}
}
}四、面试官追问
追问1:如果业务执行时间不确定,锁超时时间如何设置?
我的回答:有两种策略。第一,评估业务最大执行时间,设置一个足够大的超时(如业务平均时间的5倍),接受锁超时的小概率风险,简单可靠。第二,使用看门狗续期(Redisson的默认行为):初始超时30秒,每10秒检查业务是否还在进行,是则续期30秒。业务完成后停止续期,锁自然过期或主动释放。Redisson的看门狗通过RenewExpiration任务实现,只要客户端进程存活,就会持续续期;客户端进程崩溃,看门狗也停止,锁在TTL后自动释放。
追问2:ZK的临时节点和Curator框架是怎么关联的?
我的回答:ZK的原生API操作临时有序节点需要自己处理很多细节(路径管理、监听管理、Session过期重连等),很繁琐。Curator是Apache出品的ZK客户端库,对ZK原生API做了大量封装。Curator的InterProcessMutex类实现了分布式互斥锁,底层正是基于临时有序节点的方案。使用方式和Java的Lock接口类似,acquire()获取锁(会阻塞等待),release()释放锁,Session断开时Curator自动处理重连和锁的重新获取,非常方便。大多数项目如果用ZK做分布式锁,都用Curator而不是自己写。
追问3:用数据库实现分布式锁可行吗?
我的回答:可行,但性能差,一般不推荐。基于数据库唯一索引实现:INSERT INTO distributed_lock(resource, holder, expire_time) VALUES (?, ?, ?),唯一索引在resource字段上,插入成功即获得锁,其他人插入失败。释放锁DELETE这条记录。优点:不需要额外组件,利用数据库的强一致性。缺点:数据库QPS远低于Redis,锁的超时需要另外的定时清理机制,在高并发下数据库锁竞争本身就是瓶颈。只适合低频、不敏感延迟的场景(如定时任务防重)。
五、同类题目举一反三
分布式限流如何实现?
分布式限流也是分布式锁的延伸。常用方案:Redis的INCR+EXPIRE实现计数器限流(每秒INCR,超过阈值拒绝,每秒key自动过期),缺点是存在时间窗口边界问题;Redis + Lua脚本实现滑动窗口限流(维护一个有序集合,value是请求时间戳,SCORE也是时间戳,每次请求ZADD + ZREMRANGEBYSCORE清理过期 + ZCARD计数);Redis Cell模块或Sentinel实现令牌桶/漏桶算法。
六、踩坑实录
坑一:加锁成功但没有设置value,无法安全释放
早期写的分布式锁实现,SET key 1 NX EX 30,value就是字面量"1"。释放时直接DEL key,结果其他进程加锁后,原先的进程执行完了也去DEL,把别人的锁给删了。加上UUID作为value,配合Lua脚本验证,问题解决。
坑二:ZK锁的惊群效应
早期没用Curator,自己实现了ZK锁,释放锁时删除节点,所有等待的节点都收到了通知并尝试获取锁,产生了"惊群效应",大量并发请求打到ZK。Curator的实现已经优化了这个问题:每个节点只监听自己前一个节点,释放时只通知一个等待者。
坑三:Redisson看门狗不续期
用Redisson的lock()方法,发现看门狗不续期了。排查后发现:看门狗只对lock()方法(不带超时参数)有效,如果用lock(30, TimeUnit.SECONDS)(指定了超时),Redisson不启动看门狗,认为用户自己管理超时时间。改为lock()(让Redisson的默认30秒超时管理),看门狗正常运行了。
七、总结
分布式锁的选型建议:
- 高性能 + 允许少量不一致:Redis单节点 + SET NX EX + Lua释放,或Redisson
- 高性能 + 强可靠性:Redisson + Redis主从哨兵/Cluster
- 强一致 + 不容忍安全性问题:ZK + Curator,或etcd
- 低频 + 简单场景:数据库唯一索引
Redlock在理论上有争议,生产中用得不多,了解即可。大多数场景Redisson已经足够,生产稳定可靠。
