五,redis实现优惠卷秒杀(消息队列,分布式锁,全局id生成器,lua脚本)

一,全局唯一ID

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

  • 唯一性
  • 高可用
  • 高性能
  • 递增性
  • 安全性

全局唯一ID生成策略:

  • UUID:生成的是16进制数值,返回的是String,不是单调递增的特性
  • Redis自增
  • snowflake雪花算法:不依赖redis,但是对系统时间依赖很高,如果系统时间不正确会出现异常情况
  • 数据库自增:使用数据库特定表来实现自增

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

java 复制代码
@Component
public class RedisIdWorker {
    private static final long BGIN_TIMESTAP=LocalDateTime.of(2022, 1, 1, 0, 0, 0).toEpochSecond(ZoneOffset.UTC);
    //序列号的位数
    private static final int COUNT_BITS=32;

    private StringRedisTemplate redisTemplate;

    public RedisIdWorker(StringRedisTemplate redisTemplate){
        this.redisTemplate=redisTemplate;
    }

    public long nextId(String keyPrefix){
        //1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timestamp=nowSecond-BGIN_TIMESTAP;
        //2.生成序列号
        //获取当前日期
        String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
        long count=redisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date);
        //3.拼接并返回

        //先将时间戳左移32位成为高位,然后用|运算将count值原封不动填充进低位
        //因为|运算:有一个为1就为1,而此时低位全是0,我们的count就会原封不动的进入低位
        return timestamp << COUNT_BITS | count;
    }


}

二,实现优惠卷秒杀下单

  1. 优惠卷表结构

    字段 备注
    id 主键
    shop_id 商铺id
    title 代金券标题
    sub_title 副标题
    rules 使用规则
    pay_value 支付金额,单位是分。例如200代表2元
    actual_value 抵扣金额,单位是分。例如200代表2元
    type 类型0,普通券;1,秒杀券
    status 状态1,上架; 2,下架; 3,过期
    create_time 创建时间
    update_time 更新时间
  2. 下单表表结构

    字段 备注
    id 主键
    user_id 下单的用户id
    voucher_id 购买的代金券id(关联优惠卷id)
    pay_type 支付方式 1:余额支付;2:支付宝;3:微信
    status 订单状态,1:未支付;2:已支付;3:已核销;4:已取消;5:退款中;6:已退款
    create_time 下单时间
    pay_time 支付时间
    use_time 核销时间
    refund_time 退款时间
    update_time 更新时间
  3. 秒杀卷表表结构

    字段 备注
    voucher_id 关联的优惠券的id
    stock 库存
    create_time 创建时间
    begin_time 生效时间
    end_time 失效时间
    update_time 更新时间

代码实现:

java 复制代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠卷信息
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        log.info("data={}",voucher);
        //2.判断当前时间是否是秒杀时间
        boolean isAfter = voucher.getBeginTime().isAfter(LocalDateTime.now());
        log.info("isAfter={}",isAfter);
        if(isAfter){
            return Result.fail("秒杀尚未开始!");
        }

        boolean isBefore = voucher.getEndTime().isBefore(LocalDateTime.now());
        log.info("isBefore={}",isBefore);
        if(isBefore){
            return Result.fail("秒杀已结束!");
        }

        //3.开始,判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<1){
            //不足
            return Result.fail("库存不足!");
        }
        //4.充足,扣减库存
        boolean success=seckillVoucherService.update().setSql("stock=stock-1")
                .eq("voucher_id",voucherId)
                .update();
        if(!success){
            return Result.fail("库存不足!");
        }
        //5.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //代金卷id
        voucherOrder.setVoucherId(voucherId);
        //保存订单到数据库
        save(voucherOrder);
        //6.返回订单id
        return Result.ok(orderId);
    }
}

三,超卖问题

乐观锁解决超卖问题:

乐观锁如果直接按照上述方按进行,会导致大部分的请求失败,我们只需要stock>0即可更新,而不是stock必须严格的等于原来的stock,这样就可以改进。

版本号法代码实现:

java 复制代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠卷信息
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        log.info("data={}",voucher);
        //2.判断当前时间是否是秒杀时间
        boolean isAfter = voucher.getBeginTime().isAfter(LocalDateTime.now());
        log.info("isAfter={}",isAfter);
        if(isAfter){
            return Result.fail("秒杀尚未开始!");
        }

        boolean isBefore = voucher.getEndTime().isBefore(LocalDateTime.now());
        log.info("isBefore={}",isBefore);
        if(isBefore){
            return Result.fail("秒杀已结束!");
        }

        //3.开始,判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<1){
            //不足
            return Result.fail("库存不足!");
        }
        //4.充足,扣减库存
        boolean success=seckillVoucherService.update().setSql("stock=stock-1")
                .eq("voucher_id",voucherId)
                .gt("stock",0)//这里就是stock>0
                .update();
        if(!success){
            return Result.fail("库存不足!");
        }
        
        //5.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        //代金卷id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //6.返回订单id
        return Result.ok(orderId);
    }
}

四,一人一单

代码实现

java 复制代码
@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    private final Lock lock=new ReentrantLock();


    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.查询优惠卷信息
        SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
        log.info("data={}",voucher);
        //2.判断当前时间是否是秒杀时间
        boolean isAfter = voucher.getBeginTime().isAfter(LocalDateTime.now());
        log.info("isAfter={}",isAfter);
        if(isAfter){
            return Result.fail("秒杀尚未开始!");
        }

        boolean isBefore = voucher.getEndTime().isBefore(LocalDateTime.now());
        log.info("isBefore={}",isBefore);
        if(isBefore){
            return Result.fail("秒杀已结束!");
        }

        //3.开始,判断库存是否充足
        Integer stock = voucher.getStock();
        if(stock<1){
            //不足
            return Result.fail("库存不足!");
        }
        Long userId = UserHolder.getUser().getId();
        //新建订单必须加锁,不然会出现脏读的情况
        synchronized (userId.toString().intern()){
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucher(voucherId,userId);
        }
        
    }

    @Transactional
    @Override
    public Result createVoucher(Long voucherId, Long userId) {
        //4.根据优惠券id和用户id查询订单
        int count=query().eq("user_id",userId).eq("voucher_id",voucherId).count();
        //5.判断订单是否存在
        if(count > 0) {
            //存在
            return Result.fail("你已购买一次了!");
        }
        //6.订单不存在,扣减库存
        boolean success=seckillVoucherService.update().setSql("stock=stock-1")
                .eq("voucher_id",voucherId)
                .gt("stock",0)
                .update();
        
        if(!success){
            return Result.fail("库存不足!");
        }
        
        //7.创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(userId);
        //代金卷id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //8.返回订单id
        return Result.ok(orderId);
    }

}

上述代码涉及到好几个知识点:

  1. 锁的范围必须在事务之前,如果锁加到了事务方法里面,事务是必须等方法执行完才能提交的,如果此时锁先释放了,事务未提交,其他的线程就已经进来了,我们刚新增的订单可能还未写入数据库,此时新线程查询订单时订单依旧不存在,这会导致线程并发安全问题。

  2. 事务要想生效是因为Spring对当前这个类做了动态代理 ,拿到了代理对象,用代理对象来进行事务处理,如果我们不用proxy手动获取一个代理对象,而是直接执行方法,相当于使用this来执行这个方法,而this是非代理对象,这样会导致事务失效 ,解决方法就是如上代码手动创建一个代理对象,记得在启动类开启暴露代理对象:@EnableAspectJAutoProxy(exposeProxy = true) 然后添加依赖:

    xml 复制代码
    <dependency>
        <groupId>org.aspectj</groupId>
        <artifactId>aspectjweaver</artifactId>
    </dependency>

五,分布式锁

上述情况通过加锁可以解决在单机情况 下的一人一单安全问题,但是在集群模式 下就不行了,在集群模式下,每个JVM都有自己的锁 ,导致线程安全问题。

分布式锁的工作原理 :满足分布式系统或集群模式下多进程可见 并互斥的锁。 分布式锁基本特性:

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

5.1 分布式锁的实现


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

  • 获取锁: 互斥:利用setnx的互斥特性

    非阻塞:实现非阻塞式锁

    bash 复制代码
    SETNX lock thread1
    EXPIRE lock 10
    #或者直接set命令
    set lock NX EX 10
  • 释放锁:

    bash 复制代码
    DEL lock
  • 流程图:


5.2 基于Redis实现分布式锁初级版本

java 复制代码
public class SimpleRedisLock implements ILock{

    private StringRedisTemplate stringRedisTemplate;
    private String name;
    private static final String KEY_PREFIX="lock:";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        long threadId = Thread.currentThread().getId();
        //获取锁
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        //涉及自动拆箱,可能为null
        return Boolean.TRUE.equals(success);
    }
	
    @Override
    public void unLock() {
        stringRedisTemplate.delete(KEY_PREFIX + name);
    }
    
}

手动上锁解锁代码

java 复制代码
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.createVoucher(voucherId,userId);
}finally {
    lock.unLock();
}

5.3 解决锁误删问题

上述初级版本存在一个,锁误删问题,如图: 解决办法 :解锁时判断锁标识是否一致 流程图

代码实现:核心就是加上线程标识,阻止其他线程误删除锁。

java 复制代码
public class SimpleRedisLock implements ILock{

    private StringRedisTemplate stringRedisTemplate;
    private String name;
    private static final String KEY_PREFIX="lock:";
    private static final String ID_PREFIX= UUID.randomUUID().toString(true);

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

    @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);
        //涉及自动拆箱,可能为null
        return Boolean.TRUE.equals(success);
    }

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

5.4 分布式锁的原子性(lua脚本)

我们上述解决锁误删问题中,判断锁和解锁是两个动作,如果两个动作之间发生了阻塞,也会导致锁误删。如下图: 解决办法:保证判断锁和解锁两个操作是一起执行的,即原子性。


Lua脚本解决多条命令的原子性

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

  • lua脚本编写

    lua 复制代码
    -- 获取锁中线程标识
    local id=redis.call('get',KEYS[1])
    -- 比较线程标识与锁的标识是否一致
    if(id == ARGV[1]) then
        --一致,释放锁
        return redis.call('del',KEYS[1])
    end
    -- 不一致,返回0
    return 0

基于Lua脚本实现分布式锁的释放逻辑

java 复制代码
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;

    static{
        UNLOCK_SCRIPT=new DefaultRedisScript<>();
        //ClassPathResource是Spring提供的,在classpath即resource目录下找
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unLock.lua"));
        //设置返回值
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

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

5.5 Redisson

5.5.1 Redisson介绍

5.5.2 Redisson快速入门:

  1. 引入依赖

    xml 复制代码
    <dependency>
        <groupId>org.redisson</groupId>
        <artifactId>redisson</artifactId>
        <version>3.20.1</version>
    </dependency>
  2. 配置Redisson客户端

    java 复制代码
    @Configuration
    public class RedisConfig {
        @Value("${redis.host}")
        private String host;
        @Value("${redis.port}")
        private String port;
        @Value("${redis.password}")
        private String password;
    
        @Bean
        public RedissonClient redissonClient(){
            //配置类
            Config config = new Config();
            //添加redis地址,这里添加了单点地址,也可以使用config.useClusterServer()添加集群地址
            config.useSingleServer().setAddress("redis://"+host+":"+port).setPassword(password);
            return Redisson.create(config);
        }
    }
  3. 利用Redisson的分布式锁

    java 复制代码
    @SpringBootTest
    class HmDianPingApplicationTests {
        @Autowired
        private RedissonClient redissonClient;
    
        @Test
        void testRedisson() throws InterruptedException {
            //获取锁(可重入),指定锁的名称
            RLock lock = redissonClient.getLock("anyLock");
            //尝试获取锁,参数分别是:
            //获取锁的最大等待时间,锁自动释放时间,时间单位
            //如果不填最大等待时间,那么获取不到锁就返回false
            //如果添了最大等待时间,就会不断尝试直到获取到锁或到达最大等待时间
            //锁自动释放时间如果不填默认-1
            boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
            //判断释放成功
            if(isLock){
                try{
                    System.out.println("执行业务!");
                }finally {
                    //释放锁
                    lock.unlock();
                }
            }
        }
    }

使用Redisson锁替换我们自定义锁步骤:替换锁对象即可。

5.5.3 Redisson可重入锁原理

5.5.4 Redisson的锁重试和Redisson的WatchDog机制

5.5.5 Redisson分布式锁主从一致性问题

解决办法:Redisson的MultiLock

5.6 总结

三种锁分布式锁:

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

六,Redis优化秒杀

未优化前秒杀流程: 优化思路:


流程图:


代码实现:

  1. 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中

    java 复制代码
    @Transactional
    public void addSeckillVoucher(Voucher voucher) {
        // 保存优惠券
        save(voucher);
        // 保存秒杀信息
        SeckillVoucher seckillVoucher = new SeckillVoucher();
        seckillVoucher.setVoucherId(voucher.getId());
        seckillVoucher.setStock(voucher.getStock());
        seckillVoucher.setBeginTime(voucher.getBeginTime());
        seckillVoucher.setEndTime(voucher.getEndTime());
        seckillVoucherService.save(seckillVoucher);
        //保存秒杀库存到Redis中
        stringRedisTemplate.opsForValue().set("seckill:stock:"+voucher.getId(),voucher.getStock().toString());
        
    }
  2. 基于Lua脚本,判断秒杀库存,一人一单,决定用户是否抢购成功

    • Lua脚本
    lua 复制代码
    --1. 参数列表
    -- 优惠券id
    local voucherId=ARGV[1]
    -- 用户id
    local userId=ARGV[2]
    --2. 数据key
    -- 库存key
    local stockKey='seckill:stock:' .. voucherId
    -- 订单key
    local orderKey='seckill:order:' .. voucherId
    --3. 脚本业务
    -- 判断库存是否充足 get stockKey
    if(tonumber(redis.call('get',stockKey)) <= 0) then
        --库存不足,返回1
        return 1
    end
    -- 判断用户是否下单
    if(redis.call('sismember',orderKey,userId) ==1) then
        -- 存在,说明是重复下单,返回2
        return 2
    end
    -- 扣库存
    redis.call('incrby',stockKey,-1)
    -- 下单(保存用户)
    redis.call('sadd',orderKey,userId)
  3. 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列

    java 复制代码
    //创建IVoucherOrderService代理对象
    private IVoucherOrderService proxy;
    
    //阻塞队列的特点:当一个线程尝试从阻塞队列里面获取元素,如果里没有元素,这个线程就会被阻塞
    private BlockingQueue<VoucherOrder> orderTasks=newArrayBlockingQueue<>(1024*1024);
    
    @Override
    public Result seckillVoucher(Long voucherId){
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1.执行lua脚本
        Long result=stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString());
        //2. 判断结果是否为0
        int r = result.intValue();
        if( r!= 0){
            //不为0,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足":"不能重复下单");
        }
        //为0,有购买资格,把下单信息保存到阻塞队列中
        long orderId = redisIdWorker.nextId("order");
        //创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //订单id
        voucherOrder.setId(orderId);
        //用户id
        voucherOrder.setUserId(userId);
        //代金卷id
        voucherOrder.setVoucherId(voucherId);
        
        save(voucherOrder);
        orderTasks.add(voucherOrder);
        //获取代理对象
        proxy =(IVoucherOrderService) AopContext.currentProxy();
        //返回订单id
        return Result.ok(orderId);
    }
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

    java 复制代码
    //创建线程池,这里是示例,实际上不推荐用这种方法创建线程池因为这种方法会导致OOM
    private static final ExecutorServiceSECKILL_ORDER_EXECUTOR=Executors.newSingleThreadExecutor();
    //异步任务
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while(true){
                try{
                    //1.获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2.处理订单
                    handleVoucherOrder(voucherOrder);
                }catch (Exception e){
                    log.info("订单异常",e);
                }
            }
        }
        
        private void handleVoucherOrder(VoucherOrder voucherOrder)  throws InterruptedException {
            //1.获取用户
            Long userId = voucherOrder.getUserId();
            //2.创建锁对象
            RLock lock = redissonClient.getLock("lock:order:" +     userId);
            //获取锁(waitTime:获取锁的最大等待时长,给了这个参数那么获    取锁不成功就会不断地去尝试,不给就是直接返回false)
            //leaseTime:锁失效的时间,即TTL
            boolean isLock = lock.tryLock(10L, TimeUnit.MINUTES);
            //判断锁是否获取成功
            if(!isLock){
                //不成功
                log.info("不允许重复下单");
                return;
            }
            try{
                //创建订单
                proxy.createVoucher(voucherOrder);
            }finally {
                lock.unlock();
            }
        }
    }
    
    @PostConstruct
    private void init(){
        //将任务提交给线程池
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    
    //保存订单
    @Transactional
    @Override
    public void createVoucher(VoucherOrder voucherOrder) {
        Long userId = voucherOrder.getUserId();
        //1.根据优惠券id和用户id查询订单
        int count=query().eq("user_id",userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        //2.判断订单是否存在
        if(count > 0) {
            //存在
            log.info("用户已经购买过了!");
            return;
        }
        //3.订单不存在,扣减库存
        boolean success=seckillVoucherService.update().setSql   ("stock=stock-1")
                .eq("voucher_id",voucherOrder.getVoucherId())
                .gt("stock",0)
                .update();
        if(!success){
            log.info("库存不足");
            return;
        }
        //4.保存
        save(voucherOrder);
    }

七,Redis消息队列实现异步秒杀

消息队列,字面意思就是存放信息的队列,最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称作消息代理。
  • 生产者:发送消息到消息队列。
  • 消费者:从消息队列获取消息并处理消息。

Redis提供了三种不同的方式来实现消息队列:

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

这里只是演示一下Redis的消息队列,实际上要用消息队列是用MQ实现。

7.1 基于List结构模拟消息队列

Redis的list数据结构是一个双向链表,很容易就可以模拟出队列的效果。

我们只需要使用List结构的BRPOP或BLPOP来进行阻塞取出,LPUSH或RPUSH进行存入即可实现。

代码实现,就是将JVM的阻塞队列替换成Redis的List结构进行存取。

基于List的消息队列的优缺点:

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

7.2 基于PubSub结构模拟消息队列

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

  • subscribe channel [channel] 订阅一个或多个频道
  • publish channel msg 向一个频道发送消息
  • psubscribe pattern[pattern] 订阅与pattern格式匹配的所有频道

基于PubSub的消息队列优缺点:

  • 优点:采用发布订阅模型,支持多生产,多消费
  • 缺点:
    • 不支持数据持久化
    • 无法避免消息丢失
    • 消息堆积有上限,超出时数据丢失

7.3 基于Stream的消息队列

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

7.3.1 单消费者

Stream类型消息队列的XREAD命令特点:

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

7.3.2 消费者组

创建消费者组:

bash 复制代码
XGROUP create key groupName id [mkstream]
  • key:队列名称
  • groupName:消费者组名称
  • ID:起始id标识,$代表队列中最后一个消息,0则代表队列中第一个消息。
  • mkstream:队列不存在时自动创建队列

其他常见命令:

bash 复制代码
# 消息确认
XACK key group ID [ID...]
# 查看pending-list
XPENDING key group [  [IDLE min-idle-time] start end count [consumer] ]
  • IDLE min-idle-time:获取消息以后确认之前的时间,单位毫秒
  • start:pendling-list里面最小的id,可以为-,即最小的
  • end:pendling-list里面最大的id,可以为+,即最大的
  • count:获取pendling的数量
  • consumer:指定消费者的pendling-list

消费者监听基本思路:伪代码

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

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

7.3.3 代码实现

  1. 创建一个Stream类型的消息队列,名称为:stream.orders

    objectivec 复制代码
    XGROUP CREATE stream.orders g1 0 MKSTREAM

    只需要创建一次即可,因为是一直保留在redis里面的。

  2. 修改之前的lua脚本,在确认有抢购资格后,直接向stream.orders中添加消息,内容包含:voucherId,userId,orderId

    lua脚本修改

    lua 复制代码
    --1. 参数列表
    -- 优惠券id
    local voucherId=ARGV[1]
    -- 用户id
    local userId=ARGV[2]
    -- 订单id
    local orderId=ARGV[3]
    --2. 数据key
    -- 库存key
    local stockKey='seckill:stock:' .. voucherId
    -- 订单key
    local orderKey='seckill:order:' .. voucherId
    --3. 脚本业务
    -- 判断库存是否充足 get stockKey
    if(tonumber(redis.call('get',stockKey)) <= 0) then
        --库存不足,返回1
        return 1
    end
    -- 判断用户是否下单
    if(redis.call('sismember',orderKey,userId) ==1) then
        -- 存在,说明是重复下单,返回2
        return 2
    end
    -- 扣库存
    redis.call('incrby',stockKey,-1)
    -- 下单(保存用户)
    redis.call('sadd',orderKey,userId)
    -- 发送消息到队列中,XADD stream.orders * k1 v1 k2 v2
    redis.call('XADD','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)
    return 0

    java代码修改:

    java 复制代码
    @Override
    public Result seckillVoucher(Long voucherId){
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //获取订单id
        long orderId = redisIdWorker.nextId("order");
        //1.执行lua脚本
        Long result=stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(),String.valueOf(orderId)
        );
        //2. 判断结果是否为0
        int r = result.intValue();
        if( r!= 0){
            //不为0,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足":"不能重复下单");
        }
        //获取代理对象
        proxy =(IVoucherOrderService) AopContext.currentProxy();
        //返回订单id
        return Result.ok(orderId);
    }
  3. 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单。

    • 执行任务的类
    java 复制代码
    @Component
    @Slf4j
    public class VoucherOrderHandler implements Runnable{
        @Resource
        private StringRedisTemplate stringRedisTemplate;
        @Resource
        private RedissonClient redissonClient;
        //创建IVoucherOrderService代理对象
        private IVoucherOrderService proxy;
    
        @Override
        public void run() {
            while(true){
                try{
                    //1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create("stream:orders", ReadOffset.lastConsumed())
                    );
                    //2判断消息是否获取成功
                    if(list == null || list.isEmpty()){
                        //如果失败,说明没有消息,继续下一次循环
                        continue;
                    }
                    //如果成功,则可以下单
                    //解析消息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    //3创建订单
                    handleVoucherOrder(voucherOrder);
                    //4.ack确认 sack stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge("stream:orders","g1",record.getId());
                }catch (Exception e){
                    log.error("订单异常",e);
                    handlePendingList();
                }
    
            }
        }
        private void handleVoucherOrder(VoucherOrder voucherOrder) throws InterruptedException {
            //1.获取用户
            Long userId = voucherOrder.getUserId();
            //2.创建锁对象
            RLock lock = redissonClient.getLock("lock:order:" + userId);
            //获取锁(waitTime:获取锁的最大等待时长,给了这个参数那么获取锁不成功就会不断地去尝试,不给就是直接返回false)
            //leaseTime:锁失效的时间,即TTL
            boolean isLock = lock.tryLock(10L, TimeUnit.MINUTES);
            //判断锁是否获取成功
            if(!isLock){
                //不成功
                log.info("不允许重复下单");
                return;
            }
            try{
                //创建订单
                proxy.createVoucher(voucherOrder);
            }finally {
                lock.unlock();
            }
        }
    
        private void handlePendingList(){
            while(true){
                try{
                    //1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS streams.order 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create("streams:order", ReadOffset.from("0"))
                    );
                    //2判断消息是否获取成功
                    if(list == null || list.isEmpty()){
                        //如果失败,说明pendling-list没有消息,结束循环
                        break;
                    }
                    //如果成功,则可以下单
                    //解析消息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> values = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                    //3创建订单
                    handleVoucherOrder(voucherOrder);
                    //4.ack确认 sack stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge("streams:order","g1",record.getId());
                }catch (Exception e){
                    log.error("处理pending-list异常",e);
                    try{
                        Thread.sleep(20);
                    }catch (Exception ex){
                        ex.printStackTrace();
                    }
                }
            }
        }
    }
相关推荐
码熔burning1 小时前
Redis分片集群
数据库·redis·分片集群
小五Z2 小时前
Redis--主从复制
数据库·redis·分布式·后端·缓存
马达加斯加D2 小时前
缓存 --- 缓存击穿, 缓存雪崩, 缓存穿透
数据库·redis·缓存
Hellc0073 小时前
完整的 .NET 6 分布式定时任务实现(Hangfire + Redis 分布式锁)
redis·分布式·.net
Z_z在努力4 小时前
【Redis】Redis 特性
数据库·redis
normaling6 小时前
九,Redis通过BitMap实现用户签到
redis
normaling6 小时前
七,Redis实现共同关注和关注推送
redis
normaling6 小时前
六,Redis实现点赞排行表
redis
xxy!6 小时前
Redis的数据持久化是怎么做的?
数据库·redis·缓存