秒杀系统实战|如何优雅的实现订单异步处理(代码片段)

author author     2022-12-12     353

关键词:

技术图片

前言

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

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

本篇文章主要内容

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

前文回顾

  • 零基础实现秒杀系统(一):防止超卖
  • 零基础实现秒杀系统(二):令牌桶限流 + 再谈超卖
  • 零基础实现秒杀系统(三):抢购接口隐藏 + 单用户限制频率
  • 零基础实现秒杀系统(四):数据库与缓存双写一致性深入分析
  • 零基础上手秒杀系统(五):如何优雅的完成订单异步处理(本文)
  • ...

项目源码

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

https://github.com/qqxx6661/miaosha

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

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

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来做等等。

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

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

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

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

关注我

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

各大平台都可以找到我

  • 「微信公众号:后端技术漫谈」
  • 「Github:@qqxx6661」
  • CSDN:@蛮三刀把刀
  • 知乎:@后端技术漫谈
  • 简书:@蛮三刀把刀
  • 掘金:@蛮三刀把刀
  • 腾讯云+社区:@后端技术漫谈

原创文章主要内容

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

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

    技术图片
    个人公众号:后端技术漫谈
    「如果文章对你有帮助,不妨收藏,转发,在看起来~」

往期推荐
系统设计 | 通过Binlog来实现系统间数据同步
MySQL | 敖丙的数据库调优最佳实践
【读书笔记】《漫画算法》克服入门算法的恐惧
Java | 深入理解String、StringBuilder 和 StringBuffer
开源实战 | Canal生产环境常见问题总结与分析

redis轻松实现秒杀系统(代码片段)

点击关注公众号,实用技术文章及时了解什么是秒杀秒杀场景一般会在电商网站举行一些活动或者节假日在12306网站上抢票时遇到。对于电商网站中一些稀缺或者特价商品,电商网站一般会在约定时间点对其进行限量销售... 查看详情

秒杀系统架构分析与实战(代码片段)

1秒杀业务分析2秒杀技术挑战3秒杀架构原则4秒杀架构设计5大并发带来的挑战6作弊的手段:进攻与防守7高并发下的数据安全8总结 1秒杀业务分析正常电子商务流程(1)查询商品;(2)创建订单;࿰... 查看详情

秒杀系统架构分析与实战(代码片段)

1秒杀业务分析正常电子商务流程秒杀业务的特性2秒杀技术挑战1、对现有网站业务造成冲击2、高并发下的应用、数据库负载3、突然增加的网络及服务器带宽4、直接下单5.如何控制秒杀商品页面购买按钮的点亮6.如何只允许第一... 查看详情

电商系统架构如何做秒杀?分析与实战来了!(代码片段)

...数据库知识来源:https://my.oschina.net/xianggao/blog/5249431秒杀业务分析正常电子商务流程(1)查询商品; (2)创建订单; (3)扣减库存; &# 查看详情

面试实战考核:设计一个高并发下的下单功能(代码片段)

功能需求:设计一个秒杀系统初始方案商品表设计:热销商品提供给用户秒杀,有初始库存。@EntitypublicclassSecKillGoodsimplementsSerializable@IdprivateStringid;/***剩余库存*/privateIntegerremainNum;/***秒杀商品名称*/privateStringgoodsName;秒杀订单表... 查看详情

竞拍系统设计(秒杀系统知识迁移)(代码片段)

自从上次整理了秒杀系统的文章(php+golang商品秒杀)后,知识迁移一新项目,商品竞拍。技术:php、mysql、redis、laravel业务对象:商品、场次、订单竞拍过程:一、实现商品、竞拍场次和订单的CRUD;二、定时将秒杀场次、商品、... 查看详情

基于springboot+rabbitmq+redis开发的秒杀系统(异步下单热点数据缓存解决超卖)(代码片段)

基于SpringBoot+RabbitMQ+Redis开发的秒杀系统一、简易版秒杀SeckillProject系统简介开发技术二、实现细节记录1、用户密码两次MD5加密2、分布式session维持会话3、异常统一处理4、页面缓存+对象缓存5、页面静态化6、内存标记+... 查看详情

优惠卷秒杀系统设计秒杀优化——基于阻塞队实现异步秒杀优化及基于lua脚本判断秒杀库存一人一单(代码片段)

(目录)秒杀优化1、秒杀优化-异步秒杀思路回顾一下下单流程:分成如下几个步骤:在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行的很慢,所以我们需要异步程... 查看详情

电商 秒杀系统 设计思路和实现方法(代码片段)

电商 秒杀系统 设计思路和实现方法2017年05月26日00:06:35阅读数:36621秒杀业务分析正常电子商务流程(1)查询商品;(2)创建订单;(3)扣减库存;(4)更新订单;(5)付款;(6)卖家发货秒杀业务的特性(1)低廉价格... 查看详情

实战深入了解redis+消息队列如何实现秒杀(代码片段)

SpringBoot+Redis+RabbitMQ实现高并发限时秒杀所谓秒杀,从业务角度看,是短时间内多个用户“争抢”资源,这里的资源在大部分秒杀场景里是商品;将业务抽象,技术角度看,秒杀就是多个线程对资源进... 查看详情

电商秒杀系统架构分析与实战!(代码片段)

...数据库知识来源:https://my.oschina.net/xianggao/blog/5249431秒杀业务分析正常电子商务流程(1)查询商品; (2)创建订单; (3)扣减库存; &# 查看详情

秒杀系统实战|缓存与数据库双写问题的争议(代码片段)

...,但还没学会小猪老师时间管理学的蛮三刀同学。本文是秒杀系统的第四篇,我们来讨论秒杀系统中「缓存热点数据」的问题,进一步延伸到数据库和缓存的双写一致性问题,并且给出了实现代码。前文回顾和文章规划零基础上... 查看详情

秒杀系统架构分析与实战(代码片段)

1秒杀业务分析2秒杀技术挑战3秒杀架构原则4秒杀架构设计5大并发带来的挑战6作弊的手段:进攻与防守7高并发下的数据安全8总结 1秒杀业务分析正常电子商务流程(1)查询商品;(2)创建订单;࿰... 查看详情

springboot集成rabbitmq

...处理削峰填谷、延迟处理、解耦系统之间的强耦合、处理秒杀订单。 入门rabbitmq之前主要是想了解下秒杀排队订单入库后,异步通知客户端秒杀结果。  基础知识    查看详情

rocketmq事务消息实战(代码片段)

我们以一个订单流转流程来举例,例如订单子系统创建订单,需要将订单数据下发到其他子系统(与第三方系统对接)这个场景,我们通常会将两个系统进行解耦,不直接使用服务调用的方式进行交互。其业务实现步骤通常为:... 查看详情

开源:如何优雅的实现一个操作日志组件(代码片段)

和操作日志系统日志:主要用于开发者调试排查系统问题的,不要求固定格式和可读性操作日志:主要面向用户的,要求简单易懂,反映出用户所做的动作。通过操作日志可追溯到某人在某时干了某事情,如:租户操作人时间操... 查看详情

关于秒杀的场景特点分析

 关于秒杀的场景特点分析秒杀系统的场景特点-秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增;-秒杀一般是访问请求量远远大于库存数量,只有少部分用户能够秒杀成功;-秒杀业务流程比较简单,一般... 查看详情

关于秒杀的场景特点分析

 关于秒杀的场景特点分析秒杀系统的场景特点-秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增;-秒杀一般是访问请求量远远大于库存数量,只有少部分用户能够秒杀成功;-秒杀业务流程比较简单,一般... 查看详情