流量削峰技术-削峰填谷之神级操作
-
概述
-
一、秒杀令牌
-
1.1 原理
- 1.2 代码实现
-
二、秒杀大闸
-
2.1 原理
- 2.2 代码实现:
-
三、队列泄洪
-
3.1 原理
- 3.2 代码实现
-
四、本地OR分布式
概述
在之前的课程中经历了查询的优化技术,将单机查询效率提升到了4000 QPS
对应的交易优化技术使用了缓存校验+异步扣减库存的方式,使得秒杀下单的方式有了明显的提升。
即便查询优化,交易优化技术用到极致后,只要外部的流量超过了系统可承载的范围就有拖垮系统的风险。本章通过秒杀令牌,秒杀大闸,队列泄洪等流量削峰技术解决全站的流量高性能运行效率。
项目缺陷:
- 秒杀下单接口会被脚本不停的刷新;
- 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高;
- 秒杀验证逻辑复杂,对交易系统产生无关联负载;
本章目标:
- 掌握秒杀令牌的原理和使用方式;
- 掌握秒杀大闸的原理和使用方式;
- 掌握队列泄洪的原理和使用方式.
一、秒杀令牌
1.1 原理
- 秒杀接口需要依靠令牌才能进入,对应的秒杀下单接口需要新增一个入参,表示对应前端用户获得传入的一个令牌,只有令牌处于合法之后,才能进入对应的秒杀下单的逻辑;
- 秒杀令牌由秒杀活动模块负责生成,交易系统仅仅验证令牌的可靠性,以此来判断对应的秒杀接口是否可以被这次http的request进入;
- 秒杀活动模块对秒杀令牌生成全权处理,逻辑收口;
- 秒杀下单前需要获得秒杀令牌才能开始秒杀;
1.2 代码实现
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125 1PromoService.java
2***
3//生成秒杀用的令牌
4String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId);
5***
6PromoServiceImpl.java
7***
8@Override
9 public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
10
11 //判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
12 if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
13 return null;
14 }
15 PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
16
17 //dataobject->model
18 PromoModel promoModel = convertFromDataObject(promoDO);
19 if(promoModel == null){
20 return null;
21 }
22
23 //判断当前时间是否秒杀活动即将开始或正在进行
24 if(promoModel.getStartDate().isAfterNow()){
25 promoModel.setStatus(1);
26 }else if(promoModel.getEndDate().isBeforeNow()){
27 promoModel.setStatus(3);
28 }else{
29 promoModel.setStatus(2);
30 }
31 //判断活动是否正在进行
32 if(promoModel.getStatus().intValue() != 2){
33 return null;
34 }
35 //判断item信息是否存在
36 ItemModel itemModel = itemService.getItemByIdInCache(itemId);
37 if(itemModel == null){
38 return null;
39 }
40 //判断用户信息是否存在
41 UserModel userModel = userService.getUserByIdInCache(userId);
42 if(userModel == null){
43 return null;
44 }
45
46 //获取秒杀大闸的count数量
47 long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
48 if(result < 0){
49 return null;
50 }
51 //生成token并且存入redis内并给一个5分钟的有效期
52 String token = UUID.randomUUID().toString().replace("-","");
53
54 redisTemplate.opsForValue().set("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,token);
55 redisTemplate.expire("promo_token_"+promoId+"_userid_"+userId+"_itemid_"+itemId,5, TimeUnit.MINUTES);
56
57 return token;
58 }
59****
60OrderController.java
61***
62@Autowired
63private PromoService promoService;
64
65/生成秒杀令牌
66 @RequestMapping(value = "/generatetoken",method = {RequestMethod.POST},consumes={CONTENT_TYPE_FORMED}
67 @ResponseBody
68public CommonReturnType generatetoken(@RequestParam(name="itemId")Integer itemId,
69 @RequestParam(name="promoId")Integer promoId) throws BusinessException {
70 //根据token获取用户信息
71 String token = httpServletRequest.getParameterMap().get("token")[0];
72 if(StringUtils.isEmpty(token)){
73 throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单");
74 }
75 //获取用户的登陆信息
76 UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
77 if(userModel == null){
78 throw new BusinessException(EmBusinessError.USER_NOT_LOGIN,"用户还未登陆,不能下单");
79 }
80 //获取秒杀访问令牌
81 String promoToken = promoService.generateSecondKillToken(promoId,itemId,userModel.getId());
82 if(promoToken == null){
83 throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
84 }
85 //返回对应的结果
86 return CommonReturnType.create(promoToken);
87}
88
89OrderServiceImpl.java
90***
91OrderController.java
92***
93//校验秒杀令牌是否正确
94if(promoId != null){
95 String inRedisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_"+promoId+"_userid_"+userModel.getId()+"_itemid_"+itemId);
96 if(inRedisPromoToken == null){
97 throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀令牌校验失败");
98 }
99 if(!org.apache.commons.lang3.StringUtils.equals(promoToken,inRedisPromoToken)){
100 throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀令牌校验失败");
101 }
102}
103 //修改前端代码
104 getItem.html
105 $.ajax({
106 type:"POST",
107 contentType:"application/x-www-form-urlencoded",
108 url:"http://"+g_host+"/order/generatetoken?token="+token,
109 data:{
110 "itemId":g_itemVO.id,
111 "promoId":g_itemVO.promoId
112 },
113{
114 alert("获取令牌失败,原因为"+data.data.errMsg);
115 if(data.data.errCode == 20003){
116 window.location.href="login.html";
117 }
118
119 },
120 error:function(data){
121 lert("获取令牌失败,原因为"+data.responseText);
122 }
123 });
124
125
方案缺陷
秒杀令牌活动一开始就无限制生成,影响系统性能;
二、秒杀大闸
为了解决秒杀令牌在活动一开始无限制生成,影响系统的性能,提出了秒杀大闸的解决方案;
2.1 原理
- 依靠秒杀令牌的授权原理定制化发牌逻辑,解决用户对应流量问题,做到大闸功能;
- 根据秒杀商品初始化库存颁发对应数量令牌,控制大闸流量;
- 用户风控策略前置到秒杀令牌发放中;
- 库存售罄判断前置到秒杀令牌发放中。
2.2 代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 1PromoServiceImpl.java
2***
3 public void publishPromo(Integer promoId) {
4//将大闸的限制数字设到redis内
5 redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 5);
6 }
7@Override
8public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
9 //判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
10 if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
11 return null;
12 }
13 //获取秒杀大闸的count数量
14 long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
15 if(result < 0){
16 return null;
17 }
18 }
19 OrderController.java
20 ***
21
22
方案缺陷
- 浪涌流量涌入后系统无法应对
- 多库存多商品等令牌限制能力弱;
三、队列泄洪
采用秒杀大闸之后,还是无法解决浪涌流量涌入后台系统,并且多库存多商品等令牌限制能力较弱;
3.1 原理
- 排队有些时候比并发更高效(例如redis单线程模型,innodb mutex key等);
- 依靠排队去限制并发流量;
- 依靠排队和下游阻塞窗口程度调整队列释放流量大小;
以支付宝银行网关队列为例,支付宝需要对接许多银行网关,当你的支付宝绑定多张银行卡,那么支付宝对于这些银行都有不同的支付渠道。在大促活动时,支付宝的网关会有上亿级别的流量,银行的网关扛不住,支付宝就会将支付请求队列放到自己的消息队列中,依靠银行网关承诺可以处理的TPS流量去泄洪;
消息队列就像“水库”一样,拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的;
3.2 代码实现
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 1OrderController.java
2***
3private ExecutorService executorService;
4
5 @PostConstruct
6public void init(){
7 //定义一个只有20个可工作线程的线程池
8 executorService = Executors.newFixedThreadPool(20);
9}
10 //同步调用线程池的submit方法
11//拥塞窗口为20的等待队列,用来队列化泄洪
12 Future<Object> future = executorService.submit(new Callable<Object>() {
13 @Override
14 public Object call() throws Exception {
15 //加入库存流水init状态
16 String stockLogId = itemService.initStockLog(itemId,amount);
17 //再去完成对应的下单事务型消息机制
18 if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
19 throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败");
20 }
21 return null;
22 }
23 });
24
25 try {
26 future.get();
27 } catch (InterruptedException e) {
28 throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
29 } catch (ExecutionException e) {
30 throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
31 }
32 return CommonReturnType.create(null);
33}
34
35
四、本地OR分布式
本地:将队列维护在本地内存中;
分布式:将队列设置到外部redis中
比如说我们有100台机器,假设每台机器设置20个队列,那我们的拥塞窗口就是2000,但是由于负载均衡的关系,很难保证每台机器都能够平均收到对应的createOrder的请求,那如果将这2000个排队请求放入redis中,每次让redis去实现以及去获取对应拥塞窗口设置的大小,这种就是分布式队列;
本地和分布式有利有弊:
分布式队列最严重的就是性能问题,发送任何一次请求都会引起call网络的消耗,并且要对Redis产生对应的负载,Redis本身也是集中式的,虽然有扩展的余地。单点问题就是若Redis挂了,整个队列机制就失效了。
本地队列的好处就是完全维护在内存当中的,因此其对应的没有网络请求的消耗,只要JVM不挂,应用是存活的,那本地队列的功能就不会失效。因此企业级开发应用还是推荐使用本地队列,本地队列的性能以及高可用性对应的应用性和广泛性。可以使用外部的分布式集中队列,当外部集中队列不可用时或者请求时间超时,可以采用降级的策略,切回本地的内存队列。