欢迎光临
我们一直在努力

分布式锁演进

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

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


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

阶段描述

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

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

代码实现

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) 命令。

代码实现

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)

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

代码实现

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

存在的问题

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


V4.0:引入过期时间

阶段描述 (commit 3105842)

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

代码实现

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

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

存在的问题

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

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

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

    阶段描述 (commit 75362b8)

    为了解决误删问题,我们在删除前判断:这把锁是不是我的? 我们在加锁时存入 UUID + ThreadID,删除时检查值是否一致。

    代码实现

    String uuidValue = IdUtil.simpleUUID() + ":" + Thread.currentThread().getId();
    // 加锁…

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

    存在的问题

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

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

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

    阶段描述 (commit 8e3012a)

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

    代码实现

    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 脚本:

    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 脚本:

    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)检查锁是否存在,如果存在则重置过期时间。

    代码实现

    // 加锁成功后启动定时任务
    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 的核心功能:

  • 互斥性:SETNX / Lua
  • 防死锁:TTL (Time To Live)
  • 防误删:UUID 唯一标识
  • 原子性:Lua 脚本
  • 可重入:Hash 结构 + 计数器
  • 自动续期:Timer 定时任务
  • 这正是 Redisson 客户端底层的核心实现逻辑。

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

    赞(0)
    未经允许不得转载:171主机测评 » 分布式锁演进
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址