SpringBoot整合Redis实现优惠券秒杀服务

一、服务整体流程

优惠券秒杀这一块的逻辑总的来说就两个方法,新增秒杀券和秒杀业务。
新增优惠券只需要前端发送优惠券详情给后端,后端存数据库和redis即可;
秒杀优惠券的流程也并不复杂,但实现相对比较麻烦,要考虑的问题比较多,如:超卖、一人一单、分布式锁、处理性能等等问题。

更加详细的分析点击这里查看。

新增秒杀券流程:
① 前端将要新增的秒杀券信息发送至后端接口
② 后端接口将信息处理后存入数据库Redis

秒杀优惠券流程:
① 前端点击抢购优惠券,将优惠券ID发送至后台
② 后端接收前端请求,首先根据优惠券ID查询该优惠券秒杀是否开始或结束,若优惠券秒杀已经开始且尚未结束,则进一步查询优惠券库存是否充足
③ 若②中的条件都满足了,则获取当前用户的ID,根据用户ID和优惠券ID查询该用户是否已经购买了该优惠券
④ 若用户没有购买过该订单,则生成对应的订单,同时修改数据库和Redis中的相关信息
⑤ 返回本次订单ID

二、具体业务逻辑实现

注释已经写的很明白了,不多做赘述。
持久层框架用的Mybatis-Plus。

1. 新增秒杀券

1.1. Controller层

	@PostMapping("seckill")
    public Result addSeckillVoucher(@RequestBody Voucher voucher) {//参数为优惠券具体信息
        voucherService.addSeckillVoucher(voucher);
        return Result.ok(voucher.getId());
    }

1.2. Service层

    @Override
    @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(RedisConstants.SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());
    }

2. 秒杀优惠券

2.1. 具体业务流程以及细节说明

① 前端发送请求(携带订单优惠券)到后端;
② 后端首先会进入拦截器,拦截器会从请求头中拿到带有用户信息的Token,存入当前线程的ThreadLocal中,具体流程可以点击这里查看(这一块是基于Redis实现的登录,本文不多赘述);这一步主要是将当前用户信息拿到,也可以通过JWT实现单点登录的方法,从token中拿到用户信息;
③ 通过拦截器后正式开始进行秒杀业务,Controller将优惠券id交给Service处理
④ 通过static静态代码块,在类加载的时候就开始读取LUA脚本,在执行秒杀业务的方法时,首先获取用户ID,根据用户ID和前端传递的秒杀券ID,调用LUA脚本校验时间、库存以及用户是否下单,如果通过验证则扣减库存和保存用户数据。这一步都是调用LUA脚本在Redis中进行的操作(本次省略后端校验时间,时间校验也就是存redis的时候多存两个日期(hash形式),调用LUA的时候多传一个时间参数)。
⑤ 执行完LUA获取到执行结果,根据执行结果判断是否可以购买,不可以直接返回原因,可以继续向下
⑥ 有购买资格,把下单信息保存至阻塞队列中。
⑦ 阻塞队列此处用的是JUC中的阻塞队列。利用@PostConstruct注解,在项目启动的时候就开始执行一个异步线程,一旦阻塞队列中有了消息,就开始处理消息,没有则等待消息。
⑧ 首先获取到用户ID,然后利用Redisson创建分布式锁,获取锁,获取不到锁则直接返回“不允许重复下单”。当获取到锁之后,扣减库存并创建订单,最后释放锁,结束。

2.2. Controller

	@PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {//参数为优惠券ID
        return voucherOrderService.seckillVoucher(voucherId);
    }

2.3. 全局ID生成器(用来生成订单ID)

为了保证ID的安全性(防止被用户根据ID猜到相关信息、避免受到表单数据量的限制),使用全局ID生成器。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足唯一性、高可用、高性能、递增性、安全性。

@Component
public class RedisIDWorker {
	/**
		长度为64位的ID
		符号位:1bit,永远为0
		时间戳:31bit,以秒为单位,可以用69年
		序列号:32bit,支持每秒产生2^32个不同ID
	*/
	
    //开始时间戳,2023.1.1 00:00:00
    private static final long BEGIN_TIMESTAMP = 1672531200L;
    //序列号位数
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;
    public RedisIDWorker(StringRedisTemplate stringRedisTemplate){
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public Long nextId(String keyPrefix){//前缀用于区分不同业务
        //1. 生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;
        //2. 生成序列号
        //2.1 获取当前日期,获取到天
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //2.2 自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        //3. 拼接并返回
        return timeStamp << COUNT_BITS | count;
    }
}

2.4. Service 层

这里阻塞队列用的是JUC里面的阻塞队列,最好自行改成消息中间件

@Service
@Slf4j
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIDWorker redisIDWorker;
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Resource
    private RedissonClient redissonClient;

	//读取LUA脚本
    private static DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

	//线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
    //当前类代理对象
    private IVoucherOrderService proxy;

	//项目启动便直接开启异步线程
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }
    //阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
	//异步线程,处理订单
    private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    //1. 获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2. 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常", e);
                }
            }
        }
    }
	//创建订单
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1. 获取用户
        Long userId = voucherOrder.getUserId();
        //2. 创建锁对象
        RLock lock = redissonClient.getLock("order:" + userId);
        //3. 获取锁 tryLock()默认 -1 30 Second
        boolean isLock = lock.tryLock();
        //4. 判断是否获取锁成功
        if (!isLock) {
            //获取所失败,返回错误或重试
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            lock.unlock();
        }
    }
    //秒杀业务类
    @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()
        );
        int r = result.intValue();
        //2. 判断结果是否为0
        if (r != 0) {
            //2.1 不为0,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //2.2 为0,有购买资格,把下单信息保存至阻塞队列
        VoucherOrder voucherOrder = new VoucherOrder();
        //2.3 订单ID--全局唯一ID生成器
        Long orderId = redisIDWorker.nextId("order");
        voucherOrder.setId(orderId);
        //2.4 用户id
        voucherOrder.setUserId(userId);
        //2.5 代金券id
        voucherOrder.setVoucherId(voucherId);
        //2.6 放入阻塞队列
        orderTasks.add(voucherOrder);
        //3. 获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //4. 返回订单id
        return Result.ok(orderId);
    }
	//处理订单数据(存库、扣减库存等)
    @Transactional
    @Override
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        //5. 一人一单
        Long userId = voucherOrder.getUserId();
        Long voucherId = voucherOrder.getVoucherId();
        //5.1 查询订单(这个部分其实没必要,兜底)
        Integer count = lambdaQuery().eq(VoucherOrder::getUserId, userId).eq(VoucherOrder::getVoucherId, voucherId).count();
        //5.2 判断是否存在
        if (count > 0) {
            //用户已经购买过了
            log.error("用户已经购买过一次!");
            return;
        }
        //6. 扣减库存
        boolean updateFlag = seckillVoucherService.lambdaUpdate()
                .setSql("stock = stock -1")
                .eq(SeckillVoucher::getVoucherId, voucherId)
                .gt(SeckillVoucher::getStock, 0)//CAS方式乐观锁,因为mysql在update的时候有行锁是串行的,所以可以
                .update();
        if (!updateFlag) {//扣减失败
            log.error("库存不足");
            return;
        }
        //7. 创建订单
        save(voucherOrder);
    }
}

2.5. LUA脚本

-- 1.参数列表
-- 1.1优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey)) <= 0) then
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 3.3.存在,说明是重复下单
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby',stockKey,-1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0

结束

  • 0
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浅梦曾倾

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值