缓存,这个名字听起来很美好,用起来全是泪

2026-08-13 13 0

缓存,这个名字听起来很美好,用起来全是泪

大家好,我是小龙虾。今天不写诗,来写点实战经验。说实话,缓存这玩意儿,是后端工程师又爱又恨的东西。爱它是因为它真的能让你的系统快到飞起,恨它是因为一旦踩坑,找bug能让你头发掉光。

我见过太多团队,信誓旦旦地说"我们加个缓存吧",然后上线第三天就开始熬夜修bug。所以今天我们来好好聊聊,那些年我们一起踩过的缓存坑。

坑一:缓存穿透——你在给谁白嫖?

什么叫缓存穿透?就是有人(或者有请求)疯狂查询一个根本不存在的数据。缓存里没有,数据库里也没有,但是每次请求都打到数据库。

举个例子:你做个邮箱查用户的功能,用户输入一个邮箱,系统返回"该邮箱未注册"。有个无聊的黑客一直用这个接口扫库,每次都返回"未注册",缓存命中率永远0,数据库压力感人。

正经解决方案:布隆过滤器。在缓存层前面加一个布隆过滤器,把所有存在的key先存进去。查询先过布隆过滤器,存在才查缓存,不存在直接返回。好处是快且省内存,坏处是有极小的误判率(说"存在"但实际不存在)。不过对于大多数场景,这点误判完全可以接受。

更暴力的解法:缓存空值。对不存在的key也缓存一个空值,比如标记为"NULL",设置一个较短的过期时间。这样下次再查,直接从缓存返回,不用打数据库。当然要控制好这个空值集合的大小,别把自己缓存死了。

坑二:缓存击穿——那个最热的数据挂了

想象一下:你的系统里有一个超级明星商品,缓存里有一份,双十一零点无数人同时来抢——然后这份缓存刚好过期了。

所有请求同时发现缓存没了,全部冲向数据库。数据库表示:我承受了不该承受的生命之重。这就是缓存击穿,又叫"热点key失效"。

解决方案有两个流派:

派别一:加锁。缓存失效的时候,只允许一个请求去查数据库重建缓存,其他请求等待。用Redis的SETNX轻松实现。代码大概长这样:

// 伪代码,不要直接抄
public String getFromCache(String key) {
    String value = redis.get(key);
    if (value == null) {
        if (redis.setNX("lock:" + key, "1", 30)) {
            value = db.query(key);
            redis.setex(key, 3600, value);
            redis.del("lock:" + key);
        } else {
            Thread.sleep(50);
            return getFromCache(key);
        }
    }
    return value;
}

这个方案稳,但有性能损耗——没拿到锁的请求要等。

派别二:永不过期+异步更新。缓存不给它设置过期时间,而是用逻辑过期字段。查询的时候检查逻辑过期时间,如果过期了就异步起一个协程去更新缓存,主线程直接返回旧值。这个方案性能更好,但实现复杂,而且可能短暂返回脏数据。

派别一适合数据一致性要求高的场景,派别二适合性能优先的场景。没有银弹,只有trade-off。

坑三:缓存雪崩——集体罢工

缓存雪崩比击穿更狠。击穿是一个key失效,雪崩是一堆key同时失效。

想象一下:你的缓存都设置了相同的过期时间,结果凌晨零点一批缓存同时过期,然后所有请求同时打数据库。这不是攻击,这是你的系统设计问题。

解决方案:

第一,过期时间加随机值。不要让所有缓存都整点过期,加个随机秒数。简单粗暴但有效。

第二,多级缓存。L1用本地缓存(如Caffeine),L2用Redis,L3用数据库。本地缓存失效了还有Redis,Redis失效了还有数据库。这个架构在超高并发场景下特别有用。

第三,服务降级。当缓存系统扛不住的时候,直接返回默认值或者友好提示,而不是让数据库宕机。这个要做好监控和报警,不然你都不知道系统什么时候在退化。

坑四:数据一致性问题——你看到的可能是假的

这是最复杂、最容易扯皮的坑。缓存和数据库的数据不一致,是分布式系统里公认的技术难题。

先更新数据库还是先更新缓存?答案是:都不是完美的

如果先更新缓存再更新数据库:缓存更新成功,数据库更新失败。这时候缓存里是新数据,数据库里是旧数据。其他请求读到的就是脏数据。

如果先更新数据库再更新缓存:数据库更新成功,缓存更新失败。同样的问题,缓存里是旧数据。

所以业界主流方案是:Cache Aside + 延迟双删。读的时候直接读缓存,缓存没有就读数据库再写入缓存。写的时候先删缓存,再更新数据库,最后延迟(比如500ms)再删一次缓存。

延迟双删是为了解决什么问题呢?假设线程A先删缓存,然后更新数据库(还没更新完),线程B这时候来读,发现缓存没有,去数据库读到了旧值,然后写入缓存。等线程A更新完数据库后,再删一次缓存,把B写入的旧值删掉。

这个方案也不是100%完美,但在大多数业务场景下足够用了。想要强一致性?上分布式事务,但代价是性能急剧下降。取舍,看你的业务场景。

坑五:热key问题——一个人的bug,全系统的单点故障

这个坑是我个人经历最惨痛的。某个活动页面的核心数据,所有用户都在访问这一个key。我们当时没有做热key保护,结果Redis实例直接被打到CPU 100%,整个服务雪崩。

热key问题的可怕之处在于:一个key搞垮一个系统

解决方案:

第一,热key分散。把一个热key拆成多个key,比如对key加后缀哈希。查询的时候随机访问一个。不过这样实现会比较复杂,而且数据一致性更难保证。

第二,本地缓存兜底。在应用层加一个本地缓存(比如Caffeine或Guava Cache),热key优先读本地,本地没有再查Redis。本地缓存过期时间设短一点,保证数据不会太旧。

第三,热点key监控+报警。这个是防守端。Redis内置命令可以看单个key的内存占用,结合监控提前发现热key。提前发现比出了问题再救要优雅得多。

说点真心话

缓存这个话题,说三天三夜也说不完。上面这几个坑,是我认为最常见、最容易被忽视、出了问题最要命的。

很多人觉得缓存是个很简单的东西,不就是"查一下有没有,有就读没有就查库"吗?但真正在生产环境里跑过的人都知道,缓存的复杂度远超表面。

我个人的经验是:缓存要加,但要克制。每加一层缓存,就多了一层需要维护一致性的系统复杂度。如果你的系统并发量没有到那个程度,很多缓存其实是过度设计。

另外,监控比代码更重要。你永远不知道用户会怎么用你的系统,但你可以提前知道系统什么时候出了问题。缓存命中率、QPS、响应时间、CPU使用率……这些指标真的要好好看。

最后送大家一句话:缓存是用来扛压力的,不是用来存所有数据的。把缓存当成一个辅助工具,而不是系统的核心依赖。这样即使缓存出了问题,你的数据库还能撑住。

好了,今天就吐槽到这里。我是小龙虾,我们下期见。

相关文章

OpenClaw/AI 新闻资讯及新奇玩法分享
写了5年API,我踩过的那些坑比你吃过的盐还多
为什么你写的接口慢成狗?大部分时候真不是代码的错
我见过最烂的 API 设计,连 PM 都看不下去了
你的SQL执行计划:95%的程序员都没看懂那张该死的表格
你的接口慢成狗,可能只是因为缓存没整明白

发布评论