缓存基础概念与原理

咱们今天聊聊缓存。说实话,这词儿在系统里随处可见,但真要把它讲透,还真需要点耐心。我个人的习惯是先给场景下定义——缓存说白了就是一块比原始数据源快得多的存储区域。你想想看,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,问题才解决。所以选策略前先想清楚数据的访问特征。

缓存的三大挑战

说了这么多好处,咱也得直面问题。缓存不是银弹,常见的坑有三:

  1. 缓存穿透:查一个不存在的Key,每次都绕开缓存直接怼到DB。解决方案是用布隆过滤器,或者缓存一个空值(有效期短一些)
  2. 缓存击穿:某个热点Key在过期瞬间,大量请求同时涌入DB。解决方案是加互斥锁,或者后台异步刷新缓存
  3. 缓存雪崩:大量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;
}

一张图看懂缓存体系

说了这么多,我把核心逻辑用一张图串起来。你注意看这个流程:数据怎么请求、怎么命中、怎么回填、怎么失效——其实都在里面了。

缓存核心知识体系 缓存知识体系总览 CPU L1/L2/L3 缓存 本地堆内缓存 分布式缓存 时间局部性 刚访问的数据还会再访问 空间局部性 周边数据很可能被访问 Cache-Aside Read-Through Write-Through Write-Behind FIFO / LRU LFU / TTL 三大挑战 穿透·击穿·雪崩 命中率 > 90% 才有效 否则考虑改策略或扩容

嗯,缓存的核心概念就这些。看似简单,但每一点展开都有很多细节。后面几章咱们会逐个深入:怎么设计缓存Key、怎么选替换策略、怎么处理一致性问题。先把这张图画在脑子里,后面的事就好办了。