Redis学习笔记(二)

Redis学习笔记(续)

接上一篇笔记:https://blog.csdn.net/weixin_44780078/article/details/130208505

文章目录

    • Redis学习笔记(续)
      • 十、优惠卷秒杀问题
        • 1 全局ID生成器
        • 2 优惠券秒杀
        • 3 一人一单功能
        • 4 分布式锁
        • 5 基于Redis的分布式锁优化
        • 6 Redisson 快速入门
        • 7 Redisson 解决setnx的四大问题
        • 8 Redisson 分布式锁原理总结
      • 十一、优惠卷秒杀优化
      • 十二、Redis中消息队列
        • 1 基于 List 的消息队列
        • 2 基于PubSub的消息队列
        • 3 基于 Stream 的消息队列
          • Stream入门:
          • Stream消费者组:
          • Pending 等待列表:


十、优惠卷秒杀问题

对于商城项目,每个商城都会有优惠券。而订单表如果使用数据库自增ID就存在一些问题:
1、id的规律性太明显。
2、受到单表数据量的限制。

1 全局ID生成器

全局id生成器,是一种在分布式系统下用来生成全局唯一id的工具,一般要满足下列特性:

  • 唯一性:id必须唯一。
  • 高可用:任何时候都能生成id,高可用。
  • 高性能:生成id必须要高效,效率不能低下。
  • 递增性:由于用来代替数据库id自增,因此也要满足递增特性。
  • 安全性: 数据库id自增呈现规律性,容易让用户猜测一些敏感信息。

考虑到redis的 incr 命令,因此可以使用redis来生成id。但是为了增加id的安全性,我们可以不直接使用redis的自增的数值,而是拼接一些其他数值。
在这里插入图片描述
id的组成部分:

  • 符号位:1bit,永远为0。
  • 时间戳:31bit,以秒为单位,可以使用69年。
  • 序列号:32bit,秒内的计数器,支持每秒产生2^32个不同id。
@Component
public class RedisIdWorker {

    // 开始秒数-2022年1月1日0时0分0秒
    private static final long BEGIN_TIMESTAMP = 1640995200L;

    // 序列号位数
    private static final int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public long nextId(String keyPrefix) {
        // 1.生成时间戳,以秒为单位
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC); // 当前秒数
        long timestamp = nowSecond - BEGIN_TIMESTAMP; // 当前秒数 - 2022年1月1日0时0分0秒的秒数
        // 2.生成序列号
        // 获取当前日期,精确到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyymmdd"));
        long count = stringRedisTemplate.opsForValue().increment("incr:" + keyPrefix + ":" + date);

        // 3.拼接并返回
        return timestamp << COUNT_BITS | count;
    }

}

全局唯一id生成策略:

  • UUID
  • Redis自增
  • snowflake算法(雪花算法)
  • 数据库自增

2 优惠券秒杀

实现优惠券秒杀的下单功能。

下单时需要判断两点:

  • 1、秒杀是否开始或结束,如果尚未开始或已经结束则无法下单。
  • 2、优惠券库存是否充足,不足则无法下单。
    Redis学习笔记(二)_第1张图片
    伪代码:
    @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();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        log.info("抢购成功,订单id===>{}",orderId);
        return Result.ok(orderId);
    }

超卖问题:使用JMeter压力测试,库存50,线程200,发现数据库中订单有59条,库存变成了-9。

这是因为在多线程并发的场景下,线程之间不可能完全按照顺序执行,普通情况下可能存在线程1:查询库存->库存大于0->扣减库存。特殊情况:线程1:查询库存->线程2:查询库存->线程1,2查询的库存都大于0->线程1扣除库存->线程2扣除库存。这时候就会出现超卖问题。

解决方案:加锁

  • 悲观锁:悲观锁认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。例如Synchronized、Lock 都属于悲观锁。
  • 乐观锁:乐观锁认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断(如何判断是重点)有没有其它线程对数据做了修改。
    • 如果没有修改则认为是安全的,自己才更新数据。
    • 如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常。

悲观锁伪代码:加上 synchronized 关键字

	@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();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(1001l);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        log.info("抢购成功,订单id==={}",orderId);
        return Result.ok(orderId);
    }

由于百度看到一篇博客,强调synchronized不要和@Transactional一起使用,博客链接:https://blog.csdn.net/weixin_42822484/article/details/107923220 因此本次演示我把 synchronized 关键字加在controller层演示。
controller层代码:

    @PostMapping("/seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        try {
            synchronized (this) {
                return voucherOrderService.seckillVoucher(voucherId);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return Result.fail("synchronized遇到错误");
        }
    }

加上互斥锁后,再次使用JMeter进行压力测试,发现超卖问题得到解决。


乐观锁伪代码:乐观锁的关键是判断之前查询得到的数据是否有被修改过,常见的方式有两种。

  • 版本号法:新增版本作为区分。
    Redis学习笔记(二)_第2张图片
  • CAS法:根据查询出来的库存作为判断条件。
    Redis学习笔记(二)_第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.扣减库存
        boolean success = seckillVoucherService.update()
        // 解决位置
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id",voucherId) // where voucher_id = ?
                .gt("stock",0) // and stock > 0
                .update();
        if (!success) {
            return Result.fail("扣减失败");
        }
        // 6.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
//        Long userId = UserHolder.getUser().getId();
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(1001l);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 7.返回订单id
        log.info("抢购成功,订单id==={}",orderId);
        return Result.ok(orderId);
    }

但是悲观锁和乐观锁,都各有优缺点:

  • 悲观锁:添加同步锁,让线程串行执行。
    • 优点:简单粗暴。
    • 缺点:性能一般。
  • 乐观锁:不加锁,在更新时判断是否有其它线程在修改。
    • 优点:性能好。
    • 缺点:存在成功率低的问题。

3 一人一单功能

对于秒杀的优惠券,应该设置一人只能抢一张的功能。以免所有优惠券被一人独享,失去推广宣传的意义。

Redis学习笔记(二)_第4张图片
伪代码:

加入依赖:

    <dependency>
        <groupId>org.aspectjgroupId>
        <artifactId>aspectjweaverartifactId>
    dependency>

启动类加上注解:

@EnableAspectJAutoProxy(exposeProxy = true) // 开启AOP功能

实现类:

    @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("库存不足");
        }
        // 5.一人一单
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        }
    }
    
    @Transactional
    public Result createVoucherOrder(Long voucherId, Long userId) {
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) { // 已存在,拒绝再次抢购
            return Result.fail("已秒杀过优惠卷,拒绝再次抢购");
        }
        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                // 解决位置
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id",voucherId) // where voucher_id = ?
                .gt("stock",0) // and stock > 0
                .update();
        if (!success) {
            return Result.fail("扣减失败");
        }
        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 8.返回订单id
        log.info("抢购成功,订单id===>{}",orderId);
        return Result.ok(orderId);
    }

虽然代码中使用了 synchronized 同步锁,但是在集群模式下多线程还是会出现线程安全问题:
正常情况:
Redis学习笔记(二)_第5张图片
集群模式下:由于是集群,因此就有多台jvm,线程1、2为一台jvm,线程3、4为一台jvm,不同jvm之间 synchronized 锁是互不影响的,因此线程1和线程3都会获取锁成功,因此又出现了线程安全问题,因此需要一种能在不同jvm之间实现同步锁,这就是 分布式锁。
Redis学习笔记(二)_第6张图片

4 分布式锁

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

多线程模式下演示图:
Redis学习笔记(二)_第7张图片
要想实现分布式锁,必须满足的条件有:多进程可见、互斥、高可用、高性能、安全性等等。分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有以下三种:

分布式锁 MySql Redis Zookeeper
互斥 利用mysql本身的互斥锁机制 利用setnx这样的互斥命令 利用节点的唯一性和有序性实现互斥
高可用
高性能 一般 一般
安全性 断开连接,自动锁解锁 利用锁超时时间,到期释放 断开节点,断开连接自动释放

基于redis的分布式锁:
实现分布式锁时要实现的两个基本方法:

  • 获取锁
    互斥:确保只能有一个线程获取锁
    非阻塞:尝试一次,成功返回true,失败返回false

    // 利用setnx的互斥特性
    setnx lock thread1
    // 添加超时到期
    expire thread1 10

  • 释放锁
    手动释放
    超时释放:避免服务宕机过后,没有释放掉锁,导致其他线程长时间得不到锁

    // 释放锁,删除即可
    del thread1

分布式锁流程图
Redis学习笔记(二)_第8张图片
代码实现分布式锁初级版本:

ILock.java

 /**
 * redis实现分布式锁
 */
public interface ILock {

    /**
     * @param timeoutSec 锁的有效时间,过期自动释放
     * @return
     */

    boolean tryLock(long timeoutSec);

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

}

SipmleRedisLock.java

public class SipmleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

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

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = String.valueOf(Thread.currentThread().getId());
        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        // 这里涉及到Boolean自动拆箱的问题
        return Boolean.TRUE.equals(success);
    }

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

一人一单抢购-伪代码

@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @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("库存不足");
        }
        // 5.一人一单
        Long userId = UserHolder.getUser().getId();

        // 采用分布式锁
        SipmleRedisLock lock = new SipmleRedisLock("order:" + userId, stringRedisTemplate);
        boolean isLock = lock.tryLock(1200);
        if (!isLock) {
            // 获取锁失败
            return Result.fail("不允许重复下单");
        }
        try {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId, userId);
        } finally {
            lock.unLock();
        }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId, Long userId) {
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) { // 已存在,拒绝再次抢购
            return Result.fail("已秒杀过优惠卷,拒绝再次抢购");
        }
        // 6.扣减库存
        boolean success = seckillVoucherService.update()
                // 解决位置
                .setSql("stock = stock - 1") // set stock = stock - 1
                .eq("voucher_id",voucherId) // where voucher_id = ?
                .gt("stock",0) // and stock > 0
                .update();
        if (!success) {
            return Result.fail("扣减失败");
        }
        // 7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        // 8.返回订单id
        log.info("抢购成功,订单id===>{}",orderId);
        return Result.ok(orderId);
    }

}

但是这种情况还是存在问题:
Redis学习笔记(二)_第9张图片


改进的分布式锁:

在获取锁时存入现场标识(可以用UUID表示)。
在释放锁时先获取锁中的线程标识,判断是否与当前线程标识一致。如果一致则释放锁,不一致则不释放锁。

改进后流程图:
Redis学习笔记(二)_第10张图片

改造后的分布式锁代码:SipmleRedisLock.java

public class SipmleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

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

    private static final String KEY_PREFIX = "lock:";
    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);
        // 这里涉及到Boolean自动拆箱的问题
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        // 获取线程中的锁标识
        String lockId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (threadId.equals(lockId)) {
            // 释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

现在就不存在线程误删不属于自己的锁了,但仍然存在问题:
Redis学习笔记(二)_第11张图片


最终改进的分布式锁:之前遇到的问题是判断锁标识和释放锁没有达到原子性造成的,因此可以确保这两个操作的原子性解决。

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

redis中提供了调用lua的函数:redis.call(‘命令名称’,‘key’,‘其它参数’,…)

比如一下lua脚本:

redis.call('set','name','jack')
local name = redis.call('get','name')
return name

redis调用Lua脚本:

// 0代表传参的数量
EVAL "return redis.call('set', 'name', 'jack')" 0

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

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

所以采用Lua脚本执行以下操作:

  • 获取锁中的线程标识;
  • 判断redis中的线程标识和当前线程标识是否一致;
  • 如果一致就释放锁;
  • 不一致就什么都不做;

因此采用lua脚本来实现分布式锁:
lua脚本如下:

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

SipmleRedisLock.java

public class SipmleRedisLock implements ILock {

    private String name;
    private StringRedisTemplate stringRedisTemplate;

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

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
    // 静态块先加载lua脚本,避免每次线程获取锁时都去调用脚本
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

    @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);
        // 这里涉及到Boolean自动拆箱的问题
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        // 获取线程中的锁标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();   
        // 调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                threadId
        );
    }
}

redis分布式锁总结:

基于Redis的分布式锁实现思路:
    利用set nx ex获取锁,并设置过期时间,保存线程标识;
    释放锁时先判断线程标识是否与自己一致,一致则删除锁;
特性:
    利用set nx满足互斥性;
    利用set ex保证故障时锁依然能释放,避免死锁,提高安全性利用Redis集群保证高可用和高并发特性;


5 基于Redis的分布式锁优化

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

  • 不可重入问题:同一个线程无法多次获取同一把锁;
  • 不可重试问题:获取锁只尝试一次就返回false,没有重试机制;
  • 超时释放问题:锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患;
  • 主从一致性问题:如果Redis提供了主从集群,主从同步存在延迟,当主节点宕机时,如果从节点还未同步主节点的锁数据,则锁就会出问题;

基于这些问题,此处引入Redisson:

Redisson是一个在Redis的基础上实现的Java驻内存数据网格 (In-Memory Data Grid)。它不仅提供了一系列的分布的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。

Redis学习笔记(二)_第12张图片
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson

因此以后使用分布式锁可直接使用Redisson


6 Redisson 快速入门

  1. 引入依赖:(不推荐使用与SpringBoot整合的依赖)
<dependency>
	<groupId>org.redissongroupId>
	<artifactId>redissonartifactId>
	<version>3.16.8version>
dependency>
  1. 配置Redisson客户端:
@Configuration
public class RedisConfig {
    @Bean
    public RedissonClient redissonClient() {
        // 配置类
        Config config = new Config();
        // 加redis地址,这里加了单点的地址,也可以使用config,useClusterServers()添加集群地址
        config.useSingleServer().
                setAddress("redis://192,168,91,8:6379").
                setPassword("123456");
        // 创建客户端
        return Redisson.create(config);
    }
}
  1. 直接调用即可:
    RLock lock = redissonClient.getLock("lockName"); // 指定锁的名字
    boolean isLock = lock.tryLock(); // 加锁
    lock.unlock(); // 释放锁

7 Redisson 解决setnx的四大问题

上面已介绍了setnx分布式锁存在的如下问题:

  • 不可重入问题:同一个线程无法多次获取同一把锁;
  • 不可重试问题:获取锁只尝试一次就返回false,没有重试机制;
  • 超时释放问题:锁超时释放虽然可以避免死锁,但如果业务执行耗时较长,也会导致锁释放,存在安全隐患;
  • 主从一致性问题:如果Redis提供了主从集群,主从同步存在延迟,当主节点宕机时,如果从节点还未同步主节点的锁数据,则锁就会出问题;

现详细介绍 Redisson 是如何解决这四种问题的!

  1. 不可重入问题:采用hash数据结构记录重入次数。

Redisson可重入锁采用hash结构的key-value进行存储,由于value可存入多个字段,以key为锁名,value存储当前线程标识和重入次数:

Redis学习笔记(二)_第13张图片
释放锁时并不直接删除该锁,而是对重入次数进行减一,直到次数为0时才删除。加锁与释放锁的流程图如下:
Redis学习笔记(二)_第14张图片

Redisson底层用Lua脚本来保证了锁各操作的多条 redis 命令的原子性。

Redis学习笔记(二)_第15张图片

  1. 不可重试问题:redissonClient.getLock(“xxxLock”).tryLock()方法支持传入三个参数
    /**
     * @param waitTime 失败重试时间
     * @param leaseTime 锁自动失效释放时间
     * @param unit 传入的时间单位
     */
    boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;

这三个参数可以都不传,也可以只传waitTime和TimeUnit ,也可以同时传这三个参数。
下面以传递waitTime和TimeUnit两个参数一步步进入底层代码进行分析:

步骤一:同时按住ctrl+alt+鼠标左键,点击tryLock,就会进入底层源码:

Redis学习笔记(二)_第16张图片

步骤二:按ctrl键,找到主要的tryLock方法:
在这里插入图片描述

步骤三:此处代码讲解:
time:把失败重试时间转为毫秒;
current:记录当前时间,毫秒;
threadId:当前线程标识;
ttl:此处的ttl是重点代码,因此进入tryAcquire继续分析。
Redis学习笔记(二)_第17张图片

步骤四:进入tryAcquire方法:
在这里插入图片描述

Redis学习笔记(二)_第18张图片
发现进入tryAcquireOnceAsync()方法后是根据 leaseTime 来做判断,其实在这个类中已经先对 leaseTime 进行了处理:没传值时默认就是 -1,传了值默认就是30秒,并且有一个定时任务每隔10秒重置 leaseTime 等于30(下面有讲解)。

在这里插入图片描述

继续点击 internalLockLeaseTime,进入后发现传了 leaseTime 参数后,只要不为-1, internalLockLeaseTime 默认都是30秒,并且替代 leaseTime :
Redis学习笔记(二)_第19张图片
Redis学习笔记(二)_第20张图片

步骤五:回到步骤四的 private RFuture tryAcquireAsync方法,此处的 internalLockLeaseTime 默认是30秒,并且替代 了传入的 leaseTime :
Redis学习笔记(二)_第21张图片

进入满足条件的 tryLockInnerAsync:最终看到操作redis的lua脚本,lua脚本此处是写死的,我们一句句分析:

Redis学习笔记(二)_第22张图片

// 可能大家对lua语法不太熟悉,此处我转换成熟悉的if形式供大家分析
if (redis.call('exists', KEYS[1]) == 0) { // 判断步骤一传入的锁name是否存在,等于0表示不存在
    /**
    * 不存在就存入hash结构
    * key(KEYS[1]):步骤一传入的锁name
    * value(ARGV[2])- field:线程标识
    * 重入次数+1
    */ 
	redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    /**
    * 设置key的过期时间(ARGV[1]):waitTime
    */ 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; // 获取锁成功,返回nil,就是null
}

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) { // 如果锁存在,并且判断锁标识是否是当前线程的。等于1标识锁存在
    /**
    * 存在且属于当前线程,重入次数+1
    */ 
 	redis.call('hincrby', KEYS[1], ARGV[2], 1);
    /**
    * 设置过期时间:waitTime
    */ 
  	redis.call('pexpire', KEYS[1], ARGV[1]);
  	return nil; // 获取锁成功,返回nil,就是null
}

// 如果获取锁失败,证明已存在锁,则返回锁的过期时间
// pttl:返回key有效期的毫秒值
return redis.call('pttl', KEYS[1]);

经过上述分析,就得到了步骤三的 ttl,如果 ttl 为null,返回true代表获取锁成功,否则返回的是锁的过期时间(毫秒值)。

如果传入的等待时间 - 获取锁失败耗时还有空余,则代表还有时间去尝试重新获取锁,但此时也不是立即就去重新获取,因为加锁的那个线程可能还在执行业务代码,锁还未释放,立即重新获取势必也会失败。subscribe代码代表订阅加锁的那个线程的释放锁信号。

unlock释放锁时发布的信号如下:
Redis学习笔记(二)_第23张图片

如果释放了锁,就可以一直循环去重新获取,没释放并就等待,可重试问题就这样解决了。


  1. 超时不释放问题:redisson 底层有一个 scheduleExpirationRenewal 定时任务方法,因为 internalLockLeaseTime 默认是30秒,因此在定时任务方法里每隔10秒就更新 internalLockLeaseTime ,重新变成30秒,只有锁没有释放就一直循环下去,因此永远也不会超时。

Redis学习笔记(二)_第24张图片

获取以及释放锁时的流程图如下:Redis学习笔记(二)_第25张图片

  1. 主从不一致问题:在生产环境中,redis可能会搭建集群,一般在主节点进行写入操作,从节点进行读操作,因此需要主节点同步数据到从节点。但是就是因为在主从同步的时候,存在时间的延迟,这个延迟时间间隔不论多快,终究也是存在延迟,假如在主节点写入锁成功,还没进行主从同步时,主节点出现故障,因此从节点读取不到锁,导致锁失效问题发生。

由于锁失效问题是由主从同步不一致导致,因此我们取消主从节点,把redis各节点改为平行节点,这样就算某一个节点宕机,锁依然还是有效的。当其他线程来获取锁时,只有所有节点的锁都获取锁成功,才算获取锁成功;所有节点释放锁成功,才算释放锁成功。这个由多个锁组成的新锁在redision中也有一个新的名字:multiLock(连锁)。

Redis学习笔记(二)_第26张图片
multiLock使用入门:

	// 模拟三台redis集群
    lock1 = redissonClient1.getLock("lockName");
    lock2 = redissonClient2.getLock("lockName");
    lock3 = redissonClient3.getLock("lockName");
    lock = redissonClient1.getMultiLock(lock1, lock2, lock3);
    // 获取锁
    boolean isLock = lock.tryLock();
    // 释放锁
    lock.unlock(); 

8 Redisson 分布式锁原理总结

  • 可重入:利用hash结构记录线程id和重入次数;
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制;
  • 超时续约:利用watchDog,每隔一段时间 (releaseTime / 3) = 10秒,重置超时时间;
  • 主从一致:取消主从节点,设置成平行节点,然后使用 multiLock(连锁),当所有集群的锁获取成功才算获取锁成功,所有节点的锁释放成功才算释放锁成功;

十一、优惠卷秒杀优化

由于秒杀优惠卷的整个流程步骤较多,容易造成效率低下,因此把部分步骤迁移至redis中做处理,由于redis的性能较高,能提升整个业务的效率。

Redis学习笔记(二)_第27张图片
并且在redis中采用Lua脚本来保证多条redis命令的原子性:
Redis学习笔记(二)_第28张图片

需求:

  • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中。
  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列。
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。

对于阻塞队列,此处采用Redis中的stream作为消息队列,实现异步下单:

  • 创建一个stream类型的消息队列,名为stream.orders。
  • 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherld,userld、orderld。
  • 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单。

秒杀伪代码改造:

// TODO


十二、Redis中消息队列

消息队列,字面意思就是存放消息的队列。

1 基于 List 的消息队列

Redis的 list 数据结构是一个双向链表,很容易模拟出队列效果。队列是入口和出口不在一边,因此我们可以利用: LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。

blpop 或 brpop监听消息的时候,需要设置一个有效时间:

lpush q1 a b c // 先依次存入队列q1,元素为 a b c
brpop q1 10  // 取的时候需要设置有效时间,不然会报错

优点:

  • 利用Redis存储,不受限于JVM内存上限;
  • 基于Redis的持久化机制,数据安全性有保证;
  • 可以满足消息有序性;

缺点:

  • 无法避免消息丢失(当我拿走了消息,但是没有处理就挂掉了);
  • 只支持单消费者(一条消息被拿走了就删除没有了);

2 基于PubSub的消息队列

Pubsub (发布订阅) 是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
subscribe channel :订阅一个或多个频道
publish channel msg :向一个频道发送消息
psubscribe pattern : 订阅与pattern格式匹配的所有频道

基于PubSub的消息队列有哪些优缺点?

优点:

  • 采用发布订阅模型,支持多生产、多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

3 基于 Stream 的消息队列

Stream入门:

Stream 是 Redis 5.0引入的一种新数据类型,可以实现一个功能非常完善的消息队列,支持数据持久化。

发送消息命令:xadd
Redis学习笔记(二)_第29张图片
例如:
在这里插入图片描述
需要提供key,消息ID方案,消息内容,其中消息内容为key-value型数据。 ID,最常使用 *,表示由Redis生成消息ID,这也是强烈建议的方案。 field string [field string…],就是当前消息内容,由1个或多个key-value构成。

读取消息方式之一:xread
Redis学习笔记(二)_第30张图片
例如:使用xread读取第一个消息
Redis学习笔记(二)_第31张图片

xread读消息时分为阻塞和非阻塞模式,使用BLOCK选项可以表示阻塞模式,需要设置阻塞时长。非阻塞模式下,读取完毕(即使没有任何消息)立即返回,而在阻塞模式下,若读取不到内容,则阻塞等待。

以阻塞的方式读取最新消息:
在这里插入图片描述
在实际开发中,我们可以循环调用xread阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:

    while (true) {
        // 以阻塞的方式读取队列中的消息,最多等待2秒
        Object msg = redis.execute("xread count 1 block 2000 streams user $");
        if (msg == null) {
            continue;
        }
        // 处理消息
        handleMessage(msg);
    }

但是,当我们指定id为 $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现 漏读 消息的问题。

stream 类型消息队列的 xread 命令特点:

  • 消息可回溯。
  • 一个消息可以被多个消费者读取。 !!!
  • 可以阻塞读取。
  • 有消息漏读的风险。 !!!

由此可见还是存在多种弊端,因此,需要寻找另一种方案:消费者组。

Stream消费者组:

消费者组(Consumer Group):当多个消费者同时消费一个消息队列时,就会重复的消费相同的消息,假如消息队列中有10条消息,三个消费者都会重复去消费这10条消息。因此将多个消费者划分到一个组中,监听同一个队列。消费者组具备下列特点:

Redis学习笔记(二)_第32张图片
消费者组模式的支持主要由两个命令实现:

  • XGROUP:用于管理消费者组,提供创建组,销毁组,更新组起始消息ID等操作。
  • XREADGROUP:分组消费消息操作。

创建消费者组:
Redis学习笔记(二)_第33张图片
其它常见命令:

  1. 先向队列mq中插入5条消息
127.0.0.1:6379> MULTI // muti是原子操作,保证以下5条指令同时执行
OK
127.0.0.1:6379> XADD mq * msg 1
QUEUED
127.0.0.1:6379> XADD mq * msg 2
QUEUED
127.0.0.1:6379> XADD mq * msg 3
QUEUED
127.0.0.1:6379> XADD mq * msg 4
QUEUED
127.0.0.1:6379> XADD mq * msg 5
QUEUED
127.0.0.1:6379> exec
1) "1683960419497-0"
2) "1683960419497-1"
3) "1683960419497-2"
4) "1683960419497-3"
5) "1683960419497-4"
  1. 创建消费组 mqGroup
// mq:队列名
// mqGroup :消费组名
// 参数0,表示该组从第一条消息开始消费
127.0.0.1:6379> XGROUP CREATE mq mqGroup 0
OK
  1. 消费组内消费者A,从消息队列mq中消费第一条消息。
127.0.0.1:6379> XREADGROUP group mqGroup consumerA count 1 streams mq >  // 此处的 > 代表读取队列中的消息,写成0代表读取Pending-list中有问题的消息
1) 1) "mq"
   2) 1) 1) "1683960419497-0"
         2) 1) "msg"
            2) "1"
// 消费者A继续消费第二条,再次执行即可
127.0.0.1:6379> XREADGROUP group mqGroup consumerA count 1 streams mq > 
1) 1) "mq"
   2) 1) 1) "1683960419497-1"
         2) 1) "msg"
            2) "2"
  1. 消费组内消费者B,从消息队列mq中消费第3条消息。
127.0.0.1:6379> XREADGROUP group mqGroup consumerB count 1 streams mq > 
1) 1) "mq"
   2) 1) 1) "1683960419497-2"
         2) 1) "msg"
            2) "3"
// 消费者B继续消费第四条,再次执行即可
127.0.0.1:6379> XREADGROUP group mqGroup consumerB count 1 streams mq >
1) 1) "mq"
   2) 1) 1) "1683960419497-3"
         2) 1) "msg"
            2) "4"

  1. 消费组内消费者C,从消息队列mq中消费第5条消息。
127.0.0.1:6379> XREADGROUP group mqGroup consumerC count 1 streams mq >
1) 1) "mq"
   2) 1) 1) "1683960419497-4"
         2) 1) "msg"
            2) "5"
  1. 读取了消息,需要进行消息确认才算完成。(没有确认会进入Pending-list)
// xack: 进行消息确认,后面跟id
127.0.0.1:6379> xack mq mqGroup 1683962319384-0 1683962319384-1 1683962319384-2 1683962319384-3 1683962319384-4
(integer) 5
  1. 删除指定的消费者组
// mq: 队列名
// mqGroup: 消费者组
127.0.0.1:6379> xgroup destroy mq mqGroup
(integer) 1

Pending 等待列表:

进行消息读取过后,需要进行消息确认才算完成,没有进行确认的消息会进入Pending-list。

  1. 接着上面的步骤5.,消费者读取消息后先不进行确认,而是先查看Pending-list:
127.0.0.1:6379> XPENDING mq mqGroup
1) (integer) 5                       // 表示有5个已读取但未处理的消息
2) "1683966537372-0"                 // 起始ID
3) "1683966537372-4"                 // 结束ID
4) 1) 1) "consumerA"      
      2) "2"                         // 消费者A有2个
   2) 1) "consumerB"
      2) "2"                         // 消费者B有2个
   3) 1) "consumerC"
      2) "1"                         // 消费者C有1个
  1. 使用 start end count 选项可以获取详细信息
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1683966537372-0"
   2) "consumerA"
   3) (integer) 254181              // 从读取到现在经历了254181毫秒
   4) (integer) 1                   // 读取的次数
2) 1) "1683966537372-1"
   2) "consumerA"
   3) (integer) 252139
   4) (integer) 1
3) 1) "1683966537372-2"
   2) "consumerB"
   3) (integer) 247445
   4) (integer) 1
4) 1) "1683966537372-3"
   2) "consumerB"
   3) (integer) 246054
   4) (integer) 1
5) 1) "1683966537372-4"
   2) "consumerC"
   3) (integer) 241850
   4) (integer) 
  1. 只查看固定的消费者的Pendling列表:
127.0.0.1:6379> XPENDING mq mqGroup - + 10 consumerA
1) 1) "1683966537372-0"
   2) "consumerA"
   3) (integer) 404400
   4) (integer) 1
2) 1) "1683966537372-1"
   2) "consumerA"
   3) (integer) 402358
   4) (integer) 1
  1. 确认这些消息后再去查看Pending-list,发现为空:
127.0.0.1:6379> xack mq mqGroup 1683966537372-0 1683966537372-1 1683966537372-2 1683966537372-3 1683966537372-4
(integer) 5
127.0.0.1:6379> XPENDING mq mqGroup - + 10
(empty array)

stream 消息队列基于消费者组特定总结:

  • 消息可回溯:从队列中取消息过后并不会从队列中删除,而是加入Pending-list,待确认后再从Pending-list中删除。
  • 可以多消费者争抢消息,加快消费速度:同一消费者组的消费者都可以读取消息。
  • 可以阻塞读取:block xxx 可以实现阻塞读取。
  • 没有消息漏读的风险:队列中可对消息进行标记,下一次读取从标记的地方开始读取。
  • 有消息确认机制,保证消息至少被消费一次。

List、PubSub、Stream三种消息队列比较:(但是redis 的这三种消息队列都是应对基本简单的项目,如果项目庞大复杂,还是推荐更专业的RabbitMQ、RocketMQ等等)
Redis学习笔记(二)_第34张图片

你可能感兴趣的:(redis,学习,笔记)