来源:博客园
功能03-优惠券秒杀044.功能03-优惠券秒杀4.7Redis优化秒杀4.7.1优化分析现在来回顾一下优惠券秒杀业务的两个主要问题:
(1)首先是对优惠券的扣减,需要防止库存超卖现象;
(资料图片)
(2)其次,需要对每个用户下单数量进行限制,实现一人一单的功能。
处理秒杀优惠券的业务:
先根据获取到的优惠券id,先到数据库中判断是否存在,若存在;
再判断优惠券是否在设定的有效期,如果是,则进行一人一单的业务处理:
2.1 利用分布式锁,key存储的是order+用户id:当同一时间,一个用户发起了多个线程请求,其中的某个线程获取到了锁,由于互斥性,无论这个用户发起了多少个请求,只有一个线程能进入接下来的业务。(不同用户发起的不同线程之间不影响)
2.2 接下来,查询该用户是否已经买过这张秒杀券了,如果买过了,则不允许重复购买,如果是第一次购买,就进入到防止超卖的业务:
2.2.1 到这一步可能会有多个用户的单个线程进入这个业务,为了防止超卖问题,这里使用乐观锁方案。乐观锁的关键是判断之前查询到的数据是否有被修改过,但缺点是失败率高,因此我们又使用了mysql的行锁解决。(详见day05-优惠券秒杀01)因为整个过程有很多对数据库的操作(查询优惠券、查询订单、减库存、创建订单),因此这个业务的性能并不是很好:
优化前:
上述业务看似复杂,实际上只有两个过程:(1)对于用户资格的校验:库存够不够,该用户买没买过(一人一单)(2)然后才是真正的下单业务。
我们可以对这两个过程进行分离,别分使用两个线程进行操作:主线程负责对用户购买资格的校验,如果有购买的资格,再开启一个独立的线程,来处理耗时较久的减库存和创建订单操作。
为了提高效率,使用redis判断秒杀库存和校验一人一单,如果校验通过,则redis会记录优惠券信息、用户信息、订单信息到阻塞队列。一方面:tomcat服务器去读取这个队列的信息,完成下单。另一方面:redis给用户返回一个订单号,代表该用户抢单成功,用户可以根据这个订单号去付款。
优化后:
这样,整个秒杀流程就变为:直接在redis中判断用户的秒杀资格和库存,然后将信息保存到队列里。
秒杀业务的流程变短了,而且是基于Redis,性能得到很大的提升,整个业务的吞吐能力、并发能力可以大大提高了。
那么,如何在Redis中完成对秒杀库存的判断和一人一单的判断呢?
首先是对数据的存储:
使用String类型,key存储 业务前缀+秒杀券id,value保存优惠券对应的库存;因为要保证一人一单,使用set类型,key保存业务前缀+秒杀券id,value保存下单的用户id,保证元素不可重复。优化后,在Redis中需要执行的具体流程:
异步秒杀优化总结:
上述的优化操作,一方面缩短了秒杀业务的流程,从而大大提高了秒杀业务的并发;另一方面,redis的操作和数据库的操作是异步的,对数据库操作的时效性不再要求那么高了,减轻了数据库的压力。
4.7.2代码实现改进秒杀业务,提高并发性能。需求:
新增秒杀优惠券的同时,将优惠券信息保存到Redis中基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢占成功如果抢占成功,将优惠券id和用户id封装后存入阻塞队列开启线程任务,不断地从阻塞队列中获取信息,实现异步下单功能需求1:新增秒杀优惠券的同时,将优惠券信息保存到Redis中
(1.1)修改IVoucherService
package com.hmdp.service;import com.baomidou.mybatisplus.extension.service.IService;import com.hmdp.dto.Result;import com.hmdp.entity.Voucher;/** * 服务类 * * @author 李 * @version 1.0 */public interface IVoucherService extends IService { void addSeckillVoucher(Voucher voucher);}
(1.2)修改VoucherServiceImpl
package com.hmdp.service.impl;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.hmdp.entity.Voucher;import com.hmdp.mapper.VoucherMapper;import com.hmdp.entity.SeckillVoucher;import com.hmdp.service.ISeckillVoucherService;import com.hmdp.service.IVoucherService;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import javax.annotation.Resource;import static com.hmdp.utils.RedisConstants.SECKILL_STOCK_KEY;/** * 服务实现类 * * @author 李 * @version 1.0 */@Servicepublic class VoucherServiceImpl extends ServiceImpl implements IVoucherService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private StringRedisTemplate stringRedisTemplate; @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(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); }}
(1.3)修改VoucherController
package com.hmdp.controller;import com.hmdp.dto.Result;import com.hmdp.entity.Voucher;import com.hmdp.service.IVoucherService;import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;/** * 前端控制器 * * @author 李 * @version 1.0 */@RestController@RequestMapping("/voucher")public class VoucherController { @Resource private IVoucherService voucherService; /** * 新增秒杀券 * @param voucher 优惠券信息,包含秒杀信息 * @return 优惠券id */ @PostMapping("seckill") public Result addSeckillVoucher(@RequestBody Voucher voucher) { voucherService.addSeckillVoucher(voucher); return Result.ok(voucher.getId()); }}
(1.4)使用postman进行测试,返回结过显示插入成功,data为插入的秒杀券的id
数据库和Redis中也分别插入成功了:
需求2:基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢占成功
在resources目录下新建一个Lua脚本
seckill.lua:
-- 1.参数列表-- 1.1 优惠券idlocal voucherId = ARGV[1]-- 1.2 用户idlocal userId = ARGV[2]-- 2.数据key-- 2.1 库存keylocal stockKey = "seckill:stock:" .. voucherId-- 2.2 订单keylocal orderKey = "seckill:order" .. voucherId-- 3.脚本业务-- 3.1判断库存是否充足 get stockKeyif (tonumber(redis.call("get", stockKey)) <= 0) then -- 3.2库存不足,返回1 return 1end-- 3.3库存充足,判断用户是否下过单(判断用户id是否在订单key对应的集合中)-- sismember orderKey userIdif (redis.call("sismember", orderKey, userId) == 1) then -- 3.4 若存在,说明是重复下单,返回2 return 2end-- 3.5 扣库存 incrby stockKey -1redis.call("incrby", stockKey, -1)-- 3.6 下单(保存用户) sadd orderKey userIdredis.call("sadd", orderKey, userId)return 0
需求3:如果抢占成功,将优惠券id和用户id封装后存入阻塞队列
需求4:开启线程任务,不断地从阻塞队列中获取信息,实现异步下单功能
修改VoucherOrderServiceImpl:
请求先来到seckillVoucher()方法,该方法先调用lua脚本,尝试判断用户有没有购买资格、库存是否充足。如果有,创建订单,放到阻塞对列中。此时整个秒杀业务就结束了,用户可以得到结果。创建阻塞队列和线程池,在类初始化的时候就执行线程池。线程池的业务就是不断地从阻塞队列中获取订单信息,然后创建订单(调用handleVoucherOrder()方法)package com.hmdp.service.impl;import com.hmdp.dto.Result;import com.hmdp.entity.VoucherOrder;import com.hmdp.mapper.VoucherOrderMapper;import com.hmdp.service.ISeckillVoucherService;import com.hmdp.service.IVoucherOrderService;import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;import com.hmdp.utils.RedisIdWorker;import com.hmdp.utils.UserHolder;import lombok.extern.slf4j.Slf4j;import org.redisson.api.RLock;import org.redisson.api.RedissonClient;import org.springframework.aop.framework.AopContext;import org.springframework.core.io.ClassPathResource;import org.springframework.data.redis.core.StringRedisTemplate;import org.springframework.data.redis.core.script.DefaultRedisScript;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;import javax.annotation.PostConstruct;import javax.annotation.Resource;import java.util.Collections;import java.util.concurrent.*;/** * 服务实现类 * * @author 李 * @version 1.0 */@Service@Slf4jpublic class VoucherOrderServiceImpl extends ServiceImpl implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIdWorker redisIdWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; private static final DefaultRedisScript SECKILL_SCRIPT; //类一加载就初始化脚本 static { SECKILL_SCRIPT = new DefaultRedisScript<>(); SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); SECKILL_SCRIPT.setResultType(Long.class); } //阻塞队列:当一个线程尝试从队列中获取元素时,如果队列中没有元素,那么该线程就会被阻塞,直到队列中有元素,线程才会被唤醒并获取元素 private BlockingQueue orderTasks = new ArrayBlockingQueue<>(1024 * 1024); //线程池 private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor(); //在当前类初始化完毕之后就执行 @PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } //执行异步操作,从阻塞队列中获取订单 private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { try { //1.获取队列中的订单信息 /* take()--获取和删除阻塞对列中的头部,如果需要则等待直到元素可用 (因此不必担心这里的死循环会增加cpu的负担) */ VoucherOrder voucherOrder = orderTasks.take(); //2.创建订单 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("处理订单异常", e); } } } } private IVoucherOrderService proxy; private void handleVoucherOrder(VoucherOrder voucherOrder) { //获取用户(因为目前的是线程池对象,不是主线程,不能使用UserHolder从ThreadLocal中获取用户id) Long userId = voucherOrder.getUserId(); //创建锁对象,指定锁的名称 RLock lock = redissonClient.getLock("lock:order:" + userId); //获取锁(可重入锁) boolean isLock = lock.tryLock(); //判断是否获取锁成功 if (!isLock) { //获取锁失败 log.error("不允许重复下单"); } try { proxy.createVoucherOrder(voucherOrder); } finally { //释放锁 lock.unlock(); } } @Override public Result seckillVoucher(Long voucherId) { //获取用户id 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) { //2.1如果不为0,代表没有购买资格 return Result.fail(r == 1 ? "库存不足" : "不能重复下单"); } //2.2如果为0,代表有购买资格,将下单信息保存到阻塞对列中 VoucherOrder voucherOrder = new VoucherOrder(); //设置订单id long orderId = redisIdWorker.nextId("order"); voucherOrder.setId(orderId); //设置用户id voucherOrder.setUserId(userId); //设置秒杀券id voucherOrder.setVoucherId(voucherId); //将上述信息保存到阻塞队列 orderTasks.add(voucherOrder); //3.获取代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); //4.返回订单id return Result.ok(0); } @Transactional public void createVoucherOrder(VoucherOrder voucherOrder) { //一人一单 Long userId = voucherOrder.getUserId(); //查询订单 int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count(); if (count > 0) {//说明已经该用户已经对该优惠券下过单了 log.error("用户已经购买过一次!"); return; } //库存充足,则扣减库存(操作秒杀券表) boolean success = seckillVoucherService.update() .setSql("stock = stock -1")//set stock = stock -1 //where voucher_id =? and stock>0 .gt("stock", 0).eq("voucher_id", voucherOrder.getVoucherId()).update(); if (!success) {//操作失败 log.error("秒杀券库存不足!"); return; } //将订单写入数据库(操作优惠券订单表) save(voucherOrder); }}
重启项目,进行测试:
(1)初始数据:
(2)使用jemeter进行测试:使用1000个不同的用户同时向服务器发送抢购秒杀券的请求
测试结果:可以看到平均响应实现为216毫秒,最小值为17毫秒,比之前平均500毫秒的响应时间缩短了一半。
4.7.3秒杀优化总结(1)秒杀业务的优化思路是什么?
先利用Redis完成库存余量判断、一人一单判断,完成抢单业务再将下单业务放入阻塞队列,利用独立线程异步下单(2)基于阻塞队列的异步秒杀存在哪些问题?
内存限制问题:
这里我们使用的是JDK里面的阻塞队列,它使用的是JVM里面的内存。如果不加以限制,在高并发的情况下,可能会有非常多的订单对象需要去创建,放入阻塞队列中,可能会导致内存溢出。虽然我们限制了队列的长度,但是如果队列存满了,再有新的订单来,就放不下了。
数据安全问题:
现在的代码基于内存来保存订单信息,如果服务器宕机了,那么阻塞队列中的所有订单信息将会丢失
我们将在接下来的分析中对上述两个问题进行解决。
4.8Redis消息队列实现异步秒杀要解决上面的两个问题,最佳的解决方案就是使用消息队列。
4.8.1什么是消息队列消息队列(Message Queue,简称MQ),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:
消息队列:存储和管理消息,也被称为消息代理(Message Broker)生产者:发送消息到消息队列消费者:从消息队列获取消息并处理消息4.8.2消息队列实现异步秒杀的优势使用消息队列实现异步秒杀的优势:
消息队列是JVM以外的独立服务,不受JVM内存的限制,这就解决了之前的内存限制问题消息队列不仅仅是做数据存储,它还要确保数据安全,即消息队列里的所有消息都要做持久化,这样不管是服务宕机还是重启,数据都不会丢失消息队列将消息投递给消费者之后,要求消费者做消息的确认。如果消息没有被确认,这个消息就会在队列中依然存在,下一次会再次投递给消费者,直到收到消息确认为止。当下比较知名的消息引擎,包括:ActiveMQ、RabbitMQ、Kafka、RocketMQ、Artemis 等
这里使用Redis实现消息队列:
Redis提供了三种不同的方式来实现消息队列:
list结构:基于List结构模拟消息队列PubSub:基本的点对点消息模型Stream:比较完善的消息队列模型4.8.3基于List结构模拟的消息队列Redis的List数据结构是一个双向链表,很容易模拟出队列效果。队列是入口和出口不在一边,我们可以利用:LPUSH结合RPOP、或者RPUSH结合LPOP来实现。
不过要注意的是,当队列中没有消息时,RPOP或LPOP操作会返回null,并不像JVM的阻塞队列那样会阻塞并等待消息。因此这里应该使用BRPOP或者BLPOP来实现阻塞效果。
BRPOP key [key ...] timeoutsummary: Remove and get the last element in a list, or block until one is availablesince: 2.0.0RPOP keysummary: Remove and get the last element in a listsince: 1.0.0
基于List的消息队列有哪些优缺点?
优点:
利用Redis存储,不受限于JVM内存上限基于Redis的持久化机制,数据安全性有保证可以满足消息有序性缺点:
无法避免消息丢失只支持单消费者4.8.4基于PubSub的消息队列PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或者多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。
SUBSCRIBE channel [channel]:订阅一个或者多个频道PUBLISH channel msg:向一个频道发送消息PSUBSCRIBE pattern [pattern]:订阅与pattern格式相匹配的所有频道基于PubSub的消息队列有哪些优缺点?
优点:采用发布订阅模型,支持多生产、多消费
缺点:
不支持数据持久化无法避免消息丢失消息堆积有上限,超出时数据丢失4.8.5基于Stream的消息队列Stream是Redis5.0引入的一种新的数据类型,可以实现一个功能非常完善的消息队列。
发送消息的命令:
标签: