秒杀P11-削峰限流、防刷

290 阅读11分钟

总图

image.png

扣减库存流程的代码

  • 消息队列rocket包下把生产者,消费者拆分
  • rocket/producer/LocalTransactionListenerImpl——本地事务监听器
  • rocket/consumer/IncreaseSalesConsumer——增加销量
  • rocket/consumer/DecreaseStockConsumer——减少库存

更新销量

service/impl/OrderServiceImpl

订单生成完以后,异步更新销量——简单的发送异步消息

  • 注入rocketMQ的bean
  • 声明JSONobject对象。存入消息内容:商品id,数量
  • 构建message对象,用MessageBuilder方法构建,构建时把jsonobject对象转换成字符串存入
  • rocketMQTemplate.asyncSend()方法,异步发送消息。消息转换成二进制数据存到消息队列
    • 参数:目标主题+标签,消息内容,回调函数,超时时间
    • 发送成功,记debug日志。失败记异常日志

image.png

更新销量消费者

rocket/consumer/IncreaseSalesConsumer

  • @Service注解。表明是独立的service
  • @RocketMQMessageListener注解,标记消息监听器,声明消费者消费哪个主题+标签。可以接受消息队列的消息
  • public class IncreaseSalesConsumer implements RocketMQListener<String>,声明消息格式String字符串
  • 注入itemservice
  • 实现RocketMQListener接口的onMessage方法
    • 当消息发送给队列后,队列会通知消费者消费
  • 调用itemservice的增加销量接口,修改MySQL

  • 发消息有数据转换过程。

    • 消息用对象封装,转换成字符串,底层转成二进制数据发给队列。
    • 队列通知消费者消费时,把二进制数据给消费者,消费者根据我们声明的泛型“RocketMQListener< String >”把二进制恢复成对应格式string。
    • 我们再把字符串抓换成业务逻辑需要的数据格式。

image.png

预减库存

test/SimulateBackendData.java——缓存预热

  • 测试代码中手动模拟缓存预热
  • 把MySQL中商品+库存的数据加载到缓存

image.png

service/impl/ItemServiceImpl——实操业务逻辑

扣减缓存库存

  • decreaseStockInCache()方法,传入商品id,数量,直接扣redis中库存。返回布尔类型
  • 如果数量大于库存,扣完是负数,回补相同数量库存。扣减库存失败。
  • 先判断参数合不合法
  • 声明key,拼一下格式。
  • 直接减库存。调用操作redis的decrement(key,数量)方法,减少key对应的value库存值。返回扣减后的新库存值result
  • 如果result < 0,说明库存不足,就不能让你扣库存。所以回补库存increaseStockInCache()方法,打印”回补库存完成“日志
  • 如果刚好库存扣为0,在redis中增加一个售罄标识kv数据。key是“售罄标识语句+商品id”,value是任意数字。打印“售罄标识完成”日志
  • 返回扣减后新库存值是否>=0。
    • result < 0, 返回false,扣减失败

image.png

回补库存 传入商品id数量,拼key,调用操作redis的increment()方法增加库存值

image.png

库存流水

库存流水其实就是库存日志表

  • item_stock_log表,id,item_id,amount,status。
  • id不用自增
  • status有0、1、2.生成时为0,更新成功1,更新失败2。

image.png

库存流水的增删改查操作。插件生成

  • entity/ItemStockLog 流水表的实体类
  • dao/ItemStockLogMapper 默认生成增删改查接口
  • resourses/mappers/ItemStockLogMapper.xml sql文件
  • 使用时直接调用mapper

扣减库存

rocket/consumer/DecreaseStockConsumer.java

  • 类似更新销量,
  • @Service注解
  • @RocketMQMessageListener监听器注解,用在消费者类上。参数定义:主题、消费者组、标签
  • 创建日志
  • 注入itemservice
  • onMessage(message)方法,参数传入接收到的string格式message。
    • 字符串消息转换成json对象
    • 从中解析出商品id和数量
  • 调用itemService中原先操作MySQL的方法,decreaseStock减少库存/持久化
    • 成功、出现问题,打印日志。

image.png

生成流水、发送消息,预减库存+创建订单、更新流水

rocket/producer/LocalTransactionListenerImpl 监听器实现类

  • 监听器监听消息时,可以从消息头中获取到当前消息的tag标签,以此处理不同业务

  • 返回值都是RocketMOLocalTransactionState消息队列本地事务状态

  • 注:一个项目只有一个处理事务消息的Listener,需要事务保障的业务,都需要走这个Listener。所以不止有扣减库存逻辑,还有创建订单等等逻辑,要判断

本地事务

——围绕创建订单

  • executeLocalTransaction()本地事务方法,返回值类型是RocketMOLocalTransactionState
  • 从message拿到消息头,获取消息的标签,转换成字符串
  • 如果标签是“减少库存”,就创建订单
  • 否则,就返回unknown。目前业务逻辑只有创建订单,没有其他操作
  • 如果期间出现异常报错,记录日志,rollback回滚

image.png

创建订单

  • createOrder()方法
  • 通过传入的arg参数,获取到/解析出:用户id,商品id,数量,活动id,流水id
  • 调用orderServicecreateOreder()方法,创建订单。
  • 因为引入了流水,创建订单后要更新流水,所以创建订单方法改了,传入参数增加流水id
  • 成功打印日志,返回commit。失败打印日志,返回rollback

image.png

回查

  • checkLocalTransaction()方法
  • 同样先获取tag标签,判断业务
  • 是扣减库存就调用当前类checkStockStatus()方法,检查库存状态
  • 否则,返回unknown
  • 如果出异常,打印日志,返回rollback

image.png

查库存状态(第六步)

  • checkStockStatus()方法
  • 从message中得到二进制数据,还原成string,再还原成jsonobject对象
  • 从jsonobject获取流水id
  • 调用itemService的findItemStockLogById()方法通过流水id查流水
  • 如果查不到流水,说明有问题,返回rollback回滚
  • 如果查到了状态为0(默认),说明没成功也没失败,返回unknown
  • 如果查到了状态为1,说明成功,commit。否则为2,失败,回滚rollback

以上本地事务监听器类,⭐消息业务逻辑包括:本地事务,创建订单,回查+查库存

service/impl/OrderServiceImpl.java——创建订单方法(实操业务逻辑),本地事务调用这个类

  1. 创建订单方法createOrder()。传入了流水id。

  2. @Transactional注解,有事务保障,让订单生成和流水更新,同时成功或失败

  3. 判断判断判断。。。。

  4. 预减库存。扣缓存的库存,返回标识successful是否成功。没成功抛异常。

  5. 成功,就创建订单。new一个order,存入各种数据。

  6. 异步更新销量

  7. 更新库存流水状态。因传入了流水id,直接调用itemService的updateItemStockLogStatus(流水id,状态值)方法,由流水id更新状态为1

    image.png

image.png

image.png

image.png

image.png

image.png

⭐包含预减库存、创建订单、异步更新销量、更新库存流水,的实操业务逻辑代码

⭐rocket包中都是消息队列的业务逻辑代码。实操业务逻辑是调用service的接口完成

⭐本篇内容包含前两大块。注意逻辑区分。

  • 消息业务逻辑——rcoket包
  • 实操业务逻辑——service/impl
  • SQL代码——mapper

生成流水+发送消息

image.png

controller/OrderController

其中的create() 方法,生成流水+发送消息

其中调用orderService.createOrderAsync()方法,生成流水+发送消息,逻辑是异步创建订单但主要是发消息

image.png

service/impl/OrderSer

viceImpl.java——createOrderAsync()方法

  • createOrderAsync()方法,传入参数
  • 先判断redis中售罄标识是否存在。未售罄继续
  • 调用itemService.createItemStockLog()生成流水。打印日志
    • 看一下service/ItemServiceImpl.javacreateItemStockLog()方法
      • 传入参数,实例化log即流水。来简单封装
      • 用uuid做id,存入log,默认状态为0
      • 调用itemStockLogMapper.insert,把流水存到数据库中
      • 返回log流水

image.png

  • 消息体。用body对象封装发送消息所需参数。包含:商品id+数量+流水id
    • 事务成功时,发的消息被消费者接收;不成功,在回查时被接收。
    • 消费者要扣减库存,需要itemId+amount
    • 回查需要流水id,itemStockLogId

image.png

  • 本地事务只在第三步做,单独一个arg对象封装参数

    • LocalTransactionListenerlmpl.java的本地事务executeLocalTransaction方法有两个参数:事务消息msg+额外参数arg
  • new对象,put入参数:用户id、商品id、数量、活动id、流水id

image.png

  • 封装好后,发送消息
  • 定义消息发送目的地dest
  • 由body消息体build构建message
  • 发消息前debug日志,便于跟踪执行流程
  • 调用rocketMQTemplate.sendMessageInTransaction()方法,发送事务性消息。参数有:消息目的地dest,消息msg,消息参数arg。返回一个发送结果对象。
  • 判断发送消息的返回结果的状态码,是未知或回滚就报错。都不是表示创建成功。

以上成功,表示controller/OrderController中生成流水和发送消息成功。 走完给前端返回正式消息

image.png

image.png

为什么要用事务,预减库存和扣减库存要保证原子性。

热点问题

image.png

消息丢失。

5、6可以采用多节点主从复制,从节点复制保存消息。 或者同步双写,但是影响性能

image.png

默认10秒以后

image.png

消息重投——会引起重复消费

image.png

image.png

削峰限流防刷

image.png

解决OrderController中create创建订单接口(对前端来说是接口),流量大的问题。 虽然做了异步,但是流量太大也不行。

限制进来的流量,令牌数,100w发1k个

“交易”业务最终走到OrderServiceImpl.java的createOrder()方法, 把验证迁移出来,单独做一个验证环节

  1. 验证码。平滑流量,一秒流量平摊到多秒。
  2. 大闸。令牌有限(我设成库存10倍),用户抢。令牌有富余,才颁发。
  3. 库存很多,令牌颁发过多,加限流器。限制服务器单机TPS,防止崩溃。限制访问量接近服务器极限值,多余的请求拒绝。
  4. 用队列(线程池)做缓冲。拓宽单线程的瓶颈。

image.png

依赖注入,修改配置

采用easy-captcha组件

image.png

resources/application-dev.properties 配置线程池

  • 核心线程数5
  • 队列10。核心线程放满了放队列
  • 最大扩展核心线程数30

image.png

验证码组件功能测试

SeckillApplicationTest.java测试验证码组件

  • new一个file文件,声明文件路径、文件名
  • 输出流绑定文件
  • new一个SpeCaptcha对象,生成图片。定义图片宽度、高度、字符数
  • 调用对象.out,把图片传到输出流
  • 返回验证码图片的答案

字符、算式、gif图、汉字验证码同理

image.png

SimulateBackendData.java 测试代码模拟后台

  • 活动商品不同,库存不同,大闸不同。每次上架商品,初始化大闸。项目没做后台功能,测试代码模拟

  • 遍历活动商品,在redis中每个活动商品set新增对应的大闸kv,k:promotion:gate:活动id,v:库存x10

image.png

OrderController.java——正式业务,前端接口

注入spring提供的线程池封装

image.png

声明限流器。每秒并发量1000.

image.png

创建订单前有几个步骤

1.获取验证码

⭐前端请求调用这个方法,响应一个图片

  • getCaptcha()方法
  • 登录后才能秒杀,所以参数有tocken
  • 生成四字验证码图片
  • 如果tocken不为空,从redis里获取用户
  • 用户不为空,以用户维度,记录对应的生成的验证码。把key(captcha+用户id),value(生成验证码的答案)存入reids。一分钟过期。

configuration/WebMvcConfiguration 此处拦截器也做拦截

想要获取验证码,必须先验证登录状态。

image.png

  • ⭐方法void无返回值,因为响应返回一个图片/流,需要自己处理。所以注入 HttpServletResponse response参数,要求spring传入response响应对象,我们从response响应对象获取图片
  • 设置响应对象格式,图片格式
  • 从response里获取输出流
  • specCaptcha.out利用输出流向客户端(前端)输出图片

image.png

2.请求令牌

前端请求验证码成功,才可以请求令牌

  1. generateToken()方法,返回响应对象,参数:商品id,活动id,登录tocken,前端输入的验证码
  2. 获取登录的用户
  3. 判断验证码,为空抛异常报错。非空继续
  4. 拼出验证码key的格式,从redis里取真正的验证码(前面生成验证码时存的答案)
  5. 对比输入验证码和实际验证码,不相等报错。equalsIgnoreCase()表示不区分大小写。
  6. 验证码正确,调用promotionService接口generateToken()方法,创建令牌

promotionServiceImpl.java实现类——包含验证+请求令牌

  • generateToken()方法
  • 验证售罄标识。有说明卖完,不发。无继续
  • 用户为空、商品为空、活动为空/不对/状态不行,都不发。都正确继续
  • 验证大闸,redis中获取该商品的大闸数,直接减1。小于零,大闸已用完,不发
  • 验证都通过,生成令牌。k-【拼key格式,表明令牌的用户+商品+活动】。v-【uuid生成tocken】。对应kv存redis中,十分钟失效要再次申请

image.png

image.png

promotionServiceImpl.java实现类——包含验证+请求令牌

image.png

  1. 请求到令牌后,判断是否为空,为空抛异常。非空把秒杀令牌存入响应对象返回

image.png

3.创建订单

  • create()方法,返回响应对象。参数:商品id、数量、活动id、秒杀大闸令牌登录令牌
  • ⭐限制单机流量。
    • rateLimiter.tryAcquire(timeout: 1, TimeUnit.SECONDS)尝试申请限流器的令牌,一秒之内申请到就继续。没申请到说明已达上限,抛异常
  • 申请到,就获取登录用户
  • 验证秒杀/活动令牌。正确继续
  • ⭐加入队列等待。
    • taskExecutor.submit()方法,线程池提交一个线程去执行。
    • new Callable()
    • call()方法就是执行。调用异步下单方法orderService.createOrderAsync()
    • 这种方式返回对象,但我们不关注结果,只要不报错就行,返回null
  • 验证队列处理结果
    • future.get()没报错创建订单成功。报错抛异常,下单失败
    • 返回正确的响应对象

image.png

image.png

前端。

  • 单击购买,调用后端OrderController接口的获取验证码getCaptcha()方法,弹出框,显示验证码图片。
  • 输入验证码,确定以后。走到前端回调函数。从框中得到输入的验证码,向服务器发起生成tocken请求,带上输入的验证码。异步。
  • 验证码正确,下单成功。

image.png