Redis 缓存穿透、雪崩与 缓存一致性
前段时间接手了一个老服务,缓存这块写得很朴素,就是经典的“查缓存,没有就查库,查完回写”。我一开始也没觉得有啥问题,直到某天凌晨被告警短信吵醒,看了一眼监控,MySQL 的连接数和 CPU 直接拉满,缓存命中率却掉得一塌糊涂。排查了一圈,发现是缓存这层埋了几个经典的坑。趁还热乎,把这几个问题和解法整理一下,免得下次再踩。
缓存穿透:查一个根本不存在的数据
先说穿透。正常情况下请求先查缓存,缓存没有再查数据库,查完把结果写回缓存,下次就不用碰数据库了。但如果有大量请求去查一条数据库里也不存在的数据,那缓存里永远不会有值,每次请求都得实打实地落到数据库上。比如有人拿着 id=-1 这种参数疯狂刷接口,或者业务上已经删掉的数据还在被旧页面反复请求,数据库就这么被拖垮了。
最省事的解法是缓存空值:查库发现没有,也往 Redis 里塞一个空标记,TTL 设短一点,60 秒左右就够:
String value = redis.get(key);
if (value != null) {
// 空字符串是我们自己埋的空标记
return value.isEmpty() ? null : deserialize(value);
}
User user = db.queryById(id);
if (user == null) {
redis.set(key, "", 60);
return null;
}
redis.set(key, serialize(user), 3600);
注意判断的时候要能区分“空标记”和“压根没缓存”这两种情况,不然业务逻辑容易出一些莫名其妙的 bug。
如果穿透的 key 范围是可控的,比如商品 id 必须是真实存在的,那可以在前面加一层布隆过滤器:把所有合法 id 提前塞进去,请求来了先过一遍过滤器,过滤器说不存在就直接返回,连 Redis 都不用查。布隆过滤器的特点是说“不存在”那就百分百不存在,说“存在”可能有误判,拿来挡穿透刚好合适。
缓存雪崩:一大波 key 同时失效
雪崩的场景也很直白:假设做活动的时候批量往 Redis 里灌了一批数据,过期时间全都设成 24 小时,那 24 小时之后这批 key 会手拉手一起失效,那一瞬间所有请求全部涌向数据库,数据库大概率扛不住。还有一种更彻底的情况是 Redis 本身挂了,效果一样,缓存层直接消失,流量全砸到数据库上。
解法倒是不难,按成本从低到高来。第一招是给过期时间加个随机值,让 key 错峰失效,一行代码的事:
int ttl = 3600 + ThreadLocalRandom.current().nextInt(600);
redis.set(key, serialize(user), ttl);
然后 Redis 本身要做高可用,哨兵或者 Cluster 部署起来,别让缓存层自己先躺了。如果是有个别热点 key,可以用逻辑过期加互斥锁:值里不放真实 TTL,而是存一个逻辑过期时间字段,发现过期了先抢分布式锁,抢到的线程去查库重建,没抢到的先返回旧数据。顺带一提,单个热点 key 失效被打穿这事有个专门的名字叫缓存击穿,解法和这里是一回事。最后还有兜底:限流、熔断、降级,牺牲一部分请求保住数据库别死。
说真的,对大部分体量不大的系统,随机 TTL 加高可用做好就够用了,别一上来就往复杂了整。
缓存一致性:先动数据库还是先动缓存
这个话题最容易聊出分歧。只要缓存和数据库是两个独立的存储,就不存在绝对的强一致,能追求的是最终一致,并且把不一致的窗口压到尽量短。
先说结论:写操作的时候,先更新数据库,再删除缓存。
为什么是删除而不是更新缓存?一来写多读少的场景下提前更新缓存纯属浪费,二来两个写请求并发的时候,更新缓存的先后顺序没法保证,很容易把旧值写回去。删除就简单粗暴,下次读的时候自然会用最新数据回填。
为什么是先更新数据库再删缓存,而不是反过来?假设反过来,先删缓存:线程 A 删完缓存还没更新数据库,线程 B 恰好过来读,缓存未命中,从数据库读到旧值,然后回写缓存。等 A 慢吞吞地把数据库更新完,缓存里躺着的还是旧值,而且因为缓存里有值,后续请求根本不会再查库,这个旧值就一直赖着不走了。出这种问题排查起来是真烦,代码看着一点毛病没有。
先更新数据库再删缓存理论上也有不一致的可能,读线程查到了旧数据,恰好在写线程删完缓存之后才慢悠悠地把旧值写回缓存。但这个条件比上面那个苛刻得多,一般就认了。想再稳一点可以上延迟双删,写完库删一次,隔个几百毫秒再删一次,把那种边缘情况兜住:
db.update(user);
redis.del(key);
// 延迟双删,兜住并发读回写旧值的边缘情况
scheduler.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);
删除失败了怎么办?把 key 扔进消息队列让消费者重试,或者干脆订阅数据库 binlog(比如拿 Canal 监听变更)异步删缓存,把删除这个动作从业务代码里彻底摘出去。后者对业务零侵入,代价是多维护一个组件,看团队情况取舍。
写在最后
这几个问题单拎出来都不算复杂,网上资料一搜一大把,但真到线上出事的时候,能不能第一反应出“这是穿透还是雪崩”,还是得靠平时过脑子。上面这些方案里,空值缓存和随机 TTL 基本零成本,建议无脑加上;布隆过滤器、延迟双删、binlog 订阅这些看业务量再决定,别为了用方案而方案。反正我是先把随机 TTL 补上了,剩下的,下次再说吧……
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。