5.页面级高并发秒杀优化(Redis缓存+静态化分离)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/sunweiguo1/article/details/80470780

1. 页面缓存

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

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

@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";
}

给他添加页面缓存:

@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;
}

对于商品详情页面的缓存,原来是这样写的:

@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作为区别:

@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这个对象进行缓存:

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。那么就要注意,先更新数据库,在更新好数据库之后,一定要注意处理相关的缓存。

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技术。

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

@Data
public class DetailVo {
    private int miaoshaStatus = 0;
    private int remainSeconds = 0;
    private GoodsVo goods;
    private MiaoshaUser user;
}

将detai这个接口改为:

@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页面,里面讲themleaf的动态获取的对象全部去除。改为最普通的html,只要用id来标识一下,然后在js中赋值即可。比如:

 <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部分,首先是打开页面就执行这个方法:

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

里面的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("客户端请求有误");
        }
    });
}

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

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

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

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;
};

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

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():

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("秒杀已经结束");
    }
}

上面的日期格式化为:

//设定时间格式化函数,使用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数据,原来是这样写的:

@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";
}

现在改为:

@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);
}

下面按秒杀按钮:

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

下面进行处理:

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中的处理与上面的一样:

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这个接口,需要order和goods两个对象,所以新建一个vo:

@Data
public class OrderDetailVo {
    private GoodsVo goods;
    private OrderInfo order;
}

对OrderController增加接口:

@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:指定缓存有多少时间

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

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即:

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

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

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

7. 静态资源优化

  • js/css压缩

  • 多个js/css组合,减少连接数

  • CDN就近访问

  • nginx加缓存,页面缓存,对象缓存

展开阅读全文

没有更多推荐了,返回首页