缓存数据一致性这个话题,我在实际项目里踩过的坑比吃过的盐还多。刚入行那会儿觉得加个缓存不就是读的时候查Redis、写的时候删Redis嘛,能有多难?结果线上跑了两周,用户反馈看到的订单状态和实际不符,排查半天发现是并发写场景下缓存和数据库彻底对不上了。从那以后我才明白,缓存一致性不是“加个删除操作”就能糊弄过去的,它涉及并发时序、失败重试、延迟双删、订阅补偿等一整套组合拳。这篇内容就是把我这些年做缓存架构的经验完整拆开,从方案选型到代码落地到线上排查,全部讲透。不管你是刚接触缓存的新手,还是正在为线上脏数据头疼的老手,都能从里面找到可以直接抄作业的东西。
1. 缓存一致性到底在解决什么问题
1.1 先搞清楚“不一致”是怎么产生的
很多人一上来就背“先删缓存再更新数据库”或者“先更新数据库再删缓存”,但如果不理解不一致的根因,背了也白背。缓存本质上是数据库的一份副本,副本和源数据之间天然存在时间窗口。这个窗口里发生的任何读写交错,都可能让副本变成“过期信息”。
我画不了图,但你可以脑补一个最简单的场景:线程A读缓存没命中,去查数据库拿到旧值10,准备写回缓存;与此同时线程B把数据库更新成了20,并且删了缓存。然后线程A才慢悠悠地把10写进缓存。好了,缓存里是10,数据库里是20,而且这个10会一直待着直到过期。这就是最经典的读写并发导致的不一致。
还有一种更隐蔽的:更新数据库成功了,删缓存那一步网络超时失败了。数据库是20,缓存还是10,同样不一致。这种叫操作失败导致的不一致。两种根因对应不同的解法,前者靠时序控制,后者靠重试补偿,混为一谈就会做出四不像的方案。
1.2 一致性的三个层次,别一上来就追求强一致
我在评审方案时经常问一句话:你到底要多强的一致?很多需求方张口就说“必须强一致”,但你真给他算一下强一致的代价——每次读都穿透到数据库、加分布式锁、吞吐掉一个数量级——他马上就改口了。
实际工程里我习惯把一致性分成三档:
| 一致性级别 | 含义 | 典型手段 | 代价 |
|---|---|---|---|
| 强一致 | 任何时刻读到的都是最新值 | 读写都走数据库、分布式锁串行化 | 吞吐低、延迟高 |
| 准强一致 | 极短窗口内可能读到旧值,之后必然一致 | 延迟双删、订阅binlog补偿 | 实现复杂,需处理补偿 |
| 最终一致 | 允许秒级到分钟级延迟 | 过期时间兜底、异步补偿 | 实现简单,但有窗口 |
绝大多数业务场景,比如商品详情、用户资料、评论列表,最终一致就够了。真正需要准强一致的是库存扣减、账户余额这类。强一致基本只存在于理论讨论和极少数金融核心链路。选型第一步不是选技术,是选级别,级别选错了后面全白搭。
1.3 缓存模式的选择决定了后续所有动作
在聊一致性方案之前,得先把缓存的使用模式定下来,因为不同模式下一致性的处理方式完全不同。我常用的就三种:
- Cache-Aside(旁路缓存):业务代码自己管缓存,读时先查缓存再查库回填,写时更新库再操作缓存。最灵活,也最考验代码质量,一致性方案基本都围绕它展开。
- Read-Through / Write-Through:缓存层代理数据库读写,业务不感知。一致性由缓存组件保证,但灵活性差,出问题不好排查。
- Write-Behind(写回):先写缓存,异步刷库。性能最好,但宕机丢数据的风险实打实存在,我只在日志、计数这类可容忍丢失的场景用。
下面所有的一致性讨论,默认都基于Cache-Aside模式,因为它是互联网项目里用得最多、也最容易出问题的模式。把这个模式的一致性搞定了,其他模式都是它的变体。
2. 主流方案拆解与选型逻辑
2.1 先更新数据库再删缓存:为什么它是默认答案
这个方案几乎是所有团队的起步选择,流程就两步:写请求先更新数据库,成功后再删除缓存。读请求不变,缓存没命中就查库回填。
为什么是“删缓存”而不是“更新缓存”?这里有个很多人忽略的点。更新缓存意味着你要把新值算出来写进去,如果这个值的计算逻辑复杂(比如聚合了多张表),每次写都算一遍很浪费;而且并发写的时候,两个线程各自算出的值写回顺序可能颠倒,反而制造不一致。删除就简单粗暴,下次读自然回填最新值,把“算”的时机推迟到真正需要读的时候。这就是所谓的懒加载思想,用一次读的穿透换写入的简洁。
那这个方案有没有不一致窗口?有。就是我1.1节说的读写并发场景:读线程回填旧值的动作晚于写线程删缓存。但这个窗口的概率其实不高,需要读线程在“查库”和“写缓存”之间恰好被写线程插队,而且写线程的删缓存还要先完成。实际压测下来,除非QPS极高且热点集中,否则这个窗口造成脏数据的概率很低。所以它是性价比最高的默认方案,先上它,出问题再加固。
2.2 延迟双删:给并发窗口打补丁
既然2.1有窗口,那就想办法把这个窗口堵上。延迟双删的思路是:更新数据库后删一次缓存,然后等一小段时间再删一次。第二次删除的目的,就是把那些在窗口期被回填的旧值再清掉。
关键在“等多久”。这个时间不是拍脑袋,要覆盖“读线程查库+回填缓存”的耗时。我一般的估算方法是:延迟时间 = 读请求查库耗时P99 + 回填缓存耗时P99 + 一小段缓冲。比如查库P99是50ms,回填P99是10ms,那延迟设100ms左右比较稳妥。
但这个方案有个硬伤:延迟任务怎么执行。用Thread.sleep阻塞线程肯定不行,线程池会被拖垮。我一般用延时队列或者调度框架异步执行第二次删除。还有个坑是,如果第二次删除也失败了怎么办?所以延迟双删必须配合重试机制,否则就是掩耳盗铃。
注意:延迟双删的延迟时间设太长会影响删除的及时性,设太短又覆盖不了窗口。我的经验是宁可稍微长一点,配合过期时间兜底,不要为了追求“删得快”而让方案失效。
2.3 订阅数据变更日志:从“猜时机”到“等通知”
延迟双删本质还是在“猜”窗口什么时候结束,猜得准不准全看延迟时间设得对不对。更靠谱的思路是:不猜了,等数据库明确告诉我数据变了,我再去删缓存。这就是订阅变更日志(比如MySQL的binlog)的方案。
流程是:业务代码只管更新数据库,一个独立的消费者订阅binlog,解析出哪张表哪行变了,然后去删对应的缓存。这样做的好处是删除动作和数据变更严格绑定,不存在“更新成功但删缓存失败”的遗漏,因为binlog是数据库自己保证投递的。而且删除逻辑从业务代码里剥离出来,业务代码更干净。
代价是什么?一是架构复杂度上去了,要部署消费者、处理binlog解析、保证消费不重复不遗漏;二是有延迟,binlog从产生到被消费再到删缓存,中间有毫秒到秒级的间隔,这个间隔内缓存还是旧的。所以它适合对实时性要求没那么苛刻、但要求“最终一定一致”的场景。另外消费者本身要做幂等,同一条变更被消费两次不能出问题(删缓存天然幂等,删两次和删一次效果一样,这点倒是省心)。
2.4 方案对比:没有银弹,只有取舍
把几个方案摆在一起看,选型就清晰了:
| 方案 | 一致性强度 | 实现复杂度 | 额外组件 | 适用场景 |
|---|---|---|---|---|
| 先更库再删缓存 | 最终一致(有小窗口) | 低 | 无 | 绝大多数普通业务 |
| 延迟双删 | 准强一致 | 中 | 延时队列 | 热点数据、并发写多 |
| 订阅变更日志 | 最终一致(延迟稍大但必达) | 高 | 消息队列/消费者 | 强补偿要求、多级缓存 |
| 读写串行化加锁 | 强一致 | 高 | 分布式锁 | 库存、余额等核心链路 |
我的实际做法通常是组合拳:普通数据用先更库再删缓存加过期时间兜底;核心热点数据叠加延迟双删;对补偿要求极高的链路再上变更日志订阅。不要指望一个方案打天下,按数据的重要程度分级治理才是正道。
3. 落地实现的关键细节
3.1 删除失败怎么办:重试机制必须做
不管哪个方案,“删缓存”这个动作都可能失败——网络抖动、缓存集群短暂不可用、超时。失败不处理,数据就一直脏着。所以删除操作必须带重试。
最朴素的做法是同步重试几次,但会拖慢写请求的响应。我一般用异步重试加有限次数:删除失败后把任务丢进重试队列,隔一小段时间再试,最多试3到5次,还不行就记告警日志人工介入。重试次数不能无限,否则缓存一直不可用会把重试队列撑爆。
代码层面大概长这样(Java伪代码,重点看逻辑):
public void updateAndEvict(Long id, Data newData) { // 1. 更新数据库 db.update(id, newData); // 2. 尝试删缓存,失败进重试队列 boolean ok = cache.delete(buildKey(id)); if (!ok) { retryQueue.offer(new EvictTask(buildKey(id), 0)); } } // 重试消费者 public void consumeEvictTask(EvictTask task) { boolean ok = cache.delete(task.key); if (!ok && task.retryCount < MAX_RETRY) { task.retryCount++; retryQueue.offerDelay(task, backoff(task.retryCount)); } else if (!ok) { alert.warn("缓存删除最终失败,需人工处理: " + task.key); } }这里backoff是退避策略,第一次隔100ms,第二次500ms,第三次2s,避免缓存刚恢复就被重试打垮。这套东西不复杂,但必须有,我见过太多项目删缓存失败就静默吞掉,最后靠用户投诉才发现。
3.2 缓存过期时间是最后一道防线
不管你的一致性方案做得多严密,过期时间一定要设。它是兜底中的兜底,是所有补偿机制都失效时的最后保障。我一般根据数据变更频率设:变更频繁的设几分钟,变更少的设几十分钟到几小时,但绝不设永不过期。
有人会说“我数据基本不变,设永不过期省得回填”。这种想法很危险,一旦某次一致性方案出漏洞,永不过期的数据就永远脏着,连自愈的机会都没有。过期时间的存在,让系统具备了“最终自愈”的能力,这个价值远超那点回填开销。
过期时间还有个讲究:加随机抖动。如果一大批缓存同时过期,会造成缓存击穿,请求全打到数据库。我一般在实际过期时间上叠加一个±10%的随机值,把过期时间打散。
3.3 热点key的特殊处理
普通key的不一致窗口可能几毫秒就过去了,但热点key不一样——它被高频读写,窗口被反复触发,脏数据被反复读到。对热点key我一般做两件事:
一是本地缓存加短过期。热点数据在应用进程内再缓存一份,过期设1到2秒。这样即使Redis里的值短暂不一致,本地缓存很快刷新,影响被压缩。但本地缓存会带来多实例之间的不一致,所以过期必须短,且只用于读多写少的热点。
二是对热点key的写操作加锁。用分布式锁把同一个key的写操作串行化,保证“更新库+删缓存”这个组合原子执行,杜绝并发交错。锁的粒度要细到key级别,不能一把大锁锁全表,否则吞吐直接崩。
提示:热点key的识别不能靠猜,要靠监控。我一般统计单位时间内每个key的访问次数,超过阈值自动标记为热点,触发上述特殊处理。这套逻辑做成通用的,比每个业务自己判断靠谱。
3.4 多级缓存下的一致性传递
当系统有本地缓存、Redis、数据库三层时,一致性传递就变成了链式问题。更新数据库后,要依次让Redis失效、让各实例的本地缓存失效。本地缓存失效没法直接“删”,只能靠广播失效消息:每个实例订阅一个失效频道,收到消息就清掉自己进程内的对应key。
这里有个坑:广播消息可能丢失,某个实例没收到就还留着旧值。所以本地缓存必须配短过期兜底,广播只是加速失效,不是唯一手段。另外广播消息要带版本号或时间戳,防止乱序消息导致“用旧失效覆盖新失效”的诡异问题。
4. 线上排查与避坑实录
4.1 怎么发现不一致:监控比排查更重要
不一致最恶心的地方是它往往不报错,系统照常跑,只是数据悄悄错了。等用户投诉时,脏数据可能已经存在很久了。所以主动发现比事后排查重要得多。
我一般布三道监控:第一道是抽样比对,定时随机抽一批key,同时读缓存和数据库,比对值是否一致,不一致就告警。抽样比例不用高,千分之一就够,关键是持续跑。第二道是缓存命中率突降告警,命中率突然掉往往意味着大量key被删或过期异常。第三道是写后读校验,核心链路写完数据库后延迟一小段时间读一次缓存,看是否已是新值,不是就记一笔。
这三道监控搭起来,大部分不一致能在用户感知前被发现。我吃过没监控的亏,那次脏数据跑了三天才被发现,回滚和修数据搞了一整夜。
4.2 常见问题速查表
把我在线上遇到过和同行交流中收集的典型问题整理成表,方便对照排查:
| 现象 | 可能原因 | 排查方向 | 处理办法 |
|---|---|---|---|
| 偶发读到旧值,刷新后正常 | 读写并发窗口 | 查该key的写QPS和读QPS | 上延迟双删或写锁 |
| 更新后长时间读到旧值 | 删缓存失败且无重试 | 查删除操作的失败日志 | 补重试机制,手动清key |
| 大批key同时失效打爆数据库 | 过期时间集中 | 查过期时间分布 | 加随机抖动 |
| 本地缓存和Redis不一致 | 广播失效丢失 | 查广播消费日志 | 缩短本地过期,补消费确认 |
| 热点key反复不一致 | 并发写交错 | 查该key写并发度 | key级锁串行化 |
这张表我建议贴在工位上,出问题时先对号入座,能省不少排查时间。
4.3 几个反直觉的坑
第一个坑:以为先删缓存再更新数据库更安全。很多人直觉觉得先删缓存能让后续读都穿透到库拿新值,但实际是先删缓存后、更新数据库前,如果有读请求进来,它会查到旧值并回填,然后数据库才更新,脏数据又回来了,而且这次连“删缓存”都没机会补救。所以顺序必须是先更库再删缓存,这个顺序不能反。
第二个坑:在事务提交前删缓存。如果更新数据库在一个事务里,你在事务内就删了缓存,但事务后来回滚了,缓存被白删不说,如果这期间有读请求回填了旧值,事务回滚后数据库还是旧值,看似一致,但如果事务是部分成功部分失败,就乱套了。正确做法是事务提交后再删缓存,用事务同步回调或者提交后事件来触发。
第三个坑:重试队列把缓存打垮。缓存集群刚恢复时,积压的重试任务瞬间涌入,可能又把它打挂。所以重试必须带退避和限流,恢复期要慢启动,不能一股脑全放出去。
4.4 修数据比防数据更难
真出了不一致,修数据是个技术活。直接删key让下次读回填是最简单的,但如果这个key是热点,删的瞬间大量请求穿透到库,可能引发雪崩。我一般对热点key采用先写新值再删或者灰度删除:先删一小批,观察数据库压力,没问题再扩大。另外修数据要有记录,哪些key在什么时间被修过,方便事后复盘根因,不然修完就忘,下次还犯。
5. 一套可直接复用的落地方案
5.1 分层治理的配置模板
把前面的东西组装成一套可落地的配置,我按数据重要程度分了三层,每层给一套参数模板:
普通层(商品详情、用户资料等):先更库再删缓存,删除失败异步重试3次带退避,过期时间30分钟加±10%抖动,抽样比对监控千分之一。这套配置覆盖80%的场景,成本低效果好。
核心层(库存、余额等):先更库再删缓存,叠加key级分布式锁串行化写操作,删除失败重试5次,过期时间5分钟加抖动,写后读校验必开。锁的超时时间要设短(比如3秒),防止死锁拖垮链路。
补偿层(对账、报表等):订阅变更日志异步删缓存,消费者做幂等,过期时间1小时,抽样比对比例提高到百分之一。这层追求的是“一定一致”,延迟可以放宽。
5.2 上线前的自检清单
方案写完别急着上线,过一遍这个清单:删除操作是否都有失败重试?过期时间是否都设了且带抖动?事务内的删除是否挪到了提交后?热点key是否有识别和特殊处理?监控三道是否都布了?重试队列是否有限流和退避?修数据是否有灰度手段?这七条全过,基本能挡住绝大多数线上事故。我每次上线缓存相关改动都对着过一遍,救过我好几次。
5.3 后续可以继续深挖的方向
这套方案跑稳之后,如果想继续优化,有两个方向值得投入。一是把一致性治理做成平台化能力,把删除重试、抽样比对、热点识别这些通用逻辑抽成SDK或者边车,业务接入零成本,避免每个团队重复造轮子还造得参差不齐。二是引入变更数据捕获做实时对账,把binlog消费和对账系统打通,做到分钟级发现并自动修复不一致,把人工介入降到最低。这两个方向我在不同项目里都推过,前期投入不小,但长期看能把缓存一致性问题从“救火”变成“防火”,值。
我个人在实际操作中的体会是,缓存一致性从来不是靠某一个“完美方案”解决的,而是靠“默认方案加多层兜底加主动监控”这套组合。别追求一步到位,先把先更库再删缓存加过期时间这套基本盘打牢,再根据线上暴露的问题逐步加固,比一上来就上重型方案要稳得多。踩过的坑告诉我,最简单的方案配上最严密的监控,往往比最复杂的方案配上松散的运维更可靠。