页面级高并发秒杀优化

    技术2022-07-10  159

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

    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。

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

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

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

    怎么QPS还变低了

    重新测试,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; } }

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

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

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

    二、商品详情页面静态化

    直接将页面缓存到浏览器,相比于缓存到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.查看是否缓存成功

    查看页面,观察到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/

    再次查看:

    可以发现,这次的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合成一个联合索引。

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

    五、静态资源优化

    JS/CSS压缩,减少流量

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

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

    CDN 内容分发网络
    Processed: 0.013, SQL: 9