4、缓存策略入门:缓存的概念、缓存命中率、缓存穿透/雪崩/击穿
各位同学,今天我们来聊聊缓存。说实话,缓存这东西,你只要做过几年后端开发,肯定天天跟它打交道。但真正把它用明白的人,其实不多。
我记得刚入行那会儿,觉得缓存不就是把数据放内存里嘛,有啥难的?结果第一次线上事故,就是缓存搞出来的。嗯,从那以后,我再也不敢小看缓存了。
4.1 缓存到底是什么?
说白了,缓存就是一块更快的存储空间。我们把热点数据放进去,下次访问就不用去慢速的数据库里翻了。
你想想看,行情系统里,每秒可能有几万条报价进来。如果每条都去查MySQL,数据库早就挂了。这时候,缓存就是我们的救星。
我个人习惯把缓存分成三层:
- 本地缓存:就在进程内存里,速度最快,但容量有限
- 分布式缓存:比如Redis,可以跨进程共享
- 客户端缓存:在浏览器或App端,减少网络请求
行情系统里,我们最常用的是前两层。本地缓存扛高频访问,Redis做全局共享。
核心原则:缓存不是万能的。它解决的是"读多写少"的场景。如果你的数据每秒都在变,缓存反而成了累赘。
4.2 缓存命中率——你的缓存到底有没有用?
很多同学上来就加缓存,加完也不管效果。我问你,你的缓存命中率是多少?答不上来吧?
命中率,就是请求在缓存中找到数据的比例。公式很简单:
命中率 = 缓存命中的次数 / 总请求次数 × 100%
我在项目中遇到过,有人把命中率做到99%,结果系统还是慢。为什么?因为那1%的未命中,全是热点数据!
所以,光看整体命中率不够。你得关注:
- 热点数据命中率:最常访问的那批key,命中率必须接近100%
- 冷数据命中率:偶尔访问的数据,命中率低一点没关系
我的经验:行情系统里,我一般要求整体命中率不低于95%。如果低于90%,说明缓存策略有问题,得赶紧排查。
4.3 缓存穿透——最容易被忽视的坑
什么是缓存穿透?简单说,就是请求的数据在缓存和数据库里都不存在。
举个例子:用户查一个不存在的股票代码。缓存里没有,数据库里也没有。每次请求都穿透到数据库,白白浪费资源。
我曾经接手过一个系统,高峰期每秒有几千个无效请求,全打在数据库上。数据库CPU直接飙到100%。
怎么解决?我常用的方法有三种:
- 缓存空值:查不到的数据,也缓存一个空结果,设置短过期时间
- 布隆过滤器:把所有合法key存到布隆过滤器里,请求来了先过滤一遍
- 参数校验:在入口层就拦截掉明显不合法的请求
// 伪代码示例:缓存空值
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 一张图看懂缓存策略
说了这么多,我画张图帮你理清思路。这张图展示了缓存的核心流程和三大问题的应对策略。
避坑指南:我曾经在项目中同时遇到穿透和击穿,那叫一个酸爽。后来我总结了一个原则——先防穿透,再防击穿,最后防雪崩。优先级不能乱。
好了,这一章的内容就到这里。缓存策略入门,说白了就是搞清楚三个问题:数据存哪里?怎么保证命中率?遇到异常怎么兜底?
下一章,我们会深入聊聊本地缓存的具体实现。到时候我会拿Guava Cache和Caffeine做对比,看看哪个更适合行情系统。