每日Java面试场景题知识点之-如何设计分布式锁
一、为什么需要分布式锁?
在单机环境中,我们可以通过 synchronized 或 ReentrantLock 轻松实现线程间的互斥访问。但在分布式系统中,多个服务实例部署在不同机器上,JVM级别的锁无法跨进程生效,此时就必须引入分布式锁来保证跨节点的资源互斥访问。
典型场景:
- 电商库存扣减,防止超卖
- 定时任务防重复执行
- 分布式环境下的幂等性保证
- 账户余额变更
二、分布式锁的核心设计原则
设计一个合格的分布式锁,必须满足以下核心要素:
1. 互斥性
任意时刻,只有一个客户端能持有锁,这是分布式锁最基本的要求。
2. 可重入性
同一个线程/进程可以多次获取同一把锁而不会产生死锁,类似 ReentrantLock 的语义。
3. 防死锁
锁必须设置超时时间,即使持有锁的客户端发生崩溃,锁也能在超时后自动释放,避免其他客户端永远无法获取锁。
4. 高可用
锁服务本身应当具备高可用性,不能因为单个节点故障导致锁服务不可用。
5. 加锁和解锁的归属一致性
只有加锁的客户端才能解锁,不能出现A加的锁被B解掉的情况。
6. 高性能
加锁和解锁操作应当高效,不能成为系统的性能瓶颈。
三、主流实现方案深度解析
方案一:基于 Redis 实现分布式锁
核心原理
利用 Redis 的 SETNX(Key不存在时才设置)命令实现互斥,结合 EXPIRE 设置过期时间防止死锁。
基础实现(加锁)
java
public boolean tryLock(String lockKey, String requestId, long expireTime) {
// 使用 SET 命令原子性加锁并设置过期时间
// NX: 不存在才设置 PX: 毫秒级过期
String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
return "OK".equals(result);
}
关键点: 必须使用 SET key value NX PX expireTime 一条命令完成加锁和设置过期时间,保证原子性。如果先用 SETNX 再用 EXPIRE,两步操作之间如果宕机,会导致死锁。
解锁实现(Lua脚本保证原子性)
java
public boolean unlock(String lockKey, String requestId) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Object result = jedis.eval(luaScript,
Collections.singletonList(lockKey),
Collections.singletonList(requestId));
return Long.valueOf(1).equals(result);
}
``n
**为什么用Lua脚本?** 解锁需要两步操作:先GET判断是否是自己的锁,再DEL删除。这两步必须原子执行,否则可能出现:A的锁刚好过期,B获取了锁,A的DEL请求删除了B的锁。Lua脚本在Redis中单线程执行,保证原子性。
#### Redisson 框架(生产级方案)
手写Redis分布式锁容易踩坑,生产环境推荐使用 **Redisson** 框架:
```java
// 引入依赖后直接使用
RLock lock = redisson.getLock("myLock");
try {
// 尝试加锁:等待时间5秒,锁自动释放时间30秒
if (lock.tryLock(5, 30, TimeUnit.SECONDS)) {
// 执行业务逻辑
doBusiness();
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
Redisson 的核心优势:
- 看门狗机制(Watchdog): 默认锁超时30秒,Redisson会每10秒(超时时间的1/3)自动续期,只要客户端还活着,锁就不会过期释放,完美解决业务执行时间不确定的问题
- 可重入: 内部维护加锁次数计数器,支持同一客户端多次加锁
- 公平锁支持: 提供
RedissonFairLock,按请求顺序获取锁 - 读写锁: 提供
RedissonReadWriteLock,支持读写分离场景 - 联锁(MultiLock): 同时对多个Redis实例加锁,提高可靠性
Redis方案优缺点
优点: 性能极高,SET/DEL操作亚毫秒级;实现简单,生态成熟
缺点:
- 主从切换时可能丢失锁信息(主节点加锁后未同步到从节点就宕机)
- 锁过期时间难以精确预估,业务未执行完锁可能已释放
方案二:基于 ZooKeeper 实现分布式锁
核心原理
利用 ZooKeeper 的临时顺序节点 和Watch机制实现分布式锁:
- 每个客户端在锁节点下创建一个临时顺序节点
- 判断自己是否为序号最小的节点,是则获取锁成功
- 否则Watch前一个节点的删除事件,前一个节点删除时被唤醒重新判断
- 客户端断开连接后,临时节点自动删除,锁自动释放
Curator 框架实现
java
// 使用 Curator 框架(ZooKeeper的生产级客户端)
InterProcessMutex lock = new InterProcessMutex(client, "/locks/myLock");
try {
if (lock.acquire(5, TimeUnit.SECONDS)) {
try {
// 执行业务逻辑
doBusiness();
} finally {
lock.release();
}
}
} catch (Exception e) {
log.error("获取分布式锁异常", e);
}
ZooKeeper方案优缺点
优点:
- 可靠性极高: 临时节点+Session机制,客户端宕机后锁自动释放,不存在死锁风险
- 公平锁: 顺序节点天然保证FIFO,先到先得
- CP特性: ZK保证一致性,不会出现多个客户端同时持有锁
缺点:
- 性能较低: 每次加锁需要创建节点、网络通信,性能远不如Redis
- 性能瓶颈: 大量客户端频繁创建删除节点时,ZK可能成为瓶颈
- 运维复杂: ZK集群维护成本高于Redis
方案三:基于 MySQL 实现分布式锁
方案一:唯一索引法
sql
-- 创建锁表
CREATE TABLE distributed_lock (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
lock_key VARCHAR(128) NOT NULL UNIQUE,
lock_value VARCHAR(128) NOT NULL,
expire_time BIGINT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 加锁:插入成功即获取锁
INSERT INTO distributed_lock(lock_key, lock_value, expire_time)
VALUES('myLock', 'uuid-xxx', UNIX_TIMESTAMP() * 1000 + 30000);
-- 解锁:删除对应记录
DELETE FROM distributed_lock
WHERE lock_key = 'myLock' AND lock_value = 'uuid-xxx';
方案二:乐观锁法(基于版本号)
sql
-- 使用版本号实现乐观锁
UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = #{currentVersion};
MySQL方案优缺点
优点: 实现简单,无需引入额外中间件,适合已有MySQL的环境
缺点: 性能最差,数据库成为性能瓶颈;锁无超时自动释放机制(需额外定时清理);不适合高并发场景
四、三种方案对比总结
性能维度: Redis > ZooKeeper > MySQL
可靠性维度: ZooKeeper > Redis > MySQL
实现复杂度: MySQL最简单,Redis中等(Redisson封装后也很简单),ZooKeeper较复杂
适用场景:
- Redis分布式锁: 适用于高并发、追求极致性能的场景,绝大多数互联网公司首选
- ZooKeeper分布式锁: 适用于对可靠性要求极高、并发量中等的场景,如金融核心系统
- MySQL分布式锁: 适用于并发量低、不想引入额外中间件的轻量级场景
五、面试高频追问与最佳实践
追问1:Redis主从切换导致锁丢失怎么办?
使用 RedLock 算法(Redis作者提出):向N个独立的Redis实例同时加锁,超过半数(N/2+1)加锁成功才算获取锁成功。
java
// Redisson 实现红锁
RLock lock1 = redisson1.getLock("myLock");
RLock lock2 = redisson2.getLock("myLock");
RLock lock3 = redisson3.getLock("myLock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
redLock.lock();
// ...业务逻辑...
redLock.unlock();
注意:RedLock算法在学术界存在争议(Martin Kleppmann的批评),实际生产中多数公司仍使用单Redis实例+Redisson看门狗方案。
追问2:锁续期问题怎么解决?
业务执行时间不确定时,锁可能提前过期。解决方案:
- Redisson看门狗: 自动续期,最简方案
- 手动续期: 启动后台线程定期延长过期时间
- 合理设置超时: 根据P99耗时设置超时时间,留有冗余
追问3:分布式锁和分布式事务有什么关系?
分布式锁保证互斥访问 (串行化),分布式事务保证数据一致性。两者是不同层面的问题,但经常配合使用:先通过分布式锁保证同一时刻只有一个客户端操作资源,再通过事务保证操作的一致性。
生产级最佳实践
- 优先使用Redisson,不要手写Redis分布式锁
- 必须设置合理的超时时间,防死锁
- 解锁必须验证归属,用Lua脚本或Redisson
- 加锁时设置唯一requestId,避免误删他人锁
- 业务代码必须放在try-finally中,确保异常时也能释放锁
- 考虑降级方案,当锁服务不可用时系统如何兜底
- 监控锁的持有时间和等待时间,及时发现异常
六、结语
分布式锁是分布式系统中的基础设施级组件,选型时需要在性能、可靠性、复杂度之间做权衡。面试中不仅要答出实现方案,更要展示对各种边界情况(死锁、主从切换、续期)的深入理解,这才是高级工程师应有的思考深度。
感谢读者观看