4、缓存策略入门:缓存的概念、缓存命中率、缓存穿透/雪崩/击穿

各位同学,今天我们来聊聊缓存。说实话,缓存这东西,你只要做过几年后端开发,肯定天天跟它打交道。但真正把它用明白的人,其实不多。

我记得刚入行那会儿,觉得缓存不就是把数据放内存里嘛,有啥难的?结果第一次线上事故,就是缓存搞出来的。嗯,从那以后,我再也不敢小看缓存了。

4.1 缓存到底是什么?

说白了,缓存就是一块更快的存储空间。我们把热点数据放进去,下次访问就不用去慢速的数据库里翻了。

你想想看,行情系统里,每秒可能有几万条报价进来。如果每条都去查MySQL,数据库早就挂了。这时候,缓存就是我们的救星。

我个人习惯把缓存分成三层:

  • 本地缓存:就在进程内存里,速度最快,但容量有限
  • 分布式缓存:比如Redis,可以跨进程共享
  • 客户端缓存:在浏览器或App端,减少网络请求

行情系统里,我们最常用的是前两层。本地缓存扛高频访问,Redis做全局共享。

核心原则:缓存不是万能的。它解决的是"读多写少"的场景。如果你的数据每秒都在变,缓存反而成了累赘。

4.2 缓存命中率——你的缓存到底有没有用?

很多同学上来就加缓存,加完也不管效果。我问你,你的缓存命中率是多少?答不上来吧?

命中率,就是请求在缓存中找到数据的比例。公式很简单:

命中率 = 缓存命中的次数 / 总请求次数 × 100%

我在项目中遇到过,有人把命中率做到99%,结果系统还是慢。为什么?因为那1%的未命中,全是热点数据!

所以,光看整体命中率不够。你得关注:

  • 热点数据命中率:最常访问的那批key,命中率必须接近100%
  • 冷数据命中率:偶尔访问的数据,命中率低一点没关系

我的经验:行情系统里,我一般要求整体命中率不低于95%。如果低于90%,说明缓存策略有问题,得赶紧排查。

4.3 缓存穿透——最容易被忽视的坑

什么是缓存穿透?简单说,就是请求的数据在缓存和数据库里都不存在。

举个例子:用户查一个不存在的股票代码。缓存里没有,数据库里也没有。每次请求都穿透到数据库,白白浪费资源。

我曾经接手过一个系统,高峰期每秒有几千个无效请求,全打在数据库上。数据库CPU直接飙到100%。

怎么解决?我常用的方法有三种:

  1. 缓存空值:查不到的数据,也缓存一个空结果,设置短过期时间
  2. 布隆过滤器:把所有合法key存到布隆过滤器里,请求来了先过滤一遍
  3. 参数校验:在入口层就拦截掉明显不合法的请求
// 伪代码示例:缓存空值
public Stock getStock(String code) {
    // 1. 查缓存
    Stock stock = cache.get(code);
    if (stock != null) {
        return stock;
    }
    
    // 2. 缓存里没有,查数据库
    stock = dao.getByCode(code);
    
    // 3. 不管有没有,都缓存
    if (stock == null) {
        cache.set(code, EMPTY_FLAG, 60); // 空值缓存60秒
    } else {
        cache.set(code, stock, 300);
    }
    
    return stock;
}

注意:布隆过滤器有误判率。我一般控制在1%以内。太高了会误杀正常请求,太低了占用内存多。

4.4 缓存雪崩——系统崩溃的元凶

缓存雪崩,就是大量缓存同时失效,请求全部打到数据库上。

为什么会这样?最常见的原因是:

  • 缓存设置了相同的过期时间
  • 缓存服务挂了
  • 重启后缓存全部清空

我记得有一次,凌晨三点被电话叫醒。系统挂了,所有接口都超时。查了半天,发现是缓存批量过期,数据库扛不住。

从那以后,我养成了几个习惯:

  • 过期时间加随机值:比如基础时间300秒,再加0-60秒的随机偏移
  • 多级缓存:本地缓存扛第一波,Redis扛第二波
  • 限流降级:数据库扛不住时,直接返回旧数据或提示"稍后再试"
// 过期时间加随机值
int baseExpire = 300;
int randomOffset = new Random().nextInt(60);
cache.set(key, value, baseExpire + randomOffset);

4.5 缓存击穿——热点数据的致命一击

缓存击穿和雪崩有点像,但针对的是单个热点key。

想象一下:某个股票突然暴涨,所有人都来查它的行情。这个key的缓存刚好过期了。一瞬间,几千个请求同时去查数据库。

这就是击穿。一个key就能把数据库打垮。

我的解决方案是:

  • 互斥锁:第一个请求去查数据库,其他请求等待
  • 逻辑过期:缓存永不过期,后台异步更新
  • 热点数据预热:系统启动时,主动加载热点数据

我个人推荐:行情系统里,我倾向于用"逻辑过期"方案。因为互斥锁在高并发下会有性能损耗,而逻辑过期可以做到无锁更新。

// 逻辑过期示例
public Stock getStock(String code) {
    // 1. 查缓存
    CacheItem item = cache.get(code);
    if (item == null) {
        return null; // 缓存不存在,走降级
    }
    
    // 2. 检查是否逻辑过期
    if (item.isExpired()) {
        // 3. 异步更新缓存
        asyncRefresh(code);
    }
    
    // 4. 返回旧数据(即使过期了也返回)
    return item.getData();
}

4.6 一张图看懂缓存策略

说了这么多,我画张图帮你理清思路。这张图展示了缓存的核心流程和三大问题的应对策略。

缓存策略核心流程图 客户端请求 布隆过滤器 缓存层 数据库 三大缓存问题及解决方案 缓存穿透 数据不存在 绕过缓存查DB 解决方案 1. 缓存空值 2. 布隆过滤器 3. 参数校验 缓存雪崩 大量缓存同时失效 DB瞬间被打垮 解决方案 1. 过期时间加随机值 2. 多级缓存 3. 限流降级 缓存击穿 单个热点key失效 高并发打到DB 解决方案 1. 互斥锁 2. 逻辑过期 3. 热点数据预热

避坑指南:我曾经在项目中同时遇到穿透和击穿,那叫一个酸爽。后来我总结了一个原则——先防穿透,再防击穿,最后防雪崩。优先级不能乱。

好了,这一章的内容就到这里。缓存策略入门,说白了就是搞清楚三个问题:数据存哪里?怎么保证命中率?遇到异常怎么兜底?

下一章,我们会深入聊聊本地缓存的具体实现。到时候我会拿Guava Cache和Caffeine做对比,看看哪个更适合行情系统。

专注资料整理