小Cの已经记不起来的博客

缓存一致性 的十种打开方式:工程实践笔记

上周评审代码,看到同事在一个写接口里先更新数据库、紧接着又把新值 set 回缓存,我就多问了一句:“这俩操作中间挂了怎么办?”他愣了一下,我也愣了一下,因为发现要把这件事说明白,还真得从头捋一遍。干脆趁着有空把这些年用过、见过、踩过的方案都整理一遍,就当是给自己的备忘。

1. Cache Aside:最常用,但口诀别背错

读:先读缓存,未命中再读库并回填。写:先更新数据库,再删除缓存。

注意是删缓存,不是更新缓存。原因很实际:一是并发写的时候,两个请求写缓存的顺序可能和写库的顺序相反,缓存里就留下旧值了;二是很多缓存值是聚合算出来的,写的时候未必算得出来,不如等下次读的时候再加载。

它还有个经典竞态:读请求未命中 → 从库里读到旧值 → 此时另一个线程写库并删缓存 → 读线程慢悠悠把旧值回填进缓存。概率很低,但不是零,所以过期时间必须有。

2. 先删缓存再更新库,配延迟双删

有人反过来:先删缓存,再更新库。能避免脏读,但删完到写完之间,读请求会把旧值重新灌回缓存。于是有了延迟双删:

delete cache(key)
db.update(key, value)
sleep(500ms)   # 等读请求把旧值回填完
delete(key)

丑是丑了点,但管用。至于那个 sleep 到底该设多少,说实话我到现在也没找到特别科学的算法,基本都是拍,接口高峰期读库耗时长就往大调。另外第二次删除要是失败了,前面全白干,得扔进队列重试,别在请求线程里裸睡,我们是吃过亏的。

3. 订阅 binlog,异步刷缓存

用 Canal 或者 Debezium 订阅 MySQL 的 binlog,收到变更后去删缓存:

binlog -> canal -> mq -> consumer: redis.delete(key)
                       └─ 失败重投,超限进死信 + 告警

最大的好处是业务代码完全不用管缓存,新同事想忘删都没机会。代价是链路变长了,Canal 挂了、MQ 堆积、消费失败,每一环都得有监控兜着。我们生产上是 Canal + MQ,消费失败自动重投,重投几次还不行就报警,人来处理。

4. Read Through / Write Through

应用只跟缓存打交道,缓存层自己负责同步数据库。图上看着很干净,但这套东西一般得有现成组件撑着,自己裸写很容易写错。干了这么多年,我只在博客和面试题里见过,生产上一次都没落地过……

5. Write Back:异步写回

写只落缓存,攒一批再异步刷回数据库。性能是真的好,但缓存一挂,没刷回去的数据就全没了。所以只适合丢一点无所谓的场景,比如浏览量、点赞数。我们有个帖子的阅读数就是这么做的,定时任务批量刷回库,挂了顶多少统计几万次,能接受。涉及钱的千万别这么玩。

6. 过期时间,永远的兜底

严格说不算独立方案,但值得单独说。上面不管选哪种花活,最后都得配一个 TTL。它保证的是:就算你哪步漏了、失败了、顺序反了,最多脏到过期为止。有人觉得加过期时间是对方案不自信,我的看法正相反,承认自己会犯错,才是做工程的起点。

7. 版本号

给数据带个版本号,回填缓存前先比一下,版本旧就不覆盖;或者干脆把版本号放进缓存 value,更新时先 +1,让旧数据写不进来。能实打实解决回填竞态,但读写路径都得改,侵入不小。我们只在个别竞争特别激烈的核心 key 上用过,没敢全面铺。

8. 分布式锁串行化

读写都先抢锁,强排队,一致性最强,性能最差,还额外引入了锁服务本身的可用性问题。一般只留给“错一个就是事故”的场景,比如库存扣减前的读。要用就按 key 细粒度加锁,别一把大锁把整个服务锁趴下。

9. 多级缓存:本地缓存的失效广播

本地缓存 + Redis 组成两级结构时,麻烦在于各实例的本地缓存互相不知道对方的存在。常见做法是变更时通过 pub/sub 或者 MQ 广播一条失效消息,各实例收到后删自己进程内的那份。广播会丢,所以本地缓存的 TTL 必须单独设,而且要远短于 Redis 那层,拿时间换最终一致。

10. 实在不行,就别上缓存

这条听着像凑数,其实是认真的。缓存一致性的根源就是同一份数据存了两处,那能只存一处就只存一处。不少业务的读压力,加个索引、调条 SQL 就够了,非要引入 Redis,等于花钱给自己买了一套一致性问题。先量清楚是不是真有性能瓶颈,再决定为它付多大代价。

最后

我自己总结下来就一句话:绝大多数场景,Cache Aside 加过期时间足够了;核心链路再加个 binlog 订阅兜底;真需要强一致的那几个点,要么绕开缓存直接读库,要么上锁。分布式环境下别指望理论上的完美一致,能把不一致窗口压到业务无感,这活儿就算干明白了。

以上有些是我亲手踩过的坑,有些是看别人文档和源码总结的,理解上肯定有不到位的地方,欢迎指正,反正我也是边写边查的...

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放