一个基于Redis+Redisson+阻塞队列模式的一种异步秒杀下单代码,提高并发能力。

学习Redis时,练习的实战项目代码——基于阻塞队列模式的异步秒杀下单。

说明: 企业级开发都不会采用该模式来实现异步秒杀的。这儿只是练习而使用的。电商异步秒杀都是采用的基于专门的消息中间件来完成异步秒杀的,除了异步方式不具有参考价值,但万变不离其宗,道理还是相通的,秒杀资格判断和下单部分还是有参考价值的。

一、缺点(问题)

1. 并发量大了容易内存溢出
2. 数据不安全,容易丢失

二、业务需求

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

三、需求分析

  1. 大致流程。
    来源于黑马程序员的redis课程
  2. 利用lua脚本保证原子性,加上set集合的唯一性来保证一人一单的问题和超卖问题,然后再次利用分布式锁兜底保证一人一单、超卖等问题。

四、项目部分代码

1、业务代码
package com.hmdp.service.impl;

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.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

/**
 * <p>
 * 服务实现类:实现异步秒杀抢购卷
 *
 * </p>
 *
 * @author TH
 * @since 2022-04-02
 */
@Slf4j
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Autowired
    private SeckillVoucherServiceImpl seckillVoucherService;
    //全局唯一id生成器
    @Autowired
    private RedisIdGenerator redisIdGenerator;
    //redis的常用方法工具类
    @Autowired
    private RedisUtils redisUtils;
    //redisson
    @Autowired
    private RedissonClient redissonClient;
    /**
     * 读取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"));
    }

    /**
     * 定义阻塞队列
     */
    private BlockingQueue<VoucherOrder> voucherOrderQueueTask = new ArrayBlockingQueue<>(1024 * 1024);
    /**
     * 手动定义核心线程-10,最大线程-10的 线程池
     */
    private final static ExecutorService SECKILL_ORDER_EXECUTOR = new ThreadPoolExecutor(10, 10, 
            0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), 
            new NamedThreadFactory("执行秒杀订单消息队列", false));
    /**
     * 定义一个内部类,实现 Runnable接口,执行创建订单的任务
     */
    private class VoucherOrderHeader implements Runnable {
        @Override
        public void run() {
            //如果队列中没有数据那么take()会卡在这个地方。有的时候再执行。
            while (true) {
                try {
                    //1.获取队列的订单信息
                    VoucherOrder voucherOrder = voucherOrderQueueTask.take();
                    //2.创建订单、扣减库存
                    createVoucherOrderCluster(voucherOrder);
                } catch (Exception e) {
                    log.error("队列处理订单异常", e);
                }
            }
        }
    }

    /**
     * 定一个初始化方法:@PostConstruct在该类初始化完毕后就要执行该方法。
     * 任务必须在活动之前就要开始执行队列中的订单创建任务方法
     */
    @PostConstruct
    private void init() {
        //在初始化时执行任务。
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHeader());
    }

    /**
     * 秒杀卷的抢购方法
     *
     * @param voucherId
     * @return
     */
    @Override
    public Result seckillVoucher(Long voucherId) {
        //1.判断用户是否登录过
        Long userId = UserHolder.getUser().getId();
        if (userId == null) {
            return Result.fail("对不起,请先登录");
        }
        //2.定义key的集合
        List<String> keysList = new ArrayList<>(2);
        //2.1设置库存的key
        String seckillVoucherKey = RedisConstants.SECKILL_VOUCHER_KEY + voucherId;
        keysList.add(seckillVoucherKey);
        //2.2 设置下单用户的key
        String seckillUserorderKey = RedisConstants.SECKILL_USERORDER_KEY + voucherId;
        keysList.add(seckillUserorderKey);
        //3.执行lua脚本,返回结果信息
        Long resultInfo = redisUtils.execute(SECKILL_VOUCHER, keysList, userId.toString());
        //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 addQueue(voucherId, userId);
            default:
                //4.5 其他,说明lua脚本有问题
                return Result.fail("lua脚本结果错误!");
        }
    }

    /**
     * 获取到抢购成功的令牌,执行把订单信息加入阻塞队列:采用阻塞队列来创建订单
     *
     * @param voucherId
     * @param userId
     * @since v1.0
     * @return
     */
    private Result addQueue(Long voucherId, Long userId) {
        // 6.2创建订单信息
        long id = redisIdGenerator.nextId("order");
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setId(id);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        //7.把订单信息加入到阻塞队列中
        voucherOrderQueueTask.add(voucherOrder);
        //8.返回订单id
        return Result.ok(id);
    }

    /**
     * 创建订单信息:用互斥锁作为兜底,确保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脚本
---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by TH
--- DateTime: 2022/4/5 21:51
---
---1.定义参数
local userId=ARGV[1];
--local voucherId=ARGV[2];
--某抢购卷的库存数量key如:seckillvoucher:stock:12 固定部分+秒杀卷的id,
--如果不想传key的参数,也可以在这儿拼接库存key,秒杀用户key。
--如:local seckillVoucherKey="seckillvoucher:stock:" .. voucherId;
local seckillVoucherKey=KEYS[1];
--下单的用户key:seckillUser:order:12
local seckillUserOrderKey=KEYS[2];
---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);
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序列化配置:请点我…

最后:感谢B站黑马程序员-虎哥的redis教程!!!如果有朋友想要学习建议去b站找他。
  • 3
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
是的,Redis可以用作异步队列。Redis提供了列表(List)数据结构,可以很方便地作为任务队列使用。 下面是使用Redis作为异步队列的一般步骤: 1. 将任务数据序列化为字符串,例如使用JSON格式。 2. 使用Redis的LPUSH命令将任务数据推送到列表中,表示将任务添加到队列的头部。 3. 使用Redis的BRPOP命令阻塞地(或使用RPOP命令轮询地)从队列尾部获取任务数据。BRPOP命令可以设置阻塞超时时间,当队列中没有任务时,会等待新任务的到来。 Redis作为异步队列的优点包括: 1. 简单易用:Redis提供了直观的列表操作命令,使用起来非常简单。 2. 高性能:Redis的内存存储和基于事件驱动的设计使得它具有出色的性能,可以处理高并发的任务。 3. 持久化:Redis可以通过持久化机制将队列数据保存到磁盘,即使出现故障也能保证数据不丢失。 然而,Redis作为异步队列也存在一些缺点: 1. 有界限:Redis的内存有限,当队列中的任务数量超过一定阈值时,可能会导致内存溢出或性能下降。 2. 单点故障:如果Redis作为任务队列的唯一节点,当Redis节点发生故障时,整个队列的可用性将受到影响。 3. 无法保证严格顺序:Redis的列表是无序的,任务的处理顺序可能会受到影响。 至于生产一次消费多次,Redis作为异步队列默认是一次性消费的,即任务被一个消费者获取后就会从队列中删除。如果要实现一次生产多次消费,可以通过在任务处理完成后手动将任务重新添加到队列中的方式来实现。 需要注意的是,这种方式可能会引入重复消费的问题,需要在业务逻辑中进行去重判断或使用幂等操作来保证任务只被处理一次。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值