前面几篇我们从限流角度,缓存角度来优化了用户下单的速度,减少了服务器和数据库的压力。这些处理对于一个秒杀系统都是非常重要的,并且效果立竿见影,那还有什么操作也能有立竿见影的效果呢?答案是下单的异步处理。
前期概述
在秒杀系统用户进行抢购的过程中,由于在同一时间会有大量请求涌入服务器,如果每个请求都立即访问数据库进行扣减库存和写入订单的操作,对数据库的压力是巨大的。我们可以将每一条秒杀的请求存入消息队列(例如RabbitMQ)中,放入消息队列后,给用户返回类似“抢购请求发送成功”的结果。而在消息队列中,我们将收到的下订单请求一个个的写入数据库中,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少。这种实现可以理解为是一中流量削峰,让数据库按照他的处理能力,从消息队列中拿取消息进行处理。接下来我们用代码来具体来实现一下订单的异步处理。
订单异步处理
代码编写
在原来代码的基础上,OrderController中增加接口createUserOrderWithMq,代码如下:
/**
* 下单接口:异步处理订单
* @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封装为消息体传给消息队列处理;
- 这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证,作为兜底逻辑。
我们新建一个消息队列,采用之前使用过的RabbitMQ,写一个RabbitMqConfig:
@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集合记录商品和用户的关系,
@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());
}
key是商品id,value是用户id的集合,这样有一些不合理的地方:
- 这种结构默认了一个用户只能抢购一次这个商品
- 使用set集合,在用户过多后,每次检查需要遍历set,用户过多有性能问题
大家知道需要做这种操作就好,具体如何在生产环境的redis中存储这种关系,大家可以深入优化下,我这里只是做个示范。整个上述实现只考虑最精简的流程,不把前几篇文章的限流,验证用户等加入进来,并且默认考虑的是每个用户抢购一个商品就不再允许抢购,我的想法是保证每篇文章的独立性和代码的任务最小化,至于最后的整合我相信小伙伴们自己可以做到。
流程测试
写完了代码以后接下来让我们实际来操作验证一下。为了对比,这里我们使用非异步与异步下单来进行结果的对比,这样也更能看出异步下单的好处。这里为了方便,我把用户购买限制先取消掉,不然还要来模拟多个用户id,直接把接口中的检查缓存中该用户是否已经下单过的检验注释掉即可。
使用常规的非异步下单接口,模拟1000个用户同时抢购,商品库存为500个。可以看到,非异步的情况下,吞吐量是142.8个请求/秒:
而异步情况下,吞吐量为200.7个请求/秒:
这里截图了在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请求两次下单的接口,发现缓存里面已经有数据,但是实际数据库中没有数据,即没有产生实际的订单:
但是却给用户返回的已经秒杀成功,这样显然会让买家产生疑问。
我们加入checkOrderByUserIdInCache接口以后,前端不停调用获取真实的订单信息,第一次请求时:
第二次请求时:
再第二次请求时先去调用以下接口,将以下信息返回
一直刷刷刷接口,数据成功插入以后,接口返回”恭喜您,抢购成功“,这个时候将信息返回给前端进行展示,如下图:
整个流程就走完了。整个秒杀下订单的主流程我们全部介绍完了。当然里面很多东西都非常基础,不过之前也说了这只是一个简单的秒杀系统,供大家入门理解使用,更复杂的业务大家可以在原来的基础上慢慢增加。
猜你感兴趣:
教你从0到1搭建秒杀系统-防超卖
教你从0到1搭建秒杀系统-限流
教你从0到1搭建秒杀系统-抢购接口隐藏与单用户限制频率
教你从0到1搭建秒杀系统-缓存与数据库双写一致
教你从0到1搭建秒杀系统-Canal快速入门(番外篇)
教你从0到1搭建秒杀系统-订单异步处理
更多文章请点击:更多…