说一下你对分布式锁的理解以及分布式锁的实现
说一下你对分布式锁的理解以及分布式锁的实现
面试指北 · 蔚来
题意
这道题表面上是「请罗列几种分布式锁的实现」,实际上面试官想听的是三层内容:
- 概念层:分布式锁和
synchronized、ReentrantLock到底差在哪,为什么单机锁在分布式场景下失效; - 原理层:每种实现靠什么机制保证互斥,锁怎么释放、怎么防死锁、怎么防止误删别人的锁;
- 权衡层:一致性、性能、可用性三者如何取舍,为什么大多数业务最终选择了 Redis + Redisson,而不是 ZooKeeper 或数据库。
因此回答的重点不在于「有四种」,而在于「分布式锁必须满足哪些条件、每种方案分别在哪些条件上打了折扣」。
结论
分布式锁的本质是:在多个进程 / 多个节点之间,对同一份共享资源做互斥访问。一个「能用」的分布式锁至少要满足以下几点:
- 互斥:同一时刻只有一个持有者;
- 不死锁:持锁方崩溃后,锁最终能被释放(靠过期时间或会话超时);
- 不乱释放:只能由加锁方自己解锁,不能把别人的锁删掉;
- 可接受的一致性:锁服务本身宕机或主从切换时,最坏情况下会导致什么结果,心里要有数。
常见四种实现及其推荐度:
| 实现方式 | 核心机制 | 一句话评价 |
|---|---|---|
| 数据库 | 唯一索引 / 行锁 | 简单可靠但性能差,适合低频场景兜底 |
| Redis | SET key value NX PX + 过期时间 |
性能最好,AP 取向,工程上最常用 |
| ZooKeeper | 临时顺序节点 + Watch | 强一致、天然公平,但性能一般、实现偏重 |
| Redisson | 对 Redis 锁的工程化封装 | 看门狗续期、可重入、阻塞等待都已内置,业务首选 |
如果只能给一句总结:要性能选 Redis(Redisson),要强一致选 ZooKeeper,什么都不想引入就用数据库凑合。
要点展开
0. 先想清楚:分布式锁到底解决什么问题
单机里 synchronized 能work,是因为所有线程共享同一个 JVM 的监视器对象。到了分布式,服务被拆成多个进程、多台机器,甚至同一个服务多副本,JVM 内的锁彼此看不见,就只能把「锁」这个状态外置到一个所有节点都能访问的地方——数据库、Redis、ZooKeeper 都是在扮演这个「公共裁判」的角色。
反过来说,只要不是「多进程争抢同一资源」,就不该上分布式锁:单机内用 ReentrantLock 更快、更稳;能靠数据库唯一约束或乐观锁(version 字段)解决的,也不必引入分布式锁。锁是最后手段,不是第一选择。
1. 基于数据库的分布式锁
原理:在数据库里建一张锁表,lock_id 上加唯一索引。谁 INSERT 成功,谁就拿到了锁;插入时报唯一键冲突,说明锁已被别人占着。行锁流派(SELECT ... FOR UPDATE)则是借数据库事务的行级锁来实现互斥,思路类似,但要求事务期间一直持有同一个连接,粒度更难控制。
原文伪代码:
public boolean acquireLock(String lockId) {
String sql = "INSERT INTO locks (lock_id) VALUES (?)";
try {
jdbcTemplate.update(sql, lockId);
return true; // 成功获取锁
} catch (DataIntegrityViolationException e) {
return false; // 锁已被占用
}
}
public void releaseLock(String lockId) {
String sql = "DELETE FROM locks WHERE lock_id = ?";
jdbcTemplate.update(sql, lockId);
}
这段代码只演示了「唯一索引互斥」这个骨架,真用到生产要补三件事:
- 谁加的谁解:只有
lock_id一个字段,任何客户端都能执行DELETE,包括那个根本没拿到锁的。要加owner字段,删除时带上AND owner = ?; - 防死锁:持锁进程在
INSERT之后、DELETE之前挂了(或抛异常没走到释放),这条记录就永远留在表里,锁永久不释放。需要加expire_at过期时间字段,并在获取锁前先清理过期记录,或者用后台定时任务扫; - 性能:每次加解锁都是一次数据库写,持有连接和事务,并发一高数据库就是瓶颈。
补全后的写法:
// 表结构:lock_id 唯一索引,owner 标识持有者,expire_at 记录过期时间
public boolean acquireLock(String lockId, String owner, long ttlMillis) {
// 先清掉已经过期的历史锁(也可交给后台定时任务)
jdbcTemplate.update("DELETE FROM locks WHERE lock_id = ? AND expire_at < ?",
lockId, new Date());
try {
jdbcTemplate.update(
"INSERT INTO locks (lock_id, owner, expire_at) VALUES (?, ?, ?)",
lockId, owner, new Date(System.currentTimeMillis() + ttlMillis));
return true;
} catch (DuplicateKeyException e) {
return false; // 锁已被占用
}
}
public boolean releaseLock(String lockId, String owner) {
// 带上 owner 条件,保证只删自己加的锁
int rows = jdbcTemplate.update(
"DELETE FROM locks WHERE lock_id = ? AND owner = ?", lockId, owner);
return rows > 0;
}
适用场景:并发量不大、系统里已经有数据库、不想再引入中间件的情况,比如定时任务的单实例执行、低频的配置变更。优点是实现直观、可靠性跟数据库一致;缺点是性能差、锁表会随业务膨胀、还要自己处理过期清理,本质上是在用业务库做锁服务,不太划算。
2. 基于 Redis 的分布式锁
原理:把锁当成一个 key 存进 Redis,用 SET key value NX PX expireTime 一条命令完成「不存在则写入 + 设置过期时间」。Redis 单线程执行命令,这条命令天然原子,谁 SET 成功谁持有锁;过期时间兜底防死锁,进程挂了 key 也会自己到期消失。
原文伪代码(注意:这里存在问题,下面会说明):
public boolean acquireLock(String lockId, long expireTime) {
String result = redisTemplate.opsForValue().get(lockId);
if (result == null) {
// 设置锁
redisTemplate.opsForValue().set(lockId, "locked", expireTime, TimeUnit.MILLISECONDS);
return true; // 成功获取锁
}
return false; // 锁已被占用
}
public void releaseLock(String lockId) {
redisTemplate.delete(lockId);
}
这段代码有两个典型问题,恰恰是面试高频追问点:
get和set是两条命令,不是原子操作。A、B 两个客户端可能同时get到null,然后都去set,后写的覆盖先写的,两个人都以为自己拿到了锁。必须换成SET NX PX(对应 Java 客户端的setIfAbsent(key, value, timeout, unit)),把「判断存在」和「写入」合成一条原子命令。delete不校验持有者,会误删别人的锁。经典场景:A 拿到锁但业务执行超时,锁自动过期;B 拿到锁开始执行;此时 A 执行完走到finally里delete,把 B 的锁删了,C 又能进来,临界区里同时有 B 和 C。解决办法是把持有者标识(UUID + 线程 ID)写进 value,释放时用 Lua 脚本「先比对 value 再删」,保证比对和删除也是原子的。
修正后的写法:
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";
public boolean acquireLock(String lockId, String owner, long expireTime) {
// SET key value NX PX expireTime,判断与写入原子完成
Boolean ok = redisTemplate.opsForValue()
.setIfAbsent(lockId, owner, expireTime, TimeUnit.MILLISECONDS);
return Boolean.TRUE.equals(ok);
}
public boolean releaseLock(String lockId, String owner) {
// Lua 脚本里比对 value 再删除,避免误删他人锁
Long ret = redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
Collections.singletonList(lockId), owner);
return ret != null && ret > 0;
}
还剩两个问题需要自己处理:
- 续期:业务执行时间 > 过期时间,锁会提前失效。过期时间设太长,一旦节点挂掉锁就长时间不释放;设太短又容易误失效。所以要么把过期时间评估得足够宽松,要么用「看门狗」在业务执行期间定时续期(Redisson 已经做了);
- 一致性是 AP 的:Redis 主从是异步复制,主节点写入锁之后还没同步给从节点就宕机,从节点升主,锁就丢了,另一个客户端可以拿到同一把锁。这个窗口在极端情况下是存在的,所以才有了 Redlock(向多数派节点加锁)这样的方案。不过大多数业务场景能接受这个概率,理解风险在哪即可。
适用场景:高并发、对互斥的要求远高于对强一致的要求,绝大多数互联网业务的分布式锁都落在这一档。
3. 基于 ZooKeeper 的分布式锁
原理:ZooKeeper 的节点分持久节点和临时节点,临时节点(EPHEMERAL)的生命周期跟客户端会话绑定——客户端断连、会话超时,节点会被 ZK 自动删除。这天然解决了「持锁方崩溃了锁怎么办」的问题,不需要依赖过期时间兜底。多个客户端在同一个父节点下创建临时顺序节点(EPHEMERAL_SEQUENTIAL),序号最小的那个节点就代表锁的归属,天然公平、先到先得。
原文伪代码:
public void acquireLock(String lockPath) {
// 创建临时节点
String lockNode = zookeeperClient.create(lockPath + "/lock-", new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
}
public void releaseLock(String lockNode) {
zookeeperClient.delete(lockNode, -1);
}
这段只写了「创建节点」和「删除节点」,中间最关键的两步缺了:
- 没判断自己是不是最小的节点。只要节点创建成功就当拿到锁,那多个客户端会同时“拿到锁”,互斥直接失效;
- 没有 Watch 等待机制。正确的做法是:取出父节点下所有子节点排序,自己不是最小的,就
exists(前一个节点, watch=true)监听它;前一个节点被删除(持锁方释放或会话超时)时被回调唤醒,再重新判断。只监听紧邻的前一个节点,而不是监听父节点下所有变更,否则每次锁释放都会把所有等待者唤醒(羊群效应/惊群问题),ZK 的 QPS 会被打爆。
补全后的伪代码:
public String acquireLock(String lockPath) throws Exception {
// 1. 创建临时顺序节点,例如 /locks/order/lock-0000000017
String node = zookeeperClient.create(lockPath + "/lock-", new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
while (true) {
// 2. 取子节点并排序,判断自己是不是最小的
List<String> children = zookeeperClient.getChildren(lockPath, false);
Collections.sort(children);
String self = node.substring(node.lastIndexOf('/') + 1);
if (self.equals(children.get(0))) {
return node; // 最小节点 = 真正拿到锁
}
// 3. 不是最小的,只监听紧邻的前一个节点,避免惊群
int index = children.indexOf(self);
String prev = lockPath + "/" + children.get(index - 1);
zookeeperClient.exists(prev, true);
// 等待前一个节点被删除事件回调唤醒后继续循环
waitForPrevNodeDelete();
}
}
public void releaseLock(String lockNode) {
zookeeperClient.delete(lockNode, -1);
}
适用场景:对一致性要求高、并发不算高的场景,比如选主、元数据变更、配置切换。ZK 是 CP 的(ZAB 协议),写操作走 Leader,性能远不如 Redis,但胜在不会像 Redis 主从那样「锁悄悄丢了」。
注意一个坑:ZK 会话超时会导致临时节点被删、锁自动释放,但此时原客户端可能只是发生了长时间 Full GC 或网络抖动,进程还活着、业务还在跑。于是新客户端拿到锁进来,两边同时操作临界区。所以业务侧对「锁已经不在自己手里」这件事最好有二次校验(类似 fencing token 的思路)。
4. 基于 Redisson 的分布式锁
原理:Redisson 是 Redis 的 Java 客户端,它把分布式锁上面那一堆「手写容易写错」的细节全都封装好了:原子加锁、Lua 解锁带 owner 校验、看门狗自动续期、可重入计数、阻塞式等待重试。业务侧把它当 ReentrantLock 用就行。
原文伪代码:
RLock lock = redisson.getLock("myLock");
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock();
}
值得讲清楚的几个细节:
- 看门狗(watchdog):
lock()不传leaseTime时,默认锁租期是 30 秒,Redisson 会启动一个后台任务,每 10 秒(租期的 1/3)检查一次,只要持锁线程还活着就给锁续期到 30 秒,直到调用unlock()。这就解决了「业务没跑完锁先过期」的问题。如果显式传了leaseTime,看门狗就不生效了,此时过期时间就是你业务执行时间的硬上限。 - 可重入:底层用 Hash 结构存
{锁名: {线程标识: 重入次数}},同一线程再次加锁就是 count + 1,释放时 count - 1,减到 0 才真正del。 - 解锁必须在 finally 里、必须由加锁线程调用,否则会抛
IllegalMonitorStateException。这也意味着unlock()不能跨线程(比如丢到线程池里异步释放)。 - 非阻塞版本用
tryLock(waitTime, leaseTime, unit):最多等waitTime去抢锁,抢不到返回 false,抢到后最多持有leaseTime。定时任务类场景一般用它,避免把线程卡死。 - 多节点强一致:Redisson 也提供
RedissonRedLock(联锁),要求多数派节点都加锁成功才算成功,用来缓解主从切换丢锁的问题,代价是性能下降、实现更重。业务里用得不多,但面试可以主动提一句,说明你知道 Redis 锁在一致性上的短板。
RLock lock = redisson.getLock("order:lock:" + orderId);
// 最多等 3 秒,抢到后持有 10 秒(不启用看门狗)
boolean locked = lock.tryLock(3, 10, TimeUnit.SECONDS);
if (!locked) {
// 抢锁失败,走降级或快速返回
return;
}
try {
// 临界区代码
} finally {
lock.unlock();
}
适用场景:Spring Boot 项目里做分布式锁的默认选择,包一层工具类即可,不需要自己写 Lua、不需要自己管续期。
5. 四种方案的对比
| 维度 | 数据库 | Redis | ZooKeeper | Redisson |
|---|---|---|---|---|
| 一致性 | 跟随 DB,一般较强 | AP,主从切换有丢锁窗口 | CP,强一致 | 同 Redis(另有 RedLock 可选) |
| 性能 | 差(每次一次写库) | 最好 | 一般(写走 Leader) | 最好 |
| 死锁兜底 | 需自己做过期清理 | 靠 key 过期时间 | 靠临时节点 / 会话超时 | 过期时间 + 看门狗续期 |
| 公平性 | 非公平 | 非公平(除非自己实现队列) | 天然公平(顺序节点) | 非公平 |
| 实现复杂度 | 低 | 中(原子性、误删、续期都要自己处理) | 中高(顺序节点 + Watch + 惊群) | 低(API 封装完备) |
| 典型场景 | 低频定时任务、兜底 | 高并发业务互斥 | 选主、强一致元数据操作 | 业务默认首选 |
易错点与对比
- 「先 get 再 set」拿 Redis 锁——最常见的错误。判断和写入分成两条命令就有竞态,必须
SET NX PX/setIfAbsent一条命令搞定。 - 释放锁直接
delete——不校验 owner 就删,会误删别人的锁。必须用 Lua 脚本「比对 value + 删除」原子完成。 - 锁的 value 写成固定字符串——
"locked"这种值无法标识持有者,等于没做 owner 校验。要用 UUID + 线程 ID 之类的唯一标识。 - 不设过期时间——进程崩溃或异常没走到 finally,锁永久滞留 = 死锁。
- 过期时间拍脑袋定——太短,业务没跑完锁就失效,互斥形同虚设;太长,节点挂了锁长时间不释放。要么评估准确并留足余量,要么用 Redisson 的看门狗。
- 抢锁失败立刻返回 vs 无限自旋——自旋重试一定要加等待时间和随机退避,否则所有失败者一起空转,把 Redis / ZK 打满。
- ZK 只创建临时节点就当拿到锁——没判断最小节点顺序、没 Watch 前一个节点,互斥和公平性都不成立。
- ZK 监听父节点而非前一个节点——一次锁释放唤醒所有等待者,惊群问题,ZK 集群压力剧增。
- 误以为分布式锁能替代事务——锁只保证临界区互斥,不保证多步操作的原子提交。跨库、跨资源时还要考虑本地事务、消息最终一致或 Seata 之类。
- 锁的粒度设计——key 太粗(比如整个订单模块一把锁)会变成全局串行,吞吐暴跌;太细又容易漏加,需要按业务资源 ID 设计 key 前缀。
unlock()抛IllegalMonitorStateException——通常是三种情况:不是加锁线程释放、重复释放、或者锁已经过期被别的线程重入。排查时先看业务执行时间是否超过了 leaseTime。- 忽略锁服务的可用性——锁服务本身挂了怎么办:Redis 集群不可用时是降级放行(可能重复执行)还是直接失败(可能业务不可用)?这个取舍要在设计阶段就想好,而不是出事时再临时决定。
小结
- 数据库锁:靠唯一索引互斥,实现最简单,但性能低、要自己做过期清理和 owner 校验,适合并发低、不想引入中间件的场景。
- Redis 锁:靠
SET NX PX加过期时间,性能最好、最常用,但原子性、误删、续期三件事都要自己处理对,一致性上是 AP,极端情况下可能丢锁。 - ZooKeeper 锁:靠临时顺序节点 + Watch,天然公平、会话断开自动释放,CP 强一致,但性能一般、实现要小心惊群和会话假死。
- Redisson 锁:Redis 锁的工程化封装,看门狗续期、可重入、阻塞等待、Lua 安全释放全都内置,Spring Boot 项目里做分布式锁的默认推荐。
面试作答可以按「为什么需要 → 合格标准 → 四种实现及各自短板 → 对比选型 → 项目中怎么用」这个顺序展开,把原子性、误删、续期、主从切换这几个坑主动说出来,往往比背完四种实现更能加分。