分布式锁演进

Redis 分布式锁实战演进:从单机锁到 Redisson 核心原理

本文将基于本项目的 Git 提交记录,详细梳理一个分布式锁是如何一步步完善的。我们将看到每一次迭代背后的思考:遇到了什么问题?如何解决?引入了什么新问题?


V1.0 - V2.0:单机锁的局限性

阶段描述

最初的业务场景非常简单,在一个 InventoryService 中扣减库存。

  • V1.0 (commit 3ba341a):没有任何锁,高并发下出现超卖。
  • V2.0 (commit 62eefd9) :引入 Java 自带的锁(synchronizedReentrantLock)。

代码实现

java 复制代码
private Lock lock = new ReentrantLock();

public String sale() {
    lock.lock();
    try {
        // 1. 查询库存
        // 2. 扣减库存
    } finally {
        lock.unlock();
    }
}

存在的问题

单机锁无法解决分布式问题

当项目部署了两个服务实例(如 8081 和 8082,commit a7c6adc)时,ReentrantLock 只能锁住当前 JVM 的线程。两个不同的 JVM 进程依然可以同时读取到相同的库存数量,导致超卖。


V3.1:简单的分布式锁(递归重试)

阶段描述 (commit 523ec3c)

为了解决分布式环境下的互斥问题,我们引入 Redis 的 SETNX (Set if Not Exists) 命令。

代码实现

java 复制代码
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue);
if (!flag) {
    // 获取失败,递归重试
    try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) {}
    sale(); 
} else {
    try {
        // 业务逻辑
    } finally {
        stringRedisTemplate.delete(key);
    }
}

存在的问题

栈溢出 (StackOverflowError)

在高并发场景下,如果大量线程抢不到锁并不断递归调用 sale(),会导致方法调用栈过深,最终抛出 StackOverflowError


V3.2:优化重试策略(自旋锁)

阶段描述 (commit ae63c87)

为了解决递归导致的栈溢出,将递归调用改为循环等待(自旋)。

代码实现

java 复制代码
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue)) {
    try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) {}
}
// 业务逻辑...

存在的问题

死锁风险

如果在 setIfAbsent 成功后,业务代码执行过程中服务宕机(或断电),finally 块中的 delete 永远无法执行。Redis 中的 key 一直存在,导致其他线程永远无法获取锁。


V4.0:引入过期时间

阶段描述 (commit 3105842)

为了解决死锁问题,必须给锁加一个过期时间。

代码实现

java 复制代码
// 使用原子命令:SET key value NX EX 30
while (!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue, 30L, TimeUnit.SECONDS)) {
    // ...
}

注意:必须使用 SET 的原子命令,不能先 SETNX 再 EXPIRE,否则在两步之间宕机依然会导致死锁。

存在的问题

误删锁

假设线程 A 获取锁,设置 30s 过期。

  1. A 业务执行慢,用了 35s。
  2. 30s 时锁自动过期。
  3. 线程 B 获取到锁。
  4. A 执行完毕,执行 delete(key)
    此时 A 删除的是 B 的锁!B 的业务还在跑,失去了锁的保护,线程 C 又能进来了。

V5.0:防误删(UUID 校验)

阶段描述 (commit 75362b8)

为了解决误删问题,我们在删除前判断:这把锁是不是我的?

我们在加锁时存入 UUID + ThreadID,删除时检查值是否一致。

代码实现

java 复制代码
String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();
// 加锁...

try {
    // 业务...
} finally {
    // 判断是否是自己的锁
    if (stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)) {
        stringRedisTemplate.delete(key);
    }
}

存在的问题

原子性问题
get 判断和 delete 删除是两个独立的操作,不是原子的。

极端场景:

  1. A 判断锁是自己的(返回 true)。
  2. 此时锁恰好过期(或被 GC 停顿)。
  3. B 抢到新锁。
  4. A 恢复执行,继续执行 delete
    A 还是把 B 的锁删了。

V6.0:原子删除(Lua 脚本)

阶段描述 (commit 8e3012a)

为了保证"判断 + 删除"的原子性,引入 Lua 脚本。Redis 会将 Lua 脚本作为一个整体执行,中间不会被插入其他命令。

代码实现

java 复制代码
String luaScript = 
    "if redis.call('get',KEYS[1]) == ARGV[1] then " +
    "return redis.call('del',KEYS[1]) " +
    "else " +
    "return 0 " +
    "end";
stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), Arrays.asList(key), uuidValue);

存在的问题

不可重入

如果 sale() 方法中调用了另一个需要相同锁的方法 method2(),由于 SETNX 的特性,method2() 会因为锁已被自己持有(但无法识别)而阻塞,导致死锁。Java 的 ReentrantLock 是支持可重入的。


V7.0:可重入锁(Hash + Lua)

阶段描述 (commit 07035b9)

为了支持可重入,我们需要记录"是谁加的锁"以及"加了几次"。

数据结构从 String 变为 Hash:

  • Key: lockName
  • Field: UUID:ThreadID
  • Value: 重入次数

代码实现

加锁 Lua 脚本

lua 复制代码
if redis.call('exists', KEYS[1]) == 0 or redis.call('hexists', KEYS[1], ARGV[1]) == 1 then
    redis.call('hincrby', KEYS[1], ARGV[1], 1)
    redis.call('expire', KEYS[1], ARGV[2])
    return 1
else
    return 0
end

解锁 Lua 脚本

lua 复制代码
if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then
    return nil
elseif redis.call('hincrby', KEYS[1], ARGV[1], -1) == 0 then
    return redis.call('del', KEYS[1])
else
    return 0
end

同时引入工厂模式 DistributedLockFactory 方便使用。

存在的问题

锁过期时间不够用(业务没跑完锁没了)

虽然解决了误删和可重入,但如果业务执行时间超过设置的过期时间(如 30s),锁依然会释放,导致并发安全问题。


V8.0:自动续期(看门狗机制)

阶段描述 (commit 21da498)

为了解决锁提前过期的问题,引入"看门狗"机制:开启一个后台线程(定时任务),每隔一段时间(如过期时间的 1/3)检查锁是否存在,如果存在则重置过期时间。

代码实现

java 复制代码
// 加锁成功后启动定时任务
private void resetExpire() {
    String script = "if redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
            "return redis.call('expire',KEYS[1],ARGV[2]) " +
            "else " +
            "return 0 " +
            "end";
    
    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            if (stringRedisTemplate.execute(...)) {
                resetExpire(); // 递归调用,继续续期
            }
        }
    }, (this.expireTime * 1000) / 3);
}

总结

至此,我们手写的分布式锁已经具备了 Redisson 的核心功能:

  1. 互斥性:SETNX / Lua
  2. 防死锁:TTL (Time To Live)
  3. 防误删:UUID 唯一标识
  4. 原子性:Lua 脚本
  5. 可重入:Hash 结构 + 计数器
  6. 自动续期:Timer 定时任务

这正是 Redisson 客户端底层的核心实现逻辑。

配套的代码在 https://github.com/yisuibandamowang/RedisDistributedLockDemo 喜欢可以点个 star

相关推荐
无心水5 小时前
【任务调度:数据库锁 + 线程池实战】3、 从 SELECT 到 UPDATE:深入理解 SKIP LOCKED 的锁机制与隔离级别
java·分布式·科技·spring·架构
何中应11 小时前
RabbitMQ安装及简单使用
分布式·后端·消息队列
何中应11 小时前
SpringAMQP消息转化器
分布式·后端·消息队列
Rick199312 小时前
如何保证数据库和Redis缓存一致性
数据库·redis·缓存
indexsunny14 小时前
互联网大厂Java求职面试实战:基于电商场景的技术问答及解析
java·spring boot·redis·kafka·security·microservices·面试指导
渣瓦攻城狮15 小时前
互联网大厂Java面试:从数据库连接池到分布式缓存及微服务
java·redis·spring cloud·微服务·hikaricp·数据库连接池·分布式缓存
Coder_Boy_17 小时前
Java高级_资深_架构岗 核心知识点——高并发模块(底层+实践+最佳实践)
java·开发语言·人工智能·spring boot·分布式·微服务·架构
tod11319 小时前
Redis 分布式锁进阶:从看门狗到 Redlock 的高可用实践
数据库·redis·分布式
闲人编程19 小时前
Celery分布式任务队列
redis·分布式·python·celery·任务队列·异步化
渣瓦攻城狮20 小时前
互联网大厂Java面试:Spring、微服务与消息队列技术详解
java·redis·spring·微服务·消息队列·面试指南·程序员面试