如何优雅的完成订单异步处理

本文涉及的产品
云数据库 Tair(兼容Redis),内存型 2GB
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
简介: 本篇文章主要内容为何我们需要对下订单采用异步处理简单的订单异步处理实现非异步与异步下单接口的性能对比一个用户抢购体验更好的实现方式


前言



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


本篇文章主要内容

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


项目源码


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

https://github.com/qqxx6661/miaosha

整个项目源码仓库使用了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);
}
复制代码


正文



简单的订单异步处理实现


介绍

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

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

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

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

我们改成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秒之后,接口返回”恭喜您,抢购成功“,如下图:

整个流程就走完了。


结束语



这篇文章介绍了如何在保证用户体验的情况下完成订单异步处理的流程。内容其实不多,深度没有前一篇那么难理解。


参考




相关文章
|
缓存 Java 应用服务中间件
Tomcat是如何打破"双亲委派"机制的?
上文我们详细了解了类加载以及什么是双亲委派机制,相信很多童鞋都了解Tomcat打破了双亲委派机制,本文将对Tomcat为什么要打破双亲委派机制,以及Tomcat是如何打破双亲委派机制的,进行完整性的复盘与解析。
3657 0
Tomcat是如何打破"双亲委派"机制的?
|
消息中间件 SQL 存储
超详细的RabbitMQ入门,看这篇就够了!
RabbitMQ入门,看这篇就够了
216774 68
|
网络协议 Java 应用服务中间件
在spring boot中配置HTTP/2
在spring boot中配置HTTP/2
在spring boot中配置HTTP/2
|
消息中间件 程序员 调度
简单高效!本地消息表助你轻松实现分布式事务
本文由小米分享,介绍如何使用本地消息表解决分布式事务问题。分布式事务在微服务架构中变得复杂,本地消息表提供了一种简单高效的方法。它通过在同一事务中处理业务操作和消息记录,然后异步发送消息,确保数据一致性。文章详细阐述了本地消息表的原理、实现步骤、优势及不足,强调了其实现的简单性、高性能和高可靠性,但也指出其潜在的开发复杂度和延迟性问题。
1364 9
|
缓存 NoSQL 安全
|
12月前
|
JSON NoSQL Java
redis的java客户端的使用(Jedis、SpringDataRedis、SpringBoot整合redis、redisTemplate序列化及stringRedisTemplate序列化)
这篇文章介绍了在Java中使用Redis客户端的几种方法,包括Jedis、SpringDataRedis和SpringBoot整合Redis的操作。文章详细解释了Jedis的基本使用步骤,Jedis连接池的创建和使用,以及在SpringBoot项目中如何配置和使用RedisTemplate和StringRedisTemplate。此外,还探讨了RedisTemplate序列化的两种实践方案,包括默认的JDK序列化和自定义的JSON序列化,以及StringRedisTemplate的使用,它要求键和值都必须是String类型。
redis的java客户端的使用(Jedis、SpringDataRedis、SpringBoot整合redis、redisTemplate序列化及stringRedisTemplate序列化)
|
小程序 JavaScript
【微信小程序-原生开发】实用教程11 - 用户登录鉴权(含云函数的创建、删除、使用,通过云函数获取用户的openid)
【微信小程序-原生开发】实用教程11 - 用户登录鉴权(含云函数的创建、删除、使用,通过云函数获取用户的openid)
366 0
|
监控 Ubuntu 安全
|
缓存 监控 前端开发
java简历2年经验编写教程+面试题
是花了我很多天的心思,用心打造出来的Java简历分析模板,适合新手包装成有一点工作年限(1-2年),但又不会太老手的简历;让你的简历做得跟别人不一样;
4313 0