我回来啦,前段时间忙得不可开交。这段时间终于能喘口气了,继续把以前挖的坑填起来。写完上一篇秒杀系统(四):数据库与缓存双写一致性深刻分析后,感受文章深度一会儿被我抬高了一些,如今构思新文章的时候,反而畏手畏脚,不敢随便写了。对于将来文章内容的想法,我写在了本文的末尾。前端
本文咱们来聊聊秒杀系统中的订单异步处理。git
不再用担忧看完文章不会代码实现啦:github
https://github.com/qqxx6661/miaosha面试
我发现该仓库的star数不知不觉已经超过100啦。❞redis
我努力将整个仓库的代码尽可能作到整洁和可复用,在代码中我尽可能作好每一个方法的文档,而且尽可能最小化方法的功能,好比下面这样:算法
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)模块同样,尽可能让小伙伴们能够复制粘贴,整合到本身的代码里,稍做修改适配即可以使用。」spring
能够翻阅该系列的第一篇文章,这里再也不回顾:数据库
零基础实现秒杀系统(一):防止超卖json
前面几篇文章,咱们从「限流角度,缓存角度」来优化了用户下单的速度,减小了服务器和数据库的压力。这些处理对于一个秒杀系统都是很是重要的,而且效果立竿见影,那还有什么操做也能有立竿见影的效果呢?答案是对于下单的异步处理。后端
在秒杀系统用户进行抢购的过程当中,因为在同一时间会有大量请求涌入服务器,若是每一个请求都当即访问数据库进行扣减库存+写入订单的操做,对数据库的压力是巨大的。
如何减轻数据库的压力呢,「咱们将每一条秒杀的请求存入消息队列(例如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接口总体流程以下:
消息队列是如何接收消息的呢?咱们新建一个消息队列,采用第四篇文中使用过的RabbitMQ,我再稍微贴一下整个建立RabbitMQ的流程把:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency>
@Configuration public class RabbitMqConfig { @Bean public Queue orderQueue() { return new Queue("orderQueue"); } }
@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的集合,固然这样有一些不合理之处:」
@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个库存刚恰好消耗完的时候的日志,能够看到,一旦库存没有了,消息队列就完成不了扣减库存的操做,就不会将订单写入数据库,也不会向缓存中记录用户已经购买了该商品的消息。
那么问题来了,咱们实现了上面的异步处理后,用户那边获得的结果是怎么样的呢?
用户点击了提交订单,收到了消息:您的订单已经提交成功。而后用户啥也没看见,也没有订单号,用户开始慌了,点到了本身的我的中心——已付款。发现竟然没有订单!(由于可能还在队列中处理)
这样的话,用户可能立刻就要开始投诉了!太不人性化了,咱们不能只为了开发方便,舍弃了用户体验!
因此咱们要改进一下,如何改进呢?其实很简单:
实现起来,咱们只要在后端加一个独立的接口:
/** * 检查缓存中用户是否已经生成订单 * @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来作等等。
「因此以后我文章的重点会更加关注某个特定的方面」,好比:
固然,其余内容的文章我也会不断积累总结啦。
「个人公众号包括博客流量很是小,看见最近那么多公众号都很快的发展庞大起来,我也很羡慕,但愿你们多多转发支持,在这里谢谢你们啦。
我是一名后端开发工程师。主要关注后端开发,数据安全,爬虫,物联网,边缘计算等方向,欢迎交流。
我的公众号:后端技术漫谈
「若是文章对你有帮助,不妨收藏,转发,在看起来~」
往期推荐系统设计 | 经过Binlog来实现系统间数据同步MySQL | 敖丙的数据库调优最佳实践【读书笔记】《漫画算法》克服入门算法的恐惧Java | 深刻理解String、StringBuilder 和 StringBuffer开源实战 | Canal生产环境常见问题总结与分析