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

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

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

一、缺点(问题)

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

二、业务需求

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

三、需求分析

  1. 大致流程。
    一个基于Redis+Redisson+阻塞队列模式的一种异步秒杀下单代码,提高并发能力。_第1张图片
  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.*;

/**
 * 

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

* * @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站找他。

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