【小众点评项目】 开发日记 DAY 03 使用锁解决秒杀问题

1. 为订单生成全局唯一ID

1.1 原因

当用户进行抢购时,每成功下单一次,都会在order表中产生一条记录,我们需要给每一条订单添加一个全局的唯一ID

1.2 方案

  • 使用数据库的主键自增字段。
    • 这种方式的id太过明显,很容易让用户或者商业对象猜测出我们一天的销量等敏感信息,这显然是不合适的。
    • 单表容量的限制。随着order表中数据原来越多,我们不得不进行拆表操作,这样在高并发环境下多个用户向多个表同时写入信息,就有可能导致ID的重复。
  • 采用UUID
    • UUID是无规律性,会降低数据库索引性能。
  • 利用Redis中的自增运算实现在分布式系统下实现全局唯一ID。
    在这里插入图片描述
  • 唯一性:由于Redis是独立于系统之外的,不同于MySQL的多个表,当很多线程同时访问Redis的时候,也可以保证自增是唯一的。
  • 高可用,高性能:Redis集群,且基于内存。
  • 递增型:Redis中采用递增命令实现。
  • 安全性:为了保证ID安全性,通常会在前面在拼接时间戳等信息。

1.3 全局唯一ID生成器的设计

ID的组成部分:符号位:1bit,永远为0
时间戳:31bit,以秒为单位,可以使用69年。当前时间戳–自定义的起始时间戳
序列号:32bit,秒内的计数器,支持每秒产生2^32个不同ID
在这里插入图片描述

1.4 Redis的Key的设计

因为Redis单个键中值的最大值就是2^64 如果时间久了 肯定会超过这个数值,导致序列号无法继续自增。因此可以每一天设置一个key 一天中订单不可能超过2^64g个。icr:order:2022:11:23 就是一个key,设置成这样的好处是可以根据月份,天和年进行分别查找,因为都是用:隔开了。
Redis中存储的形式

KeyValue
icr:order:2022:11:231(递增)

1.5 代码实现

public class RedisIdWorker {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    public static final Long BEGIN_TIMESTAMP = 1640995200L; // 起始时间戳
    public Long nextId(String prefixKey) {
        // 1.获取当前时间戳
        LocalDateTime now = LocalDateTime.now();
        long currentTimeStamp = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = currentTimeStamp - BEGIN_TIMESTAMP;
        // 2.计算序列号
        // 这里需要说明一下Redis中保存这个自增值的键的命名 首
        // [1]先应该有前缀 "icr:" [2]业务名字,比如订单就是order
        // [3]日期 因为Redis单个键中值的最大值就是2^64 如果时间久了 肯定会超过这个数值,导致序列号无法继续自增
        // 因此可以每一天设置一个key 一天中订单不可能超过2^64
        // icr:order:2022:11:23 就是一个键,设置成这样的好处是可以根据月份,天和年进行分别查找,因为都是用:隔开了
        String data = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // 序列号从0自增,每次增加1
        Long serialCode = stringRedisTemplate.opsForValue().increment("icr:" + prefixKey + data);
        // 3.拼接结果 时间戳由于需要到高32位,因此使用位运算 << 向左移动32位
        // 那么此时时间戳的第32位都为0,然后进行和序列号按位与运算,可以将序列号拼接到时间戳的低32位中,最终形成id
        return timeStamp << 32 | serialCode;
    }
}

2. 优惠券秒杀V1.0

2.1 具体思路

在这里插入图片描述
从上图中可以看出,当用户点击抢购按钮后,前端会向后端发送一个请求,后端Controller接收到请求后处理用户相应即可。在用户下单时需要考虑的因素有

  • 秒杀是否已经开始或已经结束?
  • 当前是否还有库存?

秒杀流程图如下
在这里插入图片描述

2.2 代码实现

@Override
@Transactional
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("库存不足!");
    }
    //5,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //6.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 6.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 6.2.用户id
    Long userId = UserHolder.getUser().getId();
    voucherOrder.setUserId(userId);
    // 6.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    return Result.ok(orderId);
}

2.3 存在的问题

这样的代码看起来没有问题,但是一旦在高并发的情况下运行,那么很有可能出现超卖的现象。

3. 秒杀优惠券V1.1 ---- 解决超卖问题

3.1 超卖问题发生的原因

在这里插入图片描述

如上图所示,目前库存为1。在多个进程并发执行的情况下

  • 线程1访问查询库存,得到的结果是还有库存,因此执行扣减库存操作。
  • 还没有等线程1执行完,线程2查询库存,发现库存有,因此也执行扣减库存操作。
  • 等线程2执行完扣减操作,线程1由于已经判断过是否有库存了,所以直接执行扣减库存操作,导致库存出现-1的情况。

3.2解决方法

使用锁机制解决超卖问题。这里有两种锁:悲观锁和乐观锁。
在这里插入图片描述悲观锁会导致所有的并发访问请求都变成串行化执行,在高并发的情况下会导致效应速度严重下降。因此在应对秒杀的情况,这里使用乐观锁来解决。

3.3版本号机制解决超卖问题

版本号简介
在这里插入图片描述

这种方法在会在记录后面添加一个vision字段,在查询库存的同时会查询vision信息

  • 当线程1访问库存时,查询到的是库存1,vision1。于是它执行扣减库存操作。
  • 还没有等线程1执行完,线程2也去查询,查询到的是库存1,vision1。
  • 此时线程1完成扣减库存操作,update库存,vision++ 。
  • 线程2也扣减库存,在update时发现vision变成了2,不等于之前查到的vision=1,因此放弃这次操作。超卖问题不会发生。
    在这里插入图片描述

代码实现

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1") //set stock = stock -1
            .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();         //where id = ? and stock = ?

这里我们没有使用vision,而是直接使用库存代替了vision。是因为只要修改过库存,那么库存就会发生变化,我们在update的时候只需要判断当前库存和我们当时查询出来的库存是否相等即可。原理和版本号一直。
通过修改成上述代码,可以解决超卖的问题,但是又出现了新的问题。

问题:上述代码的逻辑是:只要我修改库存的时候和我查询的时候库存没有发生变化,那么就可以执行扣减库存的操作。但是通过Jmeter的测试发现,即使还有库存,也会存在很多扣减失败的情况。
原因分析:通过分析上述的逻辑可以得出,假设同时有100个线程同时拿到了100个库存,那么他们拿到的版本号就应该是相同的,此时只能有一个线程扣减库存成功,修改了版本号,其余所有线程会因为版本号不一致而扣减失败。因此我们还需要进一步的优化。

boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update().gt("stock",0); 
            //where id = ? and stock > 0

之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可

4.优惠券秒杀V1.2 – 实现一人一单

4.1问题分析

低价优惠券的目的是为了引流,但是目前我们的代码一个人可以无限次的下单,这样就丧失了优惠券的作用,因此我们需要修改代码,限制每一个用户只能下一单。

4.2流程分析

在之前下单的基础上,我们还需要在库存充足的情况下根据用户id和优惠券id判断用户是否已经下单过了,然后来判断是否可以再次下单。
在这里插入图片描述

4.3初步代码

@Override
@Transactional
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("库存不足!");
    }
    // 5.一人一单逻辑
    // 5.1.用户id
    Long userId = UserHolder.getUser().getId();
    int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
    // 5.2.判断是否存在
    if (count > 0) {
        // 用户已经购买过了
        return Result.fail("用户已经购买过一次!");
    }
    //6,扣减库存
    boolean success = seckillVoucherService.update()
            .setSql("stock= stock -1")
            .eq("voucher_id", voucherId).update();
    if (!success) {
        //扣减库存
        return Result.fail("库存不足!");
    }
    //7.创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 7.1.订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    voucherOrder.setUserId(userId);
    // 7.3.代金券id
    voucherOrder.setVoucherId(voucherId);
    save(voucherOrder);
    return Result.ok(orderId);
}

存在的问题:在使用Jmeter测试的时候发现一个用户仍然可以多次购买优惠券。
原因分析:

  • 当一个用户多次点击购买,那么就会产生多个请求,每一个请求对应一个线程
  • 当线程1判断用户不存在购买记录时,执行下单的代码
  • 此时线程2进来,也去判断用户是否存在购买记录,由于此时线程1还没执行完下单操作,所以线程2也判断用户没有购买记录,于是也进入下单代码。这就导致了多次下单的问题。

4.4 代码优化

上述问题的本质还是并发线程安全问题,因此还是需要通过加锁来实现。这就要考虑使用悲观锁还是乐观锁。和之前下单不同的时,这儿我们是插入数据而不是更新数据,因此无法使用乐观锁来判断。因此这里我们使用悲观锁来解决这个问题。

  • STEP 1:将查询用户是否已经下单到最后的下单这一段代码封装成一个方法。并加上synchronized的关键字。
    • 存在的问题:如果将synchronized加在非静态方法上,那么这个synchronized的锁监视器就是this,而this又是VoucherOrderServiceImpl,他是单例的,也就是说所有的线程在访问的时候都需要获取锁,导致秒杀活动变成串行执行的。因此我们需要修改锁的粒度。
  • STEP 2 根据分析,我们这个锁是针对的同一个用户的不同线程需要获取锁,而对于不同用户这个锁应该是不生效的。因此我们考虑将锁的监视器设置为UserId。

根据上述分析我们修改代码

@Transactional
public  Result createVoucherOrder(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    synchronized(userId.toString().intern()){
         // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // 5.2.判断是否存在
        if (count > 0) {
            // 用户已经购买过了
            return Result.fail("用户已经购买过一次!");
        }
        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id", voucherId).gt("stock", 0) // where id = ? and stock > 0
                .update();
        if (!success) {
            // 扣减失败
            return Result.fail("库存不足!");
        }
        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 7.1.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 7.2.用户id
        voucherOrder.setUserId(userId);
        // 7.3.代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        return Result.ok(orderId);
    }
}

intern() 这个方法是从常量池中拿到数据,如果我们直接使用userId.toString()
他拿到的对象实际上是不同的对象,new出来的对象,我们使用锁必须保证锁必须是同一把,所以我们需要使用intern()方法

public static String toString(long i) {
    int size = stringSize(i);
    if (COMPACT_STRINGS) {
        byte[] buf = new byte[size];
        getChars(i, size, buf);
        return new String(buf, LATIN1);
    } else {
        byte[] buf = new byte[size * 2];
        StringUTF16.getChars(i, size, buf);
        return new String(buf, UTF16);
    }
}

上述代码还存在问题,原因在于当前方法被spring的事务控制,如果你在方法内部加锁,可能会导致当前方法事务还没有提交,但是锁已经释放也会导致问题。
因此我们需要把锁提到方法的外部,等方法执行完提交事务后再将锁释放
在这里插入图片描述
但是上述代码还存在问题,因为Spring是通过代理来实现事务管理的,因此我们这里通过this调用方法会使得Spring的事务管理失效,因此这里我们不能使用this,而是获取到this对象的代理类对象,通过它调createVoucherOrder方法来执行。
在这里插入图片描述
注意 在使用AopContext.currentProxy()方法时需要在pom中引入

<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
    <version>1.6.6</version>
</dependency>

在启动类添加这个注解

@EnableAspectJAutoProxy(exposeProxy = true)

5.优惠券秒杀V1.3 – 一人一单 集群并发环境下的问题

5.1原因分析

上一章中采用同步代码块在单台服务器的环境下是可以正常工作的,但是在集群中,同步代码块就会失效。原因是因为锁监视器在多个JVM中是不共享的。
由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。
在这里插入图片描述

5.2分布式锁

分布式锁的核心思想就是保证大家都使用同一个锁监视器,只要使用同一个锁监视器,那么就可以在分布式的环境下锁住线程。
在这里插入图片描述
分布式锁应该满足的条件

  • 可见性:多个线程可以看到相同的结果。多个线程之间都可以感受到锁监视器状态的变化
  • 互斥性:这是锁的基本原则
  • 高可用,高性能:Redis基于内存,性能很好
  • 安全性:使用安全。指的是一旦出现故障,会不会有死锁。
    在这里插入图片描述

5.3利用Redis实现分布式锁

5.3.1实现分布式锁时需要实现的两个基本方法:
  • 获取锁
    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false
  • 释放锁:
    • 手动释放
    • 超时释放:获取锁时添加一个超时时间。防止服务宕机后产生死锁问题。
5.3.2核心思路

核心思想是利用了Redis的setnx方法,当多个线程进入时,只有一个线程能够执行setnx方法返回值为true,其余线程因为key已经存在返回的都是false,这就实现互斥。
另外,当该用户的其他线程得到的结果是false的时候,应该直接返回"一个用户只能下一单"的提示,而不是继续等待。

5.3.3 分布式锁V1.0

接口

public interface ILock {
    boolean tryLock(Long timeout);
    void unlock();
}

通过setnx方法加锁实现互斥,并且设置过期时间防止死锁。这里redis里面存储的锁的value实际上没有要求,但是这里线程id在是为了后续继续优化的目的

public class SimpleRedisLock implements ILock {
    private static final String KEY_PREFIX="lock:"
    private StringRedisTemplate stringRedisTemplate;
    private String name; // 业务名称 在秒杀中就是"order"
    public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.name = name;
    }
    
    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标示
        String threadId = Thread.currentThread().getId()
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }
}

释放锁,直接在redis中删除相应的key即可

public void unlock() {
    //通过del删除锁
    stringRedisTemplate.delete(KEY_PREFIX + name);
}
@Override
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(1200);
//加锁失败
        if (!isLock) {
            return Result.fail("不允许重复下单");
        }
        try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            //释放锁
            lock.unlock();
        }
}

5.4Redis实现分布式锁的误删问题

5.4.1问题说明
  • 假设线程1已经获取到了锁,但是在执行业务的时候发生了较长时间的阻塞,导致锁到时间自动释放了。
  • 此时线程2尝试获取锁并成功,然后开始执行业务。
  • 此时线程1执行完业务,然后直接释放掉了线程2锁获取的锁。
  • 线程3此时也尝试获取锁并成功。

问题的本质是一个线程删除掉了不属于他的锁。

5.4.2问题的解决

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

5.4.3代码实现

核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
另外由于目前是分布式系统,因此pid号并不一定唯一,因此我们需要将pid号的前面拼接一个UUID来保证唯一性。
在这里插入图片描述
具体代码如下

private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {
   // 获取线程标示
   String threadId = ID_PREFIX + Thread.currentThread().getId();
   // 获取锁
   Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
   return Boolean.TRUE.equals(success);
}
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);
    }
}

至此,Redis实现分布式锁误删问题似乎已经解决了,但是还有一种更加极端的情况。

5.5解锁操作的原子性问题

5.5.1问题分析
  • 考虑一种最极端的情况,当线程1已经判断完这个锁属于自己后,正要执行删除的命令,结果阻塞了,然后锁过期了。
  • 此时线程2申请锁成功,然后开始执行业务。
  • 线程1醒来以后,因为已经判断完这个锁属于自己了,就直接执行删除key操作,导致把线程2的锁给删除了。

产生这种现象的原因线程1的拿锁,比锁,删锁,实际上并不是原子性的,我们要防止刚才的情况发生

5.5.2使用Lua脚本来解决多条命令原子性的问题

我们将之前我们写在unlock方法中的逻辑搬到lua脚本中编写。

local key = KEYS[1]-- 使用这个key到redis读取标识 UUID + ThreadId
local threadId = ARGV[1] -- 传递过来的当前的 UUID + ThreadId
-- 从redis中根据key读取出id
local id = redis.call("get", key);
if(id == threadId) then
   -- 如果相等则释放锁
   return redis.call("del", key);
end
return 0;

改造SimpleRedisLocker代码

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
// 这里我们使用静态代码块初始化读取lua脚本,避免每次删除锁都需要读取导致效率降低的问题
    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());
}

6. 可重入锁的原理

底层采用Redis的哈希存储方式,除了存储以 lock:order:userId作为key,以字段名threadId值为statsu的变量作为值。当线程获取锁的时候,当重入时会先判断一下当前获取锁的线程是不是threadid里面的线程,如果是则status+1
当释放锁的时候,首先判断是不是自己获取的锁。如果是,将statsu-1,然后判断status是不是为0,如果此时为0,则释放锁,否则不释放锁。

下面是使用Lua脚本实现的加锁和解锁的代码

local key = KEYS[1] -- 锁的key 如lock:order+userID
local threadId = ARGV[1] -- 由UUID和当前线程ID拼接的线程唯一标识
local releaseTime = ARGV[2] -- 超时时间

-- 1.判断锁是否存在
if (redis.call('exists', key) == 0) then
    -- 2.锁不存在 获取锁
    redis.call('hset', key, threadId, '1')
    -- 3.设置有效期
    redis.call('expire', key, releaseTime)
    return 1
end

-- 2.如果锁已经存在 则判断是否是自己的锁
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
-- 这里redis的哈希存储的field就是ThreadId, 而field里面的值就是计数器的值
-- redis.call('hexists', key, threadId) == 1
-- 这里通过threadId获取里面计数器的值,如果threadId是自己的 就能获取到 所以返回1 否则返回0
local key = KEYS[1] -- 锁的key 如lock:order+userID
local threadId = ARGV[1] -- 由UUID和当前线程ID拼接的线程唯一标识
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,如果不为0,更新过期时间
if (count > 0) then
    redis.call('expire', key, releaseTime)
    return nil
else
    redis.call('del', key)
end

7.Redisson实现分布式锁的原理

  • 可重入:使用哈希结构配合status实现
  • 可重试,获取锁失败后隔一段时间重新获取锁。利用了信号量和PubSub机制
  • 超时续约。当一个线程业务还没有执行完的时候会自动延长时间。看门狗机制。
  • 看门狗机制只有在使用Redisson创建锁的时候不指定锁的过期时间的时候才会生效。
  • 如果有一个线程获取到了锁,此时redis宕机了,那么会产生死锁吗。不会产生,redis宕机,则定时任务不会再次执行,那么锁过了30s自然释放。

8.利用Redis实现分布式锁的总结

实现的思路

  • 利用Redis中的setnx命令,并加以设置过期时间。
    • setnx满足互斥性,多个线程执行只有一个返回true
    • 使用过期时间可以保证出现故障后锁依然可以释放,不会产生死锁问题。
    • 利用redis集群来提高可用性。
  • 释放锁的时候需要判断当前的锁是不是自己加的,只有当前的锁是自己加的才可以删除。
  • 删除锁的动作需要具备原子性,因此我们使用了Lua脚本实现多条指令的原子性。

9.分布式锁-redission锁的MutiLock

问题:为了提高redis的可靠性,通常会搭建主从集群来扩展redis。试想一下下面的情况:

  • 线程1获取锁,redis执行写操作,将锁从master上写入,而由于redis主从之间同步信息是需要时间的,主机上的信息还没有完全同步到从机上,结果主机宕机了。
  • 此时根据哨兵机制,会从从机上选择一个作为新的主机,而新的主机上还没有保存之前的锁,就造成了线程安全问题。
    在这里插入图片描述
    解决方案:使用redisson中的MutiLock。
    原理:不在使用主从机制。而是所有的redis都是地位相同的节点。此时获取锁需要分别从3个redis结点中获取锁,只有3个结点都写入锁成功,才算获取到锁。
    在这里插入图片描述

10.关于锁的总结

  • 我们自定义的锁
    • 原理:
      • 利用setnx的互斥性实现。
      • 使用ex避免死锁;
      • 利用UUID+线程标识防止线程误删不属于自己的锁。
      • 利用哈希结构实现可重入
    • 缺陷:无法重试,锁超时无法续期
  • Redisson分布式锁
    * 利用哈希结构实现重入
    * 利用看门狗机制实现续期
    * 利用信号量控制锁重试
  • MutiLock
    • 利用redis多个节点,只有在所有节点上都获取锁成功才成功
    • 运维成本高
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值