Java知网

  • 首页
  • Spring Boot
  • 面试精选
  • 程序人生
  • 资源
  • 友链
  • 关于我
  1. 首页
  2. Java
  3. 正文

秒杀系统(五)订单异步处理

2021年4月18日 444点热度 1人点赞 0条评论

简单的订单异步处理实现

介绍

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

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

如何减轻数据库的压力呢,「我们将每一条秒杀的请求存入消息队列(例如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还要来模拟多个用户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带宽小水管云数据库打穿了!

img

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

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

img

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

img

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

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

img

img

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

img

更加优雅的实现

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

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

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

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

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

「是不是很小米(滑稽.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

img

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

因为这时候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

img

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

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

img

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

img

整个流程就走完了。

结束语

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

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

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

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

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

源码

github.com/qqxx6661/mi…

作者:蛮三刀把刀
来源:https://www.jianshu.com/p/8dc4dc2337ee

相关文章:

  1. 秒杀系统(三)用户下单频率限制
  2. 秒杀系统(四)数据库与缓存双写一致性
标签: 秒杀系统
最后更新:2021年9月4日

javatip

这个人很懒,什么都没留下

点赞
< 上一篇
下一篇 >

文章评论

取消回复
搜一搜
扫一扫
    关注公众号
  • 技术干货推送
  • 免费资料领取
  • 定时福利发放
分类
  • Java / 110篇
  • Mysql / 17篇
  • Redis / 10篇
  • Spring Boot / 29篇
  • Spring Cloud / 16篇
  • 消息队列 / 14篇
  • 程序人生 / 21篇
  • 资源 / 4篇
  • 面试 / 23篇
归档
  • 2022年7月 / 1篇
  • 2022年4月 / 1篇
  • 2022年1月 / 1篇
  • 2021年12月 / 9篇
  • 2021年11月 / 2篇
  • 2021年9月 / 10篇
  • 2021年8月 / 4篇
  • 2021年7月 / 2篇
  • 2021年6月 / 10篇
  • 2021年5月 / 18篇
  • 2021年4月 / 75篇
  • 2021年3月 / 78篇

COPYRIGHT © 2021 javatip.cn. ALL RIGHTS RESERVED.

陇ICP备19004310号-2

甘公网安备 62010202003150号