java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > Redisson分布式锁原理

Redisson分布式锁原理深入分析

作者:风吹迎面入袖凉

文章介绍了Redisson分布式锁的工作原理,并详细解释了加锁、续期、解锁的核心流程,Redisson利用Redis的单线程、自动续期和Lua脚本原子性等特点,避免了传统锁常见的死锁、锁争用和误删等问题,此外,还总结了使用Redisson分布式锁时的注意事项和避坑点

一. Redisson是什么?

先澄清一个误区:Redisson不是新的中间件,它就是一个操作Redis的Java工具(客户端),基于Netty做的,速度很快。它的核心作用,就是把Redis里复杂的分布式锁操作,封装成了简单的Java代码——咱们不用记Redis的命令,不用写复杂的逻辑,调用它的API就能实现分布式锁,就像用本地的Lock一样简单。

Redisson里最核心的就是RLock这个接口,它和咱们本地用的Lock用法差不多,学起来特别简单。但它背后的逻辑,比咱们想的要严谨,这也是它能避免很多坑的原因。

这里贴一段咱们实际开发中最常用的Redisson锁代码,先有个直观感受:

// 1. 获取Redisson客户端(项目里一般是配置好的,直接注入)
RedissonClient redissonClient = Redisson.create();
// 2. 获取一把分布式锁(锁的名字自己定义,比如“order:lock:123”,唯一就行)
RLock lock = redissonClient.getLock("order:lock:123");
try {
    // 3. 加锁:默认30秒过期,也能自己设置,比如lock.lock(60, TimeUnit.SECONDS)
    lock.lock();
    // 4. 执行业务逻辑(比如修改订单状态、扣减库存,这部分是咱们自己的代码)
    doBusiness();
} finally {
    // 5. 解锁:必须放在finally里,防止业务报错,锁没释放
    lock.unlock();
}

就是这么简单!一行lock()加锁,一行unlock()解锁,剩下的底层逻辑,Redisson全帮咱们搞定了。接下来,咱们就扒一扒这两行代码背后,Redisson到底做了什么。

二. Redisson锁能干活,全靠Redis这3个本事

Redisson分布式锁,本质上是靠Redis实现的,没有Redis,它也玩不转。主要依赖Redis的3个核心能力:

  1. Redis是单线程干活:Redis同一时间只执行一个命令,不会出现两个命令同时执行的情况。这就天然保证了,同一时刻只有一个线程能抢到锁,不会出现“两个人同时抢到锁”的尴尬。
  2. 锁能自动过期:给锁设置一个过期时间(比如30秒),就算持有锁的线程崩溃了、网络断了,过了这个时间,锁会自动消失,不会一直占着资源,避免了“死锁”(锁一直没人放,其他线程都抢不到)。
  3. 一堆命令能一次性执行完:Redisson的加锁、解锁这些操作,都是用Lua脚本写的。Lua脚本能把多个Redis命令打包,要么全部执行成功,要么全部失败,不会出现“执行了一半卡住”的情况。比如“检查锁是否存在→创建锁”,这两步能一次性完成,避免了“两个线程同时检查到锁不存在,同时创建锁”的问题。

除此之外,Redisson还做了个优化:把Lua脚本缓存起来,不用每次都传输完整脚本,能节省时间、提升速度,尤其是在多台Redis组成的集群里,效果更明显。

三. 核心流程:加锁、续期、解锁

Redisson分布式锁的核心,就是“加锁→续期→解锁”这三步。

1. 加锁:怎么保证只有一个线程能抢到锁?

咱们平时调用的lock()方法,底层最终会调用RedissonLock类的lockInterruptibly()方法(核心加锁方法),再往下走,会调用tryAcquire()方法,尝试获取锁。这里贴tryAcquire()的核心源码:

// 核心加锁方法:尝试获取锁,leaseTime是过期时间,unit是时间单位
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
    // 1. 如果设置了过期时间,直接调用tryLockInnerAsync(真正执行加锁的方法)
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    // 2. 如果没设置过期时间(用默认30秒),先尝试加锁
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    // 3. 加锁成功后,启动“看门狗”(续期用的),后面会讲
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        if (e == null) {
            if (ttlRemaining == null) {
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}

// 真正执行加锁的方法:调用Lua脚本,和咱们之前讲的Lua逻辑一致
private <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisCommand<T> command) {
    // 转换过期时间为毫秒
    long ttl = unit.toMillis(leaseTime);
    // 返回Lua脚本的执行结果,这里就是调用Redis执行Lua脚本
    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return nil; " +
            "end; " +
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return nil; " +
            "end; " +
            "return redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getName()), ttl, getLockName(threadId));
}

逐行解读源码,不用懂复杂语法:

这段源码其实就是把咱们之前讲的“加锁逻辑”,用Java代码实现了一遍,核心还是Lua脚本的原子性,保证只有一个线程能抢到锁。这里再强调3个关键设计,彻底解决咱们自己写锁的坑:

2. 续期:看门狗机制

很多人会有疑问:如果我的业务逻辑比较复杂,执行时间超过了锁的过期时间(比如默认30秒),怎么办?这时候锁会自动过期,当锁一过期的时候,就给了其他重试获取锁的线程可乘之机,它们会抢到锁执行自己的操作,导致数据错乱。

Redisson早就想到了这个问题,自带了“看门狗”机制(Watch Dog),核心作用就是:只要线程还持有锁,就会每隔一段时间(默认10秒),把锁的过期时间刷新回30秒,直到线程释放锁。

核心源码:

// 启动看门狗,续期用的
private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    // 把当前线程的续期信息,存到本地缓存里
    EXPIRATION_RENEWAL_MAP.put(getEntryName(), entry);
    // 启动一个定时任务,每隔10秒执行一次,刷新锁的过期时间
    entry.task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 调用续期的Lua脚本,把锁的过期时间刷新回30秒
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    // 续期失败,移除本地缓存,停止续期
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }
                if (res) {
                    // 续期成功,继续启动下一个定时任务,循环续期
                    scheduleExpirationRenewal(threadId);
                } else {
                    // 续期失败,移除本地缓存
                    cancelExpirationRenewal(threadId);
                }
            });
        }
    }, getLockWatchdogTimeout() / 3, TimeUnit.MILLISECONDS);
}

// 续期的核心方法:调用Lua脚本,刷新过期时间
private RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                "return 1; " +
            "end; " +
            "return 0;",
            Collections.singletonList(getName()), getLockWatchdogTimeout(), getLockName(threadId));
}

大白话解读看门狗机制:

3. 解锁 :怎么正确释放锁?

解锁的逻辑和加锁对应,核心是“只有持有锁的线程,才能释放锁”,而且要处理“可重入”的情况(计数减1,直到为0才删除锁)。咱们平时调用的unlock()方法,底层调用的是RedissonLock类的unlockAsync()方法:

// 核心解锁方法
public RFuture<Void> unlockAsync(long threadId) {
    // 调用解锁的Lua脚本,返回解锁结果
    RFuture<Boolean> future = unlockInnerAsync(threadId);
    future.onComplete((opStatus, e) -> {
        // 解锁成功后,停止看门狗续期
        cancelExpirationRenewal(threadId);
        if (e != null) {
            throw new CompletionException(e);
        }
        // 如果返回null,说明解锁失败(不是锁的持有者)
        if (opStatus == null) {
            throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + getNodeId() + " thread-id: " + threadId);
        }
    });
    return future;
}

// 真正执行解锁的方法:调用Lua脚本
private RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then " +
                "return nil; " +  // 不是锁的持有者,返回null,解锁失败
            "end; " +
            "local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1); " +  // 重入计数减1
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[1]); " +  // 计数还大于0,刷新过期时间
                "return 1; " +  // 解锁成功(只是计数减1,没删除锁)
            "else " +
                "redis.call('del', KEYS[1]); " +  // 计数为0,删除锁
                "return 1; " +  // 解锁成功(删除锁)
            "end;",
            Collections.singletonList(getName()), getLockWatchdogTimeout(), getLockName(threadId));
}

解锁源码:

四. Redisson分布式锁的避坑点

五. 总结

Redisson分布式锁的核心逻辑:基于Redis的单线程、过期机制和Lua脚本原子性,封装了加锁、续期、解锁的逻辑,还提供了可重入、看门狗、红锁、公平锁、读写锁等实用功能,让我们能“开箱即用”。

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文