1. 缓存入门:为什么需要缓存、缓存的工作原理、缓存带来的挑战

各位同学,咱们今天聊聊缓存。说实话,我做了十几年后端,见过太多系统因为没用好缓存而「翻车」的例子。有一次线上告警,数据库连接池被打满,CPU 飙到 99%,我一看——好家伙,同一个查询每秒跑了上万次。加了个缓存,响应时间从 800ms 降到了 5ms。你说,这玩意儿重不重要?

1.1 为什么需要缓存

先问个问题:你的 API 慢在哪?绝大多数情况下,瓶颈都在 I/O——读数据库、调远程服务、访问磁盘文件。这些操作动辄几十毫秒甚至几百毫秒,而内存访问只要几微秒。缓存说白了,就是把「慢操作」的结果存到「快介质」里,下次直接拿。

我归纳了三个核心场景:

  • 减轻数据库压力:高频查询不走 DB,缓存扛住 90% 的流量
  • 降低响应延迟:内存读取 vs 磁盘/网络 I/O,差两个数量级
  • 提升系统吞吐:同样的硬件,缓存命中率高时能多扛几倍并发

一个真实数据:我在某电商项目里做过压测,商品详情页不加缓存时 QPS 只有 800,加了一层 Redis 本地缓存后,QPS 直接冲到 12000。不是算法优化,就是缓存。

1.2 缓存的工作原理

缓存的核心逻辑其实就四个字:先查缓存,再查源。画个图你就明白了。

缓存读写流程 缓存读写基本流程 客户端请求 缓存命中? 是 返回缓存数据 否 查询数据库/源服务 写入缓存 + 返回数据

流程很简单:

  1. 请求来了,先查缓存
  2. 缓存有数据(命中),直接返回——完事
  3. 缓存没数据(未命中),查数据库,把结果写回缓存,再返回

代码实现也很直白。我用伪代码写一下核心逻辑:

function getData(key) {
    // 1. 先查缓存
    data = cache.get(key)
    if (data != null) {
        return data  // 命中,直接返回
    }
    
    // 2. 未命中,查数据库
    data = db.query("SELECT * FROM table WHERE id = ?", key)
    
    // 3. 写回缓存,设置过期时间
    cache.set(key, data, ttl=3600)
    
    return data
}

我的习惯:生产环境里,我一般把缓存 TTL 设成业务能接受的最大延迟。比如商品详情页,允许 5 分钟延迟更新,TTL 就设 300 秒。别设太长,否则数据不一致会让你头疼。

1.3 缓存带来的挑战

缓存不是银弹。用不好,反而会引入三个大坑:一致性、雪崩、穿透。我一个一个说。

1.3.1 缓存一致性

说白了就是:数据库改了,缓存还是旧数据。用户看到的是「过期」内容。

我在项目中遇到过最典型的场景:用户修改了个人信息,数据库已经更新了,但缓存没清掉。结果用户刷新页面,看到的还是老头像。气得用户直接投诉。

常见的处理方式有三种:

策略 做法 优点 缺点
先更新 DB,再删除缓存 写 DB → 删缓存 实现简单,最终一致 删除可能失败,需重试
先删缓存,再更新 DB 删缓存 → 写 DB 避免读到旧数据 写 DB 期间缓存为空,压力大
延迟双删 删缓存 → 写 DB → 延迟再删 一致性较高 实现复杂,有延迟窗口

我曾经踩过的坑:用了「先删缓存,再更新 DB」的策略,结果在并发场景下,另一个线程在删缓存后、写 DB 前读到了旧数据并写回缓存。最后不得不改成「延迟双删 + 消息队列兜底」才解决。嗯,一致性这事,没有完美的方案,只有适合业务的方案。

1.3.2 缓存雪崩

想象一下:你的缓存里存了 100 万条数据,全部在同一时刻过期。下一秒,所有请求都穿透到数据库——数据库瞬间被打爆。这就是缓存雪崩。

为什么会这样?很多时候是因为 TTL 设成了固定值。比如所有商品缓存都设 3600 秒,那每小时整点就是一场灾难。

怎么防?我总结了三个办法:

  • TTL 加随机偏移:基础值 3600 秒,再随机加 0~600 秒,避免集体过期
  • 多级缓存:本地缓存 + Redis + 数据库,层层兜底
  • 限流降级:缓存失效后,对 DB 查询做限流,保护后端
// 伪代码:TTL 加随机偏移
int baseTtl = 3600;
int randomOffset = new Random().nextInt(600);  // 0~600 秒随机
cache.set(key, data, baseTtl + randomOffset);

1.3.3 缓存穿透

缓存穿透和雪崩不一样。雪崩是缓存有数据但集体过期,穿透是缓存和数据库都没有这个数据。比如恶意攻击者拿一个不存在的用户 ID 疯狂请求,每次都不命中缓存,直接打到数据库。

我见过最夸张的一次:某系统被爬虫用自增 ID 遍历,大量不存在的 ID 导致数据库 CPU 100%。加缓存没用,因为根本不存在的数据缓存里也没有。

解决方案:

  • 缓存空值:查询结果为空时,也缓存一个「空标记」,TTL 设短一点(比如 60 秒)
  • 布隆过滤器:请求来之前先判断 key 是否存在,不存在直接拒绝
  • 参数校验:不合法的 ID 直接返回 400,不查缓存也不查 DB

我的建议:布隆过滤器虽然好用,但要注意它有误判率(会把不存在判成存在)。我一般把误判率设成 1%,然后配合缓存空值做兜底。两层防护,基本能挡住 99% 的穿透攻击。

1.4 本章小结

缓存的核心就三件事:为什么用、怎么用、有什么坑。

  • 为什么用?——快,省资源,扛并发
  • 怎么用?——先查缓存,未命中再查源,写回缓存
  • 有什么坑?——一致性要权衡,雪崩要防集体过期,穿透要防空值攻击

记住一句话:缓存是加速器,不是救世主。用得好,API 响应快十倍;用得不好,半夜爬起来修数据。后面的章节,我会带你深入每种策略的细节和实战代码。咱们一步步来。