免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Redis缓存与数据库一致性实战:方案选型与踩坑总结

Redis缓存与数据库一致性实战:方案选型与踩坑总结 1. 缓存命中率上去了数据却乱了一致性问题的本质来源我记得第一次在生产环境把Redis缓存铺到订单详情页的时候效果立竿见影——接口平均耗时从380ms掉到40msDB主库的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、按数据表维度批量清理三种方式。第三不要迷信强一致。在分布式环境下追求绝对强一致代价是指数上升的。很多时候产品上最终一致完全够用关键是要把不一致的窗口控制到用户无感知。如果产品经理非说必须强一致你把成本换算给他们看最后大概率也会妥协成秒级一致。最后分享一个小技巧每一条缓存写入都可以带一个业务时间戳出问题时可以快速判断缓存里的数据是什么时候写入的和数据库的变更时间一对比根因立刻浮出水面。这个字段几乎不增加存储成本但在排障的时候能省下半天时间。我的所有缓存结构体里都预留了这个字段强烈建议你也加上。缓存与数据库保持一致的这条路说到底是工程取舍的平衡——没有绝对正确的方案只有适合你业务场景的方案。把这篇文章里的机制都理解透再结合自己的线上数据形态做剪裁比照搬任何一个银弹方案都可靠得多。
返回列表