缓存基础概念与原理
咱们今天聊聊缓存。说实话,这词儿在系统里随处可见,但真要把它讲透,还真需要点耐心。我个人的习惯是先给场景下定义——缓存说白了就是一块比原始数据源快得多的存储区域。你想想看,CPU速度那么快,内存根本跟不上,更不用说硬盘了。这时候中间加一层命中率高的高速存储,问题不就解决了?
多级缓存架构:从CPU到数据库
缓存不是单打独斗的东西。它是一层层堆上去的。我举个例子,一个典型的服务端应用,缓存至少分这么几层:
- CPU L1/L2/L3 缓存:纳米级的延迟,按KB到MB计算
- 本地堆内缓存(如 Caffeine、Guava Cache):微秒级,存热点小数据
- 分布式缓存(如 Redis、Memcached):毫秒级,适合跨进程共享
- HTTP/CDN 缓存:对用户端透明,缓解回源压力
- 数据库查询缓存:虽然MySQL现在不怎么推荐了,但理念还在
为什么需要这么多层?因为每层的容量和速度是反比的。越靠近CPU的越快,但越贵、越小。离得远的虽然慢,但能存海量数据。我当年在做一个高并发交易系统时,就吃过只依赖Redis的亏——网络往返一多,热点Key直接把Redis打满,最终还得靠本地缓存挡掉99%的重复请求。
缓存局部性:为什么缓存能提速
说到底,缓存能生效全靠局部性原理。嗯,其实核心就两个原则:
- 时间局部性:刚访问的数据,很快还会再访问。比如循环里的累加变量
- 空间局部性:访问了这个地址,周围的地址也可能会被访问。比如遍历数组
我记得有一次优化一个报表系统,SQL里频繁查询近7天的订单。最开始没加缓存,每次跑20秒。后来我把7天内的订单按日期分片,用本地缓存预加载+LRU淘汰,最热的那天查询直接降到毫秒级。说白了就是利用了时间局部性——用户只爱看最新的数据。
关键指标:缓存命中率低于80%时,先别急着加内存。先看看你的访问模式是否真的符合局部性。不符合的话,缓存不仅没用,还浪费资源。
缓存读写模式:你属于哪一种?
那缓存到底怎么用呢?代码层面,常见的读写模式就那几种。我简单列一下:
| 模式 | 读操作 | 写操作 | 适用场景 |
|---|---|---|---|
| Cache-Aside | 先查缓存,未命中则查DB并回填 | 先更新DB,再删除缓存 | 最通用,适合读多写少的业务 |
| Read-Through | 缓存层封装DB查询,自动回填 | 同Cache-Aside | 希望业务代码更简洁时 |
| Write-Through | 与Read-Through相同 | 先写缓存,由缓存同步写DB | 需要强一致性时,但写延迟会高 |
| Write-Behind | 与Read-Through相同 | 只写缓存,异步批量写DB | 写吞吐要求极高,能接受少量数据丢失 |
我个人的经验是,90%的场景用Cache-Aside就够了。曾经有个朋友用Write-Through做支付系统,结果缓存挂了,丢了几分钟的数据不敢写DB……嗯,这得看业务能不能接受。
缓存替换策略:满了怎么办?
缓存空间不是无限的。一旦满了,就得踢人出去。常见的淘汰策略有这么几种:
- FIFO(先进先出):谁先进来谁先走。简单粗暴,但可能把热数据踢掉
- LRU(最近最少使用):最常用的还在,不常用的淘汰。实现用双向链表+哈希表,性能好
- LFU(最不经常使用):按访问次数淘汰。有段时间流行的策略,但实现复杂,容易把旧热点留住
- TTL(过期时间):放进去时定个时间,到点自动失效。最省心
避坑指南:我曾经在一个项目中踩过一个坑——直接用了Redis的默认LRU,结果秒杀场景下大量冷Key被误判为热Key,导致热点数据频繁被淘汰。后来改成allkeys-lfu,问题才解决。所以选策略前先想清楚数据的访问特征。
缓存的三大挑战
说了这么多好处,咱也得直面问题。缓存不是银弹,常见的坑有三:
- 缓存穿透:查一个不存在的Key,每次都绕开缓存直接怼到DB。解决方案是用布隆过滤器,或者缓存一个空值(有效期短一些)
- 缓存击穿:某个热点Key在过期瞬间,大量请求同时涌入DB。解决方案是加互斥锁,或者后台异步刷新缓存
- 缓存雪崩:大量Key同时过期,或者整个缓存节点挂了。解决方案是TTL加随机偏移,或者做高可用集群
嗯,这三个问题我在线上都遇到过。尤其是缓存穿透那次,DBA半夜打电话说数据库连接数打满了,排查半天发现是爬虫在扫不存在的用户ID……从那以后,所有查询入口我都强制加了一层布隆过滤器。
代码片段:一个简单的穿透解决方案——缓存空值。注意有效期别设太长,5分钟以内就行。
// 伪代码:缓存空值防穿透
public User getUser(String id) {
// 1. 查缓存
User user = cache.get("user:" + id);
if (user != null) {
// 命中空标记,直接返回null
if (user.isEmptyMark()) return null;
return user;
}
// 2. 查数据库
user = db.query("SELECT * FROM user WHERE id = ?", id);
// 3. 回填缓存(查不到就存空标记)
if (user == null) {
cache.set("user:" + id, EMPTY_MARK, 300); // 5分钟过期
} else {
cache.set("user:" + id, user, 3600);
}
return user;
}
一张图看懂缓存体系
说了这么多,我把核心逻辑用一张图串起来。你注意看这个流程:数据怎么请求、怎么命中、怎么回填、怎么失效——其实都在里面了。
嗯,缓存的核心概念就这些。看似简单,但每一点展开都有很多细节。后面几章咱们会逐个深入:怎么设计缓存Key、怎么选替换策略、怎么处理一致性问题。先把这张图画在脑子里,后面的事就好办了。