页面级高并发秒杀优化

一、商品列表页面缓存实现

1.页面缓存

要展示一个页面时,先从缓冲中取;如果取不到,再进行渲染,然后将渲染后的结果写入缓存。

对商品列表页进行优化,修改toList()这个Controller。之前是直接返回html的文件名,让SpringBoot负责渲染。现在将代码改为:

    @RequestMapping(value = "/to_list",produces = "text/html")
    @ResponseBody
    public String toList(Model model, MiaoshaUser user,
                         HttpServletRequest request,HttpServletResponse response){
        model.addAttribute("user",user);
        List<GoodsVo> goodsVos = goodsService.listGoodsVo();
        model.addAttribute("goodsList",goodsVos);
        //先从缓存中取
        ValueOperations<String, String> opsForValue = redisTemplate.opsForValue();
        String html = opsForValue.get(GoodsKey.getGoodsList.getPrefix());
        if(!StringUtils.isEmpty(html)){
            return html;
        }else {
            //取不到手动渲染,使用ThymeleafViewResolver
            WebContext webContext = new WebContext(request, response, request.getServletContext(), request.getLocale(),
                    model.asMap());
            html = thymeleafViewResolver.getTemplateEngine().process("goods_list", webContext);
            if(!StringUtils.isEmpty(html)){
                //写入缓存
 opsForValue.set(GoodsKey.getGoodsList.getPrefix(),html,GoodsKey.getGoodsList.expireSeconds(), TimeUnit.SECONDS);
            }
            return html;
        }
    }

页面缓存的有效期通常是比较短的,只是为了防止短时间内大量访问。这里设置为60s。

看一看这里的页面缓存能带来多大的性能提升:

这是加入缓存之前的压测数据:

image-20200630104310902

这是加入缓存之后的压测数据(5000个用户,循环访问10次):

image-20200630104000307

怎么QPS还变低了

image-20200630104551437

重新测试,QPS为3000,上涨50%。

2.URL缓存

与页面缓存相似,只不过是带有参数的。用来优化一下详情页面。

3.对象缓存

对象缓存是更细粒度的缓存。页面缓存适合不会经常变动信息,并且访问次数较多的页面。

其实之前就用到了对象缓存,比如在redis中存储token和用户信息,这就属于对象缓存

(1)优化getById()方法

这是MiaoshaService中getById()的原方法:

	public MiaoshaUser getById(long id) {
		return miaoshaUserDao.getById(id);
	}

非常的简单,只是从数据库中根据Id查询用户。现在我们加入对象缓存。

	public MiaoshaUser getById(long id) {
		//取缓存
		ValueOperations<String, MiaoshaUser> opsForValue = redisTemplate.opsForValue();
		MiaoshaUser user = opsForValue.get(MiaoshaUserKey.getById.getPrefix() + id);
		if(user!=null){
			return user;
		}else {
			MiaoshaUser userDaoById = miaoshaUserDao.getById(id);
			if(userDaoById!=null){
				opsForValue.set(MiaoshaUserKey.getById.getPrefix() + id,userDaoById);
			}
			return userDaoById;
		}
	}

如果要实现修改密码功能,那么步骤应该是:

  1. 先把数据存到数据库中
  2. 成功后,再让缓存失效

不能先操作缓存,因为如果先让缓存失效,那么在处理数据库的过程中,就有可能会被其他操作重新将旧的数据插入缓存。

二、商品详情页面静态化

直接将页面缓存到浏览器,相比于缓存到redis,效率更高。这里尝试将商品详情页静态化,动态的资源是通过接口发送的。

1.重写Controller

首先,修改Controller代码,将所有的model.addAttribute()删掉,因为这是动态的。

然后定义一个对象,作为向页面传输的值:

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

然后改造Controller,不再返回页面,而是返回一个Result<GoodsDetailVo>,前端通过Ajax获取数据再渲染到页面上。

    @RequestMapping("/to_detail/{goodsId}")
    @ResponseBody
    public Result<GoodsDetailVo> detail(Model model, MiaoshaUser user,
                                        @PathVariable("goodsId")long goodsId){
        GoodsVo goods = goodsService.getByGoodsId(goodsId);

        long startAt = goods.getStartDate().getTime();
        long endAt = goods.getEndDate().getTime();
        long now = System.currentTimeMillis();

        int miaoshaStatus = 0;
        int remainSeconds = 0;
        if(now<startAt){
            //秒杀还未开始
            miaoshaStatus = 0;
            remainSeconds = (int) ((startAt-now)/1000);
        }else if(now>endAt){
            //秒杀已结束
            miaoshaStatus = 2;
            remainSeconds = -1;
        }else{
            //秒杀进行中
            miaoshaStatus = 1;
            remainSeconds = 0;
        }
        GoodsDetailVo goodsDetailVo= new GoodsDetailVo();
        goodsDetailVo.setGoods(goods);
        goodsDetailVo.setMiaoshaStatus(miaoshaStatus);
        goodsDetailVo.setRemainSeconds(remainSeconds);
        goodsDetailVo.setMiaoshaUser(user);
        return Result.success(goodsDetailVo);
    }

2.页面静态化

对html文件做出修改,之前跳转是通过后端进行页面跳转,现在可以直接通过前端进行跳转再请求数据。

在商品列表页的跳转按钮,其href应该这样设置:

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

此时goods_detail.html应该放在static的目录下,在goods_detail.html文件中获取参数使用的是正则表达式来匹配。

<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"οnclick="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 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();
    }

    $(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>

这就是新的页面代码,所有的动态数据都是通过Ajax异步请求来获取的

三、订单详情页面静态化

1.跳转订单页面

秒杀功能页面的静态化和商品详情相似。当点击“立即秒杀”进入此方法:

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

2.MiaoshaController重写

    @PostMapping("/do_miaosha")
    @ResponseBody
    public Result<OrderInfo> miaosha(Model model, MiaoshaUser user, @RequestParam("goodsId")long goodsId){
        if(user==null){
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        //判断商品是否有库存
        GoodsVo goodsVo = goodsService.getByGoodsId(goodsId);
        int stock = goodsVo.getStockCount();
        if(stock<=0){
            return Result.error(CodeMsg.MIAOSHA_OVER);
        }
        //判断是否已经买过此商品(防止一人买多个)
        MiaoshaOrder order = orderService.getOrderById(user.getId(),goodsId);
        if(order!=null){
            return Result.error(CodeMsg.REPEATE_MIAOSHA);
        }
        //开始秒杀:减库存、下订单、写入秒杀订单(事务)
        OrderInfo orderInfo = miaoshaService.miaosha(user,goodsVo);
        return Result.success(orderInfo);
    }

3.订单详情页面

<body>
<div class="panel panel-default" >
    <div class="panel-heading">秒杀订单详情</div>
    <table class="table" id="goodslist">
        <tr>
            <td>商品名称</td>
            <td colspan="3" id="goodsName"></td>
        </tr>
        <tr>
            <td>商品图片</td>
            <td colspan="2"><img  id="goodsImg" width="200" height="200" /></td>
        </tr>
        <tr>
            <td>订单价格</td>
            <td colspan="2"  id="orderPrice"></td>
        </tr>
        <tr>
            <td>下单时间</td>
            <td id="createDate" colspan="2"></td>
        </tr>
        <tr>
            <td>订单状态</td>
            <td id="orderStatus">
            </td>
            <td>
                <button class="btn btn-primary btn-block" type="submit" id="payButton">立即支付</button>
            </td>
        </tr>
        <tr>
            <td>收货人</td>
            <td colspan="2">XXX  18812341234</td>
        </tr>
        <tr>
            <td>收货地址</td>
            <td colspan="2">北京市昌平区回龙观龙博一区</td>
        </tr>
    </table>
</div>
</body>
</html>
<script>
    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("客户端请求有误");
            }
        });
    }
</script>

4.查看是否缓存成功

image-20200630160039857查看页面,观察到order_detail.html的Status Code是304.

304所表达的含义就是,客户端执行了GET请求,文件并没有变化。这时文件就可以使用本地缓存的html文件了。

但是这样客户端与服务端还是发生了一次交互,如果要客户端直接从浏览器取数据,则还需要一些配置:

#static
spring.resources.add-mappings=true
spring.resources.cache.period= 3600
spring.resources.chain.cache=true
spring.resources.chain.enabled=true
spring.resources.chain.compressed=true
spring.resources.chain.html-application-cache=true
spring.resources.static-locations=classpath:/static/

再次查看:

image-20200630162155848

可以发现,这次的code成了200,并且后面的括号(from disk cache),说明这就是从缓存获取的页面。

5.OrderController

订单详情数据需要从Controller中请求,所以写一个Controller用来返回数据。

首先创建传输的数据OrderDetailVo:

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

然后根据传入的OrderId将数据写入Vo并返回:

    @GetMapping("/detail")
    @ResponseBody
    public Result<OrderDetailVo> orderInfo(MiaoshaUser user, @RequestParam("orderId") long orderId){
        if(user==null){
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        OrderInfo orderInfo = orderService.getOrderInfoById(orderId);
        if(orderInfo==null){
            return Result.error(CodeMsg.ORDER_NOT_EXIST);
        }
        Long goodsId = orderInfo.getGoodsId();
        GoodsVo goodsVo = goodsService.getByGoodsId(goodsId);
        OrderDetailVo orderDetailVo = new OrderDetailVo();
        orderDetailVo.setGoods(goodsVo);
        orderDetailVo.setOrder(orderInfo);
        return Result.success(orderDetailVo);
    }

四、防止超卖

之前在压测时发现库存会变成负数,说明大量线程并发时会发生线程安全问题。

那么怎么解决呢?

首先在数据库层面,修改SQL语句。

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

这样就可以保证只有在库存数大于0的情况下才会减库存。

现在还有一个问题,那就是一旦一个人同时发送两个请求,同时进入了下订单的方法。那么判断逻辑根本检测不出来,这样一个人就能多次下单。

解决方法还是要通过数据库,可以利用数据库的唯一索引。将MiaoshaOrder表中的userid和orderId合成一个联合索引。

image-20200630190219893

其实这里还有一个问题,老师并没有提到,那就是尽管数量不会为负数了,但是订单还是会超出数量

五、静态资源优化

  1. JS/CSS压缩,减少流量

  2. 多个JS/CSS组合,减少连接数

现在有很多模板可以自动实现这个功能,比如淘宝的Tengine,专门用来打包的Webpack。

  1. CDN 内容分发网络
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值