秒杀系统实战如何优雅的完成订单异步处理

86次阅读

共计 8097 个字符,预计需要花费 21 分钟才能阅读完成。

前言

我回来啦,前段时间忙得不可开交。这段时间终于能喘口气了,持续把之前挖的坑填起来。写完上一篇秒杀零碎(四):数据库与缓存双写一致性深入分析后,感觉文章深度一下子被我贬低了一些,当初构思新文章的时候,反而畏手畏脚,不敢轻易写了。对于将来文章内容的想法,我写在了本文的开端。

本文咱们来聊聊秒杀零碎中的订单异步解决。

本篇文章次要内容

  • 为何咱们须要对下订单采纳异步解决
  • 简略的订单异步解决实现
  • 非异步与异步下单接口的性能比照
  • 一个用户抢购体验更好的实现形式

前文回顾

  • 零根底实现秒杀零碎(一):避免超卖
  • 零根底实现秒杀零碎(二):令牌桶限流 + 再谈超卖
  • 零根底实现秒杀零碎(三):抢购接口暗藏 + 单用户限度频率
  • 零根底实现秒杀零碎(四):数据库与缓存双写一致性深入分析
  • 零根底上手秒杀零碎(五):如何优雅的实现订单异步解决(本文)

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

我的项目源码

再也不必放心看完文章不会代码实现啦:

https://github.com/qqxx6661/m…

我发现该仓库的 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/xiang…
  • https://www.cnblogs.com/xiang…

关注我

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

各大平台都能够找到我

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

原创文章次要内容

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

集体公众号:后端技术漫谈

如果文章对你有帮忙,无妨珍藏,转发,在看起来~

本文由博客群发一文多发等经营工具平台 OpenWrite 公布

正文完
 0