Redis高并发分布式锁

187 阅读5分钟

进程锁有什么弊端?

当遇到集群架构时, 进程锁就不能保证数据的安全;

例如售出减库存时, 连个进程的请求拿到的库存数都是100, 都-1后库存数应该是98 , 但是这种情况库存数就会更新成99, 出现超卖的问题。

如何解决

  • 使用Redis的setnx实现一把分布式锁, 只有能拿到锁的进程才可以扣减库存。

image.png

这样使用有什么问题?

  • 如果在业务层面出现了异常, 锁永远都释放不了, 就会出现死锁

    解决方法: try catch finally

  • 这样解决后第二个问题是, 如果代码执行到一半服务器宕机了, 那么也会造成死锁问题

    解决方法: 设置超时时间

image.png

  • 第三个问题是, 这样操作是有原子问题的, 可能在两行代码之间还会出现宕机情况, 出现死锁

    解决方法:使用redisTemplate自带的原子操作

image.png

  • 第四个问题是业务处理时间超过设置的超时时间, 导致锁失效,另一个进程获取锁还没有完成业务逻辑, 第一个进程就把锁给释放掉了, 所以问题点在解锁上, 自己的锁被其他进程释放掉了。

    解决方法: 生成一个唯一ID, 将分布式锁的value设置为ID, 解锁时判断是否是自己的锁

image.png

image.png

  • 第五个问题是, if逻辑和解锁不是原子操作, 过期时间在临界区时如果业务执行完if逻辑有可能导致锁过期, 下一个进程拿到锁后马上又被第一个进程给释放掉了。

锁续命: 开启一个新的线程来定时监控锁是否被释放,如果没有被释放掉就将超时时间重置, 这个时间一定是比超时时间要小


## Redisson解决分布式锁

引入redisson依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.6.5</version>
</dependency>

将Redisson注入到Spring中

@Bean
public Redisson redisson(){
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6379").setDatabase(0);
    return (Redisson) Redisson.create(config);
}


使用Redisson分布式锁

RLock lock = redisson.getLock(lockKey);
try {
    lock.lock();
    System.out.println("locked");
}finally {
    lock.unlock();
}

Redisson分布式锁流程图

image.png

Redisson分布式锁是怎么实现的?

我们来分析一下源码

当调用lock.lock();时,会调用RedissonLock的lockInterruptibly方法, 注意leaseTime默认是-1

image.png image.png 核心逻辑在tryAcquire方法中, 然后调用tryAcquireAsync方法

image.png 最终调用tryLockInnerAsync方法

image.png 我们发现字符串中有一堆代码, 这个其实是Lua脚本

所以redisson分布式锁使用lua脚本实现了加锁逻辑

那么什么是Lua脚本?

Lua脚本

Redis在2.6版本退出了脚本功能, 允许开发者将Lua脚本传到Redis执行。

可以使用EVAL命令对Lua脚本进行处理, 格式如下:

EVAL 被执行的脚本 键数量 key [key ...] arg [arg ...]
# 示例:
EVAL "return {KEY[1],KEY[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

其中数字2制定了键名参数的数量, key1和key2是键名参数, 使用KEY[1],KEY[2]访问, first和second是附加参数, 使用ARGV[1],ARGV[2]访问。

Lua脚本的好处

  • 减少网络开销: 本来N次的请求可以在一个请求中完成。
  • 原子操作: Redis会将Lua脚本当成一个整体来执行,Redis批量操作命令是保证原子性的。
  • 替代redis的事物功能: redis自带的事务功能很鸡肋, lua脚本的方式几乎实现了常规的事务功能, 而且官方也推荐使用lua代替事务。

锁续命核心源码

尝试获取锁后会返回Futrue, 当执行结束后会调用FutureListener实现的方法, 注意, 如果leaseTime的值大于-1是不会走续命逻辑的

image.png

然后就会开启一个定时任务scheduleExpirationRenewal(threadId);//计划到期续签

image.png

未拿到锁的进程处理

尝试获取锁后会返回一个字段ttl, 那么这个ttl是什么呢?

我们来重新分析一下尝试加锁的Lua脚本

image.png

从上图分析得知, 如果加锁成功会返回null, 如果没有加锁成功会返回锁剩余的超时时间, 回到尝试加锁之后, 就可以拿到ttl判断是否加锁成功, 如果没有拿到锁, 那么久再次尝试加锁一次, 如果还没有拿到锁那么就会阻塞剩余的超时时间(s)

image.png

这里的阻塞实际是利用了Semaphore来实现的

查看getLatch()方法

image.png

然后通过Semaphore的tryAcquire的方式进行阻塞。

那么问题来了, 难道来了100个线程都拿不到锁,它们都要阻塞住傻傻的等着吗? 很明显Redisson是不会做出来这么傻缺的产品的。

在阻塞逻辑之前我们似乎忽略了点什么

image.png

image.png

image.png 这个就是redis实现的发布订阅模式, 没有尝试获取到锁的线程会订阅一个channel频道,监听channel, 相当于mq的Queue, 那什么时候发布消息呢?

那就需要看一下解锁逻辑了, lock.unlock()

image.png 进到这个方法后, 发现这里又是一段Lua脚本, 分析如下图

image.png

那么阻塞的线程是如何感知到消息的呢?

Redisson封装了LockPubSub, 就是控制发布订阅的,onMessage方法用来监听消息

image.png

这下就彻底打通了, 也就是说当解锁时被阻塞的线程就会被唤醒重新尝试获取锁, 我们再重新回顾一下代码片段。

image.png

觉得写得还不错就为我点赞吧, 这是对我最大的鼓励, 让我更有信心坚持做更好的文章!