五,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();
                    }
                }
            }
        }
    }
相关推荐
都叫我大帅哥12 分钟前
Redis哨兵完全指南:从救火队员到集群守护神
redis
都叫我大帅哥13 分钟前
Redis主从架构:从菜鸟到大神的通关秘籍
redis
草履虫建模7 小时前
Redis:高性能内存数据库与缓存利器
java·数据库·spring boot·redis·分布式·mysql·缓存
A-刘晨阳8 小时前
【Linux】Redis 6.2.6 的二进制部署【适用于多版本】
linux·运维·redis
程序猿ZhangSir9 小时前
Redis 缓存进阶篇,缓存真实数据和缓存文件指针最佳实现?如何选择?
数据库·redis·缓存
段帅龙呀17 小时前
Redis构建缓存服务器
服务器·redis·缓存
用户8324951417321 天前
Spring Boot 实现 Redis 多数据库切换(多数据源配置)
redis
傲祥Ax1 天前
Redis总结
数据库·redis·redis重点总结
都叫我大帅哥1 天前
Redis AOF持久化深度解析:命令日志的终极生存指南
redis
都叫我大帅哥1 天前
Redis RDB持久化深度解析:内存快照的魔法与陷阱
redis