分布式锁完全指南:从数据库到 Redisson 的演进

19 阅读1分钟

分布式锁完全指南:从数据库到 Redisson 的演进

单体应用时代,一个 synchronized 就能解决并发互斥。但服务一旦部署多个实例,JVM 锁就只能锁住自己进程内的线程——三个实例三把锁,互斥名存实亡。这时候就需要分布式锁:让所有实例去竞争同一把"外部锁"。这篇文章按照工程演进的顺序,把数据库锁 → Redis SETNX → Lua 原子释放 → Redisson 看门狗 → Redlock → ZooKeeper/etcd一步步讲透:每一代方案解决了什么、又暴露了什么新问题。

文章配了 10 张原创 SVG 图解、6 段核心代码,读完你不仅能选对分布式锁方案,还能在面试里把"误删他人锁、看门狗续期、主从切换锁失效"这些经典追问全部接住。

1. 为什么单机锁在分布式环境下失效?

先看一个最典型的场景:秒杀扣库存。业务逻辑是"读库存 → 判断够不够 → 扣减 → 写回",这几步必须是原子的,否则并发下会超卖。单机时代我们用 JVM 锁保护这段临界区,一切正常;但部署了三个实例之后,每个实例的 synchronized 只对自己进程内的线程生效。

图 1:单机锁失效场景——多实例各自持锁,互斥被打破

解决思路很直接:找一个所有实例共享的外部系统来保存"这把锁现在归谁",各实例加锁/释放锁都请求这个外部系统。数据库、Redis、ZooKeeper 都能扮演这个角色——它们的差异在于性能、可靠性和实现复杂度

2. 一个合格的分布式锁要满足什么?

动手实现之前,先明确分布式锁的三条硬性要求,后面每一代方案的缺陷,都可以对照这三条来检查:

图 2:分布式锁三要素——互斥、防死锁、容错

另外还有两个加分项:可重入(同一线程可以重复获取同一把锁而不死锁)和高性能(加锁/释放的开销要小)。带着这把"尺子",我们开始逐代演进。

3. 方案一:用数据库实现分布式锁

最朴素的做法:建一张锁表,给 lock_key唯一索引。加锁就是 INSERT 一条记录——成功即拿到锁,冲突(唯一索引报错)说明别人持有;释放锁就是 DELETE。

    -- 锁表:lock_key 唯一索引保证互斥
    CREATE TABLE distributed_lock (
      id         BIGINT PRIMARY KEY AUTO_INCREMENT,
      lock_key   VARCHAR(64) NOT NULL,
      owner      VARCHAR(128) NOT NULL,      -- 持有者标识(IP+线程)
      expire_at  DATETIME NOT NULL,          -- 兜底过期时间
      UNIQUE KEY uk_lock_key (lock_key)
    ) ENGINE=InnoDB;

    -- 加锁:插入成功 = 拿到锁
    INSERT INTO distributed_lock (lock_key, owner, expire_at)
    VALUES ('lock:order:1001', '10.0.0.5:thread-1', NOW() + INTERVAL 30 SECOND);
    -- 唯一索引冲突 = 锁被占用

    -- 释放:删除自己的锁(owner 条件防止误删)
    DELETE FROM distributed_lock
    WHERE lock_key = 'lock:order:1001' AND owner = '10.0.0.5:thread-1';

图 3:数据库唯一索引锁的加锁 / 冲突 / 释放时序

数据库方案的适用面很窄:并发量低、又不想引入新组件的场景可以凑合用。并发一高,锁表就成了热点行,数据库 QPS 直接被打满。另外它也没有"锁自动过期"——持有者宕机后记录残留,需要额外的定时清理任务兜底,三要素里的"防死锁"要靠人肉补齐。

顺带一提乐观锁:很多"防超卖"场景其实根本不需要显式加锁,用版本号一条 UPDATE 搞定:UPDATE stock SET num = num - 1, version = version + 1 WHERE id = 1 AND version = ?。影响行数为 0 就说明被人抢先改了,重试即可。能用乐观锁解决的,别上分布式锁。

4. 方案二:Redis 分布式锁的正确姿势

Redis 性能远超数据库,是分布式锁的主流选择。但"用 Redis 写一把正确的锁"远没有看起来那么简单——网上大量教程的第一版就是错的。我们按演进顺序来。

4.1 第一步的坑:SETNX 和 EXPIRE 不是原子的

很多老教程分两步加锁:SETNX key value 拿锁,再 EXPIRE key 30 设过期。问题是这是两条命令——如果客户端在两条命令之间宕机,锁被设置了但永远不会过期,其他客户端全部卡死。

图 4:SETNX + EXPIRE 两步写法的死锁风险与原子命令修复

    # 正确姿势:一条命令同时完成"互斥 + 过期"
    SET lock:order:1001 8a3f9c2e-1111:thread-1 NX PX 30000

    # 返回 OK  → 加锁成功
    # 返回 nil → 锁被占用

4.2 第二个坑:释放锁时删掉了别人的锁

锁有过期时间就有新问题:业务没执行完,锁先过期了。看下面这条事故时间线:

图 5:误删他人锁事故时间线,以及"唯一 value + Lua 校验释放"的修复方案

修复后的释放逻辑用 Lua 脚本实现——比对 value 和删除必须在同一个原子操作里完成

    -- release_lock.lua:校验 value 一致才删除,否则返回 0(不是自己的锁,不动)
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end

4.3 第三个坑:业务没跑完,锁先过期了怎么办?

Lua 只解决了"别删错锁",但"锁过期、业务还在跑"本身仍然会导致互斥被破坏。两种应对:

  • 把过期时间设得足够长:治标不治本——设太长,持有者宕机后其他客户端要白等很久;设太短,照样过期。
  • 锁自动续期(看门狗):业务执行期间,定时把锁的有效期往后延,直到业务执行完再释放。这是正确答案,也是 Redisson 的核心卖点。

在进入 Redisson 之前,先自己手写一个"能用的" Redis 锁客户端,体会一下要补齐多少细节:

    public class SimpleRedisLock {
        private final StringRedisTemplate redis;
        private final String lockKey;
        private final String value;   // 唯一标识:UUID:threadId

        public boolean tryLock(long waitMs, long leaseMs) {
            long deadline = System.currentTimeMillis() + waitMs;
            while (System.currentTimeMillis() < deadline) {
                Boolean ok = redis.opsForValue()
                        .setIfAbsent(lockKey, value, Duration.ofMillis(leaseMs)); // 原子加锁
                if (Boolean.TRUE.equals(ok)) return true;
                sleep(50);  // 自旋重试,生产建议指数退避
            }
            return false;
        }

        public void unlock() {
            // 必须用 Lua 保证"比对 + 删除"原子执行
            DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_LUA, Long.class);
            redis.execute(script, List.of(lockKey), value);
        }
    }

手写版仍然缺两块:① 没有自动续期——业务超过 leaseMs 依然会互斥失效;② 不可重入——同一线程二次加锁会把自己锁死。这两个坑,正是 Redisson 存在的理由。

5. 方案三:Redisson——生产级的 Redis 分布式锁

Redisson 是 Redis 官方推荐的 Java 客户端之一,它的 RLock 把上面所有坑都填平了:原子加锁、唯一 value、Lua 校验释放、可重入、看门狗自动续期,开箱即用。

5.1 看门狗(Watchdog):自动续期机制

图 6:看门狗续期流程——业务不死,锁不过期;宕机则自然释放

5.2 可重入原理:Hash 结构记录重入次数

Redisson 的锁不是简单 String,而是Hash 结构:field 是"客户端 ID : 线程 ID",value 是重入次数。同一线程第二次加锁,次数 +1;每次 unlock 减 1;减到 0 才真正删除锁。

图 7:Redisson 可重入锁的 Hash 结构与加减计数流程

5.3 标准用法

    RLock lock = redissonClient.getLock("lock:order:1001");
    try {
        // 推荐:等待 5s;不传 leaseTime → 启用看门狗自动续期
        boolean acquired = lock.tryLock(5, TimeUnit.SECONDS);
        if (!acquired) {
            throw new BusyException("获取锁失败,请稍后重试");
        }
        // ... 临界区业务 ...
    } finally {
        // 只释放自己持有的锁,避免 IllegalMonitorStateException
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
    # Redisson 接入 Spring Boot
    spring:
      redis:
        redisson:
          config: |
            singleServerConfig:
              address: "redis://127.0.0.1:6379"
              connectionMinimumIdleSize: 8
            # watchdogTimeoutMs 不配则默认 30s,续期间隔 = 30s / 3 = 10s

6. 更深一层:主从切换时锁会丢——Redlock 出场

到这里 Redisson 已经很完善了,但还有一个架构层面的漏洞:Redis 主从复制是异步的

图 8:异步复制 + 主从切换 = 锁凭空消失,A/B 双持锁

**Redlock(红锁)**的解法是:部署 N 个(通常 5 个)完全独立、互不复制的 Redis 主节点。加锁时客户端依次向所有节点发送加锁请求,满足以下条件才算成功:

图 9:Redlock 算法——5 节点多数派成功且总耗时小于锁有效期才算加锁成功

7. 另一条路线:ZooKeeper 与 etcd

Redis 系方案的本质是"允许极小概率出错,换取极致性能"。如果业务对正确性要求苛刻(如资金、调度选主),可以换用基于共识协议的组件:

  • ZooKeeper:客户端在锁节点下创建临时顺序节点,序号最小的获得锁;其余节点 watch 自己前一个节点,前者释放后依次被唤醒(避免惊群)。会话断开时临时节点自动删除,天然防死锁。互斥性由 ZAB 共识协议保证,比 Redis 主从切换可靠得多。
  • etcd:用 lease(租约)+ Revision 全局有序版本号实现同类语义,Raft 协议保证一致性,Kubernetes 选主用的就是这套。

代价是吞吐量比 Redis 低一到两个数量级,且要多运维一套组件。它们与 Redis 方案不是替代关系,而是正确性与性能之间的取舍

8. 横评与选型:一张表 + 一棵决策树

方案互斥强度性能防死锁实现复杂度适用场景
数据库唯一索引 / 行锁差(DB 瓶颈)需自建清理任务极低低并发、不想引组件
Redis SET NX PX + Lua中(主从可丢锁)极高过期时间兜底中(细节多易踩坑)高并发、容忍极小概率失效
Redisson中(同上,但细节完备)极高看门狗 + 过期低(开箱即用)绝大多数 Java 业务的首选
Redlock较强(多数派仲裁)各节点过期高(5 独立节点)对主从丢锁敏感的核心链路
ZooKeeper / etcd强(共识协议)会话结束自动释放强一致要求:选主、资金调度

图 10:选型决策树——大多数场景的答案是"Redisson + 业务幂等兜底"

比选型更重要的兜底思维:分布式锁从来不是 100% 可靠的,成熟系统的做法是"锁 + 幂等"双保险——锁用来减少冲突、提升效率;就算锁失效,业务层的幂等校验(版本号、状态机、唯一约束)也能拦住重复写入。

9. 面试速查与总结

高频追问速答:

  1. 为什么 SETNX 和 EXPIRE 要合成一条命令?——两条命令之间宕机会产生永不过期的死锁;SET key value NX PX 30000 原子完成。
  2. 为什么释放锁要用 Lua?——"GET 比对 value"和"DEL"必须原子,否则比对后锁过期被他人拿走,仍会误删。
  3. 看门狗什么时候生效?——lock() 不传 leaseTime 时,默认 30s 有效期、每 10s 续期;手动传了 leaseTime 就不续期。
  4. Redisson 可重入怎么实现?——Hash 结构,field 为客户端 ID:线程 ID,value 计数加减,减到 0 删除。
  5. 主从切换为什么会丢锁?——复制异步,锁 key 还没同步到从库主库就宕机,从库晋升后锁不存在。
  6. Redlock 成功条件?——多数派(N/2+1)加锁成功,且总耗时小于锁有效期;失败则向全部节点释放。

一句话总结:分布式锁的演进史,就是不断修补互斥漏洞的历史——数据库方案把压力给了 DB,Redis 方案用性能换取主从复制的一致性缺口,Redisson 用看门狗和 Lua 堵上了使用层面的坑,Redlock 和 ZooKeeper 则试图在架构层面根治。对大多数业务,Redisson + 业务幂等兜底就是那个投入产出比最高的答案;对零容忍场景,请让共识协议上场。