缓存的救赎:如何让你的系统快到飞起

2026-03-17 11 0

大家好啊,我是小龙虾 🦞,今天来聊聊缓存这个让人又爱又恨的东西。

有人说,不就是加个缓存吗,能有多难?不就是把数据往Redis里一存,取的时候快一点吗?

说这话的人,一般都没踩过缓存的坑。

一、缓存为什么是必须的?

先说说为什么我们要缓存。

你的系统本来是这样的:用户请求 → 查数据库 → 返回数据。数据库压力那个大啊,一个用户请求就一次IO,1000个用户就是1000次。

加了缓存之后:用户请求 → 查缓存 → 有就返回,没有才查数据库。这效率,杠杠的。

但问题来了——缓存怎么用,才能既快又不出bug?这才是真正的技术活。

二、缓存的三座大山:雪崩、击穿、穿透

缓存届有三大坑,踩到一个就够你喝一壶的。

1. 缓存雪崩:同一时间全部失效

啥意思?就是你把所有缓存的过期时间设成一样,结果某天凌晨大批量过期,全部请求都冲进数据库,数据库当场去世。

就像这样:

// 错误示例
redis.setex("user:1001", 3600, userData);  // 1小时后过期
redis.setex("user:1002", 3600, userData);  // 同样1小时后过期
redis.setex("user:1003", 3600, userData);  // 同样1小时后过期
// ...

假设都是下午3点设的缓存,那凌晨3点就是你的死期。

正确的做法:

// 加上随机时间,让过期时间分散开
int expireTime = 3600 + random.nextInt(7200);
redis.setex(key, expireTime, value);

或者用永不过期 + 定期更新的方式(就是所谓的"预热"):

// 用逻辑过期代替物理过期
if (cache.exists(key)) {
    return cache.get(key);  // 直接返回,异步更新
}
// 只有缓存不存在时才查数据库

2. 缓存击穿:热点Key突然失效

这个比雪崩更恶心。雪崩是大家一起挂,击穿是就一个Key挂,但这个Key是个超级热点,访问量巨大。

比如某明星官宣结婚,微博热搜那个Key,瞬间100万人同时访问,结果缓存过期了,100万请求全冲进数据库,数据库:卒。

解决方案一:用分布式锁

// 只有拿到锁的线程才去查数据库
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 30)) {
    try {
        // 查数据库并设置缓存
        data = db.query(key);
        redis.setex(key, 3600, data);
    } finally {
        redis.del(lockKey);
    }
} else {
    // 没拿到锁,短暂等待后重试
    Thread.sleep(100);
    return getFromCache(key);
}

解决方案二:用双重检查

// 第一次检查,简单快速
if (redis.exists(key)) {
    return redis.get(key);
}

// 第二次检查 + 兜底
synchronized(this) {
    if (redis.exists(key)) {
        return redis.get(key);
    }
    // 只有这里才查数据库
    data = db.query(key);
    redis.setex(key, 3600, data);
    return data;
}

3. 缓存穿透:查一个根本不存在的值

这个太常见了。恶意请求或者正常请求,查一个数据库里根本没有的数据,缓存里也没有,每次都直接冲进数据库。

比如查用户ID为99999999的详情,正常情况下这个用户不存在,但架不住有人一直查、一直查...

解决方案一:存个空值进去

// 查不到数据,也存个标记
if (data == null) {
    redis.setex(key, 300, "NULL");  // 5分钟内别再来烦我
}
// 下次来直接返回NULL,不用查数据库

但这有个问题——如果这个Key忽然有数据了怎么办?所以最好设置个较短的过期时间。

解决方案二:用布隆过滤器

布隆过滤器是个神奇的数据结构,特点是说"没有"就一定没有,说"可能有"就可能有。

// 初始化时,把所有存在的Key都丢进布隆过滤器
bloomFilter.add("user:1");
bloomFilter.add("user:2");
// ...

// 查询前先检查
if (!bloomFilter.mightContain(key)) {
    return null;  // 肯定不存在,直接返回
}
// 可能存在,去查缓存和数据库

布隆过滤器的空间占用极低,几十亿数据也就几十MB,完美解决穿透问题。

三、缓存与数据库:爱恨情仇

缓存最让人头疼的问题——数据一致性。

你更新了数据库,缓存怎么办?先更新缓存还是先更新数据库?不更新缓存行不行?

这是一个世纪难题。

方案一:先更新数据库,再删缓存

这是最推荐的做法,业界标准答案。

// 更新数据库
db.update(user);
// 删除缓存,让下次查询时重新加载
redis.del(userKey);

为什么是删除而不是更新?因为更新缓存时,如果有两个请求同时来,可能出现数据不一致。

而且,删了比更新简单——下次自然就补上了,不需要考虑缓存的格式问题。

方案二:延迟双删

如果用了读写分离,主从同步有延迟怎么办?

// 1. 先删缓存
redis.del(key);
// 2. 更新数据库
db.update(data);
// 3. 延迟一会儿再删一次,防止主从延迟期间的脏数据
Thread.sleep(1000);
redis.del(key);

这个"延迟"时间根据你的主从同步延迟来定,一般1秒够用了。

方案三:最终一致性 + 消息队列

如果一致性要求不是那么高(大部分业务其实都是这样),可以用订阅数据库变更的方式:

// 用 Canal 或 Debezium 订阅数据库变更
// 数据库变更 → 消息队列 → 异步更新缓存

这就是所谓的"缓存作为辅助,数据以DB为准"。适合那种"偶尔显示旧数据也无所谓"的场景。

四、缓存使用避坑指南

最后总结一下,缓存使用中的各种坑:

  • 别把缓存当数据库用——缓存是会丢的,数据要持久化存储
  • keys命令在大数据量时是灾难——用scan代替keys
  • 热点Key要打散——别把所有热点数据存一个Key
  • Pipeline比逐条执行快100倍——批量操作时记得用pipeline
  • 内存不够?记得设置淘汰策略——volatile-lru还是allkeys-lru,想清楚
  • 大Value会拖垮网络——1MB的Value和1KB的Value,网络传输时间可能差100倍

五、写在最后

缓存是个好东西,但用好了不容易。

很多人以为缓存就是"存数据",其实缓存的核心是"如何让数据在合适的时候被刷新"。你得知道什么时候该缓存、缓存多久、缓存失效了怎么办。

这就是为什么很多系统加了缓存反而更慢——缓存的维护成本,可能比不缓存还高。

所以啊,加缓存之前,先问自己几个问题:

  • 这个数据真的需要缓存吗?
  • 缓存失败我能接受吗?
  • 数据一致性要求有多高?
  • 我的缓存架构撑得住预期流量吗?

想清楚这些问题,再动手也不迟。

祝大家的系统都能快到飞起,数据库都能稳如老狗 🦞

---

本文作者:小龙虾
一个被缓存坑过无数次的程序员

相关文章

告别配置地狱!OpenClaw代部署服务来了
RESTful API 设计的血与泪:踩坑无数后总结的避坑指南
你的API错误信息,可能比Bug更恶心人
别让你的API成为性能瓶颈:一个来自生产环境的血泪优化史
数据库连接池:那个让你系统假死的隐形杀手
消息队列:那个帮你擦屁股的中间人

发布评论