Fork me on GitHub

SpringBoot2之秒杀页面优化及解决超卖问题

页面缓存+URL缓存+对象缓存

页面缓存

其实系统访问某个页面的时候,并不是直接使用系统渲染,而是先从缓存中获取找到数据之后就然后返回给客户端,要是没有找到就手动渲染这个模板,渲染完成之后再把数据返回给客户端,同时把数据缓存到redis中。

其实流程很简单:(1)取缓存 (2)手动渲染模板 (3)结果输出

关于手动渲染,官方的介绍是这么说的;

If you use Thymeleaf, you also have a ThymeleafViewResolver named ‘thymeleafViewResolver’. It looks for resources by surrounding the view name with a prefix and suffix. The prefix is spring.thymeleaf.prefix, and the suffix is spring.thymeleaf.suffix. The values of the prefix and suffix default to ‘classpath:/templates/’ and ‘.html’, respectively. You can override ThymeleafViewResolver by providing a bean of the same name.

就是 Thymeleaf,的模板引擎的时候需要用ThymeleafViewResolver来实现资源的渲染,用的时候注入就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/商品列表页 不返回页面,直接返回HTML的代码
@RequestMapping(value = "/to_list", produces = "text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response, Model model, SecKillUser user){
model.addAttribute("user",user);
//查询商品列表
List<GoodsVo> goodsList = goodsService.listGoodsVo();
model.addAttribute("goodsList", goodsList);
//取缓存
String html = redisService.get(GoodsKey.getGoodsList,"",String.class);
if (!StringUtils.isEmpty(html)){
return html;
}

//缓存中没有数据的时候手动渲染
SpringWebContextUtil ctx = new SpringWebContextUtil(request, response, request.getServletContext(),request.getLocale(),model.asMap(),applicationContext);
html = thymeleafViewResolver.getTemplateEngine().process("goodslist.html",ctx);

if(!StringUtils.isEmpty(html)){
redisService.set(GoodsKey.getGoodsList,"",html);
}
return html;
}

这里还踩到一个小坑,取页面信息的SpringWebContext在org.thymeleaf.spring5.context这个包下面已经没有了,被删除了;在org.thymeleaf.spring4.context下面是有的,自己重写了SpringWebContext这个类。为什么非要要重写,我单独写篇博客写一下,更清晰一些。

URL缓存

其实说是URL缓存,真的是有点不太准确哈,其实和页面缓存是一样的

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
@RequestMapping(value = "/to_detail/{goodsId}", produces = "text/html")
@ResponseBody
public String detail(HttpServletRequest request, HttpServletResponse response,Model model, SecKillUser user, @PathVariable("goodsId") long goodsId){
model.addAttribute("user",user);

//取缓存
String html = redisService.get(GoodsKey.getGoodsDetail,""+goodsId,String.class);
if (!StringUtils.isEmpty(html)){
return html;
}
//手动渲染

GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods",goods);

//秒杀的详细信息
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis(); //当前的时间

int SecKillStatus = 0;
int remainSeconds = 0;
if (now < startAt){ //秒杀未开始
SecKillStatus = 0;
remainSeconds = (int)((startAt - now)/1000);
}else if (now > endAt){ //秒杀结束
SecKillStatus = 2;
remainSeconds = -1;
}else {
SecKillStatus = 1;
remainSeconds = 0;
}

model.addAttribute("miaoshaStatus",SecKillStatus);
model.addAttribute("remainSeconds",remainSeconds);


//缓存中没有数据的时候手动渲染
SpringWebContextUtil ctx = new SpringWebContextUtil(request, response, request.getServletContext(),
request.getLocale(),model.asMap(),applicationContext);
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail.html",ctx);

if(!StringUtils.isEmpty(html)){
redisService.set(GoodsKey.getGoodsDetail,""+goodsId ,html);
}

return html;
}

对象缓存

对象缓存其实就是把缓存数据和对象放在缓存中,这样每次访问的时候从缓存中读取就可以了,就相应的减少了读取数据库的次数,从而提高了网站访问的速度 。

上面的页面缓存是设置有有效期的,因为页面信息可能随时会变,一直在缓存中中就页面的信息每次读出来就不一样了,但是对象就不一样了,这个不设置有效期,或者把有效期设置的很长。

这里做个简单的例子,把做秒杀商品的用户对象放在内存中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public SecKillUser  getUserById(long id){
//取缓存
SecKillUser user = redisService.get(SecKillUserKey.getById,""+id, SecKillUser.class);
if (user !=null){
return user;
}

//缓存中没有从数据库中取出来放入缓存中
user = secKillUserDao.getUserById(id);
if (user != null){
redisService.set(SecKillUserKey.getById,""+id, user);
}
return user;
}

因为设置缓存中的对象数据永不过期,那有人更新了自己的密码或者用户名或者其他的信息怎么办,缓存也要随着更新,要不然就缓存数据不一致了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean updatePassword(String token,long id, String formPasswordNew){
SecKillUser user = getUserById(id);
if (user == null){
throw new GlobalException(CodeMsg.MOBILE_NOT_EXITS);
}
SecKillUser user2Update = new SecKillUser();
user2Update.setId(id);
user2Update.setPassword(MD5Util.fromPass2DBPass(formPasswordNew,user.getSalt()));
secKillUserDao.update(user2Update);

//修改缓存
redisService.delete(SecKillUserKey.getById,""+id);
//更新缓存中的token
user.setPassword(user2Update.getPassword());
redisService.set(SecKillUserKey.token,token, user);
return true;
}

做了部分优化,测试一下,测试的Linux服务器为1g+4核。

没有优化之前

1533475002(1)

优化之后

1533475274543

可以看到并发已经上去了,QPS从1267上升到2218了。

页面静态优化 前后端分离

先想一下我们在平常的开发中前后端交互的流程:其实服务端为动态页面作用很单一就是提供了网站需要展示的数据而已,服务端是不会创造一个新页面的。服务端提供的数据的类型也是很统一,要不就是服务端语言提供的基本数据类型例如:字符、数字、日期等等,要不就是复杂点的数据类型例如数组、列表、键值对等等,不过归属服务端的动态页面还需要服务端语言帮助做一件事情,那就是把服务端提供的数据整合到页面里,最终产生一个浏览器可以解析的html网页,这个操作无非就是使用服务端语言可以构造文件的能力构建一个符合要求的html文件而已。不过一个页面里需要动态变化的往往只是其中一部分,所以做服务端的动态页面开发时候我们可以直接写html代码,这些html代码就等于在构造页面展示的模板而已,而模板的空白处则是使用服务端数据填充,因此在java的web开发里视图层技术延生出了Thymeleaf,freemark这样的技术,我们将其称之为模板语言的由来。

由此可见,服务端MVC框架里抢夺的web前端的工作就是抢占了构建html模板的工作,那么我们在设计web前端的MVC框架时候对于和服务端对接这块只需要让服务端保持提供数据的特性即可。从这些论述里我们发现了,其实前端MVC框架要解决的核心问题应该有这两个,它们分别是:

核心问题一:让模板技术交由浏览器来做,让服务端只提供单纯的数据服务。

核心问题二:模板技术交由浏览器来承担,那么页面的动态性体现也就是根据不同的服务端数据进行页面部分刷新来完成的。

而这两个核心问题解决办法那就是使用ajax技术,ajax技术天生就符合解决这些问题的技术手段了。

简答来讲就是其实就是将页面缓存到客户的浏览器上,当用户访问页面的时候,仅从与服务器取数据,从本地缓存中取页面,节省网络流量。

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
//商品详情页
@RequestMapping(value="/detail/{goodsId}")
@ResponseBody
public Result<GoodsDetailVo> detail(HttpServletRequest request, HttpServletResponse response, Model model, SecKillUser user, @PathVariable("goodsId") long goodsId){
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);

//秒杀的详细信息
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis(); //当前的时间

int secKillStatus = 0;
int remainSeconds = 0;
if (now < startAt){ //秒杀未开始
secKillStatus = 0;
remainSeconds = (int)((startAt - now)/1000);
}else if (now > endAt){ //秒杀结束
secKillStatus = 2;
remainSeconds = -1;
}else {
secKillStatus = 1;
remainSeconds = 0;
}

GoodsDetailVo vo = new GoodsDetailVo();
vo.setRemainSeconds(remainSeconds);
vo.setSecKillStatus(secKillStatus);
vo.setGoods(goods);
vo.setUser(user);
return Result.success(vo);
}

之前我们是把数据通过model.addAttributes()传递给页面的,然后返回的是HTML页面,这里直接就是@ResponseBody,返回的是页面上需要的一些数据,不需要整合把数据整合到页面中。

对应的前端HTML的代码

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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
<!DOCTYPE HTML>
<html >
<head>
<title>商品详情</title>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!-- jquery -->
<script type="text/javascript" src="./js/jquery.min.js"></script>
<!-- bootstrap -->
<link rel="stylesheet" type="text/css" href="./bootstrap/css/bootstrap.min.css" />
<script type="text/javascript" src="./bootstrap/js/bootstrap.min.js"></script>
<!-- jquery-validator -->
<script type="text/javascript" src="./jquery-validation/jquery.validate.min.js"></script>
<script type="text/javascript" src="./jquery-validation/localization/messages_zh.min.js"></script>
<!-- layer -->
<script type="text/javascript" src="./layer/layer.js"></script>
<!-- md5.js -->
<script type="text/javascript" src="./js/md5.min.js"></script>
<!-- common.js -->
<script type="text/javascript" src="./js/common.js"></script>
</head>
<body>

<div class="panel panel-default">
<div class="panel-heading">秒杀商品详情</div>
<div class="panel-body">
<span id="userTip"> 您还没有登录,请登陆后再操作<br/></span>
<span>没有收货地址的提示。。。</span>
</div>
<table class="table" id="goodslist">
<tr>
<td>商品名称</td>
<td colspan="3" id="goodsName"></td>
</tr>
<tr>
<td>商品图片</td>
<td colspan="3"><img id="goodsImg" width="200" height="200" /></td>
</tr>
<tr>
<td>秒杀开始时间</td>
<td id="startTime"></td>
<td >
<input type="hidden" id="remainSeconds" />
<span id="miaoshaTip"></span>
</td>
<td>
<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>
<input type="hidden" name="goodsId" id="goodsId" />
</td>
</tr>
<tr>
<td>商品原价</td>
<td colspan="3" id="goodsPrice"></td>
</tr>
<tr>
<td>秒杀价</td>
<td colspan="3" id="miaoshaPrice"></td>
</tr>
<tr>
<td>库存数量</td>
<td colspan="3" id="stockCount"></td>
</tr>
</table>
</div>
</body>
<script>

function doMiaosha(){
$.ajax({
url:"/miaosha/do_miaosha",
type:"POST",
data:{
goodsId:$("#goodsId").val(),
},
success:function(data){
if(data.code == 0){
window.location.href="/order_detail.htm?orderId="+data.data.id;
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});

}

function render(detail){
var miaoshaStatus = detail.secKillStatus;
var remainSeconds = detail.remainSeconds;
var goods = detail.goods;
var user = detail.user;
if(user){
$("#userTip").hide();
}
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#startTime").text(new Date(goods.startDate).format("yyyy-MM-dd hh:mm:ss"));
$("#remainSeconds").val(remainSeconds);
$("#goodsId").val(goods.id);
$("#goodsPrice").text(goods.goodsPrice);
$("#miaoshaPrice").text(goods.miaoshaPrice);
$("#stockCount").text(goods.stockCount);
countDown();
}

$(function(){
//countDown();
getDetail();
});

function getDetail(){
var goodsId = g_getQueryString("goodsId");
$.ajax({
url:"/goods/detail/"+goodsId,
type:"GET",
success:function(data){
if(data.code == 0){
render(data.data);
}else{
layer.msg(data.msg);
}
},
error:function(){
layer.msg("客户端请求有误");
}
});
}

function countDown(){
var remainSeconds = $("#remainSeconds").val();
var timeout;
if(remainSeconds > 0){//秒杀还没开始,倒计时
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀倒计时:"+remainSeconds+"秒");
timeout = setTimeout(function(){
$("#countDown").text(remainSeconds - 1);
$("#remainSeconds").val(remainSeconds - 1);
countDown();
},1000);
}else if(remainSeconds == 0){//秒杀进行中
$("#buyButton").attr("disabled", false);
if(timeout){
clearTimeout(timeout);
}
$("#miaoshaTip").html("秒杀进行中");
}else{//秒杀已经结束
$("#buyButton").attr("disabled", true);
$("#miaoshaTip").html("秒杀已经结束");
}
}

</script>
</html>

可以看到这里把html中的原来的依赖于Thymeleaf的部分全部重写,直接从浏览器的缓存中取数据,填充页面。其实还需要做一个配置,就是把application.properties中添加上spring对于静态资源的配置,就是SPRING RESOURCES HANDLING的配置

1
2
3
4
5
6
7
8
#static
spring.resources.add-mappings=true
spring.resources.chain.cache=true
spring.resources.cache.period=3600
spring.resources.chain.enabled=true
spring.resources.chain.gzipped=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/

这样就完成了前后端的分离。

静态资源优化

代码压缩

最常规的优化手段之一。
我们在平时开发的时候,JS脚本文件和CSS样式文件中的代码,都会依据一定的代码规范(比如javascript-standard-style)来提高项目的可维护性,以及团队之间合作的效率。
但是在项目发布现网后, 这些代码是给客户端(浏览器)识别的,此时代码的命名规范、空格缩进都已没有必要,我们可以使用工具将这些代码进行混淆和压缩,减少静态文件的大小

文件合并

在npm流行的今天,前端在进行项目开发的时候,往往会使用很多第三方代码库,比如jQuery,axios,weixin-js-sdk,lodash,bootstrap等等,每个库都有属于自己的脚本或者样式文件。
按照最老的方式的话,我们会用一些标签分别引入这些库文件,导致在打开一个页面的时候会发起几十个请求,这对于移动端来说是不可接受的。
在减少文件请求数量方面大致有以下三方面:
1、合并js脚本文件
2、合并css样式文件
3、合并css引用的图片,使用sprite雪碧图。

GZip

开启GZip,精简JavaScript,移除重复脚本,图像优化

CDN优化

简介:CDN(内容发布网络),是一个加速用户获取数据的系统;既可以是静态资源,又可以是动态资源,这取决于我们的决策策略。经常大部分视频加速都依赖于CDN,比如优酷,爱奇艺等,据此加速;

原理:CDN部署在距离用户最近的网络节点上,用户上网的时候通过网络运营商(电信,长城等)访问距离用户最近的要给城域网网络地址节点上,然后通过城域网跳到主干网上,主干网则根据访问IP找到访问资源所在服务器,但是,很大一部分内容在上一层节点已经找到,此时不用往下继续查找,直接返回所访问的资源即可,减小了服务器的负担。一般互联网公司都会建立自己的CDN机群或者租用CDN。

这些就了解下原理,毕竟大部分是前端的。

关于这个还找到了一篇博客啊,仅供参考。

https://blog.csdn.net/zhangjs712/article/details/51166748

超卖问题

超发的原因

假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。(同文章前面说的场景)

img

在上面的这个图中,就导致了并发用户B也“抢购成功”,多让一个人获得了商品。这种场景,在高并发的情况下非常容易出现。

1.数据库唯一索引

就是分表,秒杀的订单和正常的订单是两张表,在数据库中建立用户id和商品id的唯一索引,防止用户插入重复的记录。

2. 悲观锁思路

解决线程安全的思路很多,可以从“悲观锁”的方向开始讨论。

悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。

img

虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。

3. FIFO队列思路

那好,那么我们稍微修改一下上面的场景,我们直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。看到这里,是不是有点强行将多线程变成单线程的感觉哈。

img

然后,我们现在解决了锁的问题,全部请求采用“先进先出”的队列方式来处理。那么新的问题来了,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态。或者设计一个极大的内存队列,也是一种方案,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比。也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入异常。

4. 乐观锁思路

这个时候,我们就可以讨论一下“乐观锁”的思路了。乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。

img

有很多软件和服务都“乐观锁”功能的支持,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。

就是采用计数器的方式,用一个集合,存放每个商品以及其对应的数量,如果只是单纯的decr函数或者是incr函数,不能解决秒杀这种问题。因为有可能在并发的情况下,两个请求取到的数都是0,然后都加1,结果为1,实际上应该是2。那么这个时候建议利用乐观锁,实现自己的decr函数。

乐观锁的机制如同版本控制,如果修改的时候,要修改的value在redis中的值已经跟取出来时不一样,则修改失败。

本文标题:SpringBoot2之秒杀页面优化及解决超卖问题

文章作者:WilsonSong

发布时间:2018年08月05日 - 08:08

最后更新:2018年08月16日 - 20:08

原始链接:https://songwell1024.github.io/2018/08/05/SecKillPageOptimise/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

-------------本文结束感谢您的阅读-------------