" />
文章

说一下你对分布式锁的理解以及分布式锁的实现

说一下你对分布式锁的理解以及分布式锁的实现

面试指北 · 蔚来

题意

这道题表面上是「请罗列几种分布式锁的实现」,实际上面试官想听的是三层内容:

  1. 概念层:分布式锁和 synchronized、ReentrantLock 到底差在哪,为什么单机锁在分布式场景下失效;
  2. 原理层:每种实现靠什么机制保证互斥,锁怎么释放、怎么防死锁、怎么防止误删别人的锁;
  3. 权衡层:一致性、性能、可用性三者如何取舍,为什么大多数业务最终选择了 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 封装完备)
典型场景 低频定时任务、兜底 高并发业务互斥 选主、强一致元数据操作 业务默认首选

易错点与对比

  1. 「先 get 再 set」拿 Redis 锁——最常见的错误。判断和写入分成两条命令就有竞态,必须 SET NX PX / setIfAbsent 一条命令搞定。
  2. 释放锁直接 delete——不校验 owner 就删,会误删别人的锁。必须用 Lua 脚本「比对 value + 删除」原子完成。
  3. 锁的 value 写成固定字符串——"locked" 这种值无法标识持有者,等于没做 owner 校验。要用 UUID + 线程 ID 之类的唯一标识。
  4. 不设过期时间——进程崩溃或异常没走到 finally,锁永久滞留 = 死锁。
  5. 过期时间拍脑袋定——太短,业务没跑完锁就失效,互斥形同虚设;太长,节点挂了锁长时间不释放。要么评估准确并留足余量,要么用 Redisson 的看门狗。
  6. 抢锁失败立刻返回 vs 无限自旋——自旋重试一定要加等待时间和随机退避,否则所有失败者一起空转,把 Redis / ZK 打满。
  7. ZK 只创建临时节点就当拿到锁——没判断最小节点顺序、没 Watch 前一个节点,互斥和公平性都不成立。
  8. ZK 监听父节点而非前一个节点——一次锁释放唤醒所有等待者,惊群问题,ZK 集群压力剧增。
  9. 误以为分布式锁能替代事务——锁只保证临界区互斥,不保证多步操作的原子提交。跨库、跨资源时还要考虑本地事务、消息最终一致或 Seata 之类。
  10. 锁的粒度设计——key 太粗(比如整个订单模块一把锁)会变成全局串行,吞吐暴跌;太细又容易漏加,需要按业务资源 ID 设计 key 前缀。
  11. unlock() 抛 IllegalMonitorStateException——通常是三种情况:不是加锁线程释放、重复释放、或者锁已经过期被别的线程重入。排查时先看业务执行时间是否超过了 leaseTime。
  12. 忽略锁服务的可用性——锁服务本身挂了怎么办:Redis 集群不可用时是降级放行(可能重复执行)还是直接失败(可能业务不可用)?这个取舍要在设计阶段就想好,而不是出事时再临时决定。

小结

  • 数据库锁:靠唯一索引互斥,实现最简单,但性能低、要自己做过期清理和 owner 校验,适合并发低、不想引入中间件的场景。
  • Redis 锁:靠 SET NX PX 加过期时间,性能最好、最常用,但原子性、误删、续期三件事都要自己处理对,一致性上是 AP,极端情况下可能丢锁。
  • ZooKeeper 锁:靠临时顺序节点 + Watch,天然公平、会话断开自动释放,CP 强一致,但性能一般、实现要小心惊群和会话假死。
  • Redisson 锁:Redis 锁的工程化封装,看门狗续期、可重入、阻塞等待、Lua 安全释放全都内置,Spring Boot 项目里做分布式锁的默认推荐。

面试作答可以按「为什么需要 → 合格标准 → 四种实现及各自短板 → 对比选型 → 项目中怎么用」这个顺序展开,把原子性、误删、续期、主从切换这几个坑主动说出来,往往比背完四种实现更能加分。

License:  CC BY 4.0