尝试对前端页面进行相应的优化,比较典型的是缓存,一些东西可以存在浏览器身上或者redis中,提高相应速度,降低后端压力。

1. 页面缓存

这里以商品列表页面为例。

原来的商品列表页面是这样写的:

1
2
3
4
5
6
7
8
9
@RequestMapping("to_list")
public String toList(Model model,MiaoshaUser user){
if(user == null)
return "login";
model.addAttribute("user",user);
List<GoodsVo> goodsVoList = goodsService.getGoodsVoList();
model.addAttribute("goodsList",goodsVoList);
return "goods_list";
}

给他添加页面缓存:

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
@RequestMapping(value = "to_list",produces = "text/html")
@ResponseBody
public String toList(Model model, MiaoshaUser user, HttpServletRequest request, HttpServletResponse response) throws IOException {
if(user == null){
response.sendRedirect("/login/to_login");
return null;
}

model.addAttribute("user",user);
//先尝试从缓存中取
String html = redisService.get(GoodsKey.getGoodsList,"",String.class);
if(!StringUtils.isEmpty(html)){
return html;
}
//取不到,则手动渲染,再保存到redis
List<GoodsVo> goodsVoList = goodsService.getGoodsVoList();
model.addAttribute("goodsList",goodsVoList);
SpringWebContext ctx = new SpringWebContext(request,response,request.getServletContext(),
request.getLocale(), model.asMap(),applicationContext);
html = thymeleafViewResolver.getTemplateEngine().process("goods_list",ctx);
if(!StringUtils.isEmpty(html)){
redisService.set(GoodsKey.getGoodsList,"",html);
}
return 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
@RequestMapping("/to_detail/{goodsId}")
public String toDetail(@PathVariable("goodsId") long goodsId,Model model, MiaoshaUser user){
if(user == null)
return "login";
model.addAttribute("user",user);

GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods",goodsVo);
long startAt = goodsVo.getStartDate().getTime();
long endAt = goodsVo.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;//秒杀活动的状态,0-秒杀前;1-正在秒杀;2-秒杀结束
int remainSeconds = 0;//秒杀活动还剩多少秒
if(now < startAt){
miaoshaStatus = Constants.MiaoshaStatus.BEFORE_START;
remainSeconds = (int)(startAt-now)/1000;
}else if (now > endAt){
miaoshaStatus = Constants.MiaoshaStatus.AFTER_MIAOSHA;
remainSeconds = -1;
}else {
miaoshaStatus = Constants.MiaoshaStatus.ON_MIAOSHA;
remainSeconds = 0;
}

model.addAttribute("miaoshaStatus",miaoshaStatus);
model.addAttribute("remainSeconds",remainSeconds);
return "goods_detail";
}

现在改为如下,以goodsid作为区别:

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
@RequestMapping(value = "/to_detail/{goodsId}",produces = "text/html")
@ResponseBody
public String toDetail(@PathVariable("goodsId") long goodsId,Model model, MiaoshaUser user, HttpServletRequest request,
HttpServletResponse response) throws IOException{
if(user == null){
response.sendRedirect("/login/to_login");
return null;
}
model.addAttribute("user",user);

//先尝试从缓存中取
String html = redisService.get(GoodsKey.getGoodsDetail,""+goodsId,String.class);
if(!StringUtils.isEmpty(html)){
return html;
}

GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
model.addAttribute("goods",goodsVo);
long startAt = goodsVo.getStartDate().getTime();
long endAt = goodsVo.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;//秒杀活动的状态,0-秒杀前;1-正在秒杀;2-秒杀结束
int remainSeconds = 0;//秒杀活动还剩多少秒
if(now < startAt){
miaoshaStatus = Constants.MiaoshaStatus.BEFORE_START;
remainSeconds = (int)(startAt-now)/1000;
}else if (now > endAt){
miaoshaStatus = Constants.MiaoshaStatus.AFTER_MIAOSHA;
remainSeconds = -1;
}else {
miaoshaStatus = Constants.MiaoshaStatus.ON_MIAOSHA;
remainSeconds = 0;
}

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

SpringWebContext ctx = new SpringWebContext(request,response,request.getServletContext(),
request.getLocale(), model.asMap(),applicationContext);
html = thymeleafViewResolver.getTemplateEngine().process("goods_detail",ctx);
if(!StringUtils.isEmpty(html)){
redisService.set(GoodsKey.getGoodsDetail,""+goodsId,html);
}
return html;
}

}

2. 对象缓存

就是对一个对象进行缓存,比如这里可以对MiaoshaUser这个对象进行缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
public MiaoshaUser getById(long id){
//先去缓存取
MiaoshaUser user = redisService.get(MiaoshaUserKey.getById,""+id,MiaoshaUser.class);
if(user != null){
return user;
}
//缓存没有则去数据库取
user = miaoshaUserDao.getById(id);
if(user != null){
redisService.set(MiaoshaUserKey.getById,""+user.getId(),user);
}
return user;
}

这个逻辑是十分清晰的,但是如果我是更新一个信息呢?比如更新登录的用户的Nickname。那么就要注意,先更新数据库,在更新好数据库之后,一定要注意处理相关的缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean updateUsername(String token,long id,String newUsername){
MiaoshaUser user = getById(id);
if(user == null)
throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
//更新数据库
MiaoshaUser toBeUpdate = new MiaoshaUser();
toBeUpdate.setId(id);
toBeUpdate.setNickname(newUsername);
miaoshaUserDao.update(toBeUpdate);
//处理缓存
redisService.del(MiaoshaUserKey.getById,""+id);
user.setNickname(newUsername);
redisService.set(MiaoshaUserKey.token,token,user);//token不能直接删除,否则会要求重新登录
return true;
}

3. 商品详情页面静态化

之前我们队商品详情页面进行了redis缓存,因为这个接口只是展示相应产品详情和秒杀倒计时等信息,只要显示几个关键信息即可,其他的都可以进行静态化。

这种技术,我们其实已经做过了,在之前的电商项目中,前端用vue.js等其他js框架或者不用框架,直接jquery。前端分为两部分,一部分是不改变的html块,还有一块就是数据,他只要后端传数据到前端即可,用到ajax技术。

确定哪些是需要传到前端的数据:

1
2
3
4
5
6
7
@Data
public class DetailVo {
private int miaoshaStatus = 0;
private int remainSeconds = 0;
private GoodsVo goods;
private MiaoshaUser user;
}

detail这个接口改为:

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
@RequestMapping(value = "/detail/{goodsId}")
@ResponseBody
public Result<DetailVo> toDetail(@PathVariable("goodsId") long goodsId, MiaoshaUser user, HttpServletRequest request,
HttpServletResponse response) throws IOException{
if(user == null){
response.sendRedirect("/login/to_login");
return null;
}

GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);

long startAt = goodsVo.getStartDate().getTime();
long endAt = goodsVo.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;//秒杀活动的状态,0-秒杀前;1-正在秒杀;2-秒杀结束
int remainSeconds = 0;//秒杀活动还剩多少秒
if(now < startAt){
miaoshaStatus = Constants.MiaoshaStatus.BEFORE_START;
remainSeconds = (int)(startAt-now)/1000;
}else if (now > endAt){
miaoshaStatus = Constants.MiaoshaStatus.AFTER_MIAOSHA;
remainSeconds = -1;
}else {
miaoshaStatus = Constants.MiaoshaStatus.ON_MIAOSHA;
remainSeconds = 0;
}

DetailVo detailVo = new DetailVo();
detailVo.setUser(user);
detailVo.setGoods(goodsVo);
detailVo.setMiaoshaStatus(miaoshaStatus);
detailVo.setRemainSeconds(remainSeconds);
return Result.success(detailVo);
}

后端的数据已经有了,那么前端只要接收这些数据即可。

首先是在static目录下新建goods_detail.htm页面,里面讲themeleaf的动态获取的对象全部去除。改为最普通的html,只要用id来标识一下,然后在js中赋值即可。比如:

1
2
3
4
5
6
7
8
9
10
11
12
<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>

js部分,首先是打开页面就执行这个方法:

1
2
3
4
$(function(){
//countDown();
getDetail();
});

里面的getDetail方法为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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("客户端请求有误");
}
});
}

获取goods_id,因为list页面的商品详情请求是

1
<td><a th:href="'/goods_detail.htm?goodsId='+${goods.id}">详情</a></td>

所以下面要获取这个参数:

1
2
3
4
5
6
function g_getQueryString(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
var r = window.location.search.substr(1).match(reg);
if(r != null) return unescape(r[2]);
return null;
};

获取到之后就请求后端接口,获取数据去渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function render(detail){
var miaoshaStatus = detail.miaoshaStatus;
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();
}

倒计时countDown()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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("秒杀已经结束");
}
}

上面的日期格式化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//设定时间格式化函数,使用new Date().format("yyyyMMddhhmmss");
Date.prototype.format = function (format) {
var args = {
"M+": this.getMonth() + 1,
"d+": this.getDate(),
"h+": this.getHours(),
"m+": this.getMinutes(),
"s+": this.getSeconds(),
};
if (/(y+)/.test(format))
format = format.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));
for (var i in args) {
var n = args[i];
if (new RegExp("(" + i + ")").test(format))
format = format.replace(RegExp.$1, RegExp.$1.length == 1 ? n : ("00" + n).substr(("" + n).length));
}
return format;
};

4. 订单详情页面静态化

之前的do_miaosha要进行修改,不能再返回String了,而是要返回Json数据,原来是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@RequestMapping("/do_miaosha")
public String do_miaosha(Model model, MiaoshaUser user, @RequestParam("goodsId") long goodsId){
if(user == null)
return "login";
model.addAttribute("user",user);
//判断库存
GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
if(goodsVo.getStockCount() <= 0){
model.addAttribute("errmsg", CodeMsg.MIAO_SHA_OVER.getMsg());
return "miaosha_fail";
}
//判断是否已经秒杀到了
MiaoshaOrder miaoshaOrder = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(),goodsId);
if(miaoshaOrder != null){
model.addAttribute("errmsg", CodeMsg.REPEATE_MIAOSHA.getMsg());
return "miaosha_fail";
}
//减库存、下订单、写入秒杀订单,需要在一个事务中执行
OrderInfo orderInfo = miaoshaService.miaosha(user,goodsVo);
model.addAttribute("orderInfo", orderInfo);
model.addAttribute("goods", goodsVo);
return "order_detail";
}

现在改为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RequestMapping(value = "/do_miaosha",method = RequestMethod.POST)
@ResponseBody
public Result<OrderInfo> do_miaosha(Model model, MiaoshaUser user, @RequestParam("goodsId") long goodsId){
if(user == null)
return Result.error(CodeMsg.SESSION_ERROR);

//判断库存
GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
if(goodsVo.getStockCount() <= 0){
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder miaoshaOrder = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(),goodsId);
if(miaoshaOrder != null){
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//减库存、下订单、写入秒杀订单,需要在一个事务中执行
OrderInfo orderInfo = miaoshaService.miaosha(user,goodsVo);

return Result.success(orderInfo);
}

下面按秒杀按钮:

1
2
3
4
<td>
<button class="btn btn-primary btn-block" type="button" id="buyButton"onclick="doMiaosha()">立即秒杀</button>
<input type="hidden" name="goodsId" id="goodsId" />
</td>

下面进行处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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("客户端请求有误");
}
});

}

一旦抢到商品,那么就跳转到订单详情页面,order_detail.htm中的处理与上面的一样:

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
function render(detail){
var goods = detail.goods;
var order = detail.order;
$("#goodsName").text(goods.goodsName);
$("#goodsImg").attr("src", goods.goodsImg);
$("#orderPrice").text(order.goodsPrice);
$("#createDate").text(new Date(order.createDate).format("yyyy-MM-dd hh:mm:ss"));
var status = "";
if(order.status == 0){
status = "未支付"
}else if(order.status == 1){
status = "待发货";
}
$("#orderStatus").text(status);

}

$(function(){
getOrderDetail();
})

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

要显示order_detail,他请求/order/detail这个接口,需要ordergoods两个对象,所以新建一个vo:

1
2
3
4
5
@Data
public class OrderDetailVo {
private GoodsVo goods;
private OrderInfo order;
}

OrderController增加接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RequestMapping("/detail")
@ResponseBody
public Result<OrderDetailVo> info(MiaoshaUser user,
@RequestParam("orderId") long orderId) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
OrderInfo order = orderService.getOrderById(orderId);
if(order == null) {
return Result.error(CodeMsg.ORDER_NOT_EXIST);
}
long goodsId = order.getGoodsId();
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
OrderDetailVo vo = new OrderDetailVo();
vo.setOrder(order);
vo.setGoods(goods);
return Result.success(vo);
}

这样就ok了,对于商品详情和订单详情两个页面完成了静态化。

5. 页面缓存

Cache-Control:指定缓存有多少时间

为了在浏览器端进行缓存,以及控制缓存时间,这里可以添加一些配置:

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

6. 解决超卖

先解决卖成负数的问题:

reduceStock(MiaoshaGoods g);这个方法里,sql要多加一个stock_count > 0即:

1
update miaosha_goods set stock_count = stock_count-1 where goods_id=#{goodsId} and stock_count > 0

miaosha_order中的user_idgoods_id建立唯一联合索引。保证同一个人不能秒杀都两个商品。

但是从压测结果来看,虽然解决了上面两个问题。但是仍然发生了超卖现象,即比如只有10件秒杀商品,但是有22个人抢到了。

2019/4/17号补充

关于这里的超卖问题,视频中的解决是用数据库的锁来实现,但是这样的话显然效率会比较低,我觉得可以交给redis+MQ来解决,redis结合lua脚本可以实现原子性,这样子可以保证redis中库存扣减不会出问题,再结合MQ的队列,可以避免高并发下发生库存扣减错误问题。这个在mama-buy这个项目中进行了一些演示和说明。

7. 静态资源优化

  • js/css压缩
  • 多个js/css组合,减少连接数
  • CDN就近访问
  • nginx加缓存,页面缓存,对象缓存