1. 缓存命中率上去了,数据却乱了:一致性问题的本质来源
我记得第一次在生产环境把Redis缓存铺到订单详情页的时候,效果立竿见影——接口平均耗时从380ms掉到40ms,DB主库的QPS从2万降到3000。正当我沾沾自喜的时候,客服那边开始出现投诉:用户下单后,订单状态在页面上十几分钟不更新,已经支付的订单还显示"待支付";后台改了商品价格,前台页面价格纹丝不动。那是我第一次被"缓存与数据库数据一致性"这个问题按在地上摩擦。
先把这个问题的本质说透。业务系统引入Redis当缓存层,等于在数据库前面加了一个"代理"。读请求先打缓存,缓存不命中再查数据库,查完把结果写回缓存;写请求直接操作数据库。这个模型下,只有一套数据源(数据库)的时候根本不存在一致性问题,因为所有读都从同一张表拿数据。一旦缓存介入,同一份数据在数据库里有一份,在Redis里可能还有一份,两份数据就会因为更新节奏不同而出现时间差,这就是"不一致"。
不一致的危害分几个等级。最轻的是展示延迟,用户看到的是旧数据,刷新一下就恢复,这种通常还能忍。严重的是脏数据,比如库存多扣了、订单状态回退了,这种一旦发生,后面做什么补偿都是麻烦事。更严重的是数据错乱持续扩散,比如缓存里存的是聚合数据(商品详情里关联了价格、库存、销量),任何一个子字段更新不及时,整个聚合页面的数据都是错的。我在实际项目中甚至见过因为缓存不一致导致两笔订单抢同一个库存快照,虽然最终靠数据库锁兜底没超卖,但页面展示已经让用户产生了严重误解。
这里要提一个容易被忽略的事实:一致性是一个程度问题,不是0和1的问题。绝对的强一致意味着两个存储系统之间没有任何时间差,这在分布式环境下几乎做不到,或者说成本极高。实际生产追求的目标,通常是在"用户可感知的时间窗口内"达成一致,比如订单支付成功后5秒内必须能看到状态变更,这个窗口可以通过业务容忍度来定。想清楚这个前提,后面的技术选型才有方向。
另外要注意,一致性问题的触发场景通常不止"读写同时发生"这么简单。线上最常见的高危场景有这几种:多实例并发写同一条数据(比如库存扣减)、批量任务在业务低峰期更新大量商品数据(比如凌晨的价格同步)、缓存服务异常导致缓存被清空后流量直击数据库。这几种场景互相叠加时,问题会指数级放大。如果你只是单机部署、读写串行,那大概率碰不到一致性问题;但生产环境基本逃不掉,所以这篇文章值得认真看。
2. 三大模式横向对比,为什么生产上最常用Cache Aside
聊缓存与数据库一致性,绕不开三种经典读写模式:Cache Aside(旁路缓存)、Read/Write Through(读写穿透)、Write Behind(写回)。很多文章把它们列在一起讲,但实际生产里的地位完全不同。
Cache Aside的操作逻辑是:读请求先读缓存,不命中就读数据库,然后回填缓存;写请求先更新数据库,再删除缓存(或者更新缓存)。这个模式的核心思路是"按需加载,缓存时刻准备被淘汰"——缓存里没有就去数据库拿,数据库改了就让缓存失效,下一次读自然会把新值拉回来。这个模式最大的好处是逻辑简单、容易理解,业务代码里加两行就能实现,而且不会出现写的放大问题。国内互联网公司90%以上的缓存架构都是这个模式。
Read/Through模式则是把缓存当成唯一入口:读请求只打缓存,缓存没有就往数据库查并回填;写请求也只打缓存,由缓存组件负责同步到数据库。这种模式下,业务代码不需要关心数据库交互,看起来更省心。但它的问题在于,缓存和数据库的同步逻辑被封装在缓存组件里,一旦同步失败很难排查,而且在没有现成中间件支持的情况下,自己实现一套Read/Through的成本远高于Cache Aside。线上环境真正采用Read/Through的,基本都是用了特定的缓存中间件产品,裸写Redis的业务很少这么做。
Write Behind(也称Write Back)更激进:写请求只写缓存,立即返回成功,由后台异步批量刷到数据库。它的优势是写性能极高,适合写多读少且能容忍数据丢失的计费、计数类场景。但代价非常明显:缓存一旦挂了,未落库的数据就丢了,而且数据库层面的数据可能长期处于滞后状态。以我的经验,绝大多数业务系统都承担不起这个风险,所以Write Behind通常只在特定场景使用。
我整理了一张对比表,方便你根据场景做选型:
- Cache Aside:一致性可控,逻辑透明,实现成本低,适合绝大多数业务系统
- Read/Through:代码侵入低,但排障成本高,需要缓存产品支持,适合标准化组件场景
- Write Behind:写入性能最高,但存在数据丢失风险,适合可容忍丢失的计数类场景
之所以国内生产环境一窝蜂选Cache Aside,还有一个很现实的原因:公司里的Redis基本就是裸Redis或者加上客户端封装,没有企业级中间件。Cache Aside不需要引入额外组件,只需要在业务代码里约定好"更新数据库后删除缓存"这个动作,就能在已有的技术栈上立刻生效。调试的时候,缓存数据不对了,直接删key就好,非常直观。
不过Cache Aside并不等于自动解决一致性问题,它只是给了你一个正确的框架。框架之内,真正的难点在于下面几章要讲的细节:缓存该删还是该更新、删除时机、删除失败怎么办、多线程并发下怎么保证不出现竞态。这些细节决定了你的方案到底是"理论正确"还是"生产可靠"。
3. Cache Aside落地三要素:过期时间、删除策略、隔离级别
不要以为用了Cache Aside就万事大吉。我见过太多团队在落地这个模式时只做了一件事——"写完后更新缓存",然后就等着出事故。其实Cache Aside要想在生产站得住,有三个要素必须同时落实。
第一个要素是为每个缓存key设置合理的过期时间。这是所有缓存一致性方案的地基。有了过期时间,即使你的删除动作失败了、更新顺序出了问题,最坏情况下缓存也会在一段时间后自动失效,重新从数据库拉取,数据最终会回归一致。没有过期时间的缓存等于给自己埋了一颗雷——一旦写逻辑出bug,脏数据会在Redis里躺一辈子。我见过一个线上事故,一个配置类的key没有设expire,发布新版本后老配置一直生效,用户页面功能错乱了三天才被发现。所以我的习惯是:除了极少数不允许失效的会话类key,其余所有业务缓存一律带TTL,通常设置成业务可容忍最长不一致时间的1.5到2倍。
第二个要素是"删除缓存"而不是"更新缓存"。很多新手最容易犯的错误是写完数据库之后,顺手把缓存也更新了。听起来好像更合理——省得下一次读还要回源。但这样做有一个致命问题:如果两个并发写请求按顺序更新数据库,但更新缓存的顺序反了,缓存里最终存的就是旧值。比如线程A先改了价格,线程B后改了价格,数据库最终是B的值,但缓存可能被A写成了旧价格。反过来,先删缓存再让读请求回源,就得避免"读回了一个旧值再写进缓存"这种情况。实际操作里"删缓存"对比"更新缓存"多了一步无效读,但在并发场景下安全性高出一截。业内默认的做法就是:写请求处理后只做删除,不做更新。
第三个要素容易被忽视——数据库隔离级别和事务边界。假设你的写请求在一个事务里改了数据库,事务还没提交,另一个读请求发现缓存里没有key,跑到数据库查到了未提交的值,回填到缓存。接着事务回滚了,缓存里就存了一个数据库中根本不存在的值。这个问题在Cache Aside模式下非常隐蔽。要解决有两个思路:一个是读请求只读已提交的数据(这个数据库层会保证,但要确认你的框架没把隔离级别改成READ UNCOMMITTED);另一个是在事务真正提交之后再删除缓存,也就是把删除动作放到事务提交后的回调里,或者干脆用最终一致性的兜底机制。很多老牌框架把缓存删除写在事务提交前,如果你在排查线上缓存问题,这个位置值得先看一眼。
在实际落地的时候,我建议你给自己定一份"缓存设计清单":建缓存的时候想好要不要写穿透、TTL设多少、key命名带不带业务维度;写接口的时候统一封装一个"淘汰缓存"工具方法,而不是每个业务各写各的;然后通过监控确认每条缓存淘汰动作确实被调用了。这套东西捋顺了,Cache Aside才算真正落地。
4. 先删缓存还是先更新库:两种顺序的竞态推演
在Cache Aside框架下,写请求的"最后一步"需要仔细设计。是"先更新数据库,再删缓存",还是"先删缓存,再更新数据库"?网上争论很多,但结论其实很统一:几乎所有的生产级方案都选择"先更新数据库、后删缓存"。理解背后的原因,比记住结论更重要。
先看"先删缓存、再更新数据库"的流程。操作顺序是:请求A删除缓存key → 请求A更新数据库 → 其他请求回源缓存。这个流程的缺陷在于删除和更新之间存在明显的时间窗口。假设请求A删掉了缓存,还没来得及更新数据库,请求B这时候来读缓存,发现没有key,于是去数据库读。如果请求B读到的还是旧值(因为A还没更新完),它就会把旧值回填到缓存里,而稍后A才把数据库更新成新值,此时缓存里的旧值没有人再清理,一致性就破掉了。有的人说"A更新完数据库之后可以再删一次缓存",对,这实际上就是延迟双删的一种形态,但单纯的先删后更方案确实存在这个问题。
再看"先更新数据库、再删缓存"的流程。请求A先改数据库,然后再删缓存。这里同样有时间窗口:在A删除缓存之前,如果有请求B读到了旧的缓存值,那B拿到的是一个过期数据。这个窗口持续多久?也就是A写完数据库到删除缓存之间的那几十毫秒。大多数情况下,这个时间窗口比"先删后更"的要短得多,而且它有一个极大的好处:如果请求A在更新数据库之后、删除缓存之前就挂了,缓存里最多还是旧值,但一旦TTL到期,数据仍能恢复一致;反过来如果"先删缓存再更新数据库"在删除后挂了,缓存直接被清空,数据库还没改,下次读会拉回旧值——两者从结果看都是旧值,但前者的自愈能力明显更差。
我们来推演一下"先更后删"的经典竞态陷阱——并发读读场景。假设缓存过期了,请求A去数据库读旧值(还没有读到或者还没有回填),同时请求B更新了数据库为新值并删除了缓存,此时请求A才把旧值写回缓存。这种"读请求回种旧值覆盖新值"的问题,实际上是唯一会让"先更后删"也失效的场景。常规缓解手段包括:缓存淘汰时的原子性保护(例如比较并交换)、在回填时校验时间戳、或者用版本号机制,后面章节会详细讲。
所以我的建议是:默认方案用"先更新数据库,后删除缓存",不要做任何额外的"更新缓存"动作。这个方案在绝大多数并发请求场景下都能满足业务一致性的要求,唯一的坑就是上面那个"读回填旧值"的极端竞态,但这个坑完全可以通过延迟双删、版本控制来补,而不是因此去选择风险更大的"先删后更"。
再补充一个细节:删除缓存本身要幂等。Redis的DEL命令天然幂等,所以不用纠结,但如果你的缓存淘汰逻辑是通过消息队列异步执行的,一定要确保同一个key的处理不因为重复消息产生副作用。此外,删除缓存失败要考虑自动重试,否则一次网络抖动就可能让脏数据存续到TTL到期。
5. 延迟双删的真实局限,以及删除失败的兜底链路
很多讲缓存一致性的文章都会提到延迟双删,但很少讲它的坑。延迟双删的流程是:首先在更新数据库之后立即删除缓存,然后等待一段时间,再次删除缓存。这个第二次删除目的就是为了覆盖第4章提到的那个极端竞态——读请求可能趁你第一次删除之前把旧值回填到了缓存,等一段时间后再删一次,就能把回填的旧值清掉。
乍一听很完美,但这里有两个问题你必须面对。第一个问题是等待时间设多少。如果设置太短,读请求还没来得及回填旧值,你第二次已经删完了,等于白等;如果设置太长,用户会在第一次删除后到第二次删除前这段时间内不断读到缓存里被回填的旧值,体验上仍然是脏数据。我的实践经验是:等待时间需要大于"一次完整的读回源 + 回填缓存"的耗时,通常是500ms到1s,如果在高延迟网络下可能要再放大。这个时间没有标准答案,必须在压测环境下实测获取。
第二个问题更致命:延迟双删无法保证100%删除成功。第二次删除动作发生在业务线程之外,Redis连接偶发超时、delete命令执行失败等情况不可预测。如果第二次删除失败,旧值就留在Redis里,直到TTL到期,这段时间的数据都是不一致的。所以延迟双删只是降低了失败概率,没有消除失败可能。要真正做到生产可靠,必须有"删除失败的兜底链路"。
我常用的一个兜底方案是这样的:更新数据库后,把"需要淘汰的缓存key"写入一个本地名单或消息队列,由一个专门的后台任务负责执行删除。后台任务先尝试直接删除缓存,如果删除失败,就进入重试循环,重试间隔从1秒、5秒、30秒逐步退避,超过了最大重试次数就告警出来让人工介入。这个方案的代码量不大,但彻底解决了"删除动作一次性执行"的脆弱性。你甚至可以把第二次延迟删除也交给后台任务来执行,这样业务线程就不需要Sleep等待了,接口时延不会受到影响。
还有一个容易被忽略的坑:延迟双删在一次大批量操作时会放大删除压力。比如0点批量更新了10万个商品的价格,每个key都触发两次删除,这个瞬时压力在某些弱鸡Redis实例上会造成阻塞。我倾向于在两个地方做保护:一是批量操作场景下,统一走后台的延迟队列分流删除,避免业务线程阻塞在缓存删除上;二是给删除任务本身加一个信号量控制并发,防止秒级内同时发送几千个DEL指令。
兜底链路要配合监控才能闭环。至少要对"删除失败"和"重试超过阈值"两个事件设置告警。不然兜底链路本身变成了一个黑盒,到时候出了事故排查起来更痛苦。我在线上就遇到过:延迟队列因为Redis连接池配置错误一直重试失败,业务方完全没感知,最后是用户投诉才发现的。加了监控之后,这类问题当天就能暴露。
6. 更强的一致性手段:版本号与binlog订阅删除
如果业务对一致性要求极高,比如涉及账户余额、支付状态、优惠券核销这类数据,光靠延迟双删可能还不够。这时可以考虑在缓存数据上附加版本号或逻辑时间戳,用数据本身来防止"旧值回填覆盖新值"。
版本号机制的原理很简单:在数据库表中增加一个版本字段,每次更新数据库时版本号加1,缓存的value里也带上这个版本号。回填缓存的时候携带当前版本号;淘汰或更新缓存的时候会先比较缓存里的版本号与数据库当前版本号,只有旧版本才允许覆盖。这样一来,即使读请求在并发场景下拿到了旧值,回写时也因为版本号落后而被拒绝,不会污染新缓存。
实践中有两类做法。一是应用层手动维护版本号:更新数据库时带上version = version + 1的条件,数据库自带乐观锁语义,然后缓存写入时把版本号带进去。读取时如果缓存版本明显低于数据库当前版本,可以选择直接淘汰缓存重新回源。二是让缓存本身存一个"更新时间戳",每次回填前比较时间戳,只有新时间戳才允许写入。这两者本质是一样的,核心在于拒绝旧数据覆盖新数据。
另一个更彻底的方案,是利用MySQL的binlog订阅来做缓存删除的最终兜底。具体来说,通过Canal这类中间件伪装成MySQL从节点,监听数据库的binlog变更事件,一旦有数据变更就解析出对应的表名和主键,异步触发缓存删除。这样缓存淘汰动作不再依赖业务代码是否写对了位置,即使业务代码漏了删除、延迟双删失败,binlog事件仍然会触发一次删除。这是一个天然的"最后一道防线"。
我自己的一个项目里,这套组合拳是这样落地的:第一层是Cache Aside的常规删除,第二层是延迟双删处理极端竞态,第三层是binlog订阅在数据库任何变更后无条件删除对应缓存。三层都失败的概率已经低到可以忽略,而且每一层都有日志和监控,出了问题可以明确知道是哪一层丢了。当然,binlog订阅方案也不是没代价——需要额外部署Canal服务,需要监控同步位点,演进复杂度不可忽视。所以它更适合订单、支付、库存这类核心链路,给普通商品列表页的缓存也上这套,成本纯属浪费。
对于大多数业务,我不会建议一上来就上Canal,因为它会让系统多一个依赖,而且如果binlog消费滞后,缓存的删除也会滞后,反而引入了新的延迟窗口。我建议的路径是:先扎实做好Cache Aside和延迟双删,等出现真实的脏数据案例,再针对核心数据逐一切换到binlog订阅方案。逐点治理比全网铺设更符合投入产出比。
7. 绕不开的周边治理:穿透、击穿、雪崩对一致性的干扰
聊到缓存一致性,很多人只盯着"写"的路径,却忽略了一个事实:缓存层的读请求异常也会把系统的稳定性搞崩,反过来加剧写入方和缓存之间的不一致。这就是缓存穿透、缓存击穿、缓存雪崩这"三兄弟"。它们表面上是性能问题,实际上和一致性深深纠缠在一起。
缓存穿透指查询一个根本不存在的数据。因为你查不到,所以不会回填缓存,每一次请求都要打到底层数据库。如果一个恶意用户持续用不存在的ID刷接口,数据库会被干挂。缓存穿透的治理方案是百花筒:返回null也做缓存,加短TTL(比如5秒),防止同一个key反复击穿;或者用布隆过滤器在缓存前面拦一道,判断请求的key是否可能存在。布隆过滤器比较适合集合类数据,比如商品ID集合、用户ID集合,可以很快过滤掉无效key。从一致性角度看,穿透本身不产生脏数据,但它会导致"该在缓存里的数据一直没进去",从而让你把"读数据不一致"误判成"缓存写错了",排查时特别容易迷惑。
缓存击穿指的是一个热点key突然过期,大量并发读请求同时回源数据库。这个时候,如果数据库恰好承接不住就挂了。就算数据库没挂,击穿瞬间的这波并发读也成了"写旧值"的最佳温床——很多个线程同时在数据库读到了旧值,争相回填缓存,这也是第4章那个竞态问题最常见的高发场景。所以击穿治理本质上是给一致性上保险。最常用的方案是做"互斥回源":用一个分布式锁让同一时刻只能有一个线程去查数据库并回填缓存,其他线程等待结果直接拿缓存。你可以理解为热点key在过期瞬间短暂地在缓存服务端加了一把锁,大幅降低回源并发度。
缓存雪崩则是大面积缓存同时失效或者Redis实例故障,导致全量请求瞬间打到数据库。雪崩和一致性的关系是:如果大量缓存同时失效,业务侧的"补偿逻辑"可能正在大规模触发缓存回填,而这时候数据库的更新操作也在进行,两者叠加就会放大不一致的窗口。所以雪崩治理里面有一个重要的动作就是给缓存加随机过期时间,让key的过期时刻离散化,避免成片失效。
针对这三兄弟,我的建议是一起做,别只做一个。布隆过滤器挡穿透,分布式锁做互斥回源限制击穿,随机过期时间加高可用架构防雪崩。三者配合,缓存层才算真正稳了。另外还想多嘴提一句:Redis虽然是缓存层,但别真把它当无限可靠的存储,给它配好持久化策略(RDB加AOF的混合模式)、主从复制和哨兵/集群架构,关键时刻能少一大半焦虑。毕竟缓存层一旦不可用,所有的读流量都会优先打到底库上,这时候你再聊一致性就没有意义了。
8. 我的最终选型建议与踩坑心得
前面把原理和方案讲得差不多了,最后从我个人的实操经验出发,给一套偏向落地、可以直接抄走的选型路径。
如果是中小型项目,能容忍5秒级的最终一致:Cache Aside加上合理的TTL就够了。写接口更新数据库后第一时间删除缓存,缓存key全部设置TTL,不追求极端实时。这一档其实最适合大多数电商、博客、内容类产品,实现成本低,问题也少。
如果是核心交易链路,能容忍1秒内的短暂延迟:在Cache Aside基础上叠加版本号方案和延迟双删。更新数据时带上版本号,缓存回填前比较版本号,拒绝旧值覆盖;写完数据库后立刻删一次缓存,稍后由后台任务再删一次。核心接口的缓存删除必须走消息队列兜底,不能删除失败就放任不管。
如果是金融级或数据准确性要求极高的链路,比如余额、积分、券状态:把binlog订阅(如Canal)加进来,让数据库的每一次变更都能驱动缓存淘汰。同时在应用层保留乐观锁,双保险。这一档的成本最高,只给核心业务用。
还有几个细节是我踩过之后觉得必须写出来的:
第一,别把缓存当唯一的真源。任何读请求回填缓存时,都要先从可靠的数据库拿数据,不要从另一个缓存聚合数据回填。我见过有人把Redis里的列表数据推给另一个缓存key,结果第一个缓存失效后,第二个也跟着错乱了。
第二,上线前一定要准备"缓存清理脚本"。线上数据不一致时,运维直接执行脚本就能把异常key清掉,不用临时找人写Java代码。这个脚本至少支持指定前缀、指定key、按数据表维度批量清理三种方式。
第三,不要迷信"强一致"。在分布式环境下追求绝对强一致,代价是指数上升的。很多时候,产品上"最终一致"完全够用,关键是要把不一致的窗口控制到用户无感知。如果产品经理非说"必须强一致",你把成本换算给他们看,最后大概率也会妥协成"秒级一致"。
最后分享一个小技巧:每一条缓存写入都可以带一个业务时间戳,出问题时可以快速判断缓存里的数据是什么时候写入的,和数据库的变更时间一对比,根因立刻浮出水面。这个字段几乎不增加存储成本,但在排障的时候能省下半天时间。我的所有缓存结构体里都预留了这个字段,强烈建议你也加上。
缓存与数据库保持一致的这条路,说到底是"工程取舍的平衡"——没有绝对正确的方案,只有适合你业务场景的方案。把这篇文章里的机制都理解透,再结合自己的线上数据形态做剪裁,比照搬任何一个"银弹方案"都可靠得多。