Bootstrap

【秒杀系统】秒杀系统实战(五): 如何优雅的完成订单异步处理

前言

我回来啦,前段时间忙得不可开交。这段时间终于能喘口气了,继续把之前挖的坑填起来。写完上一篇秒杀系统(四):数据库与缓存双写一致性深入分析后,感觉文章深度一下子被我抬高了一些,现在构思新文章的时候,反而畏手畏脚,不敢随便写了。对于未来文章内容的想法,我写在了本文的末尾。

本文我们来聊聊秒杀系统中的订单异步处理。

本篇文章主要内容

  • 为何我们需要对下订单采用异步处理
  • 简单的订单异步处理实现
  • 非异步与异步下单接口的性能对比
  • 一个用户抢购体验更好的实现方式

前文回顾

欢迎关注我的公众号关注最新的动态:后端技术漫谈

项目源码

再也不用担心看完文章不会代码实现啦:

https://github.com/qqxx6661/miaosha

我发现该仓库的star数不知不觉已经超过100啦。

整个项目源码仓库使用了Maven + Springboot进行编写,并且上传了SQL文件,支持SpringBoot一键启动,方便大家调试。

我努力将整个仓库的代码尽量做到整洁和可复用,在代码中我尽量做好每个方法的文档,并且尽量最小化方法的功能,比如下面这样:

public interface StockService {
    /**
     * 查询库存:通过缓存查询库存
     * 缓存命中:返回库存
     * 缓存未命中:查询数据库写入缓存并返回
     * @param id
     * @return
     */
    Integer getStockCount(int id);

    /**
     * 获取剩余库存:查数据库
     * @param id
     * @return
     */
    int getStockCountByDB(int id);

    /**
     * 获取剩余库存: 查缓存
     * @param id
     * @return
     */
    Integer getStockCountByCache(int id);

    /**
     * 将库存插入缓存
     * @param id
     * @return
     */
    void setStockCountCache(int id, int count);

    /**
     * 删除库存缓存
     * @param id
     */
    void delStockCountCache(int id);

    /**
     * 根据库存 ID 查询数据库库存信息
     * @param id
     * @return
     */
    Stock getStockById(int id);

    /**
     * 根据库存 ID 查询数据库库存信息(悲观锁)
     * @param id
     * @return
     */
    Stock getStockByIdForUpdate(int id);

    /**
     * 更新数据库库存信息
     * @param stock
     * return
     */
    int updateStockById(Stock stock);

    /**
     * 更新数据库库存信息(乐观锁)
     * @param stock
     * @return
     */
    public int updateStockByOptimistic(Stock stock);

}

这样就像一个可拔插(plug-in)模块一样,尽量让小伙伴们可以复制粘贴,整合到自己的代码里,稍作修改适配便可以使用。

正文

秒杀系统介绍

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

零基础实现秒杀系统(一):防止超卖

简单的订单异步处理实现

介绍

前面几篇文章,我们从限流角度,缓存角度来优化了用户下单的速度,减少了服务器和数据库的压力。这些处理对于一个秒杀系统都是非常重要的,并且效果立竿见影,那还有什么操作也能有立竿见影的效果呢?答案是对于下单的异步处理。

在秒杀系统用户进行抢购的过程中,由于在同一时间会有大量请求涌入服务器,如果每个请求都立即访问数据库进行扣减库存+写入订单的操作,对数据库的压力是巨大的。

如何减轻数据库的压力呢,我们将每一条秒杀的请求存入消息队列(例如RabbitMQ)中,放入消息队列后,给用户返回类似“抢购请求发送成功”的结果。而在消息队列中,我们将收到的下订单请求一个个的写入数据库中,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少:

  • 同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减。
  • 异步方式:一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)。

这种实现可以理解为是一中流量削峰:让数据库按照他的处理能力,从消息队列中拿取消息进行处理。

结合之前的四篇秒杀系统文章,这样整个流程图我们就实现了:

代码实现

我们在源码仓库里,新增一个controller对外接口:

/**
 * 下单接口:异步处理订单
 * @param sid
 * @return
 */
@RequestMapping(value = "/createUserOrderWithMq", method = {RequestMethod.GET})
@ResponseBody
public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid,
                              @RequestParam(value = "userId") Integer userId) {
    try {
        // 检查缓存中该用户是否已经下单过
        Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
        if (hasOrder != null && hasOrder) {
            LOGGER.info("该用户已经抢购过");
            return "你已经抢购过了,不要太贪心.....";
        }
        // 没有下单过,检查缓存中商品是否还有库存
        LOGGER.info("没有抢购过,检查缓存中商品是否还有库存");
        Integer count = stockService.getStockCount(sid);
        if (count == 0) {
            return "秒杀请求失败,库存不足.....";
        }

        // 有库存,则将用户id和商品id封装为消息体传给消息队列处理
        // 注意这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证
        LOGGER.info("有库存:[{}]", count);
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("sid", sid);
        jsonObject.put("userId", userId);
        sendToOrderQueue(jsonObject.toJSONString());
        return "秒杀请求提交成功";
    } catch (Exception e) {
        LOGGER.error("下单接口:异步处理订单异常:", e);
        return "秒杀请求失败,服务器正忙.....";
    }
}

createUserOrderWithMq接口整体流程如下:

  • 检查缓存中该用户是否已经下单过:在消息队列下单成功后写入redis一条用户id和商品id绑定的数据
  • 没有下单过,检查缓存中商品是否还有库存
  • 缓存中如果有库存,则将用户id和商品id封装为消息体传给消息队列处理
  • 注意:这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证,作为兜底逻辑

消息队列是如何接收消息的呢?我们新建一个消息队列,采用第四篇文中使用过的RabbitMQ,我再稍微贴一下整个创建RabbitMQ的流程把:

  1. pom.xml新增RabbitMq的依赖:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  1. 写一个RabbitMqConfig:
@Configuration
public class RabbitMqConfig {

    @Bean
    public Queue orderQueue() {
        return new Queue("orderQueue");
    }

}
  1. 添加一个消费者:
@Component
@RabbitListener(queues = "orderQueue")
public class OrderMqReceiver {

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

    @Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void process(String message) {
        LOGGER.info("OrderMqReceiver收到消息开始用户下单流程: " + message);
        JSONObject jsonObject = JSONObject.parseObject(message);
        try {
            orderService.createOrderByMq(jsonObject.getInteger("sid"),jsonObject.getInteger("userId"));
        } catch (Exception e) {
            LOGGER.error("消息处理异常:", e);
        }
    }
}

真正的下单的操作,在service中完成,我们在orderService中新建createOrderByMq方法:

@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

    Stock stock;
    //校验库存(不要学我在trycatch中做逻辑处理,这样是不优雅的。这里这样处理是为了兼容之前的秒杀系统文章)
    try {
        stock = checkStock(sid);
    } catch (Exception e) {
        LOGGER.info("库存不足!");
        return;
    }
    //乐观锁更新库存
    boolean updateStock = saleStockOptimistic(stock);
    if (!updateStock) {
        LOGGER.warn("扣减库存失败,库存已经为0");
        return;
    }

    LOGGER.info("扣减库存成功,剩余库存:[{}]", stock.getCount() - stock.getSale() - 1);
    stockService.delStockCountCache(sid);
    LOGGER.info("删除库存缓存");

    //创建订单
    LOGGER.info("写入订单至数据库");
    createOrderWithUserInfoInDB(stock, userId);
    LOGGER.info("写入订单至缓存供查询");
    createOrderWithUserInfoInCache(stock, userId);
    LOGGER.info("下单完成");

}

真正的下单的操作流程为:

  • 校验数据库库存
  • 乐观锁更新库存(其他之前讲到的锁也可以啦)
  • 写入订单至数据库
  • 写入订单和用户信息至缓存供查询:写入后,在外层接口便可以通过判断redis中是否存在用户和商品的抢购信息,来直接给用户返回“你已经抢购过”的消息。

我是如何在redis中记录商品和用户的关系的呢,我使用了set集合,key是商品id,而value则是用户id的集合,当然这样有一些不合理之处:

  • 这种结构默认了一个用户只能抢购一次这个商品
  • 使用set集合,在用户过多后,每次检查需要遍历set,用户过多有性能问题

大家知道需要做这种操作就好,具体如何在生产环境的redis中存储这种关系,大家可以深入优化下。

@Override
    public Boolean checkUserOrderInfoInCache(Integer sid, Integer userId) throws Exception {
        String key = CacheKey.USER_HAS_ORDER.getKey() + "_" + sid;
        LOGGER.info("检查用户Id:[{}] 是否抢购过商品Id:[{}] 检查Key:[{}]", userId, sid, key);
        return stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    }

整个上述实现只考虑最精简的流程,不把前几篇文章的限流,验证用户等加入进来,并且默认考虑的是每个用户抢购一个商品就不再允许抢购,我的想法是保证每篇文章的独立性和代码的任务最小化,至于最后的整合我相信小伙伴们自己可以做到。

非异步与异步下单接口的性能对比

接下来就是喜闻乐见的非正规性能测试环节,我们来对异步处理和非异步处理做一个性能对比。

首先,为了测试方便,我把用户购买限制先取消掉,不然我用Jmeter(JMeter并发测试的使用方式参考秒杀系统第一篇文章)还要来模拟多个用户id,太麻烦了,不是我们的重点。我们把上面的controller接口这一部分注释掉:

// 检查缓存中该用户是否已经下单过
Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
if (hasOrder != null && hasOrder) {
    LOGGER.info("该用户已经抢购过");
    return "你已经抢购过了,不要太贪心.....";
}

这样我们可以用JMeter模拟抢购的情况了。

我们先玩票大的! 在我这个1c4g1m带宽的云数据库上,设置商品数量5000个,同时并发访问10000次

服务器先跑起来,访问接口是http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

启动!

10000个线程并发,直接把我的1M带宽小水管云数据库打穿了!

对不起对不起,打扰了,我们还是老实一点,不要对这么低配置的数据库有不切实际的幻想。

我们改成1000个线程并发,商品库存为500个,使用常规的非异步下单接口

对比1000个线程并发,使用异步订单接口

可以看到,非异步的情况下,吞吐量是37个请求/秒,而异步情况下,我们的接只是做了两个事情,检查缓存中库存+发消息给消息队列,所以吞吐量为600个请求/秒。

在发送完请求后,消息队列中立刻开始处理消息:

我截图了在500个库存刚刚好消耗完的时候的日志,可以看到,一旦库存没有了,消息队列就完成不了扣减库存的操作,就不会将订单写入数据库,也不会向缓存中记录用户已经购买了该商品的消息。

更加优雅的实现

那么问题来了,我们实现了上面的异步处理后,用户那边得到的结果是怎么样的呢?

用户点击了提交订单,收到了消息:您的订单已经提交成功。然后用户啥也没看见,也没有订单号,用户开始慌了,点到了自己的个人中心——已付款。发现居然没有订单!(因为可能还在队列中处理)

这样的话,用户可能马上就要开始投诉了!太不人性化了,我们不能只为了开发方便,舍弃了用户体验!

所以我们要改进一下,如何改进呢?其实很简单:

  • 让前端在提交订单后,显示一个“排队中”,就像我们在小米官网抢小米手机那样
  • 同时,前端不断请求 检查用户和商品是否已经有订单 的接口,如果得到订单已经处理完成的消息,页面跳转抢购成功。

是不是很小米(滑稽.jpg),暴露了我是miboy的事实

实现起来,我们只要在后端加一个独立的接口:

/**
 * 检查缓存中用户是否已经生成订单
 * @param sid
 * @return
 */
@RequestMapping(value = "/checkOrderByUserIdInCache", method = {RequestMethod.GET})
@ResponseBody
public String checkOrderByUserIdInCache(@RequestParam(value = "sid") Integer sid,
                              @RequestParam(value = "userId") Integer userId) {
    // 检查缓存中该用户是否已经下单过
    try {
        Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
        if (hasOrder != null && hasOrder) {
            return "恭喜您,已经抢购成功!";
        }
    } catch (Exception e) {
        LOGGER.error("检查订单异常:", e);
    }
    return "很抱歉,你的订单尚未生成,继续排队吧您嘞。";
}

我们来试验一下,首先我们请求两次下单的接口,大家用postman或者浏览器就好:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

可以看到,第一次请求,下单成功了,第二次请求,则会返回已经抢购过。

因为这时候redis已经写入了该用户下过订单的数据:

127.0.0.1:6379> smembers miaosha_v1_user_has_order_1
(empty list or set)
127.0.0.1:6379> smembers miaosha_v1_user_has_order_1
1) "1"

我们为了模拟消息队列处理茫茫多请求的行为,我们在下单的service方法中,让线程休息10秒:

@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

    // 模拟多个用户同时抢购,导致消息队列排队等候10秒
    Thread.sleep(10000);

    //完成下面的下单流程(省略)

}

然后我们清除订单信息,开始下单:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

第一次请求,返回信息如上图。

紧接着前端显示排队中的时候,请求检查是否已经生成订单的接口,接口返回”继续排队“:

一直刷刷刷接口,10秒之后,接口返回”恭喜您,抢购成功“,如下图:

整个流程就走完了。

结束语

这篇文章介绍了如何在保证用户体验的情况下完成订单异步处理的流程。内容其实不多,深度没有前一篇那么难理解。(我拖更也有一部分原因是因为我觉得上一篇的深度我很难随随便便达到,就不敢随意写文章,有压力。)

希望大家喜欢,目前来看,整个秒杀下订单的主流程我们全部介绍完了。当然里面很多东西都非常基础,比如数据库设计我一直停留在那几个破字段,比如订单的编号,其实不可能用主键id来做等等。

所以之后我文章的重点会更加关注某个特定的方面,比如:

  • 分布式订单唯一编号的生成
  • 网关层面的接口缓存

当然,其他内容的文章我也会不断积累总结啦。

我的公众号包括博客流量非常小,看见最近那么多公众号都很快的发展庞大起来,我也很羡慕,希望大家多多转发支持,在这里谢谢大家啦。

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

参考

  • https://www.cnblogs.com/xiangkejin/p/9351463.html
  • https://www.cnblogs.com/xiangkejin/p/9351501.html

关注我

我是一名后端开发工程师。主要关注后端开发,数据安全,爬虫,物联网,边缘计算等方向,欢迎交流。

各大平台都可以找到我

原创文章主要内容

  • 后端开发
  • Java面试
  • 设计模式/数据结构/算法题解
  • 爬虫/边缘计算/物联网
  • 读书笔记/逸闻趣事/程序人生

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

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

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

;