【商城秒杀项目】-- 页面缓存、URL缓存、对象缓存

常用的页面优化技术

  • 页面缓存+URL缓存+对象缓存

由于并发瓶颈在数据库,所以要想办法来减少对数据库的访问,可以加若干缓存来解决,通过各种粒度的缓存,最大粒度的页面级缓存到最小粒度的对象级缓存

  • 页面静态化,前后端分离

页面都是纯的HTML,通过js或者ajax来请求服务器,如果做了静态化,浏览器可以把HTML缓存在客户端

  • 静态资源优化

JS/CSS压缩,减少流量(使用压缩版的JS,去掉多余的空格字符,区别于阅读版)
JS/CSS组合,减少连接数(将多个JS和CSS的组合到一个请求里面去,一下子从服务端全部下载下来)

  • CDN优化

内容分发给网络,就近访问

缓存的相关术语

命中率:当某个请求能够通过访问缓存而得到响应时,称为缓存命中;缓存命中率越高,缓存的利用率也就越高

最大空间:缓存通常位于内存中,内存的空间通常比磁盘空间小的多,因此缓存的最大空间不可能非常大。当缓存存放的数据量超过最大空间时,就需要淘汰部分数据来存放新到达的数据

缓存淘汰策略

  • FIFO(First In First Out)

先进先出策略,在实时性的场景下,需要经常访问最新的数据,那么就可以使用 FIFO,使得最先进入的数据(最晚的数据)被淘汰

  • LRU(Least Recently Used)

最近最久未使用策略,优先淘汰最久未使用的数据,也就是上次被访问时间距离现在最久的数据。该策略可以保证内存中的数据都是热点数据,也就是经常被访问的数据,从而保证缓存命中率

  • LFU(Least Frequently Used)

最不经常使用策略,优先淘汰一段时间内使用次数最少的数据

注:一般页面缓存和URL缓存时间比较短,适合场景:变化不大的页面;如果分页的话,不会全部缓存,一般缓存前一两页的数据

页面缓存

步骤:

  1. 取缓存 (缓存里面存的是HTML)
  2. 手动渲染模板
  3. 结果输出(直接输出HTML代码)

本项目对商品列表使用了页面缓存,代码如下:

/**
 * 利用redis缓存整个商品列表,防止同一时间访问量巨大,如果缓存时间过长,数据及时性就不高
 */
@RequestMapping(value = "/to_list", produces = "text/html")
@ResponseBody
public String list(HttpServletRequest request, HttpServletResponse response, Model model, MiaoshaUser user) {
    model.addAttribute("user", user);
    //取缓存
    String html = redisService.get(GoodsKey.getGoodsList, "", String.class);
    if (!StringUtils.isEmpty(html)) {
        return html;
    }
    //查询商品列表
    List<GoodsVo> goodsList = goodsService.listGoodsVo();
    model.addAttribute("goodsList", goodsList);
    for (GoodsVo goods : goodsList) {
        //如果不是null的时候,将库存加载到redis里面去,防止商品还有库存,而redis中却没有库存的情况
        redisService.set(GoodsKey.getMiaoshaGoodsStock, "" + goods.getId(), goods.getStockCount());
    }
    //使用模板引擎手动渲染,templateName:模板名称,templateName="goods_list";
    IWebContext ctx = new WebContext(request, response,
            request.getServletContext(), request.getLocale(), model.asMap());
    html = thymeleafViewResolver.getTemplateEngine().process("goods_list", ctx);
    //保存至缓存,缓存goodslist这个页面
    if (!StringUtils.isEmpty(html)) {
        redisService.set(GoodsKey.getGoodsList, "", html);
    }
    return html;
}

当访问goods_list页面的时候,如果从缓存中取到就返回这个HTML(这里方法的返回格式已经设置为text/html,这样就是返回html的源代码),如果取不到,就利用ThymeleafViewResolver的getTemplateEngine().process和我们获取到的数据渲染模板,并且在返回到前端之前保存至缓存里面,然后之后再来获取的时候,只要缓存里面存的goods_list页面的html还没有过期,那么直接返回给前端即可

一般这个页面缓存的时间也不会很长,防止数据的时效性很低,但是可以防止短时间高并发访问

GoodsKey作为页面缓存的Key的前缀,缓存有效时间,一般设置为1分钟:

package com.javaxl.miaosha_05.redis;

public class GoodsKey extends BasePrefix {

    private GoodsKey(int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }

    //商品id(1分钟)
    public static GoodsKey getGoodsList = new GoodsKey(60, "gl");
    //描述(1分钟)
    public static GoodsKey getGoodsDetail = new GoodsKey(60, "gd");
    //库存(不过期)
    public static GoodsKey getMiaoshaGoodsStock = new GoodsKey(0, "gs");
}

缓存入redis的内容如下:

URL缓存

这里的URL缓存相当于页面缓存,针对不同的详情页显示不同缓存页面,对不同的URL进行缓存(redisService.set(GoodsKey.getGoodsDetail, “” + goodsId, html),与页面缓存的实质一样,本项目对商品详情页面使用了URL缓存,代码如下:

/**
 * 单个详情页放到redis缓存中,1min后过期
 */
@RequestMapping(value = "/to_detail2/{goodsId}", produces = "text/html")
@ResponseBody
public String detail2(HttpServletRequest request, HttpServletResponse response, Model model, MiaoshaUser user,
                      @PathVariable("goodsId") long goodsId) {
    model.addAttribute("user", user);

    //取缓存
    String html = redisService.get(GoodsKey.getGoodsDetail, "" + goodsId, String.class);
    if (!StringUtils.isEmpty(html)) {
        return html;
    }
    //缓存中没有,则将业务数据取出,放到缓存中去
    GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
    model.addAttribute("goods", goods);
    //getTime()返回的是毫秒数
    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;
    }
    model.addAttribute("miaoshaStatus", miaoshaStatus);
    model.addAttribute("remainSeconds", remainSeconds);

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

对象缓存
对象缓存相比页面缓存是更细粒度的缓存。在实际项目中, 不会大规模使用页面缓存,对象缓存就是当用到用户数据的时候,可以直接从缓存中取出;比如更新用户密码、根据token来获取用户缓存对象

本项目对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, "" + id, user);
    }
    return user;
}

MiaoshaUserKey作为对象缓存的Key的前缀,这里我们认为对象缓存一般没有有效期,永久有效:

package com.javaxl.miaosha_05.redis;

public class MiaoshaUserKey extends BasePrefix {

    public static final int TOKEN_EXPIRE = 3600 * 24 * 2;

    private MiaoshaUserKey(int expireSeconds, String prefix) {
        super(expireSeconds, prefix);
    }

    public static MiaoshaUserKey token = new MiaoshaUserKey(TOKEN_EXPIRE, "tk");
    public static MiaoshaUserKey getById = new MiaoshaUserKey(0, "id");
}

更新用户密码时要注意更新数据库与缓存,一定保证数据一致性,修改token关联的对象以及id关联的对象,先更新数据库后删除缓存,不能直接删除token,删除之后就不能登录了,再将token以及对应的用户信息一起再写回缓存里面去:

/**
 * 注意数据修改时候,保持缓存与数据库的一致性
 */
public boolean updatePassword(String token,long id,String passNew) {
    //1.取user对象,查看是否存在
    MiaoshaUser user = getById(id);
    if(user == null) {
        throw new GlobalException(CodeMsg.MOBILE_NOT_EXIST);
    }
    //2.更新密码
    MiaoshaUser toupdateuser = new MiaoshaUser();
    toupdateuser.setId(id);
    toupdateuser.setPassword(MD5Util.inputPassToDbPass(passNew, user.getSalt()));
    miaoshaUserDao.update(toupdateuser);
    //3.更新数据库与缓存,一定保证数据一致性,修改token关联的对象以及id关联的对象
    redisService.delete(MiaoshaUserKey.getById, ""+id);
    //不能直接删除token,删除之后就不能登录了
    user.setPassword(toupdateuser.getPassword());
    redisService.set(MiaoshaUserKey.token, token,user);
    return true;
}

关于缓存的一些思考
为什么不能先处理缓存,再更新数据库?为什么缓存更新策略是先更新数据库后删除缓存?怎么保持缓存与数据库一致?

要解答这个问题,首先来看下数据不一致的几种情况,可将数据不一致分为三种情况:

数据库有数据,缓存没有数据
数据库有数据,缓存也有数据,数据不相等
数据库没有数据,缓存有数据

解决策略:首先尝试从缓存读取,读到数据则直接返回;如果读不到,就读数据库,并将数据会写到缓存;需要更新数据时,先更新数据库,然后把缓存里对应的数据失效掉(删掉)

读的逻辑大家都很容易理解,这里谈谈更新,如果不采取上述更新方法,还能想到什么更新方法呢?大概会是:先删除缓存,然后再更新数据库。这么做会引发的问题是,如果A、B两个线程同时要更新数据,并且A、B已经都做完了删除缓存这一步,接下来,A先更新了数据库,C线程读取数据,由于缓存没有,则查数据库,并把A更新的数据,写入了缓存,最后B更新数据库,那么缓存和数据库的值就不一致了
另外有人会问,如果采用你提到的方法,为什么最后是把缓存的数据删掉,而不是把更新的数据写到缓存里。这么做引发的问题是,如果A、B两个线程同时做数据更新,A先更新了数据库,B后更新数据库,则此时数据库里存的是B的数据,而更新缓存的时候,是B先更新了缓存,而A后更新了缓存,则缓存里是A的数据,这样缓存和数据库的数据也不一致

解决方案大概有以下几种:

  1. 对删除缓存进行重试,数据的一致性要求越高,就越要重试得快
  2. 定期全量更新,简单地说,就是定期把数据全量加载
  3. 给所有的缓存一个失效期

第三种方案可以说是一个大杀器,任何不一致,都可以靠失效期解决,失效期越短,数据一致性九越高;但是失效期越短,查数据库就会越频繁,因此失效期应该根据业务来定

缓存会出现的问题

  • 缓存穿透:指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库

解决方案:

对这些不存在的数据缓存一个空数据、对这类请求进行过滤

  • 缓存雪崩:指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库

在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求;当发生缓存雪崩时,数据库无法处理这么大的请求,从而导致数据库崩溃

解决方案:

为了防止缓存在同一时间大面积过期导致的缓存雪崩,可以通过观察用户行为,合理设置缓存过期时间来实现、为了防止缓存服务器宕机出现的缓存雪崩,可以使用分布式缓存,分布式缓存中每一个节点只缓存部分的数据,当某个节点宕机时可以保证其它节点的缓存仍然可用;也可以进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩

  • 缓存一致性:缓存一致性要求数据更新的同时缓存数据也能够实时更新

解决方案:

在数据更新的同时立即去更新缓存、在读缓存之前先判断缓存是否是最新的,如果不是最新的先进行更新;要保证缓存一致性需要付出很大的代价,缓存数据最好是那些对一致性要求不高的数据,允许缓存数据存在一些脏数据

  • 缓存 “无底洞” 现象:指的是为了满足业务要求添加了大量缓存节点,但是性能不但没有好转反而下降了的现象

产生原因:缓存系统通常采用hash函数将key映射到对应的缓存节点,随着缓存节点数目的增加,键值分布到更多的节点上,导致客户端一次批量操作会涉及多次网络操作,这意味着批量操作的耗时会随着节点数目的增加而不断增大。此外,网络连接数变多,对节点的性能也有一定影响

解决方案:

优化批量数据操作命令、减少网络通信次数、降低接入成本,使用长连接/连接池,NIO等

发布了133 篇原创文章 · 获赞 94 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_42687829/article/details/104478037