秒杀抢购异步下单:基于Redis的消息队列秒杀抢购异步下单功能

学习Redis时,练习的实战项目代码——基于Redis的Stream类型的秒杀抢购异步下单。

说明:

Redis的stream类型的消息队列实现异步下单功能。Redis版本至少要5.0及以上版本才可以使用,使用stream中的消费者组来监听同一个队列达到目的,如果业务不是很庞大、体量不是很大的话,完全可以采用该模式来实现秒杀抢购异步下单功能。

当然什么限流啊什么的就没有考虑了。如果涉及到限流了,就没必要用redis来实现异步功能.

缺点

  1. 不是最专业的消息中间件,性能不是最好的,业务系统庞大、体量非常庞大的、要求高的地方还是建议使用专业的消息中间件来使用,如:ActiveMQRabbitMQ,炙手可热的 Kafka,阿里巴巴自主开发 RocketMQ 等。
  2. 只有消费者确认机制,没有生产者确认机制。

使用消费者组特点

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

一、业务需求

  1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中;
  2. 创建一个Stream类型的消息队列,名为streams.voucher.order;
  3. 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功;
  4. 如果抢购成功,直接向streams.voucher.order中添加消息,内容包含userld、voucherld、orderld
  5. 项目启动时,开启一个线程任务,尝试获取streams.voucher.order中的消息,完成下单。

二、业务分析

1. stream消费者组的特点
秒杀抢购异步下单:基于Redis的消息队列秒杀抢购异步下单功能_第1张图片
2. 利用lua脚本保证原子性,加上set集合的唯一性来保证一人一单的问题,超卖问题,然后再次利用分布式锁兜底保证一人一单、超卖等问题。

三、项目部分代码

1. 业务代码

package com.hmdp.service.impl;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.thread.NamedThreadFactory;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisConstants;
import com.hmdp.utils.RedisIdGenerator;
import com.hmdp.utils.RedisUtils;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.connection.stream.ReadOffset;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * 

* 服务实现类:实现异步秒杀抢购卷 * *

* * @author TH * @since 2022-04-02 */
@Slf4j @Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Autowired private SeckillVoucherServiceImpl seckillVoucherService; @Autowired private RedisIdGenerator redisIdGenerator; @Autowired private RedisUtils redisUtils; @Autowired private RedissonClient redissonClient; /** * 手动定义单列一个线程池 */ private final static ExecutorService SECKILL_ORDER_EXECUTOR = new ThreadPoolExecutor(10, 10, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), new NamedThreadFactory("执行秒杀订单消息队列", false)); /** * 定一个初始化方法:@PostConstruct在该类初始化完毕后就要执行该方法。 * 任务必须在活动之前就要开始执行队列中的订单创建任务方法 */ @PostConstruct private void init() { //在初始化时执行任务。 SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHeader()); } /** * 读取lua脚本的DefaultRedisScript的初始化 */ private final static DefaultRedisScript<Long> SECKILL_VOUCHER; static { SECKILL_VOUCHER = new DefaultRedisScript<>(); //返回值类型,注意redis返回的数值类型(Integer)java必须用Long类型来接收,否则一定报错的。 SECKILL_VOUCHER.setResultType(Long.class); //读取lua资源的方式 SECKILL_VOUCHER.setLocation(new ClassPathResource("seckillvoucher.lua")); } /** * 定义一个内部类,实现 Runnable接口,执行创建订单的任务 */ private class VoucherOrderHeader implements Runnable { @Override public void run() { //如果队列中没有数据那么take()会卡在这个地方。有的时候再执行。 while (true) { try { //1.获取redis中消息队列的订单信息 //1.1创建一个消费者组(直接使用命令来创建,因为只需要创建一次即可,不用改变消费者组) //命令:xgroup create streams.voucher.order g1 0 mkstream //1.2 读取消费者组的信息, //命令:XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...] //最好消费者、组等信息可以写成yaml文件来获取 List<MapRecord<String, Object, Object>> list = redisUtils.xReadGroup(RedisConstants.GROUP_NAME, RedisConstants.CONSUMER_NAME,1, 2, RedisConstants.STREAMS_VOUCHER_ORDER,ReadOffset.lastConsumed()); // 判断消息是否为空,如果为空就说明消息队列中没有信息,则继续下一次循环 if (null==list || list.isEmpty()) { continue; } handleMessageAndXack(list); } catch (Exception e) { log.error("队列消息队列处理订单异常", e); //如果在发送消息的时候出现了异常,那么消息就会存入到pending-list中去, while (true){ //1.读取消息 List<MapRecord<String, Object, Object>> list = redisUtils.xReadGroup(RedisConstants.GROUP_NAME, RedisConstants.CONSUMER_NAME,1, 2, RedisConstants.STREAMS_VOUCHER_ORDER,ReadOffset.from("0")); //2.判断消息是否为空 ,如果为空就说明pending-list中没有信息,就结束循环 if (null==list || list.isEmpty()) { break; } try { handleMessageAndXack(list); } catch (Exception e1) { log.error("队列pending-list处理订单异常", e); //如果在处理消息的时候发生了异常,那么继续循环处理信息 continue; } } } } } } /** * 根据消费者组读取到的消息去下单,并且确认消息 * @param list */ private void handleMessageAndXack(List<MapRecord<String, Object, Object>> list) { //1.3 解析读取的信息 MapRecord<String, Object, Object> record = list.get(0); Map<Object, Object> recordValue = record.getValue(); VoucherOrder voucherOrder = BeanUtil.toBean(recordValue, VoucherOrder.class); //2.创建订单、扣减库存 createVoucherOrderCluster(voucherOrder); //3.确认消息,XACK KEY GROUP ID... String recordId = record.getId().toString(); redisUtils.xAck(RedisConstants.STREAMS_VOUCHER_ORDER, RedisConstants.GROUP_NAME, recordId); } /** * 秒杀卷的抢购方法 * * @param voucherId * @return */ @Override public Result seckillVoucher(Long voucherId) { //1.判断用户是否登录过 Long userId = UserHolder.getUser().getId(); if (userId == null) { return Result.fail("对不起,请先登录"); } // 2创建订单信息 long orderId = redisIdGenerator.nextId("order"); //3.执行lua脚本,返回结果信息 注意ARGV必须是String类型的,否则会报错 Long resultInfo = redisUtils.execute(SECKILL_VOUCHER, Collections.singletonList(RedisConstants.STREAMS_VOUCHER_ORDER), userId.toString(),String.valueOf(voucherId),String.valueOf(orderId)); //4.根据返回结果信息判断该用户是否具有秒杀资格。或者说是否拿到了秒杀的令牌。 switch (resultInfo.intValue()) { case -1: //4.1 redis缓存中没有库存的缓存数据 return Result.fail("活动未开始"); case 1: //4.2 redis缓存中库存为0 return Result.fail("优惠卷已抢空,欢迎下次光临!"); case 2: //4.3 重复下单 return Result.fail("对不起,一个用户只能抢购一次!"); case 0: //4.4 获取到抢购成功的令牌,执行把订单信息加入阻塞队列:v1.0采用阻塞队列来创建订单 return Result.ok(orderId); default: //4.5 其他,说明lua脚本有问题 return Result.fail("lua脚本结果错误!"); } } /** * 创建订单信息 * * @param voucherOrder * @return */ @Transactional(rollbackFor = Exception.class) public void createVoucherOrderCluster(VoucherOrder voucherOrder) { Long userId = voucherOrder.getUserId(); Long voucherId = voucherOrder.getVoucherId(); String lockKey = RedisConstants.LOCK_SECKILL_VOUCHER + userId; RLock redissonClientLock = redissonClient.getLock(lockKey); //1.:使用redisson的重入锁来实现。无参时,默认锁的有效期是30s。比较建议使用无参、因为有看门狗机制, // 可以业务处理期间刷新有效期时间 boolean lockSuccess = redissonClientLock.tryLock(); //1.1判断该用户是否多次购买:目的是为了在用户还没有执行下单成功(存入数据库)。即:还处于程序中时同一用户请求的另一个线程插队执行下单。 if (!lockSuccess) { //直接返回失败,有些情况是递归重试,直到该用户成功为止。 log.error("对不起,一个用户只能抢购一次!"); return; } //1.2再次判断该用户是否多次购买:目的是为了下单成功,数据已经写入到表了,此时已经释放了锁,那么上一步互斥锁就没有意义了。 try { Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count(); if (count > 0) { log.error("对不起,一个用户只能抢购一次!"); return; } //2.充足时, // 2.1扣减库存 boolean deductionFlag = seckillVoucherService.update().setSql("stock=stock-1") .eq("voucher_id", voucherId).gt("stock", 0) .update(); if (!deductionFlag) { //扣减库存失败 log.error("优惠卷已抢空,欢迎下次光临!"); return; } // 2.2创建订单信息 save(voucherOrder); } finally { //3.使用redisson的方式释放锁 redissonClientLock.unlock(); } } }

2.Lua脚本实现一人一单,并且将具有秒杀资格的用户添加到streams.voucher.order消息队列中去。

--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by TH
--- DateTime: 2022/4/5 21:51
---
---1.定义参数
---1.1用户ID-userId;
local userId=ARGV[1];
---1.2优惠卷ID-voucherId;
local voucherId=ARGV[2];
---1.3订单ID-orderId
local orderId=ARGV[3];
--1.4消费队列key
local streamKey=KEYS[1];
--某抢购卷的库存数量key如:seckillvoucher:stock:12 固定部分+秒杀卷的id
local seckillVoucherKey="seckillvoucher:stock:" .. voucherId;
--下单的用户key
local seckillUserOrderKey="seckillUser:order:" .. voucherId;
---2.判断是否存在库存的缓存数据
local stockExists=redis.call("exists",seckillVoucherKey);
if tonumber(stockExists)==0 then
    --2.1 没有库存缓存数据,返回-1,表示活动未开始
    return -1;
end
---3.获取库存数量
local stock=redis.call("get",seckillVoucherKey);
---4.判断库存是否充足'
if tonumber(stock)< 1 then
    --4.1库存不足返回1:表示活动已结束
  return 1
end
---5.判断用户是否已经下单,即判断用户是否存在于set集合中
local isUserExist=redis.call("sismember",seckillUserOrderKey,userId);
--不管是userOrderKey还是userId 不存在于缓存中都返回isUserExist==0
if tonumber(isUserExist)==1 then
    --5.1 存在用户,表示已经下过单了
    return 2;
end
---6.该用户获取到了抢购的资格
--6.1 减少库存
redis.call("decr",seckillVoucherKey);
--6.2 把获取资格的用户加入到userOrderKey中去
redis.call("sadd",seckillUserOrderKey,userId);
---7.发送消息到队列Stream中添加有资格的订单信息 ;
---添加消息命令是:XADD key *|ID Field Value... 注意:Java中(使用消费者组模式来读取数据),Field最好与我们自己需要对应实体的属性一致。
---streamKey=streams.voucher.order
redis.call("xadd",streamKey,"*","userId",userId,"voucherId",voucherId,"id",orderId);
return 0;

3.其他业务代码

A:Reddisson的bean配置类

package com.hmdp.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.net.URL;

/**
 * reddisson的bean配置类
 * @author TH
 * @date 2022/4/1
 */
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){
        //1.配置类
        Config config = new Config();
        try {
            //读取配置文件
            URL url = RedissonConfig.class.getClassLoader().getResource("redisson-config.yaml");
            config = Config.fromYAML(url);
            //直接写死的方式
//config.useSingleServer().setAddress("redis://127.0.0.1:6379").setPassword("redis6379").setDatabase(0);
        } catch (Exception e) {
            e.printStackTrace();
        }
        //创建RedissonClient对象
        return Redisson.create(config);
    }
}

B:Redisson的相关配置的yaml文件

#redisson的相关配置
singleServerConfig:
  idleConnectionTimeout: 10000 #连接空闲超时,单位:毫秒
  connectTimeout: 10000 #连接超时,单位:毫秒 同节点建立连接时的等待超时。时间单位是毫秒。
  timeout: 3000 #命令等待超时,单位:毫秒 等待节点回复命令的时间。该时间从命令发送成功时开始计时。
  retryAttempts: 3 #命令失败重试次数
  retryInterval: 1500 #命令重试发送时间间隔,单位:毫秒
  password: "redis123" # redis密码
  subscriptionsPerConnection: 5 #单个连接最大订阅数量
  clientName: null #redis 客户端名称
  address: "redis://127.0.0.1:6379" #redis地址
  subscriptionConnectionMinimumIdleSize: 1 #发布和订阅连接的最小空闲连接数
  subscriptionConnectionPoolSize: 50 #发布和订阅连接池大小
  connectionMinimumIdleSize: 32 #最小空闲连接数
  connectionPoolSize: 64 #连接池大小 默认值:64
  database: 1 #数据库编号
  dnsMonitoringInterval: 5000 #DNS监测时间间隔,单位:毫秒
threads: 0
nettyThreads: 0

C:常量定义

package com.hmdp.utils;


/**
 * Redis常量的 定义
 */
public class RedisConstants {

    /**
     * 登录发送验证码的缓存前缀key以及TTL
     */
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 5L;
    /**
     * 登录成功后的一个token缓存前缀key以及TTL
     */
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;
    /**
     * 店铺详情的缓存前缀key以及两种TTL
     */
    public static final String CACHE_SHOP = "cache:shop:";
    public static final Long CACHE_SHOP_TTL = 30L;
    public static final Long CACHE_SHOP_NULL_TTL = 5L;
    /**
     * 缓存垫布类型的缓存KEY,全称
     */
    public static final String CACHE_SHOPTYPE_LIST = "cache:shopType:list";
    /**
     * 店铺详情互斥锁:练习缓存穿透、击穿、雪崩使用的,以及过期时间TTL
     */
    public static final String LOCK_SHOP = "lock:shop:";
    public static final Long LOCK_SHOP_TTL = 3L;
    /**
     * 秒杀优惠卷互斥锁的以及TTL
     */
    public static final String LOCK_SECKILL_VOUCHER = "lock:voucher:";
    public static final Long LOCK_SECKILL_VOUCHER_TTL = 5L;
    /**
     * 秒杀卷库存key前缀:练习阻塞队列的时候秒杀下单
     */
    public static final String SECKILL_VOUCHER_KEY = "seckillvoucher:stock:";
    /**
     * 秒杀下单用户的key前缀:练习阻塞队列的时候秒杀下单
     */
    public static final String SECKILL_USERORDER_KEY = "seckillUser:order:";

    /**
     * 消息队列的全key
     */
    public static final  String STREAMS_VOUCHER_ORDER="streams.voucher.order";
    /**
     * 消费者组的名称
     */
    public static final  String GROUP_NAME="g1";
    /**
     * 消费者的名称
     */
    public static final  String CONSUMER_NAME="c1";
}

E:ID生成策略——基于redis生成全局唯一ID,单体、集群、分布式均适用:请点我…
F:Redis常用方法工具类:请点我…
G:Redis自定义redisTemplate序列化配置:请点我…

三 补充:相关的Stream的命令

#1.创建一个stream类型的数据
  XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]
  summary: Appends a new entry to a stream
  since: 5.0.0
  group: stream
#列子:xadd streams:order:12 * name jack age 12
#2. 读取某个stream类型的数据
  XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
  summary: Return never seen elements in multiple streams, with IDs greater than the ones reported by the caller for each stream. Can block.
  since: 5.0.0
  group: stream
#列子: xread streams streams:order:12 0 --0表示从第一条消息开始读取,#而$表示读取最新的一条消息
#3.创建一个消费者组
 XGROUP [CREATE key groupname ID|$ [MKSTREAM]] 
  summary: Create, destroy, and manage consumer groups.
  since: 5.0.0
  group: stream
#列子 xgroup create streams:order:12 g1 0 mkstream   
## 队列的key ;g1:组ID ;0 从消息队列中第一个获取;mkstream没有key的队列就创建队列
#3.读取消费者组
 XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  summary: Return new entries from a stream using a consumer group, or access the history of the pending entries for a given consumer. Can block.
  since: 5.0.0
  group: stream
  说明: 注意所有的大写单词都属于命令的单词,不允许写错或者不写。
  1、必穿参数:group——组ID;consumer——消费者id ; key——读取队列为key的消息 ID-:‘>’:表示为读取消息队列中最新的消息;‘0’:表示从pending-list中去读取消息
  2、非必穿参数:count——1次读取的数量; millisenconds-:阻塞标识BLOCK,阻塞毫秒数;NOACK-是否需要消费者确认消息,基本上不用;
#例子 xreadgroup group g1 c1 count 1 block 10000 streams streams:order:12 >
#4.确认消费者消费了消息
  XACK key group ID [ID ...]
  summary: Marks a pending message as correctly processed, effectively removing it from the pending entries list of the consumer group. 
  Return value of the command is the number of messages successfully acknowledged, that is, the IDs we were actually able to resolve in the PEL.
  since: 5.0.0
  group: stream
#例子: xack streams:order:12 g1 "1649477570999-0" :读取到了返回1,没有读取到就返回0

#5、 删除一个消费组
xgroup destroy streams:order:12 g1

其他命令可以去官网学习:官网地址:https://redis.io
秒杀抢购异步下单:基于Redis的消息队列秒杀抢购异步下单功能_第2张图片

最后:感谢B站黑马程序员-虎哥的redis教程!!!如果有朋友想要学习可以建议看去b站找他。

你可能感兴趣的:(redis,NoSQL,java,缓存,java,redis)