秒杀系列(二)如何优雅地异步下订单

简单的订单异步处理实现

介绍

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

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

如何减轻数据库的压力呢,「我们将每一条秒杀的请求存入消息队列(例如RabbitMQ)中,放入消息队列后,给用户返回类似“抢购请求发送成功”的结果。而在消息队列中,我们将收到的下订单请求一个个的写入数据库中」,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少:

  • 同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减。
  • 异步方式:一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)。

「这种实现可以理解为是一中流量削峰:让数据库按照他的处理能力,从消息队列中拿取消息进行处理。」

结合之前的四篇秒杀系统文章,这样整个流程图我们就实现了:

img

代码实现

我们在源码仓库里,新增一个 Controller 对外接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 下单接口:异步处理订单
* @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) {
log.info("该用户已经抢购过");
return "你已经抢购过了,不要太贪心.....";
}
// 没有下单过,检查缓存中商品是否还有库存
log.info("没有抢购过,检查缓存中商品是否还有库存");
Integer count = stockService.getStockCount(sid);
if (count == 0) {
return "秒杀请求失败,库存不足.....";
}

// 有库存,则将用户id和商品id封装为消息体传给消息队列处理
// 注意这里的有库存和已经下单都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证
log.info("有库存:[{}]", count);
JSONObject jsonObject = new JSONObject();
jsonObject.put("sid", sid);
jsonObject.put("userId", userId);
sendToOrderQueue(jsonObject.toJSONString());
return "秒杀请求提交成功";
} catch (Exception e) {
log.error("下单接口:异步处理订单异常:", e);
return "秒杀请求失败,服务器正忙.....";
}
}

createUserOrderWithMq 接口整体流程如下:

  • 检查缓存中该用户是否已经下单过:在消息队列下单成功后写入 Redis 一条用户 id 和商品 id 绑定的数据
  • 没有下单过,检查缓存中商品是否还有库存
  • 缓存中如果有库存,则将用户 id 和商品 id 封装为消息体 「传给消息队列处理」
  • 注意:这里的 「有库存和已经下单」 都是缓存中的结论,存在不可靠性,在消息队列中会查表再次验证, 「作为兜底逻辑」

消息队列是如何接收消息的呢?我们新建一个消息队列,采用第四篇文中使用过的 RabbitMQ,我再稍微贴一下整个创建 RabbitMQ 的流程吧:

  1. pom.xml 新增 RabbitMq 的依赖:
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  1. 写一个 RabbitMqConfig:
1
2
3
4
5
6
7
8
@Configuration
public class RabbitMqConfig {

@Bean
public Queue orderQueue() {
return new Queue("orderQueue");
}
}
  1. 添加一个消费者:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
@RabbitListener(queues = "orderQueue")
public class OrderMqReceiver {

private static final log log = logFactory.getlog(OrderMqReceiver.class);

@Autowired
private StockService stockService;

@Autowired
private OrderService orderService;

@RabbitHandler
public void process(String message) {
log.info("OrderMqReceiver收到消息开始用户下单流程: " + message);
JSONObject jsonObject = JSONObject.parseObject(message);
try {
orderService.createOrderByMq(jsonObject.getInteger("sid"),jsonObject.getInteger("userId"));
} catch (Exception e) {
log.error("消息处理异常:", e);
}
}
}

真正的下单的操作,在 Service 中完成,我们在 orderService 中新建 createOrderByMq 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

Stock stock;
// 校验库存(不要学我在trycatch中做逻辑处理,这样是不优雅的。这里这样处理是为了兼容之前的秒杀系统文章)
try {
stock = checkStock(sid);
} catch (Exception e) {
log.info("库存不足!");
return;
}
// 乐观锁更新库存
boolean updateStock = saleStockOptimistic(stock);
if (!updateStock) {
log.warn("扣减库存失败,库存已经为0");
return;
}

log.info("扣减库存成功,剩余库存:[{}]", stock.getCount() - stock.getSale() - 1);
stockService.delStockCountCache(sid);
log.info("删除库存缓存");

// 创建订单
log.info("写入订单至数据库");
createOrderWithUserInfoInDB(stock, userId);
log.info("写入订单至缓存供查询");
createOrderWithUserInfoInCache(stock, userId);
log.info("下单完成");
}

真正的下单的操作流程为:

  • 校验数据库库存
  • 乐观锁更新库存(其他之前讲到的锁也可以啦)
  • 写入订单至数据库
  • 「写入订单和用户信息至缓存供查询」:写入后,在外层接口便可以通过判断redis中是否存在用户和商品的抢购信息,来直接给用户返回“你已经抢购过”的消息。

「在 Redis 中记录商品和用户的关系,使用了 set 集合,key 是商品 id,而 value 则是用户 id 的集合,当然这样有一些不合理之处:」

  • 这种结构默认了一个用户只能抢购一次这个商品
  • 使用 set 集合,在用户过多后,每次检查需要遍历 set,用户过多有性能问题

大家知道需要做这种操作就好,具体如何在生产环境的 Redis 中存储这种关系,大家可以深入优化下。

1
2
3
4
5
6
@Override
public Boolean checkUserOrderInfoInCache(Integer sid, Integer userId) throws Exception {
String key = CacheKey.USER_HAS_ORDER.getKey() + "_" + sid;
log.info("检查用户Id:[{}] 是否抢购过商品Id:[{}] 检查Key:[{}]", userId, sid, key);
return stringRedisTemplate.opsForSet().isMember(key, userId.toString());
}

「整个上述实现只考虑最精简的流程,不把前几篇文章的限流,验证用户等加入进来,并且默认考虑的是每个用户抢购一个商品就不再允许抢购,我的想法是保证每篇文章的独立性和代码的任务最小化,至于最后的整合我相信大佬们自己可以做到。」

非异步与异步下单接口的性能对比

接下来就是喜闻乐见的「非正规」性能测试环节,我们来对异步处理和非异步处理做一个性能对比。

首先,为了测试方便,我把用户购买限制先取消掉,不然我用 JMeter(JMeter 并发测试的使用方式参考 秒杀系统第一篇文章 )还要来模拟多个用户 id,太麻烦了,不是我们的重点。我们把上面的 Controller 接口这一部分注释掉:

1
2
3
4
5
6
// 检查缓存中该用户是否已经下单过
Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
if (hasOrder != null && hasOrder) {
log.info("该用户已经抢购过");
return "你已经抢购过了,不要太贪心.....";
}

这样我们可以用 JMeter 模拟抢购的情况了。

「我们先玩票大的!」 在我这个 1c4g1m 带宽的云数据库上,「设置商品数量 5000 个,同时并发访问 10000 次」

服务器先跑起来,访问接口是http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

启动!

10000 个线程并发,直接把 1M 带宽小水管云数据库打穿了!

图片

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

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

图片

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

图片

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

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

img

图片

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

图片

更加优雅的实现

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

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

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

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

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

实现起来,我们只要在后端加一个独立的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 检查缓存中用户是否已经生成订单
* @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) {
log.error("检查订单异常:", e);
}
return "很抱歉,你的订单尚未生成,继续排队吧您嘞。";
}

我们来试验一下,首先我们请求两次下单的接口,大家用 Postman 或者浏览器就好:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

图片

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

因为这时候 Redis 已经写入了该用户下过订单的数据:

1
2
3
4
127.0.0.1:6379> smembers seckill_v1_user_has_order_1
(empty list or set)
127.0.0.1:6379> smembers seckill_v1_user_has_order_1
1) "1"

我们为了模拟消息队列处理茫茫多请求的行为,我们在下单的service方法中,让线程休息10秒:

1
2
3
4
5
6
7
8
9
@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

// 模拟多个用户同时抢购,导致消息队列排队等候10秒
Thread.sleep(10000);

//完成下面的下单流程(省略)

}

然后我们清除订单信息,开始下单:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

图片

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

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

图片

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

图片

整个流程就走完了。

结束语

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

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

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

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

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

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2017-2021 Shadowalker
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信