Redis实现优惠券秒杀

优惠券秒杀


全局唯一ID


问题

当用户抢购时,就会生成订单并保存到tb_voucher_order这张表中,而订单表如果使用数据库自增ID就存在一些问题

  • id的规律性太明显

  • 受单表数据量的限制

解决办法

全局唯一ID生

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

Redis实现优惠券秒杀_第1张图片

生成策略

为了增加ID的安全性,我们可以不直接使用Redis自增的数值,而是拼接一些其它信息:

Redis实现优惠券秒杀_第2张图片
ID的组成部分:

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

类似于雪花算法

@Component
public class RedisIdWorker {

  //初始时间:2022年1月1日0:00的时间戳
  private static final long BEGIN_TIMESTAMP = 1640995200L;

  private static final int COUNT_BITS = 32;

  private StringRedisTemplate stringRedisTemplate;

  // 初始化RedisTemplate
  public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
    this.stringRedisTemplate = stringRedisTemplate;
  }

  public long nextId(String keyPrefix) {
    // 1.生成时间戳
    LocalDateTime now = LocalDateTime.now();
    long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
    long timestamp = nowSecond - BEGIN_TIMESTAMP;
    // 2.生成序列号
    // 2.1.获取当前日期,精确到天
    String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
    // 2.2.自增长 使用redis的自增长
    long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
    // 3.拼接并返回
    return timestamp << COUNT_BITS | count;
  }
  
}

全局唯一ID生成策略:

  • UUID
  • Redis自增
  • snowflake算法
  • 数据库自增
  • Redis自增ID策略:
  • 每天一个key,方便统计订单量
  • ID构造是 时间戳 + 计数器

秒杀下单、一人一单、解决超卖


每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:
Redis实现优惠券秒杀_第3张图片
Redis实现优惠券秒杀_第4张图片
在这里插入图片描述

解决思路:

Redis实现优惠券秒杀_第5张图片
Redis实现优惠券秒杀_第6张图片

超卖问题

超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
Redis实现优惠券秒杀_第7张图片

乐观锁

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

  • 版本号法
  • CAS法
    本项目采用Redisson的可重入锁
  @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);
      RLock lock = redissonClient.getLock("lock:order:" + userId);
      // 获取锁
      boolean isLock = lock.tryLock();
      // 判断是否获取锁成功
      if(!isLock){
          // 获取锁失败,返回错误或重试
          return Result.fail("不允许重复下单");
      }
      try {
          // 获取代理对象(事务)
          IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
          return proxy.createVoucherOrder(voucherId);
      } finally {
          // 释放锁
          lock.unlock();
      }


解决办法

  • 使用lua脚本实现Redis命令的原子性
  • 下单信息先在Redis中保存,并且保存时判断该用户是否已经下单。(解决了超卖问题)

分布式锁

分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
Redis实现优惠券秒杀_第8张图片

分布式锁的实现

分布式锁的核心是实现多进程之间互斥,而满足这一点的方式有很多,常见的有三种:Redis实现优惠券秒杀_第9张图片

方案一 : 基于Redis的分布式锁

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

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁
    • 非阻塞:尝试一次,成功返回true,失败返回false在这里插入图片描述
  • 释放锁:

    • 手动释放
    • 超时释放:获取锁时添加一个超时时间在这里插入图片描述

Redis实现优惠券秒杀_第10张图片
Redis实现优惠券秒杀_第11张图片

Redis实现优惠券秒杀_第12张图片

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

实现简单的分布式锁
public class SimoleRedisLock implements ILock{

  private String name;

  private StringRedisTemplate stringRedisTemplate;

  private static final String KEY_PREFIX = "lock:";

  private static final String THREAD_ID = UUID.randomUUID().toString()+"-";

  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 SimoleRedisLock(){

  }

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

  @Override
  public boolean tryLock(long timeoutSec) {
    String threadId = THREAD_ID+Thread.currentThread().getId();
    Boolean isLock = stringRedisTemplate.opsForValue()
        .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(isLock);
  }

  @Override
  public void unlock() {
    stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name),THREAD_ID+Thread.currentThread().getId());
  }
}

Redis实现优惠券秒杀_第13张图片

基于Redis的分布式锁优化

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

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

Redisson

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
Redis实现优惠券秒杀_第14张图片
官网地址: https://redisson.org
GitHub地址: https://github.com/redisson/redisson

  1. 引入依赖:Redis实现优惠券秒杀_第15张图片

  2. 配置Redisson客户端:Redis实现优惠券秒杀_第16张图片

  3. 使用Redisson的分布式锁:Redis实现优惠券秒杀_第17张图片

Redisson可重入锁原理

Redis实现优惠券秒杀_第18张图片
使用Redis的hash类型的数据结构,记录次数。

获取锁的Lua脚本

Redis实现优惠券秒杀_第19张图片

释放锁的Luau脚本

Redis实现优惠券秒杀_第20张图片

Redisson分布式锁的原理

Redis实现优惠券秒杀_第21张图片
Redisson分布式锁原理:

  • 可重入:利用hash结构记录线程id和重入次数
  • 可重试:利用信号量和PubSub功能实现等待、唤醒,获取锁失败的重试机制
  • 超时续约:利用watchDog,每隔一段时间(releaseTime / 3),重置超时时间

watchDog(看门狗):它的唯一作用就是,通过递归将没有设置ttl的锁重新赋予新的ttl,防止锁失效。

方案二: Redisson的可重入锁

秒杀函数

@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);
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        // 获取锁
        boolean isLock = lock.tryLock();
        // 判断是否获取锁成功
        if(!isLock){
            // 获取锁失败,返回错误或重试
            return Result.fail("不允许重复下单");
        }
        try {
            // 获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
	@Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 5.一人一单
        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);
        }
    }

总结

  1. 不可重入Redis分布式锁:
    原理:利用setnx的互斥性;利用ex避免死锁;释放锁时判断线程标示
    缺陷:不可重入、无法重试、锁超时失效
  2. 可重入的Redis分布式锁:
    原理:利用hash结构,记录线程标示和重入次数;利用watchDog延续锁时间;利用信号量控制锁重试等待
    缺陷:redis宕机引起锁失效问题
  3. Redisson的multiLock:
    原理:多个独立的Redis节点,必须在所有节点都获取重入锁,才算获取锁成功
    缺陷:运维成本高、实现复杂

秒杀优化

需求:

Redis实现优惠券秒杀_第22张图片

秒杀优化思路

秒杀业务的优化思路是什么?

  • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单

实现方案

  • 使用lua脚本执行redis命令,可以实现原子性。
  • 在下但的时候先把优惠券的信息保存在redis中,这样可以减少响应时间提高吞吐量

基于阻塞队列的异步秒杀存在哪些问题?

  • 内存限制问题
  • 数据安全问题

-- seckill2.lua脚本
-- 确定参数列表
-- 优惠券id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 订单id
local orderId = ARGV[3]
-- 数据库key
-- 优惠券库存
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断优惠券的值是否小于1
if(tonumber(redis.call('get',stockKey)) < 1) then
    -- 库存不足
    return 1
end
-- 判断用户是否下单
if(redis.call('sismember',orderKey,userId)==1) then
    -- 存在(已经下单)
    return 2
end
-- 不存在(没下过单)
-- 扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 保存用户 sadd orderKey userId
redis.call('sadd',orderKey,userId)
-- 向stream队列发送消息,XADD stream.orders * k1 v1 k2 v2 .. ..
redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
return 0

  //lua脚本
  private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

  static {
    SECKILL_SCRIPT = new DefaultRedisScript<>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill2.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
  }
  //...........code...............//

  // 使用Spring的代理对象
  IVoucherOrderService proxy;
  @Override
  public Result seckillVoucher(Long voucherId) {
    Long userId = UserHolder.getUser().getId();
    long orderId = redisIdWorker.nextId("order");
    // 1.执行lua脚本
    Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(),
        voucherId.toString(), userId.toString(), String.valueOf(orderId)
    );
    int r = result.intValue();
    // 2.判断结果是否为0
    if (r != 0) {
      // 2.1.不为0 ,代表没有购买资格
      return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
    }
    // 3.获取Spring代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    // 4.返回订单id
    return Result.ok(orderId);
  }

Redis实现优惠券秒杀_第23张图片

Redis消息队列实现异步秒杀

Redis实现优惠券秒杀_第24张图片
Redis实现优惠券秒杀_第25张图片
Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

基于Redis的list实现的罅隙对列

消息队列Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。

不过要注意的是,当队列中没有消息时RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。
在这里插入图片描述

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

  • 优点:
    • 利用Redis存储,不受限于JVM内存上限
    • 基于Redis的持久化机制,数据安全性有保证
    • 可以满足消息有序性
  • 缺点:
    • 无法避免消息丢失
    • 只支持单消费者

基于PubSub的消息队列

PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel] :订阅一个或多个频道
  • PUBLISH channel msg :向一个频道发送消息
  • PSUBSCRIBE pattern[pattern] :订阅与pattern格式匹配的所有频道
    Redis实现优惠券秒杀_第26张图片
    基于PubSub的消息队列有哪些优缺点?
  • 优点:
    • 采用发布订阅模型,支持多生产、多消费
  • 缺点:
    • 不支持数据持久化
    • 无法避免消息丢失
    • 消息堆积有上限,超出时数据丢失

基于Stream的消息队列

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

发送消息的命令:在这里插入图片描述
例如:
在这里插入图片描述
读取消息的方式之一:XREAD
Redis实现优惠券秒杀_第27张图片
例如,使用XREAD读取第一个消息:
Redis实现优惠券秒杀_第28张图片
XREAD阻塞方式,读取最新的消息:
在这里插入图片描述
在业务开发中,我们可以循环的调用XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下:
Redis实现优惠券秒杀_第29张图片
Redis实现优惠券秒杀_第30张图片
STREAM类型消息队列的XREAD命令特点:

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

基于Stream的消息对列-消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:

  1. 消息分流:
    对列中的消息会分流给组内的不同消费者,而不是重复消费,加快消息处理的速度
  2. 消息标示:
    消费者组会维护一个标示,记录最后一个呗处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都被消费
  3. 消息确认:
    消费者获取消息后,消息处于pending状态,并存入一个pending-list。当处理完成后需要通过XACK来确认消息,标记消息为已处理,才会从pending-list移除。
创建消费者组

XGROUP CREATE key groupName ID [MKSTREAM]

  • key : 对列名称
  • groupName : 消费者组名称
  • ID :起始ID标示,$代表对列最后一个消息,0则代表队列中第一个消息
  • MKSTREAM : 队列不存在时自动创建队列

其他常见命令:
Redis实现优惠券秒杀_第31张图片

从消费者组读取消息

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ..]

  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK milliseconds:当没有消息时最长等待时间
  • NOACK:无需手动ACK,获取到消息后自动确认
  • STREAMS key:指定队列名称
  • ID:获取消息的起始ID:
    • “>”:从下一个未消费的消息开始
    • 其它:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始

STREAM类型消息队列的XREADGROUP命令特点:

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次

Redis实现优惠券秒杀_第32张图片

你可能感兴趣的:(Redis,redis,java,数据库)