我为什么把“缓存”从“银弹”降级成了“双刃剑”

举报
逆行时光 发表于 2026/11/05 10:32:09 2026/11/05
【摘要】 我为什么把“缓存”从“银弹”降级成了“双刃剑”刚学会用 Redis 那阵子,我觉得缓存简直是性能优化的万能钥匙。什么接口慢?加缓存。什么查询耗时?加缓存。数据库压力大?加缓存。那段时间我几乎给所有接口都套了一层 RedisTemplate.opsForValue().get/set,响应时间确实好看,P99 从 500ms 掉到 30ms,我一度以为自己掌握了高并发的终极密码。直到一次大促...

我为什么把“缓存”从“银弹”降级成了“双刃剑”

刚学会用 Redis 那阵子,我觉得缓存简直是性能优化的万能钥匙。什么接口慢?加缓存。什么查询耗时?加缓存。数据库压力大?加缓存。那段时间我几乎给所有接口都套了一层 RedisTemplate.opsForValue().get/set,响应时间确实好看,P99 从 500ms 掉到 30ms,我一度以为自己掌握了高并发的终极密码。

直到一次大促零点,缓存集群突然大面积超时,整个系统像多米诺骨牌一样倒下——订单、商品、用户,所有依赖缓存的接口全部 500。我手忙脚乱去查,发现是缓存击穿:一个热点商品 key 过期瞬间,几万请求直接穿透到数据库,DB CPU 秒飙 100%,连接池打满,连带拖垮了其他不相关的业务。更惨的是,缓存恢复后,大量请求回源又触发了缓存雪崩,DB 再次被打挂。

那次事故之后,我花了三天重构缓存层,也彻底改变了我对缓存的认知:缓存不是银弹,是给系统加了一层不确定性。


一、缓存带来的三个经典坑,我全踩过

1. 缓存穿透:查一个永远不存在的数据

早期商品详情接口,我先查 Redis,没有再查 DB。结果有人写脚本刷不存在的商品 ID,每次都穿透到数据库。DB 虽然没崩,但连接数一直高位运行,正常请求被拖慢。

解法:对空结果也缓存,设短过期时间(比如 2 分钟),或者用布隆过滤器提前拦截。

Product product = redis.get(productId);
if (product == null) {
    product = db.query(productId);
    if (product == null) {
        redis.set(productId, NULL_OBJ, 2, MINUTES); // 缓存空值
    } else {
        redis.set(productId, product, 30, MINUTES);
    }
}

2. 缓存击穿:热点 key 过期瞬间,请求全打到 DB

就是那次大促的事故。热点商品 key 过期那一刻,几万并发请求发现缓存没数据,全部去查 DB。

解法:用互斥锁,只让一个请求回源,其他请求等待重试。

String value = redis.get(key);
if (value == null) {
    if (redis.setIfAbsent("lock:" + key, "1", 10, SECONDS)) {
        try {
            value = db.query(key);
            redis.set(key, value, 30, MINUTES);
        } finally {
            redis.delete("lock:" + key);
        }
    } else {
        Thread.sleep(50); // 短暂等待后重试
        return get(key); // 递归或重试
    }
}

3. 缓存雪崩:大量 key 同时过期

有次我给一批缓存统一设了 1 小时过期,结果一小时后集体失效,DB 瞬间被几万请求淹没。

解法:过期时间加随机值,打散失效时间点。

int expire = 3600 + new Random().nextInt(600); // 1小时±10分钟
redis.set(key, value, expire, SECONDS);

二、缓存一致性:最让人头疼的问题

缓存和数据库的双写一致性,是永远的痛点。我试过几种方案:

方案 A:先更新 DB,再删缓存(Cache Aside)

这是最常用的,但有个极小概率的并发问题:请求 A 读缓存未命中,读 DB(旧值),同时请求 B 更新 DB 并删缓存,然后 A 把旧值写入缓存。结果缓存里是脏数据。

虽然概率极低,但在金融场景不能接受。我们的解法是:延迟双删——更新 DB 后删一次缓存,500ms 后再删一次,覆盖并发窗口。

方案 B:先删缓存,再更新 DB

也有问题:删缓存后、更新 DB 前,如果有读请求,会把旧值重新加载到缓存。

方案 C:订阅 binlog 异步更新缓存

用 Canal 监听 MySQL binlog,异步刷新缓存。这是最彻底的方案,但对架构复杂度要求高,小团队不一定值得。

我们最终选了方案 A + 延迟双删,配合缓存过期时间兜底。不是完美,但够用。


三、缓存不是“更快”,是“换一种慢法”

很多人加缓存是为了“快”,但其实缓存真正的价值是削峰——把数据库的随机 IO 变成缓存的顺序访问,把热点数据挡在 DB 前面。如果你只是为了让接口从 50ms 变成 5ms,而数据更新频繁、命中率低,那缓存带来的复杂度可能不值得。

我现在加缓存前会问自己三个问题:

  1. 命中率能到多少? 低于 70% 的缓存,大概率是负优化。
  2. 数据实时性要求多高? 能接受 1 秒延迟还是必须实时?
  3. 如果缓存全挂,系统能撑住吗? 如果答案是“不能”,那你的架构有单点风险。

四、一个心态的转变

以前我觉得缓存是“性能优化的第一步”,现在觉得应该是最后一步。先优化 SQL、加索引、拆服务、做异步,这些做完还不够快,才考虑缓存。而且缓存不是加了就完事,要监控命中率、内存使用、慢查询,要设计降级方案——缓存挂了,系统还能用,只是慢一点。

如果你现在的系统里缓存用得很随意,不妨今晚查一下命中率。如果低于 50%,可能你加的不是缓存,是负担。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。