Redis知识-实战篇(5)

详细代码在我的Github上,地址:
https://github.com/CodeTeng/RedisCase
感兴趣的朋友可以去我的语雀平台进行查看更多的知识。
https://www.yuque.com/ambition-bcpii/muziteng

4. 分布式锁

4.1 基本原理和实现方式对比

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

分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执

行,这就是分布式锁的核心思路。

Redis知识-实战篇(5)_第1张图片

分布式锁应满足的条件

Redis知识-实战篇(5)_第2张图片

注意:这个地方说的可见性并不是并发编程中指的内存可见性,只是说多个进程之间都能感知到变化的意思

常见的分布式锁

Redis知识-实战篇(5)_第3张图片

4.2 Redis分布式锁的实现核心思路

Redis知识-实战篇(5)_第4张图片

4.3 实现分布式锁版本一

锁的基本接口

public interface ILock {
    /**
     * 尝试获取锁
     *
     * @param timeoutSec 锁持有的超时时间,过期后自动释放
     * @return true代表获取锁成功; false代表获取锁失败
     */
    boolean tryLock(long timeoutSec);

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

分布式锁的实现—SimpleRedisLock

利用setnx方法进行加锁,同时增加过期时间,防止死锁,此方法可以保证加锁和增加过期时间具有原子性

public class SimpleRedisLock implements ILock {

    private String name;

    private StringRedisTemplate stringRedisTemplate;

    private static final String KEY_PREFIX = "lock:";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程的标识
        long threadId = Thread.currentThread().getId();
        // 获取锁
        Boolean isSuccess = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return BooleanUtil.isTrue(isSuccess);
    }

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

业务代码修改:

public Result seckillVoucher(Long voucherId) {
    // 1. 查询优惠卷信息
    SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
    // 2. 判断秒杀是否开始
    if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
        // 秒杀未开始
        return Result.fail("秒杀尚未开始!");
    }
    // 3. 判断秒杀是否结束
    if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
        // 秒杀已结束
        return Result.fail("秒杀已结束");
    }
    // 4. 判断库存是否充足
    if (voucher.getStock() < 1) {
        return Result.fail("库存不足!");
    }
    Long userId = UserHolder.getUser().getId();
    // 改用自己创建的分布式锁进行测试
    // 创建锁对象
    SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
    // 获取锁
    boolean isLock = lock.tryLock(1200L);
    // 判断是否获取锁成功
    if (!isLock) {
        // 获取失败 返回错误或重试
        return Result.fail("不允许重复下单!");
    }
    try {
        IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
        return proxy.createVoucherOrder(voucherId);
    } finally {
        // 释放锁
        lock.unlock();
    }
}

4.4 Redis分布式锁误删

逻辑说明:

持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁

执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是

误删别人锁的情况说明。

解决方案:

解决方案就是在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果属于自己,则不进行锁的删除,假设还是上边的情

况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属

于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于

是删除这把锁。

Redis知识-实战篇(5)_第5张图片

修改:

需求:修改之前的分布式锁实现,满足:在获取锁时存入线程标示(可以用UUID表示)

在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致

  • 如果一致则释放锁
  • 如果不一致则不释放锁

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不

是,则不进行删除。

Redis知识-实战篇(5)_第6张图片

核心代码:

加锁:

 public boolean tryLock(long timeoutSec) {
     // 获取线程的标识
     String threadId = ID_PREFIX + Thread.currentThread().getId();
     // 获取锁
     Boolean isSuccess = stringRedisTemplate.opsForValue()
         .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
     return BooleanUtil.isTrue(isSuccess);
 }

释放锁:

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);
    }
}

有关代码实操说明:

在我们修改完此处代码后,我们重启工程,然后启动两个线程,第一个线程持有锁后,手动释放锁,第二个线程此时进入到锁内部,再

放行第一个线程,此时第一个线程由于锁的value值并非是自己,所以不能释放锁,也就无法删除别人的锁,此时第二个线程能够正确释

放锁,通过这个案例初步说明我们解决了锁误删的问题。

Redis知识-实战篇(5)_第7张图片

Redis知识-实战篇(5)_第8张图片

4.5 分布式锁的原子性问题

更为极端的误删逻辑说明:

线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中。比如他已经拿到了当前这把锁确

实是属于他自己的,正准备删除锁,此时发生阻塞,但是此时线程2进来,当线程1阻塞结束后,他直接就会执行删除锁那行代码,即还是

存在分布式锁误删的情况,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁,比

锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生,必须确保操作是原子性的。

Redis知识-实战篇(5)_第9张图片

4.7 Lua脚本解决多条命令原子性问题

Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。

Lua是一种编程语言,它的基本语法参考网站:https://www.runoob.com/lua/lua-tutorial.html

这里重点介绍Redis提供的调用函数,我们可以使用lua去操作redis,又能保证他的原子性。

Redis提供的调用函数

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

写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:

Redis知识-实战篇(5)_第10张图片

如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从

KEYS和ARGV数组获取这些参数:

image-20220921155922690

用Lua脚本改造的的代码:

-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('get', KEYS[1]) == ARGV[1]) then
    -- 一致 删除
    return redis.call('delete', KEYS[1])
end
-- 不一致 直接返回
return 0

4.8 利用Java代码调用Lua脚本改造分布式锁

我们的RedisTemplate中,可以利用execute方法去执行lua脚本,参数对应关系就如下图

Redis知识-实战篇(5)_第11张图片

核心代码:

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

public void unlock() {
    // 调用lua脚本解决原子性问题
    stringRedisTemplate.execute(
        UNLOCK_SCRIPT,
        Collections.singletonList(KEY_PREFIX + name),
        ID_PREFIX + Thread.currentThread().getId()
    );
}

基于Redis的分布式锁实现思路:

  • 利用set nx ex获取锁,并设置过期时间,保存线程标示

  • 释放锁时先判断线程标示是否与自己一致,一致则删除锁,并且采用lua脚本保证原子性

特性:

  • 利用set nx满足互斥性

  • 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性

  • 利用Redis集群保证高可用和高并发特性

5. 分布式锁-redisson

5.1 redisson功能介绍

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

Redis知识-实战篇(5)_第12张图片

重入问题:重入问题是指获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码

中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以

可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。

不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是,当线程在获得锁失败后,他应该能再次尝试获得锁

**超时释放:**我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的

时候,误删别人的锁,但是毕竟没有锁住,有安全隐患。

主从一致性: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕

机了,就会出现死锁问题。

这时就需要Redisson出场。

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对

象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现

官网地址: https://redisson.org

GitHub地址: https://github.com/redisson/redisson

Redis知识-实战篇(5)_第13张图片

5.2 redisson快速入门

引入依赖:

<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
    <version>3.17.6version>
dependency>

配置Redisson客户端:

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        // 配置
        Config config = new Config();
        config.useSingleServer().setAddress("redis://192.168.150.101:6379")
            .setPassword("123321");
        // 创建RedissonClient对象
        return Redisson.create(config);
    }
}

使用

void testRedisson() throws InterruptedException {
    // 获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    // 尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
    // 判断是否获取成功
    if (isLock) {
        try {
            System.out.println("执行业务");
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}

5.3 redisson可重入锁原理

在redisson的分布式锁中,他采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持

有。

redisson中tryLock中的lua脚本

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', 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])

redisson添加锁对应的逻辑

Redis知识-实战篇(5)_第14张图片

redisson中unLock中的lua脚本

if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;
end ;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2]);
    return 0;
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
    return 1;
end ;
return nil;

redisson删除锁对应的逻辑

Redis知识-实战篇(5)_第15张图片

5.4 redisson锁重试和WatchDog机制

Redis知识-实战篇(5)_第16张图片

Redisson分布式锁原理:

  • 可重入:利用**hash结构记录线程id重入次数**

  • 可重试:利用信号量PubSub功能实现等待、唤醒,获取锁失败的重试机制

  • 超时续约:利用watchDog,每隔一段时间(releaseTime/3),重置超时时间

5.5 redisson锁的MutiLock原理

redis主从中出现的问题

此时我们去写命令,写在主机上,主机会将数据同步给从机,但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机

哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就已经丢掉了。

Redis知识-实战篇(5)_第17张图片

为了解决这个问题,redission提出来了MutiLock锁,使用这把锁咱们就不使用主从了,每个节点的地位都是一样的, 这把锁加锁的逻辑

需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功,假设现在某个节点挂了,那么他去获得锁的时候,只

要有一个节点拿不到,都不能算是加锁成功,就保证了加锁的可靠性。

Redis知识-实战篇(5)_第18张图片

代码实现:

首先配置三个节点

@Bean
public RedissonClient redisClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://×××.×××.×××.××:6379").setPassword("××××××");
    return Redisson.create(config);
}

@Bean
public RedissonClient redisClient2() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://×××.×××.×××.××:6380");
    return Redisson.create(config);
}
@Bean
public RedissonClient redisClient3() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://×××.×××.×××.××:6381");
    return Redisson.create(config);
}

然后进行创建MultiLock锁即可使用

@Resource
private RedissonClient redissonClient;

@Resource
private RedissonClient redissonClient2;

@Resource
private RedissonClient redissonClient3;

@BeforeEach
void setUp() {
    RLock lock1 = redissonClient.getLock("order");
    RLock lock2 = redissonClient2.getLock("order");
    RLock lock3 = redissonClient3.getLock("order");

    // 创建MultiLock锁
    RLock lock = redissonClient.getMultiLock(lock1, lock2, lock3);
}

Redis分布式锁总结

  • 不可重Redis分布式锁

    • 原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
    • 缺陷:不可重入、无法重试、锁超时失效
  • 可重入的Redis分布式锁

    • 原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待

    • 缺陷:redis宕机引起锁失效问题

  • Redisson的multiLock

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

    • 缺陷:运维成本高、实现复杂

你可能感兴趣的:(NoSQL,redis,java,缓存,中间件)