Java秒杀系统及优化---(5)

五、页面优化技术

  • 页面缓存+URL缓存+对象缓存
  • 页面静态化(前后端分离)

除了这两个之外,常用的还有静态资源优化和CDN优化,这里暂且没做。

 

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

1.1)页面缓存

什么是页面缓存?首先,我们访问一个页面的时候,我们不是直接让我们的系统去给页面渲染,而是说:

  • 先去缓存中取
  • 取到则返回给客户端
  • 取不到,手动渲染,把结果输出到客户端,同时缓存到我们的缓存服务器redis

下次就可以直接使用,其实就是三步:取缓存-->手动渲染模板-->结果输出。

下面,我们以商品列表为例,来学习页面缓存:

    @GetMapping("/to_list")
    public String list(Model model, SecKillUser user) {
        model.addAttribute("user", user);

        //查询商品列表
        List goodsList = goodsService.listGoodsVo();
        model.addAttribute("goodsList", goodsList);
        return "goods_list";
    }

从上面代码,我们可以看出,它渲染的是goods_list.html这个模板:我们将goodsList这个对象放到model中,方便页面获取

Java秒杀系统及优化---(5)_第1张图片

这是springboot帮我们渲染的。把数据直接放到model里面,最终是放到页面上,那么我们如何做页面缓存呢?

我们是直接返回一个html的源代码:

第一步,我们是从缓存里面来取,看能不能取到,缓存中取什么东西呢?

第二步,取到时直接返回,取不到,我们需要手动进行渲染,那么我们如何进行手动渲染呢?

其实,如果你使用了thymeleaf,这个框架会自动注入一个thymekeafViewResolver

Java秒杀系统及优化---(5)_第2张图片

我们是通过这个Resolver来进行渲染的。那到底我们如何渲染我们的模板呢?使用它的一个模板引擎,看SpringWebContext:

     * @param request the request object
     * @param response the response object
     * @param servletContext the servlet context
     * @param locale the locale
     * @param variables the variables to be included into the context
     * @param appctx the Spring application context
     */
    public SpringWebContext(final HttpServletRequest request,
                            final HttpServletResponse response,
                            final ServletContext servletContext ,
                            final Locale locale, 
                            final Map variables, 
                            final ApplicationContext appctx) {
        super(request, response, servletContext, locale, addSpringSpecificVariables(variables, appctx));
        this.applicationContext = appctx;
    }

源码,填入对应参数.

SpringWebContext ctx = new SpringWebContext(request, response, request.getServletContext(),
                request.getLocale(), model.asMap(), applicationContext);

第三步,手动渲染完毕,返回值不为空时,将返回的html写入到redis中

@GetMapping(value = "/to_list", produces = "text/html")
    @ResponseBody
    public String list(HttpServletRequest request,
            HttpServletResponse response,
            Model model, SecKillUser user) {
        model.addAttribute("user", user);

        //1.从缓存中取值,看能不能取到
        String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
        if(!StringUtils.isEmpty(html)) {
            return html;
        }

        //查询商品列表
        List goodsList = goodsService.listGoodsVo();
        model.addAttribute("goodsList", goodsList);
        //return "goods_list";

        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;

    }

注意,我们做页面缓存,一般来说,我们这个有效期会比较短,什么意思呢?我们之所以做页面缓存,是考虑,瞬间访问量过来之后导致服务器压力太大,所以我们就搞一个缓存。但是呢,如果缓存时间过长,数据的及时性就不是很高了,比如说,有些数据发生变化了,结果你这个列表还没有变,但是如果说你的缓存60秒,你这个页面只是有60秒的缓存时间,对于大部分人来说,是可以容忍的,比方说你打开一个网站,看到的是一分钟之前的网站,对你来说基本上是没有什么影响,所以说缓存就存在这么个问题,需要做一个折中,OK。这就是我们的商品列表,我们来测试:

Java秒杀系统及优化---(5)_第3张图片

Java秒杀系统及优化---(5)_第4张图片

好了,已经缓存。

1.2)URL缓存

URL缓存,其实这个叫法不准确,URL级缓存和页面缓存其实差别不是很大,我们看一下商品详情页面。我们还是仿照着商品列表页面来进行修改:

    @GetMapping(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);

        //1从缓存中取值,看能不能取到                          这里需要将参数拼接上去,每个详情页是不一样的
        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);
        //return "goods_detail";

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

启动程序,进行测试:

Java秒杀系统及优化---(5)_第5张图片

完成,这就是URL级缓存,可以认为就是页面缓存多加个参数

1.3)对象缓存

页面缓存和URL缓存,这两种缓存,一般来说,时间都比较短。他们适合那种变化不大的,像商品列表啊,实际项目中,我们的商品列表有可能是分页的,但是我们不可能 把所有页面都缓存,正常来说,我们就缓存前一两页,因为大部分用户也就点一两页就OK啦,但是他这个失效是让它自动失效,我们用户是不需要来进行手动干预的。接下来要说的是一个对象缓存。这个对象缓存跟这两个有很大区别。

之前,我们在做分布式session时,做了一个token,根据token来获取我们的对象,这种缓存就叫对象级缓存,他的粒度是最小的,拿出一个key,直接对应一个对象。

下面,我们来修改SecKillUserService中的代码:

    public SecKillUser getById(long id) {
        return secKillUserDao.getById(id);
    }
    public SecKillUser getById(long id) {
        //return secKillUserDao.getById(id);
        //取缓存
        SecKillUser user = redisService.get(SecKillUserKey.getById, ""+id, SecKillUser.class);
        if(user != null) {
            return user;
        }
        //取数据库
        user = secKillUserDao.getById(id);
        if(user != null) {
            redisService.set(SecKillUserKey.getById, ""+id, user);
        }
        return user;
    }

getById的过期时间我们设置为永不过期,只要对象不发生变化,就不过期。

当然,还有一个要注意的地方,假如我们要修改用户密码

SecKillUserDao中添加:

    @Update("update miaosha_user set password = #{password} where id = #{id}")
    public void update(SecKillUser toBeUpdate);

这里我们只更新密码,这样产生的binLog最少,不更新的东西,不要往里面传,只传需要更新的字段,减轻数据库压力。

SecKillUserService中添加:

    public boolean updatePassword(String token, long id, String formPass) {
        //取user
        SecKillUser user = getById(id);
        if(user == null) {
            throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
        }
        //更新数据库
        SecKillUser toBeUpdate = new SecKillUser();
        toBeUpdate.setId(id);
        toBeUpdate.setPassword(MD5Util.formPassToDBPass(formPass, user.getSalt()));
        secKillUserDao.update(toBeUpdate);
        //处理缓存
        redisService.delete(SecKillUserKey.getById, ""+id);
        user.setPassword(toBeUpdate.getPassword());
        redisService.set(SecKillUserKey.token, token, user);
        return true;
    }

首先是取user对象,如果user==null,则抛异常。如果存在,就是要更新该对象的密码,新建一个SecKillUser对象,设置id和新的password。该处为什么要新建一个对象去更新,而不是用原来的user对象呢?其实直接更新也是可以的,但是,更新的东西越多,产生的binlog越多,所以说,一般我们为了提高效率,修改哪个字段就更新哪个字段,其他的不管,这是一个小技巧。提高SQL性能的小技巧。如果更新数据库成功了,我们还要清理缓存,这里一定要注意,这里是要修改缓存的。那我们处理哪个缓存呢?

实际上是跟这个id对应的这个对象的所有缓存,都需要改掉。这里就涉及两个,一个是token,一个是getById,对于getById,这里直接删掉就行,对于token,直接删掉合适吗?并不合适,因为删掉token之后就没法登陆了。所以这里不能删掉,而是重新设置set,更新一下。

在更新密码时,这里是先更新数据库,然后更新缓存,那么,这个更新顺序可以反过来吗?

当然是不可以的,考虑这样一种场景:假如做更新时,先删掉缓存,这时候做了一个读操作,老数据就加载到缓存中来了,然后又做了一次更新,数据库中的数据是新的,缓存里面的数据是旧的,此时数据不一致。

之前我们还说每一个Service引用别人的时候,一定要去引用别人的Service,不要引用别人的Dao,为什么呀?如果说你在别的Service里面引用我的Dao,实际你是把缓存的东西给绕过了,这样是不对的,应该调用我的Service,Serivce里面有可能会去调缓存。我们这种搭建的系统,Service间相互调用,Service只能调用别人的Service,不可调用别人的Dao。自己可以调用自己的Dao,因为别人的Service里面可能是有缓存的。

这里我就不贴自己的压测结果了,可能是内存小,用上了缓存之后,Error更大了,而且吞吐量跟之前差不多......

 

2、页面静态化(前后端分离)

就是把页面直接缓存到用户的浏览器上,这样有什么好处呢?当用户访问页面的时候,直接就不需要跟我们的服务器进行交互了,直接从本地缓存中拿到这个页面,极大节省网络的流量,提高响应速度。

此处我们是用Jquery来模拟

这次选择商品详情页进行处理:

为什么要选择商品详情页呢?因为这个页面相对来说比较复杂。可以体现出静态化的意义。

动态数据通过接口进行获取,静态数据做静态化处理

GoodsController中的detail方法,只保留业务逻辑,删除缓存等,新建一个GoodsDetailVo对象,往页面上传值,修改之后:

    @GetMapping(value = "/to_detail/{goodsId}")
    @ResponseBody
    public Result 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 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 vo = new GoodsDetailVo();
        vo.setGoods(goods);
        vo.setUser(user);
        vo.setRemainSeconds(remainSeconds);
        vo.setMiaoshaStatus(miaoshaStatus);
        return Result.success(vo);
    }

现在来改造页面:

之前,我们是通过商品列表来跳转到商品详情,这个是经过了controller的,现在呢,我们不需要通过服务端进行跳转,直接就把它搞到客户端来,我们将这个页面放到哪里呢,静态页面我们会放到static下面。记得把后缀名改了,因为我们的配置文件中有一条配置:

# 前缀:服务端返回模板后,先拼上前缀,再拼上后缀,最后得出实际页面路径
spring.thymeleaf.prefix=classpath:/templates/
# 后缀
spring.thymeleaf.suffix=.html

原来的跳转是

详情

静态化后,将商品详情放入static目录下,修改后缀为htm,而将商品目录下的跳转修改为:

详情

再将商品详情页面,去掉所有thymeleaf的东西,因为这是完全的html了。这里就不列页面的代码了,以后会给出源码。

Java秒杀系统及优化---(5)_第6张图片

如何证明商品详情页面被缓存到本地了?

Java秒杀系统及优化---(5)_第7张图片

再次刷新时,我们发现这个请求的状态码是304,就是说,我在向服务端请求这个页面时,给服务端传递了一个参数If-Modified-Since: XXX,服务端收到这个参数之后,会检查请求的页面有没有发生变化,检查后发现没有任何变化,直接给客户端返回一个304,客户端可以直接使用本地缓存里面的内容,这样,页面的数据就不需要从服务端下载了。

Java秒杀系统及优化---(5)_第8张图片

但是呢,304不是我们最终想要的效果,即使是304,客户端和服务端还是发生了一次交互,那么,如何让客户端从浏览器直接取数据,不需要询问我们的服务端呢?这里啊,实际上需要一个配置:

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

加上这些配置之后,我们看一下有什么不同?先说一下控制浏览器缓存的几个参数

Pragma:http1.0用

Expire:http1.0,1.1  带时区的时间,格林尼治时间,这个是以服务端时间输出这个字段,但是客户端和服务端的时间有可能不一致,为了避免这种情况,新添加Cache-control

Cache-control:http1.0,1.1   单位是秒,指定缓存多少秒,这样就跟服务端的时间没有关系了

由于google浏览器在发送请求时,头部会带上Cache-Control: max-age=0

Java秒杀系统及优化---(5)_第9张图片

导致服务端不会输出我们想要看到的,所以换一个浏览器,火狐就可以看到了,感兴趣的可以试试。

对秒杀页面做静态化处理,先改controller的方法

    @PostMapping("/do_miaosha")
    public Result seckill(Model model, SecKillUser user,
                                     @RequestParam("goodsId")long goodsId) {
        model.addAttribute("user", user);
        if(user == null) {
            return Result.error(CodeMsg.SESSION_ERROR);
        }
        //判断库存
        GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
        int stock = goods.getStockCount();
        if(stock <= 0) {
            model.addAttribute("errmsg", CodeMsg.SECKILL_OVER.getMsg());
            return Result.error(CodeMsg.SECKILL_OVER);
        }
        //判断是否已经秒杀到了
        SecKillOrder order = orderService.getSecKillOrderByUserIdGoodsId(user.getId(), goodsId);
        if(order != null) {
            return Result.error(CodeMsg.REPEATE_SECKILL);
        }
        //减库存 下订单 写入秒杀订单
        OrderInfo orderInfo = secKillService.miaosha(user, goods);
        return Result.success(orderInfo);
    }

再改页面,这个不多说了。

订单详情静态化,解决超卖:

跟之前的套路一样,在加载页面时,根据参数来获取服务端数据,将页面渲染出来

新建OrderController,来响应客户端请求

    @RequestMapping("/detail")
    @ResponseBody
    public Result info(Model model, SecKillUser 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);
    }

Java秒杀系统及优化---(5)_第10张图片

好使。

然而刚巧又看到一个问题:

    @Update("update miaosha_goods set stock_count = stock_count -1 where goods_id = #{goodsId}")
    public int reduceStock(SecKillGoods g);

看一下这个SQL,当商品ID相等时,库存减一,这里有个问题,现在假设库存还有一个,两个人同时调用了减库存,很明显,就把这个值给减成-1了。怎么办?

    @Update("update miaosha_goods set stock_count = stock_count - 1 where goods_id = #{goodsId} and stock_count > 0")
    public int reduceStock(SecKillGoods g);

多加一个判断条件就好了,只有库存大于0时,才去减库存。因为更新操作,我们的数据库本身会给这个操作进行加锁,不会出现两个线程同时更新一条记录的情况,所以我们是通过数据库来保证商品不会卖超了。

还有一种情况:

第一个用户看到我们库存有10,它刷我们的接口,同时发出两个请求req1,req2,首先判断商品的库存,都是10个,req1,req2都是OK的,于是两个请求都去判断是否已经秒杀到,req1很明显,没有秒杀到,req2也没有秒杀到,然后呢,两个请求同时减库存,下订单。库存是10,两个都能减库存,都能下订单,生成订单,这存在什么问题啊?也就是出现一个用户秒杀到了两个商品。如何解决这个问题???好像不是那么容易解决。实际上,还是要通过数据库来处理。什么意思呢?判断库存,判断秒杀到没,都不能处理这个问题。但是我们这个地方:createOrder,写了两个表,一个生成订单,一个生成秒杀订单。我们是限制一个用户只能秒杀一个商品,那么我们在这个表上建立唯一索引就OK啦!第一个记录是可以插进来的,第二个记录是插不进来的。插不进来之后insertSecKillOrder就会报错,由于加了事务,这里就会回滚,这也是最有效的办法。

也就是说,我们为什么要单独写出一个秒杀订单表,这里就看出他的用处来了,我们通过给秒杀订单表设置唯一索引,就能防止插入重复记录,当然,这个只是理论上会出现一个用户发了两次请求,我们实际在做秒杀的时候,提交表单之前,我们会让他生成一个验证码,输入验证码才能提交秒杀表单。我们不会让用户发出两个请求来的。但是我们为了防止这种情况,我们一定要在miaoshaorder这个地方建立唯一索引。

Java秒杀系统及优化---(5)_第11张图片

SQL和数据库索引两方面进行处理,从而保证我们的商品绝对不会卖超的。

还有个小地方:

    public SecKillOrder getSecKillOrderByUserIdGoodsId(Long userId, Long goodsId) {
        return orderDao.getSecKillOrderByUserIdGoodsId(userId, goodsId);
    }

在判断是否已经秒杀到的时候,这里直接查询了数据库,其实这里是不用去查数据库的。当生成订单的时候啊,我们把订单写到我们的缓存中,这样,我们就不需要查数据库了,只需要查缓存就可以了,算是一个小的优化。实际上,这个优化,微乎其微。

    public SecKillOrder getSecKillOrderByUserIdGoodsId(Long userId, Long goodsId) {
        //return orderDao.getSecKillOrderByUserIdGoodsId(userId, goodsId);
        return redisService.get(OrderKey.getMiaoshaOrderByUidGid, "" + userId + "_" + goodsId, SecKillOrder.class);
    }

    @Transactional
    public OrderInfo createOrder(SecKillUser user, GoodsVo goods) {
        OrderInfo orderInfo = new OrderInfo();
        orderInfo.setUserId(user.getId());
        orderInfo.setGoodsId(goods.getId());
        orderInfo.setCreateDate(new Date());
        orderInfo.setDeliveryAddrId(0L);
        orderInfo.setGoodsName(goods.getGoodsName());
        orderInfo.setGoodsCount(1);
        orderInfo.setGoodsPrice(goods.getMiaoshaPrice());
        //1pc, 2android, 3ios
        orderInfo.setOrderChannel(1);
        //订单状态,0新建未支付,1已支付,2已发货,3已收货,4,已退款,5已完成
        orderInfo.setStatus(0);

        long orderId = orderDao.insert(orderInfo);
        SecKillOrder seckillOrder = new SecKillOrder();
        seckillOrder.setUserId(user.getId());
        seckillOrder.setOrderId(orderId);
        seckillOrder.setGoodsId(goods.getId());
        orderDao.insertSecKillOrder(seckillOrder);

        redisService.set(OrderKey.getMiaoshaOrderByUidGid, "" + user.getId() + "_" + goods.getId(), seckillOrder);

        return orderInfo;
    }

好了,今天就这些吧。

你可能感兴趣的:(Web框架)