redis分布式锁

分布式锁

  • 1. 问题分析
  • 2. 分布式锁
  • 3. 实现思路
  • 4. 初级版本
  • 5. 误删问题
    • 5.1 解决
  • 6. 分布式锁的原子性问题
    • 6.1 redis的lua脚本
      • 6.1.1 简单语法
      • 6.1.2 脚本书写释放锁的业务流程
    • 6.2 java调用lua脚本改造分布式锁
  • 7. Redisson
    • 7.1 上边实现的分布式锁存在的问题
    • 7.2 Redisson介绍
    • 7.3 入门
    • 7.4 redisson的可重入锁
      • 7.4.1 原理
    • 7.5 可重试
    • 7.6 主从一致

本文为学习redis时做的笔记,学习内容来自 黑马程序员Redis入门到实战教程,该教程是循序渐进的,所以不是一上来就讲完最后的解决方案了,请耐心看完

1. 问题分析

通过加锁可以解决在单机情况下的一人一单问题,但在集群下就不行了
redis分布式锁_第1张图片
在集群下,或者在分布式系统下,每个线程有自己的jvm,多个jvm存在,每个jvm都有自己的锁监视器,会有多个线程获得锁,可能出现安全问题,所以我们要想办法,让多个jvm使用同一把锁
即:
redis分布式锁_第2张图片
关键是让多个进程看到同一个锁,并且只有一个线程能拿到锁

2. 分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
特点:

  • 多进程可见
  • 互斥
  • 高可用
  • 高性能
  • 安全性

redis分布式锁_第3张图片
可以看到虽然redis服务宕机后不能自动释放锁,但是redis从高可用和高性能上来讲是很好的,所以接下来学习一下如何基于redis实现分布式锁

3. 实现思路

实现分布式锁肯定要实现的两个方法是:获取锁和释放锁

  1. 获取锁:

使用set nx ex 获取锁,并设置超时时间

  • 互斥:确保只能有一个线程获取锁
  • 非阻塞:尝试一次,成功返回true,失败返回false
//添加锁,nx是互斥(即这个key不存在才能创建),ex是设置超时时间
set lock thread1 nx ex 10
  1. 释放锁:
  • 手动释放
  • 超时剔除:获取锁时添加一个超时时间
//释放锁,删除即可
del key

流程:
redis分布式锁_第4张图片

4. 初级版本

先写一个锁的接口

public interface ILock {

    /**
     * 获取锁
     *
     * @param timeoutSrc
     * @return
     */
    boolean tryLock(long timeoutSrc);

    /**
     * 释放锁
     */
    void unlock();

}

写对应的实现类,实现获取锁和释放锁的方法,记着获取该线程的唯一标识,用Thread.currentThread().getId()

import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;


public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    private String name;

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSrc) {
        //作为线程的唯一标识,获取线程的id
        long threadId = Thread.currentThread().getId();
        //当这个key不存在时再执行,实现了互斥
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSrc, TimeUnit.SECONDS);
        //包装类怕有空指针异常
        return Boolean.TRUE.equals(flag);
    }

    @Override
    public void unlock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}

接下来在服务层改写原先的创建锁逻辑

Long userId = UserHolder.getUser().getId();
//创建锁对象
SimpleRedisLock redisLock = new SimpleRedisLock(stringRedisTemplate, "order" + userId);
//获取锁方法
boolean lockFlag = redisLock.tryLock(1200);
//判断是否获取成功
if (!lockFlag) {
    return Result.fail("不要重复下单");
}

//事务方法执行起来可能会出现异常,但最后都要释放锁,所以try-catch起来
try {
    //获取当前的代理对象(事物)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
} finally {
    redisLock.unlock();
}

现在在apifox中用同一个用户的token向不同线程的程序发送请求
redis分布式锁_第5张图片
redis分布式锁_第6张图片
redis分布式锁_第7张图片
同一个用户在不同线程发送请求,只有其中一个请求能获得锁,库存也只减少了1,实现了分布式锁的功能

5. 误删问题

redis分布式锁_第8张图片
有一种极端情况,线程1获取到锁,但业务阻塞时间太长,已经超过了锁的超时释放时间,锁被释放了,这时线程2获取到锁,线程2执行业务时线程1业务完成,去释放锁,就把线程2的锁释放了,这时线程3也获取到锁…
出现了线程安全问题 ,最关键的就是在释放锁的时候要判断一下是不是自己线程的锁

redis分布式锁_第9张图片

详细思路:获取锁的时候存入线程标识,在释放锁的时候获取线程标识并判断是否一致,如果不一致,就什么也不做,就可以避免误删其他线程的锁

5.1 解决

修改之前的分布式锁实现,满足:

  1. 在获取锁时存入线程标识(只用线程id可能会出现id冲突的情况,所以用UUID+线程id表示)
  2. 在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致
  • 如果一致则释放锁
  • 如果不一致则不释放锁
import cn.hutool.core.lang.UUID;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;


public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    private String name;

    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }


    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true)+"-";
    @Override
    public boolean tryLock(long timeoutSrc) {
        //线程标识
        String threadId =ID_PREFIX+ Thread.currentThread().getId();
        //当这个key不存在时再执行,实现了互斥
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId , timeoutSrc, TimeUnit.SECONDS);
        //包装类怕有空指针异常
        return Boolean.TRUE.equals(flag);
    }

    @Override
    public void unlock() {
        //判断线程标识是否一致
        //当前线程标识
        String threadId =ID_PREFIX+ Thread.currentThread().getId();
        //锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);

        if (threadId.equals(id)) {
            //标识一致再释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
        //标识不一致就不管了


    }
}

修改了线程标识的获取实现逻辑,并在释放锁时判断线程标识和锁中的标识是否一致

测试:
线程1获取到了锁
redis分布式锁_第10张图片
redis分布式锁_第11张图片

线程2获取到了锁
redis分布式锁_第12张图片
redis分布式锁_第13张图片

因为线程1的锁的键值对被我们删除,后来在释放锁时线程标识和锁中的标识就对不上,就不能删除线程2对应的锁,而线程2对应的锁能被线程2释放

6. 分布式锁的原子性问题

redis分布式锁_第14张图片
还有一种特殊情况,线程1执行完业务并判断锁标识与当前锁标识一致,准备释放锁时发生了阻塞,时间太长导致锁超时释放,线程2这时获取到了锁,在执行业务时,线程1不阻塞了,就把当前的锁释放了(加了uuid锁还被删的原因:前边判断的时候锁还是自己的)。。。
出现了线程安全问题,我们可以用redis事物和乐观锁实现,但这种做法就复杂很多了
推荐使用lua脚本

6.1 redis的lua脚本

lua是一种编程语言,redis提供了lua脚本功能,在一个脚本中编写多条redis命令,确保多条命令执行时的原子性,lua基本语法参考网站: https://www.runoob.com/lua/lua-tutorial.html

6.1.1 简单语法

重点介绍redis提供的调用函数,语法如下:

redis.call('命令名称','key','其他参数'......)

例如我们要执行set name zhuyi,则脚本是这样的:

redis.call('set','name','zhuyi')

如我们要先执行set name zhuyi,再执行get name,则脚本是这样的:

redis.call('set','name','zhuyi')
Local name = redis.call('get','name')
return name

写好脚本之后,要去调用脚本,调用脚本的常见命令如下:
redis分布式锁_第15张图片
例如,我们要执行redis.call('set','name','zhuyi')这个脚本,语法如下:
image.png

如果脚本中的key,value不想写死,可以作为参数传递。key类型的参数会放入KEYS数组,其他参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 name lvhan

其中name对应KEYS[1],lvhan对应ARGV[1]

6.1.2 脚本书写释放锁的业务流程

  1. 获取锁中的线程标识
  2. 判断是否与指定的标识(当前线程标识)一致
  3. 如果一致则释放锁(删除)
  4. 如果不一致则什么都不做

-- 比较线程标识与锁中的标识是否一致
if(redis.call('get',KEYS[1]) == ARGV[1]) then
--释放锁 del key
return redis.call('del', KEYS[1])
    end
    return 0

先判断当前线程标识与锁中的标识是否一致,一致就释放锁,不一致就不管

6.2 java调用lua脚本改造分布式锁

RedisTemplate调用lua脚本的api为:
redis分布式锁_第16张图片
其中,eval相当于execute函数,函数中的script相当于脚本,keys相当于KEYS数组,args相当于ARGV数组,numkeys相当于key的参数个数,也就是keys集合的长度

  1. 首先在idea里下载lua脚本的插件:EmmyLua

redis分布式锁_第17张图片

  1. 在resource文件夹里创建lua文件并把写好的脚本传入

redis分布式锁_第18张图片

  1. 改写释放锁的逻辑,不用自己写逻辑,使用lua脚本

使用execute函数
redis分布式锁_第19张图片
脚本类型为RedisScript,这是个类就要去加载lua文件,如果我们每次释放锁都要去读取文件,产生io流,性能不好,所以我们应该提前定义脚本
redis分布式锁_第20张图片
(该页面通过ctrl+h查看)
RedisScript是一个接口,我们应该使用它的实现类DefaultRedisScript,T是返回类型
3.1. 初始化脚本
通过静态代码块,这个类一加载,脚本初始化就完成了
setLocation()是设置lua脚本位置的,ClassPathResource是在resource目录下寻找

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

//初始化脚本
static {
    UNLOCK_SCRIPT = new DefaultRedisScript();
    //读取文件位置,classpath就是resource
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

接下来在释放锁的逻辑中调用lua脚本

@Override
public void unlock() {
    //调用lua脚本
    //Collections.singletonList()单体集合
    //释放锁如果不成功,说明锁已经被别人删了,也不用管,所以不用管返回值
    stringRedisTemplate.execute(
        UNLOCK_SCRIPT,
        Collections.singletonList(KEY_PREFIX + name),
        ID_PREFIX + Thread.currentThread().getId());
}
  • Collections.singletonList()是创建单体集合的方法
  • 释放锁成功了就成功了,如果不成功,说明锁已经被别人删了,也不用管,所以不用管返回值

测试:
redis分布式锁_第21张图片
线程1获取到锁
redis分布式锁_第22张图片
线程1的锁,现在删除这条数据
redis分布式锁_第23张图片
所以线程2获取到锁
redis分布式锁_第24张图片
线程2的锁
redis分布式锁_第25张图片
线程1执行完释放锁操作,但是并没有删除redis中的数据
redis分布式锁_第26张图片
线程2执行完释放锁操作,成功删除

在判断和删除的操作是在脚本中写的,能保证原子性

7. Redisson

7.1 上边实现的分布式锁存在的问题

基于setnx实现的分布式锁存在以下问题:

  • 不可重入

同一个线程无法多次获取同一把锁,如果a方法在方法里要去调用b方法,a方法先获取锁,再去调用b方法,但b方法也要获取这个锁,就获取不到了

  • 不可重试

我们之前获取的锁只尝试一次就返回false,没有重试机制,在很多情况下是不可接受的

  • 超时释放

可能会有其他的安全隐患,时间长了短了都不行

  • 主从一致性

如果Redis提供了主从集群(主写从读),主从同步存在延迟,当主岩机时,如果从并同步主中的锁数据,可能会有多个线程拿到锁

不推荐亲自实现,我们使用Redisson

7.2 Redisson介绍

Redisson是一个在Redis基础上实现的java驻内存数据网格,提供了一系列的Java常用对象,提供了很多分布式服务,其中就有各种分布式锁的实现
redis分布式锁_第27张图片
官网:https://redisson.org
github:https://github.com/redisson/redisson

7.3 入门

有两种方式,不推荐使用springboot-starter那种,因为会替代spring官方提供的对redis的配置和实现,我们自己写配置

  1. 先引入依赖
<dependency>
  <groupId>org.redissongroupId>
  <artifactId>redissonartifactId>
  <version>3.13.6version>
dependency>
  1. 配置redisson客户端
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        //配置类
        Config config = new Config();
        //添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServer()添加集群地址
        //有密码还要加密码
        config.useSingleServer().setAddress("redis://localhost:6379");
        //创建客户端
        return Redisson.create(config);
    }
}
  1. 使用Redisson的分布式锁

修改获取锁和释放锁的逻辑,通过redisson客户端的方法获取锁,如果获取不到就不重试了,所以redisLock.tryLock()里面我们不传入时间

//创建锁对象
RLock redisLock = redissonClient.getLock("lock:order:" + userId);
//获取锁方法
boolean lockFlag = redisLock.tryLock();
//判断是否获取成功
if (!lockFlag) {
    return Result.fail("不要重复下单");
}

//事务方法执行起来可能会出现异常,但最后都要释放锁,所以try-catch起来
try {
    //获取当前的代理对象(事物)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    return proxy.createVoucherOrder(voucherId);
} finally {
    redisLock.unlock();
}

7.4 redisson的可重入锁

使用set nx 实现的分布式锁无法实现一个线程多次获取同一把锁,我们用redisson实现

7.4.1 原理

当我们获取锁的时候,当我们判断这个锁已经有人的情况下,判断一下是否是同一个线程,如果是同一个线程,我们也让它获取锁,需要一个计数器记录一下重入的次数,在获取锁时这个数不断加1,释放锁时这个数不断减1

redis分布式锁_第28张图片
在redis里我们不光要记录线程标识,还要记录重入的次数,所以我们要用到hash结构,其中key是锁的名称,field是线程标识,value是重入的次数

redis分布式锁_第29张图片
在内部被调用的方法里边,释放锁的时候是不能直接删除锁,要使重入的次数减1,当所有的业务执行完,走到最外层方法的时候,释放锁才能删除锁,即等到重入的次数为0时

redis分布式锁_第30张图片

  • 获取锁时先判断锁是否存在,不存在就获取锁并添加线程标识,设置锁有效期,如果存在就判断锁标识是否是自己,是的话锁计数加1,不是的话就失败了。
  • 释放锁时,先判断锁是否是自己的,是的话锁计数减1,判断锁计数是否为0,不为0要重置有效期,为0要去释放锁

为了保证原子性,用lua脚本写逻辑

获取锁的lua脚本:

local key = KEYS[1]; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2]; --锁的自动释放时间

--判断锁是否存在
if(redis.call('exists',key) == 0 ) then
  --不存在,直接获取锁
  redis.call('hset',key,threadId,'1');
  --设置有效期
  redis.call('expire',key,releaseTime);
  return 1;--返回结果
end;
--锁存在,判断是否在同一个线程
if(redis.call('hexists',key,threadId) == 1 ) then
  --在同一个线程,重入次数加1
  redis.call('hincrby',key,threadId,'1');
  --设置锁有效期
  redis.call('expire',key,releaseTime);
  return 1;--返回结果
end;
return 0; --走到这一步说明不在当前获取锁的线程,获取锁失败

获取锁的lua脚本:

--释放锁
local key = KEYS[1]; --锁的key
local threadId = ARGV[1]; --线程唯一标识
local releaseTime = ARGV[2]; --锁的自动释放时间
--判断锁是否是自己的
if(redis.call('hexists',key,threadId) == 0 ) then
    return nil;
end;
--是自己的,锁计数减1
local count = redis.call('hincrby',key,threadId,-1);
--判断锁计数是否为0
if(count > 0) then
    --大于0说明不能释放锁,重置有效期
    redis.call('expire',key,releaseTime);
    return nil;
else -- 等于0说明可以释放锁
    --释放锁
    redis.call('del',key);
    return nil;
end;

7.5 可重试

原理:
利用信号量和pubsub功能实现等待、唤醒、获取锁失败的重试机制
第一次尝试获取锁失败后,不是立即失败,而是通过订阅消息,等待释放锁的消息,而成功获取锁的线程在释放锁的时候会发出这个消息,但是也不是无限的重试,有一个等待时间,如果超出这个等待时间,就不再尝试。因为这种等待唤醒的机制,cpu占用不高

开门狗机制:watchdog,超时续约,当线程成功获取锁之后,会开启一个定时任务,每隔一段时间就去重置锁的超时时间

7.6 主从一致

获取联锁

RLock lock1 = redissonClient.getLock("order:1");
RLock lock2 = redissonClient.getLock("order:2");
RLock lock3 = redissonClient.getLock("order:3");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);

原理:
多个独立的redis节点,必须在所有节点都获取重入锁,才算获取锁成功

你可能感兴趣的:(redis,redis,分布式,数据库,缓存)