流量削峰
1、抛缺陷
1、秒杀下单接口会被脚本不停的刷
对应秒杀接口其实就是对应的公网URL地址,对应这样的请求地址暴露在公网上,并且只要用户知道他自己的 Token ,知道上商品的ID和 promoId。就可以很容易的写一段脚本,通过不断发送 http 请求的方式 post 到后端流量上。
再这样的情况下,其实后端的一个应用,虽然说有基于对 promoId 的验证,但其实任然是没有效果的。虽然说可以防止活动脚本在活动开始前就把我们对应的秒杀库存一抢而空,但是一旦活动整点开始后我们对应的活动的秒杀脚本肯定会比用户的手速快,这样就会影响正常用户的下单体验。
即便是活动还没有开始,定义的秒杀下单接口在活动没有开始的前提下,仍然有被黄牛用户刷新的可能,这样会对服务器造成一些平白无故的性能压力。
2、秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
我们对秒杀验证的逻辑其实都是在秒杀下单接口中做的,包括用户的验证和对活动的验证。
其实我们最关键的是对活动的开始时间、结束时间验证,在秒杀接口下面做其实是非常浪费性能的,首先对应的秒杀下单接口每次都要验证活动的起止时间,并且秒杀下单的逻辑和对应的活动是否开启是没有关联的。那怕活动没有开始我也可以当成普通商品进行购买下单。就算活动开始了,校验活动开始时间也不应该交给下单接口。因此对应的代码是属于高度冗余的。
3、秒杀验证逻辑复杂,对交易系统产生无关联负载
其实和上面的逻辑是一样的。下单接口是什么?是生成交易订单号、扣减库存。校验活动的状态和用户的合法性,其实都并不是交易逻辑所要承担的事情。
2、秒杀令牌原理
秒杀接口需要依靠令牌才能进入
秒杀的令牌由秒杀活动模块负责生成
交易系统仅仅是验证令牌的可靠性,以此来判断秒杀接口是否可以被这次 http 请求进入
秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
秒杀活动模块需要全权负责令牌的生成周期、以及生成的方式
秒杀下单前需要先获得秒杀令牌
1、代码实现
/** * 生成秒杀用的令牌 * * @param promoId 秒杀活动ID * @return */ @Override public String createSecondKillToken(Integer promoId, Integer userId) { // 通过活动ID 获取活动 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId); if (null == promoDO.getItemId() || promoDO.getItemId().intValue() == 0) { // 活动不存在 return null; } PromoModel promoModel = convertFromDataObject(promoDO); //判断当前时间是否秒杀活动即将开始或正在进行 if (promoModel.getStartDate().isAfterNow()) { promoModel.setStatus(1); } else if (promoModel.getEndDate().isBeforeNow()) { promoModel.setStatus(3); } else { promoModel.setStatus(2); } if (promoModel.getStatus() != 2) { // 活动未开始或者已结束 return null; } // 判断商品信息是否存在 ItemModel itemModel = itemService.getItemByIdInCache(promoDO.getItemId()); if (itemModel == null) { return null; } // 判断用户信息是否合法 UserModel userModel = userService.getUserByIdInCache(userId); if (userModel == null) { return null; } // 生成令牌,并存入redis 5分钟有效期 String token = UUID.randomUUID().toString().replace("-", ""); RedisUtils.set(RedisConstant.KEY_PRE + "promo_token:" + promoId + ":" + itemModel.getId() + ":" + userId, token, 5L); return token; }
获取令牌接口
修改下单接口
service 层减少一些用户身份、秒杀是否开启判断
@Override @Transactional public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId, Integer amount) throws BusinessException { //1.校验下单状态,下单的商品是否存在,用户是否合法,购买数量是否正确 ItemModel itemModel = itemService.getItemByIdInCache(itemId); if (itemModel == null) { throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品信息不存在"); } if (amount <= 0 || amount > 99) { throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息不正确"); } //2.落单减库存(Redis) boolean result = itemService.decreaseStock(itemId, amount); if (!result) { throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH); } //3.订单入库 OrderModel orderModel = new OrderModel(); orderModel.setUserId(userId); orderModel.setItemId(itemId); orderModel.setAmount(amount); if (promoId != null) { orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice()); } else { orderModel.setItemPrice(itemModel.getPrice()); } orderModel.setPromoId(promoId); orderModel.setOrderPrice(orderModel.getItemPrice().multiply(new BigDecimal(amount))); //生成交易流水号,订单号 orderModel.setId(generateOrderNo()); OrderDO orderDO = convertFromOrderModel(orderModel); orderDOMapper.insertSelective(orderDO); //加上商品的销量 itemService.increaseSales(itemId, amount); // 事务型消息,它会在最近一个事务提交后执行操作,也就是当前方法上的 @Transactional TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter() { @Override public void afterCommit() { // 异步扣减库存 asyncReduceStock(itemId, amount); } }); //4.返回前端 return orderModel; }
3、秒杀大闸
依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量
假设对应商品的库存是 100件,那就发放 100个 令牌。若对应 100个 用户下单完成那自然而然只能有这 100个 用户下单,这样就可以控制大闸流量的功能,但是在实际使用过程中一般颁发令牌的数量会比库存多上几倍。并不是所有用户都会下单购买,控制更多的人有竞争的机会。
用户风控策略前置到秒杀令牌发放中
库存售罄判断前置到秒杀令牌发放中
我们可以在生成令牌之前,先解决秒杀大闸。
我们可以在活动发布的时候将现有的活动商品库存数量乘上一个系数,3、4倍,作为大闸的一个限制。若对应的数量没有消耗完成,你就有资格获取令牌,如果说秒杀大闸已经限制了,stock 库存已经没了,那就不能授权令牌。
生成秒杀令牌的时候,判断秒杀大闸数量限制
现在可以将库存售罄判断放入 生成秒杀用的令牌 中去
缺陷
浪涌流量涌入后系统无法应对
虽然说有大闸数量的控制,但是比如一款火爆的商品库存有 10w 个,大闸数量 = 库存 * 4 = 40w 个,就会导致瞬间有上万个 tps 涌入进来。
多库存、多商品等令牌的限制能力弱
1、队列泄洪原
排队有些时候比并发更高效(如 redis 单线程模型),先进先出,先到先得。
依靠排队去限制并发流量
依靠排队和下游拥塞窗口调整队列释放流量大小
2、队列泄洪代码实现
创建订单代码,通过前面的验证后,就直接 执行 createOrder() 方法,下单操作数据库了。这一步就可以完全依赖队列泄洪的策略去实现,因为我们真正对系统、对消息中间件、对数据库造成巨大压力的就是 orderService.createOrder(userModel.getId(), itemId, promoId, amount);
但是这两部操作除了令牌之外是没有办法保护的,令牌控制不了大数据的问题,因为要在这前面加入队列泄洪的思想。
在 controller 层初始化一个只有20个可用线程的的线程池
在创建订单方法中:所有验证都通过后,使用线程池的 submit(new Callable) 方式,将后面的 createOrder 方法放入 call() 方法中去。
最后调用:future.get(); 等待 future 执行完毕。
3、本地 or 分布式
本地:将队列维护在本地内存中
分布式:将队列设置到外部 redis 内
这里我们是本地队列内存中,使用线程池队列来实现本地队列的实现方式。一样可以达到队列泄洪的操作,我们限制每台机器20个拥塞窗口去完成对应的操作。但其实最最准确的一个方式是将队列设置到外部,比如 redis 中。去完成集中化的分布式限流,比如说有一百台机器。我们假设每台机器都设置20个队列,那我们的拥塞窗口就是2000,但是由于负载均衡的关系,很难保证每台机器都能收到对应的 createOrder 请求。但是我们如果将对应的2000大小的队列放入 redis 中,每次 redis 中去排队队列的实现,以及获取对应的拥塞窗口的设置大小。这种设置就是分布式队列,其实这两种方式都是有利有弊的。
可能初学者的同学会认定分布式的队列对比本地的队列要好,因为它可以管理整个集群的队列大小状态。但是分布式队列有一个最最严重的问题,就是它的一个性能问题,因为试想我们发生任何的一个请求都要发起一次对应的 redis 的网络消耗并且要对 redis 产生对应的负载。虽然 redis 也是集中式的,它也有可以扩展的余地,但是它本质对应的队列也是集中式的队列。因为它对应就变成了一个系统性能瓶颈而且会有一个单点的问题,若 redis 队列挂了,那整个队列就失效了。那本地的话有一个好处就是他完全维护在内存中,它对应的没有网络请求消耗,而且它的性能是非常非常高的,只要我们对应的 JVM 不挂,我们应用服务器是存活的,那这个队列功能就不会失效。因此我们在真正的一个企业级应用当中,还是强烈使用本地队列,因为本地的性能和高可用性导致它对应的应用性和广泛性。当然也有一种负载均衡的能力没有办法将请求均匀的发送到每台服务器。因此会有一个负载均衡不稳定的问题,但是在高瓶颈高可用的情况下这些问题是可以被接受的。当然我们做的最好的是我们可以使用外部集中式分布式队列,当外部集中式队列性能产生问题,比如不可用、相应时间拉倒不能接受的状态的时候,我们可以有一个降级的策略,切换到本地队列中。这种方式就是企业级高可用队列泄洪实现方案。