面试官:分布式锁怎么实现?Redis vs Zookeeper vs 数据库——从原理到 Redisson

引言

"你简历上写了分布式锁,说说怎么实现的?"——这是 Java 后端面试的高频题。大部分候选人的回答停留在"用 Redis 的 SETNX"——面试官追问三个问题就卡住了:锁过期了任务还没执行完怎么办?Redis 主从切换锁丢了怎么办?同一个线程递归调用能不能重入?

这三个问题才是分布式锁的真正考点:SETNX 只是"加锁"这一个动作,而分布式锁的完整语义包括——互斥、防死锁、可重入、防误删、续期、故障容忍。每一项都有对应的坑和解决方案。这篇文章从"为什么需要分布式锁"讲起,对比数据库/Redis/Zookeeper 三种实现的底层原理,再深入 Redisson 看门狗的自动续期机制,最后逐一拆解三大坑的成因与解法。


一、为什么需要分布式锁

1.1 本地锁管不住分布式场景

// 单机时代:synchronized / ReentrantLock 就能解决并发
public synchronized void deductStock(Long skuId) {
    Stock stock = stockMapper.selectBySkuId(skuId);
    if (stock.getCount() > 0) {
        stockMapper.deduct(skuId);
    }
}

单机时 JVM 内的锁有效——所有线程共享同一把锁对象。但服务多实例部署后:

实例 A(JVM 1): synchronized 拿到本地锁 → 扣库存
实例 B(JVM 2): synchronized 拿到本地锁 → 扣库存  ← 锁不住!不同 JVM 的锁互不相干
实例 C(JVM 3): synchronized 拿到本地锁 → 扣库存

3 个实例各拿各的锁,库存被扣了 3 遍——超卖

本地锁的可见范围是单个 JVM 进程,跨 JVM/跨机器的互斥需要一个"所有实例都能看到的共享存储"来协调——这就是分布式锁。

1.2 分布式锁的六个必备特性

特性含义不满足的后果
互斥性任意时刻只有一个客户端持有锁超卖/重复执行
防死锁持锁客户端宕机后锁能自动释放锁永久被占,其他客户端永远拿不到
可重入同一客户端可多次获取同一把锁递归调用/嵌套方法死锁
防误删只能删自己的锁,不能删别人的A 的锁过期被 B 拿到,A 删了 B 的锁
锁续期任务没执行完时自动延长锁过期时间锁提前过期,并发进入临界区
故障容忍存储组件主从切换时锁语义尽量不丢主从切换后锁"消失",两个客户端同时持锁

后面的方案对比和三大坑,全部围绕这六个特性展开


二、方案一:数据库锁——最简单但性能最差

2.1 唯一索引方案(悲观锁)

-- 利用唯一索引的冲突拒绝实现互斥
CREATE TABLE distributed_lock (
    lock_key    VARCHAR(64) NOT NULL PRIMARY KEY,   -- 锁名(唯一索引)
    holder      VARCHAR(64) NOT NULL,               -- 持有者标识(实例ID:线程ID)
    expire_time DATETIME    NOT NULL,               -- 过期时间(防死锁)
    create_time DATETIME    NOT NULL DEFAULT NOW()
);

-- 加锁:插入成功=拿到锁,唯一键冲突=别人持有
INSERT INTO distributed_lock(lock_key, holder, expire_time)
VALUES ('order:create:1001', 'instance-A:thread-3', DATE_ADD(NOW(), INTERVAL 30 SECOND));
-- 成功 → 拿到锁
-- 报错 Duplicate entry → 锁被占用

-- 释放锁:只能删自己的锁(防误删)
DELETE FROM distributed_lock
WHERE lock_key = 'order:create:1001' AND holder = 'instance-A:thread-3';

-- 防死锁:定时清理过期锁(宕机持有者的锁能被回收)
DELETE FROM distributed_lock WHERE expire_time < NOW();

2.2 乐观锁方案(版本号)

-- 用版本号实现 CAS 互斥(适合"更新同一行"场景)
UPDATE stock SET count = count - 1, version = version + 1
WHERE sku_id = 1001 AND version = 17 AND count > 0;
-- affected rows = 1 → 抢到更新权
-- affected rows = 0 → 版本号已变,别人先改了

2.3 数据库锁的优劣

维度评价
✅ 实现简单不引入新组件,有数据库就能用
✅ 强一致数据库 ACID 保证,锁语义可靠
❌ 性能差加锁/释放都是磁盘 IO,QPS 上限低(单库 ~1000 TPS)
❌ 单点风险数据库挂了锁全失效(主从切换有延迟)
❌ 续期麻烦要自己写 UPDATE expire_time 的续期 SQL + 定时任务
❌ 不可重入唯一索引方案天然不可重入,要加可重入计数字段

适用场景:并发量低(< 100 QPS)、不想引入 Redis/ZK、团队只有数据库运维能力——比如后台管理系统的低频定时任务防重。高并发场景不要用数据库锁——锁表成为整个系统的瓶颈。


三、方案二:Redis 锁——性能与可靠性的平衡点

3.1 从 SETNX 演进到正确写法

第一代(错误):
  SETNX lock 1          ← 加锁
  (业务执行中宕机,DEL 没执行)→ 锁永久存在 → 死锁

第二代(仍有漏洞):
  SETNX lock 1
  EXPIRE lock 30        ← 两条命令非原子!中间宕机一样死锁

第三代(原子命令,正确基础版):
  SET lock 1 NX EX 30   ← 加锁+过期一条命令原子完成
// 正确的基础版加锁:SET key value NX EX seconds
public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
    Boolean ok = redisTemplate.opsForValue().setIfAbsent(
            lockKey, requestId, expireSeconds, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(ok);
}

// 释放锁:必须校验 value 是自己的 requestId(防误删)
// 且"判断+删除"要用 Lua 脚本保证原子
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
    """;

public boolean unlock(String lockKey, String requestId) {
    Long result = redisTemplate.execute(
            new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class),
            Collections.singletonList(lockKey), requestId);
    return Long.valueOf(1).equals(result);
}

3.2 为什么 value 必须是唯一 requestId

时刻 T1: A 拿到锁,requestId=A,过期时间 30s
时刻 T31: 锁过期(A 的任务还没执行完)
时刻 T32: B 拿到同一把锁,requestId=B
时刻 T35: A 执行完,执行 DEL lock ← 删的是 B 的锁!
时刻 T36: C 拿到锁 → B 和 C 同时持锁 → 互斥被打破

解法:DEL 前先 GET 校验 value == A 的 requestId
     且 GET+DEL 用 Lua 脚本原子执行(否则 GET 后、DEL 前锁刚好过期又被别人拿到)

requestId 通常用 UUID + 线程ID——全局唯一标识锁的持有者。

3.3 Redis 锁的优劣

维度评价
✅ 性能高内存操作,单实例 10 万+ QPS
✅ 实现相对简单SET NX EX + Lua 释放,几十行代码
✅ 生态成熟Redisson 提供生产级实现(见第五章)
❌ 续期要自己做裸 SETNX 没有自动续期,锁过期了任务没完就出并发
❌ 主从切换丢锁锁写到 Master 后异步同步到 Slave,Master 宕机时锁可能没同步过去
⚠️ 非强一致Redis 是 AP 模型,极端故障下锁互斥可能被打破(见第七章坑 2)

四、方案三:Zookeeper 锁——强一致但重

4.1 临时顺序节点原理

锁节点路径:/locks/order_lock/

客户端 A 加锁:
  创建临时顺序节点 /locks/order_lock/node_0000000001
  → 列出所有子节点 → 自己是最小的 → 拿到锁

客户端 B 加锁:
  创建临时顺序节点 /locks/order_lock/node_0000000002
  → 列出所有子节点 → 前面有 node_1 → 不是最小
  → 在 node_1 上注册 watcher(只监听前一个节点,避免惊群)
  → 等待 node_1 删除

A 释放锁:删除 node_1(或 A 宕机,临时节点随 session 断开自动删除)
  → B 的 watcher 被触发 → B 检查发现自己变成最小 → 拿到锁
// Curator 框架的标准写法(不用自己处理节点/watcher 细节)
InterProcessMutex lock = new InterProcessMutex(client, "/locks/order_lock");

// 尝试加锁(等待最多 10 秒)
boolean acquired = lock.acquire(10, TimeUnit.SECONDS);
if (acquired) {
    try {
        // 业务逻辑
    } finally {
        lock.release();   // 释放锁
    }
}

4.2 ZK 锁的四个特性如何被天然满足

特性ZK 的实现机制
互斥只有序号最小的节点持锁
防死锁临时节点(EPHEMERAL):客户端 session 断开节点自动删除
可重入Curator 的 InterProcessMutex 内部记录本线程重入次数,同一 JVM 可重入
防惊群只 watch 前一个节点,而非所有节点——锁释放时只唤醒一个等待者

4.3 三种方案横向对比

维度数据库RedisZookeeper
一致性强一致AP(异步复制)CP(Zab 协议强一致)
性能(加锁 QPS)~1K10 万+~1~2 万(写 Leader + 半数确认)
防死锁自己写过期清理TTL 过期临时节点自动删除
锁等待轮询 SQL自旋/订阅watcher 事件通知(不轮询)
运维成本低(已有 DB)低(已有 Redis)高(独立 ZK 集群,至少 3 节点)
典型实现唯一索引/版本号RedissonCurator
适用低并发高并发 + 容忍极端故障高并发 + 要求强一致

一句话选型:90% 的业务场景用 Redis(Redisson)——性能够高、生态成熟、团队基本都有 Redis;对锁正确性要求到"宁可拒绝服务也不能并发进入"的金融级场景(如资金账户操作),考虑 ZK;数据库锁只在低频场景用。


五、Redisson:生产级 Redis 锁的标准答案

5.1 最简用法

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.31.0</version>
</dependency>
@Service
@RequiredArgsConstructor
public class OrderService {

    private final RedissonClient redissonClient;

    public void createOrder(Long userId) {
        String lockKey = "lock:order:create:" + userId;
        RLock lock = redissonClient.getLock(lockKey);

        boolean acquired = false;
        try {
            // tryLock(等待时间, 锁过期时间, 单位)
            // 不传锁过期时间 → 启用看门狗(默认 30s,自动续期)
            acquired = lock.tryLock(10, TimeUnit.SECONDS);
            if (!acquired) {
                throw new BizException("操作太频繁,请稍后重试");
            }
            // 业务逻辑(耗时可能超过 30 秒也不怕,看门狗续期)
            doCreateOrder(userId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BizException("加锁被中断");
        } finally {
            // 必须校验是当前线程持有才释放(否则抛 IllegalMonitorStateException)
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

5.2 看门狗(WatchDog)自动续期机制

加锁(不指定 leaseTime):
  → 锁的过期时间 = 30s(lockWatchdogTimeout 默认值)
  → 启动一个定时任务(Netty 的 HashedWheelTimer 时间轮)
  → 每 10s(看门狗超时的 1/3)检查一次:
       如果当前线程还持有锁 → 把过期时间重置回 30s
  → 业务执行多久,锁就续多久(不会出现"锁过期了任务没完")

释放锁 / 客户端宕机:
  → 正常:unlock 删除 key + 取消续期任务
  → 宕机:续期任务随进程消失 → key 在 30s 后自动过期 → 防死锁
时间线:
  T0   加锁,TTL=30s,看门狗启动
  T10  看门狗续期,TTL 重置为 30s(实际剩余 30s)
  T20  看门狗续期,TTL=30s
  T30  看门狗续期,TTL=30s
  T45  业务执行完毕 unlock,key 删除,看门狗停止

如果 T20 业务实例宕机:
  T20 续期任务停止 → T30(key 还剩 10s)后 key 过期 → 其他实例可拿锁

关键细节

细节说明
看门狗只在不传 leaseTime 时生效tryLock(10, 30, SECONDS) 指定了 30s → 锁 30s 后必过期,不续期
续期间隔 = 看门狗超时 / 3默认 30s / 3 = 10s 续一次,留两次容错
看门狗续的是"存在性"续期操作用 Lua 脚本:只有 key 还属于当前线程才续(防止误续别人的锁)
锁可重入Redisson 锁的 value 是 Hash:field=客户端ID:线程ID,value=重入次数,重入 +1,解锁 -1,归零删 key

5.3 Redisson 锁的存储结构

# 可重入锁在 Redis 里是一个 Hash(不是简单 String)
Key:   lock:order:create:1001
Field: 9f8e7d6c...:thread-3        (Redisson 客户端 UUID + 线程 ID)
Value: 2                            (重入次数)
TTL:   30s(看门狗持续续期)

加锁 Lua 脚本核心逻辑(简化):
  if not exists(key)              → 没人持有 → HSET key field 1 + PEXPIRE
  if field == currentClientThread → 自己重入 → HINCRBY field 1 + PEXPIRE
  else                            → 别人持有 → 返回 TTL(加锁失败,客户端按 TTL 等待)

5.4 Redisson 还提供了什么

锁类型适用场景
RLock(可重入锁)通用互斥,默认选择
RReadWriteLock(读写锁)读多写少:读读共享,读写/写写互斥
RFairLock(公平锁)按请求顺序拿锁,防饥饿(性能略低)
RCountDownLatch分布式闭锁:N 个任务都完成后继续
RSemaphore分布式信号量:限流(如限制 DB 并发连接数)
RMultiLock(红锁 RedLock 的实现)多个独立 Redis 实例同时加锁,应对主从切换丢锁

六、分布式锁三大坑

坑 1:锁过期了,任务还没执行完

T0   A 拿到锁,TTL=30s
T30  锁过期(A 的批处理任务跑了 40 秒还没完)
T31  B 拿到同一把锁 → A 和 B 同时在临界区执行 → 超卖/重复扣款

两种解法

解法说明
看门狗自动续期(推荐)Redisson 不传 leaseTime,任务不结束锁不过期;宕机后 30s 自动释放
合理评估过期时间按任务 P99 耗时的 2~3 倍设 leaseTime——但无法覆盖极端长尾,治标

注意:看门狗不是万能——GC 停顿超过 30s(如大堆 Full GC)时续期任务也会被暂停,key 一样过期。极端场景把 lockWatchdogTimeout 调大,或配合幂等设计兜底(锁是效率手段,幂等才是正确性底线)。

坑 2:主从切换,锁丢了

T0   A 向 Master 申请锁 SET lock A NX EX 30 → 成功
T0.1 Master 还没把锁同步给 Slave(Redis 主从复制是异步的)
T0.2 Master 宕机
T1   Sentinel 把 Slave 提升为新 Master(新 Master 上没有 lock 这个 key)
T2   B 向新 Master 申请同一把锁 → 成功!
T3   A 和 B 同时持锁 → 互斥失效

三种应对

方案做法代价
RedLock(红锁)在 N(通常 5)个独立 Master 上加锁,超过半数(3)成功才算拿到部署成本高、争议大(网络分区/时钟漂移下仍有质疑)、性能下降
wait 复制确认用 WAIT 命令等锁写入复制到指定数量从节点再返回牺牲可用性,WAIT 不保证严格一致
接受 + 幂等兜底(工程主流)承认 Redis 锁在极端故障下可能失效,业务层做幂等(唯一键/状态机)成本最低,正确性由业务幂等保证

工程实践的普遍选择是第三种:Redis 分布式锁用于"防 99.9% 的并发重复"(效率),业务幂等保证"极端情况下也不出资损"(正确性)。真正强一致的资金核心操作走数据库唯一约束/状态机,不依赖锁。

坑 3:不可重入导致的自锁死锁

// 场景:加锁方法内部调用了另一个也要加同一把锁的方法
public void methodA() {
    lock.lock();
    try {
        methodB();   // methodB 内部也要获取同一把锁
    } finally {
        lock.unlock();
    }
}

public void methodB() {
    lock.lock();   // ← 如果锁不可重入:自己等自己释放,永久死锁
    // ...
}

解法

  • Redisson 的 RLock 天然可重入——基于线程标识 + Hash 重入计数,同一线程重复加锁只是计数 +1
  • 自研锁必须在 value 里记录持有者(客户端+线程),重入时识别"持有者是自己"并计数,不能只存一个 "1"
  • 注意陷阱@Transactional 方法里加锁/锁内开事务可能让"锁释放在事务提交前"——锁先放、事务后提交,并发请求在事务提交前读到旧数据。正确顺序是锁包在事务外层(锁获取 → 开事务 → 提交事务 → 释放锁)。

七、常见问题

7.1 Redisson 看门狗续期失败怎么办?

看门狗续期是"尽力而为"——续期 Lua 脚本执行失败(网络抖动/Redis 短暂不可用)时 Redisson 会重试,但如果 Redis 长时间不可达,续不上期 key 就会过期。此时任务还在跑但锁没了——所以业务临界区必须有幂等兜底,看门狗降低概率但不承诺绝对。监控上可以订阅 Redisson 的 lock expired 事件打告警,出现续期失败立刻排查。

7.2 锁的等待时间(waitTime)设多少?

按"临界区平均执行时间 × 预估并发数"估算:临界区平均 200ms,最多 10 个并发排队 → waitTime 设 2~3 秒。拿不到锁快速失败比长时间阻塞好——用户等 10 秒拿到锁时上游网关早就超时了。配合"快速失败 + 友好提示"("操作太频繁,请稍后重试")比死等体验更好。

7.3 Redis 集群模式(Cluster)下锁 key 怎么处理?

Redisson 在 Cluster 模式下按 key 的 hash slot 定位节点——同一把锁的 key 落在固定 slot,加锁/续期/释放都打到同一节点,语义和单机一致。注意 hash tag:如果多把锁需要落在同一节点(如 RedLock 或批量操作),用 lock:{order:1001}:alock:{order:1001}:b{} 语法强制同 slot。

7.4 RedLock 到底该不该用?

Martin Kleppmann(《DDIA》作者)与 antirez(Redis 作者)有过著名论战:Kleppmann 认为 RedLock 在 GC 停顿、时钟跳变下仍不安全,且依赖各节点时钟同步;antirez 认为在合理运维下 RedLock 提供了足够的概率保证。实践共识:RedLock 部署复杂(5 个独立 Master)、性能损耗大,而它防的主从切换丢锁是低频事件——绝大多数团队选择"单集群 Redisson 锁 + 业务幂等兜底"。只有当你已经有 5 个独立 Redis 集群且确实不能接受极端故障时才考虑。

7.5 分布式锁能替代 synchronized 吗?

不能互相替代,是两层防线:synchronized/ReentrantLock 防单机内多线程并发(无网络开销,纳秒级),分布式锁防跨实例并发(有网络开销,毫秒级)。典型用法:外层分布式锁保证跨实例互斥,进入临界区后对本地共享资源仍可用本地锁保护。不要为了"统一"把单机并发也上分布式锁——10 万 QPS 的本地锁场景换 Redis 锁,Redis 直接被打爆。

7.6 Zookeeper 临时节点为什么比 Redis TTL 更可靠?

ZK 临时节点的生命周期绑定 session:客户端与 ZK 集群保持心跳(session alive)节点就存在,session 超时(心跳断了超过 sessionTimeout)节点才被删除。这是集群通过 Zab 协议达成一致后删除的——不存在"主节点单方面有锁、从节点没有"的窗口期。而 Redis TTL 是基于时间的过期,主从异步复制存在"锁已写 Master 未同步 Slave"的窗口。代价是 ZK 每次加锁都要 Leader 写 + 半数 Follower 确认(至少一次磁盘 fsync),性能低于 Redis 内存写。


八、总结

三方案速查卡

┌────────────┬──────────┬──────────────┬──────────────┐
│ 维度        │ 数据库    │ Redis        │ Zookeeper    │
├────────────┼──────────┼──────────────┼──────────────┤
│ 一致性      │ 强        │ AP(极端丢锁)│ CP(强一致)  │
│ 加锁性能    │ ~1K QPS  │ 10万+ QPS    │ 1~2万 QPS    │
│ 防死锁      │ 过期清理  │ TTL+看门狗    │ 临时节点自动删│
│ 等待机制    │ 轮询      │ 自旋/订阅     │ watcher 通知 │
│ 运维成本    │ 低        │ 低           │ 高           │
│ 实现        │ 唯一索引  │ Redisson     │ Curator      │
│ 适用        │ 低频      │ 90% 业务      │ 金融强一致   │
└────────────┴──────────┴──────────────┴──────────────┘
Redisson 看门狗:不传 leaseTime → 默认 30s,每 10s 检查续期
三大坑:锁过期任务没完(看门狗) / 主从切换丢锁(幂等兜底) / 不可重入(RLock 天然可重入)

一句话

分布式锁的考点从来不是 SETNX 这个命令,而是互斥、防死锁、可重入、防误删、续期、故障容忍六个特性的完整闭环:数据库唯一索引强一致但性能只有千级 QPS 只适合低频场景;Redis SET key NX EX + Lua 原子释放是基础版,生产直接用 Redisson——看门狗机制在不传 leaseTime 时每 10 秒把锁续回 30 秒,任务不结束锁不过期、实例宕机 30 秒后自动释放;Zookeeper 用临时顺序节点 + watcher 天然解决防死锁和锁等待,强一致但运维重。三大坑要记住:锁过期任务没完靠看门狗但要防 GC 停顿,主从切换丢锁靠业务幂等兜底而不是迷信 RedLock,可重入靠 Redisson 的 Hash 重入计数。最后一句话送给所有候选人:锁是效率手段,幂等才是正确性底线——资金类操作永远以数据库唯一约束为准,不要把命押在分布式锁上。

给团队的建议

建议
默认选择Redisson RLock(tryLock + 看门狗 + isHeldByCurrentThread 释放)
锁粒度Key 带业务维度(lock:order:create:{userId}),别用全局大锁
超时参数waitTime 按临界区耗时×并发估算,快速失败优于长等
事务顺序锁在事务外层:加锁 → 事务 → 提交 → 释放
正确性底线业务幂等(唯一键/状态机)兜底锁失效,资金操作不信锁
强一致场景资金核心走 ZK 或数据库约束,Redis 锁用于高并发防重
监控加锁失败率、持锁时长 P99、看门狗续期失败事件

互动话题:你们生产环境用的哪种分布式锁?遇到过锁续期失败或主从切换丢锁的真实事故吗?RedLock 在评论区吵了很多年,你站哪边?


参考资料


标题:面试官:分布式锁怎么实现?Redis vs Zookeeper vs 数据库——从原理到 Redisson
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/17/1789224193026.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消