前言

本文是秒杀系统的第二篇,通过实际代码讲解,帮助你快速的了解秒杀系统的关键点,上手实际项目。

本篇主要讲解接口限流措施,接口限流其实定义也非常广,接口限流本身也是系统安全防护的一种措施,暂时列举这几种容易理解的:

  • 令牌桶限流
  • 单用户访问频率限流
  • 抢购接口隐藏

此外,前文发出后很多同学对于乐观锁在高并发时无法卖出全部商品提出了“严正抗议”,所以还是在本篇中补充讲解下乐观锁与悲观锁。

前文回顾和文章规划:

  • 从零开始打造简易秒杀系统:防止超卖
  • 从零开始打造简易秒杀系统:接口限流(令牌桶限流)+ 再谈超卖
  • 从零开始打造简易秒杀系统:接口限流(单用户限流 + 抢购接口隐藏)
  • 从零开始打造简易秒杀系统:使用Redis缓存热点数据
  • 从零开始打造简易秒杀系统:消息队列异步处理订单

欢迎关注我的个人公众号获取最全的原创文章:后端技术漫谈(二维码见文章底部)

正文

项目源码在这里

妈妈再也不用担心只看文章不会实现啦:

https://github.com/qqxx6661/miaosha

秒杀系统介绍

可以翻阅该系列的第一篇文章,这里不再回顾:

从零开始搭建简易秒杀系统(一):防止超卖

接口限流

在面临高并发的请购请求时,我们如果不对接口进行限流,可能会对后台系统造成极大的压力。尤其是对于下单的接口,过多的请求打到数据库会对系统的稳定性造成影响。

所以秒杀系统会尽量选择独立于公司其他后端系统之外进行单独部署,以免秒杀业务崩溃影响到其他系统。

除了独立部署秒杀业务之外,我们能够做的就是尽量让后台系统稳定优雅的处理大量请求。

接口限流实战:令牌桶限流算法

令牌桶限流算法网上已经有了很多介绍,我摘抄一篇介绍过来:

令牌桶算法最初来源于计算机网络。在网络传输数据时,为了防止网络拥塞,需限制流出网络的流量,使流量以比较均匀的速度向外发送。令牌桶算法就实现了这个功能,可控制发送到网络上数据的数目,并允许突发数据的发送。

大小固定的令牌桶可自行以恒定的速率源源不断地产生令牌。如果令牌不被消耗,或者被消耗的速度小于产生的速度,令牌就会不断地增多,直到把桶填满。后面再产生的令牌就会从桶中溢出。最后桶中可以保存的最大令牌数永远不会超过桶的大小。

令牌桶算法与漏桶算法

漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。

令牌桶算法不能与另外一种常见算法漏桶算法相混淆。这两种算法的主要区别在于:

漏桶算法能够强行限制数据的传输速率,而令牌桶算法在能够限制数据的平均传输速率外,还允许某种程度的突发传输。在令牌桶算法中,只要令牌桶中存在令牌,那么就允许突发地传输数据直到达到用户配置的门限,因此它适合于具有突发特性的流量

使用Guava的RateLimiter实现令牌桶限流接口

Guava是Google开源的Java工具类,里面包罗万象,也提供了限流工具类RateLimiter,该类里面实现了令牌桶算法。

我们拿出源码,在之前讲过的乐观锁抢购接口上增加该令牌桶限流代码:

OrderController:

@Controller
public class OrderController {

    private static final Logger LOGGER = LoggerFactory.getLogger(OrderController.class);

    @Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;
    
    //每秒放行10个请求
    RateLimiter rateLimiter = RateLimiter.create(10);

    @RequestMapping("/createWrongOrder/{sid}")
    @ResponseBody
    public String createWrongOrder(@PathVariable int sid) {
        int id = 0;
        try {
            id = orderService.createWrongOrder(sid);
            LOGGER.info("创建订单id: [{}]", id);
        } catch (Exception e) {
            LOGGER.error("Exception", e);
        }
        return String.valueOf(id);
    }

    /**
     * 乐观锁更新库存 + 令牌桶限流
     * @param sid
     * @return
     */
    @RequestMapping("/createOptimisticOrder/{sid}")
    @ResponseBody
    public String createOptimisticOrder(@PathVariable int sid) {
        // 阻塞式获取令牌
        //LOGGER.info("等待时间" + rateLimiter.acquire());
        // 非阻塞式获取令牌
        if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
            LOGGER.warn("你被限流了,真不幸,直接返回失败");
            return "购买失败,库存不足";
        }
        int id;
        try {
            id = orderService.createOptimisticOrder(sid);
            LOGGER.info("购买成功,剩余库存为: [{}]", id);
        } catch (Exception e) {
            LOGGER.error("购买失败:[{}]", e.getMessage());
            return "购买失败,库存不足";
        }
        return String.format("购买成功,剩余库存为:%d", id);
    }
}

代码中,RateLimiter rateLimiter = RateLimiter.create(10);这里初始化了令牌桶类,每秒放行10个请求。

在接口中,可以看到有两种使用方法:

  • 阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,就在这里阻塞住,等待令牌的发放。
  • 非阻塞式获取令牌:请求进来后,若令牌桶里没有足够的令牌,会尝试等待设置好的时间(这里写了1000ms),其会自动判断在1000ms后,这个请求能不能拿到令牌,如果不能拿到,直接返回抢购失败。如果timeout设置为0,则等于阻塞时获取令牌。

我们使用JMeter设置200个线程,来同时抢购数据库里库存100个的iphone。(数据库结构和JMeter使用请查看从零开始搭建简易秒杀系统(一):防止超卖

我们将请求响应结果为“你被限流了,真不幸,直接返回失败”的请求单独断言出来:

我们使用rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS),非阻塞式的令牌桶算法,来看看购买结果:

可以看到,绿色的请求代表被令牌桶拦截掉的请求,红色的则是购买成功下单的请求。通过JMeter的请求汇总报告,可以得知,在这种情况下请求能够没被限流的比率在15%左右。

可以看到,200个请求中没有被限流的请求里,由于乐观锁的原因,会出现一些并发更新数据库失败的问题,导致商品没有被卖出。这也是上一篇小伙伴问的最多的问题。所以我想再谈一谈乐观锁与悲观锁。

再谈锁之前,我们再试一试令牌桶算法的阻塞式使用,我们将代码换成rateLimiter.acquire();,然后将数据库恢复成100个库存,订单表清零。开始请求:

这次的结果非常有意思,先放几张结果图(按顺序截图的),爱思考的同学们可以先推测下我接下来想说啥。


总结:

  • 首先,所有请求进入了处理流程,但是被限流成每秒处理10个请求。
  • 在刚开始的请求里,令牌桶里一下子被取了10个令牌,所以出现了第二张图中的,乐观锁并发更新失败,然而在后面的请求中,由于令牌一旦生成就被拿走,所以请求进来的很均匀,没有再出现并发更新库存的情况。这也符合“令牌桶”的定义,可以应对突发请求(只是由于乐观锁,所以购买冲突了)。而非“漏桶”的永远恒定的请求限制。
  • 200个请求,在乐观锁的情况下,卖出了全部100个商品,如果没有该限流,而请求又过于集中的话,会卖不出去几个。就像第一篇文章中的那种情况一样。

Guava中RateLimiter实现原理

令牌桶的实现原理,本文中不再班门弄斧了,还是以实战为主。

毕竟Guava是只提供了令牌桶的一种实现,实际项目中肯定还要根据需求来使用或者自己实现,大家可以看看这篇文章:

https://segmentfault.com/a/1190000012875897

再谈防止超卖

讲完了令牌桶限流算法,我们再回头思考超卖的问题,在海量请求的场景下,如果像第一篇文章那样的使用乐观锁,会导致大量的请求返回抢购失败,用户体验极差。

然而使用悲观锁,比如数据库事务,则可以让数据库一个个处理库存数修改,修改成功后再迎接下一个请求,所以在不同情况下,应该根据实际情况使用悲观锁和乐观锁。

悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。

两种锁各有优缺点,不能单纯的定义哪个好于哪个。

  • 乐观锁比较适合数据修改比较少,读取比较频繁的场景,即使出现了少量的冲突,这样也省去了大量的锁的开销,故而提高了系统的吞吐量。
  • 但是如果经常发生冲突(写数据比较多的情况下),上层应用不不断的retry,这样反而降低了性能,对于这种情况使用悲观锁就更合适。

实现不需要版本号字段的乐观锁

上一篇文章中,我的乐观锁建立在更新数据库版本号上,这里贴出一种不用额外字段的乐观锁SQL语句。

<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock">
    update stock
    <set>
      sale = sale + 1,
    </set>
    WHERE id = #{id,jdbcType=INTEGER}
    AND sale = #{sale,jdbcType=INTEGER}
</update>

实现悲观锁

我们为了在高流量下,能够更好更快的卖出商品,我们实现一个悲观锁(事务for update更新库存)。看看悲观锁的结果如何。

在Controller中,增加一个悲观锁卖商品接口:

/**
 * 事务for update更新库存
 * @param sid
 * @return
 */
@RequestMapping("/createPessimisticOrder/{sid}")
@ResponseBody
public String createPessimisticOrder(@PathVariable int sid) {
    int id;
    try {
        id = orderService.createPessimisticOrder(sid);
        LOGGER.info("购买成功,剩余库存为: [{}]", id);
    } catch (Exception e) {
        LOGGER.error("购买失败:[{}]", e.getMessage());
        return "购买失败,库存不足";
    }
    return String.format("购买成功,剩余库存为:%d", id);
}

在Service中,给该卖商品流程加上事务:

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public int createPessimisticOrder(int sid){
    //校验库存(悲观锁for update)
    Stock stock = checkStockForUpdate(sid);
    //更新库存
    saleStock(stock);
    //创建订单
    int id = createOrder(stock);
    return stock.getCount() - (stock.getSale());
}

/**
 * 检查库存 ForUpdate
 * @param sid
 * @return
 */
private Stock checkStockForUpdate(int sid) {
    Stock stock = stockService.getStockByIdForUpdate(sid);
    if (stock.getSale().equals(stock.getCount())) {
        throw new RuntimeException("库存不足");
    }
    return stock;
}

/**
 * 更新库存
 * @param stock
 */
private void saleStock(Stock stock) {
    stock.setSale(stock.getSale() + 1);
    stockService.updateStockById(stock);
}

/**
 * 创建订单
 * @param stock
 * @return
 */
private int createOrder(Stock stock) {
    StockOrder order = new StockOrder();
    order.setSid(stock.getId());
    order.setName(stock.getName());
    int id = orderMapper.insertSelective(order);
    return id;
}

这里使用Spring的事务,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED),如果遇到回滚,则返回Exception,并且事务传播使用PROPAGATION_REQUIRED–支持当前事务,如果当前没有事务,就新建一个事务,关于Spring事务传播机制可以自行查阅资料,以后也想出一个总结文章。

我们依然设置100个商品,清空订单表,开始用JMeter更改请求的接口/createPessimisticOrder/1,发起200个请求:

查看结果,可以看到,HMeter给出的汇总报告中,200个请求,100个返回了抢购成功,100个返回了抢购失败。并且商品卖给了前100个进来的请求,十分的有序。

所以,悲观锁在大量请求的请求下,有着更好的卖出成功率。但是需要注意的是,如果请求量巨大,悲观锁会导致后面的请求进行了长时间的阻塞等待,用户就必须在页面等待,很像是“假死”,可以通过配合令牌桶限流,或者是给用户显著的等待提示来优化。

悲观锁真的锁住库存了吗?

最后一个问题,我想证明下我的事务真的在执行for update后锁住了商品库存,不让其他线程修改库存。

我们在idea中打断点,让代码运行到for update执行完成后。然后再mysql命令行中,执行 update stock set count = 50 where id = 1;试图偷偷修改库存,再回车之后,你会发现命令行阻塞了,没有返回任何消息,显然他在等待行锁的释放。

接下里,你手动继续运行程序,把该事务执行完。在事务执行完成的瞬间,命令行中成功完成了修改,说明锁已经被线程释放,其他的线程能够成功修改库存了。证明事务的行锁是有效的!

总结

本项目的代码开源在了Github,大家随意使用:

https://github.com/qqxx6661/miaosha

下一篇,将会继续讲解接口限流(单用户限流 + 抢购接口隐藏)。

现在有点累,休息休息。

希望大家多多支持我的公主号:后端技术漫谈。

参考

  • https://cloud.tencent.com/developer/article/1488059
  • https://juejin.im/post/5dd09f5af265da0be72aacbd
  • https://crossoverjie.top/%2F2018%2F05%2F07%2Fssm%2FSSM18-seconds-kill%2F
  • https://www.jianshu.com/p/5d4fe4b2a726
  • https://segmentfault.com/a/1190000012469586
  • https://zhenganwen.top/posts/30bb5ce6/

关注我

我是一名后端开发工程师。

主要关注后端开发,数据安全,物联网,边缘计算方向,欢迎交流。

各大平台都可以找到我

原创博客主要内容

  • 后端开发技术
  • Java面试知识点
  • 设计模式/数据结构
  • LeetCode/剑指offer 算法题解析
  • SpringBoot/SpringCloud入门实战系列
  • 数据分析/数据爬虫
  • 逸闻趣事/好书分享/个人生活

个人公众号:后端技术漫谈

如果文章对你有帮助,不妨收藏,转发,在看起来~