分布式缓存一致性实战:从缓存穿透到双写一致
分布式缓存一致性实战:从缓存穿透到双写一致
摘要: 缓存用好了是加速器,用不好就是一致性灾难的源头。本文从一次真实的缓存雪崩复盘出发,讲清缓存穿透、击穿、雪崩的成因与解法,以及数据库与缓存双写场景下,如何用延迟双删、消息补偿和版本号把一致性拉回到可接受的范围。
一、背景:一次凌晨三点的缓存雪崩
去年双十一大促前夜,我们的商品详情接口突然全线超时。排查发现,某个热点商品的缓存 key 在同一秒过期,数万个请求同时打到数据库,连接池瞬间打满。更糟的是,数据库恢复后,缓存里还躺着一批旧价格数据,前端卖价和订单价对不上,客诉直接冲进群。
那次事故让我明白:缓存的问题从来不是"要不要用",而是"失效之后会发生什么"。事后复盘我们发现,系统对缓存失效完全没有分级预案:所有请求共用一个超时时间,所有 key 的 TTL 都是固定 30 分钟,过期时间点高度重合。更深层的原因是团队把缓存当成了"免费的数据库",以为写进去就万事大吉,却没有人负责失效策略。本文把缓存一致性的几个经典问题摊开讲:穿透、击穿、雪崩,以及最难的双写一致,每个问题都附上我们最终落地的解法。
二、核心原理:强一致与最终一致的取舍
缓存和数据库是两个独立的数据源,写入时不可能同时更新。业界的共识是:缓存场景默认接受最终一致,关键是把"最终"控制在业务可接受的时间窗内。
Cache-Aside 是最常用的模式:读时先查缓存,命中直接返回;未命中查库并回填;写时先更新数据库,再删除缓存。删除而不是更新缓存,是为了避免并发写把旧值又写回缓存。注意顺序:先删缓存再更新库,会出现读到旧值的窗口;先更新库再删缓存,窗口小得多,这是目前最稳妥的顺序。还有一种 Write-Through 模式,由缓存层同步写数据库,强一致性更好,但写入延迟更高,一般只用在对一致性要求极高的配置类数据上。我们内部的选型原则是:读多写少用 Cache-Aside,配置类数据用 Write-Through,绝不混用。
三、缓存穿透:不存在的数据也能打垮你
穿透指查询根本不存在的数据:每次请求都穿过缓存直达数据库,缓存形同虚设。攻击者用随机 id 轮询,几分钟就能把库打满。
解法有三层:第一,接口层做参数校验,非法 id 直接拒绝;第二,布隆过滤器前置拦截,把"一定不存在"的请求挡在缓存之外;第三,空值缓存——对查不到的 key 也缓存一个短 TTL 的空值。我们当时三层全上,布隆过滤器拦截了九成以上的无效请求,数据库的慢查询量直接降了一个数量级。
四、缓存击穿与雪崩:热点 key 的两种死法
击穿是单个热点 key 过期,瞬间并发全部打到库上;雪崩是一批 key 同时过期,效果类似但规模更大。两者解法相通:热点 key 逻辑过期——缓存里多存一个过期时间字段,数据"过期"后先由一个请求去异步刷新,其他请求继续返回旧值;或者互斥锁——只有一个请求能回源,其他请求等待或返回旧值。
我们最终用的是逻辑过期加随机 TTL:新写入的 key 在基础 TTL 上加一个 0 到 300 秒的随机抖动,让过期时间均匀散开,大促再也没有出现过整点集中的过期潮。互斥锁我们也保留了,作为第二道防线:拿到锁的请求回源刷新,其他请求直接返回旧值并打上"数据可能过期"的标记,前端据此决定是否提示用户刷新。两层防护叠加之后,热点 key 失效再也没出过事故。另外一个容易被忽略的点是监控:我们给缓存命中率、回源 QPS、锁等待时长都加了告警,命中率掉到 90% 以下就自动通知值班,问题在酿成事故之前就能被发现。
五、双写一致性:延迟双删与消息补偿
双写场景最怕的是更新库成功、删缓存失败,或者并发读写交叉。延迟双删的做法是:更新库 → 删除缓存 → sleep 几百毫秒 → 再删除一次缓存。第二次删除兜住了"删完缓存后、旧读请求又把旧值写回缓存"的竞态窗口。代价是一次写操作的延迟增加了几百毫秒,对写少读多的场景完全可以接受。
更彻底的方案是消息补偿:写操作只更新数据库,同时发一条变更消息到消息队列,由消费者异步删除缓存。消息队列的重试机制兜底了删除失败,系统最终一致。这里有个细节:消息本身也要幂等,消费者收到重复消息时重复删除缓存是无害的,但如果是更新缓存就必须加版本号,否则乱序消息会把旧值写回去。我们的做法是缓存值里带上数据版本号,写入时只接受版本号更大的值,从根本上堵住了乱序。我们在订单这种强一致场景用消息补偿,在商品详情这种弱一致场景用延迟双删,两套方案并存,按业务等级选。选型会上定过一条规矩:新服务默认用延迟双删,只有订单、资金类服务才允许上消息补偿,避免过度设计。
六、总结
缓存一致性没有银弹,只有一组按场景选型的组合拳:读多写少用 Cache-Aside 加延迟双删;热点 key 用逻辑过期或互斥锁;不存在的数据用布隆过滤器加空值缓存;强一致场景上消息补偿。记住两条底线:第一,缓存失效的代价要小于缓存命中的收益;第二,任何删除失败都要有兜底,不要让一次删缓存失败变成一次线上事故。把这两条守住,缓存就能从事故源头变回加速器。
- 点赞
- 收藏
- 关注作者
评论(0)