缓存这玩意儿刚工作那会儿我以为是性能优化的万能药后来在线上被扇了好几次耳光才明白缓存跟数据库之间那点账算不清楚是会出事的。尤其是那种看起来没什么技术含量的先更新数据库再删缓存和先删缓存再更新数据库用错了顺序脏数据能让你排查到怀疑人生。这篇文章就把缓存与数据库一致性问题掰开了讲从最基础的模式选择到Redis缓存治理、MyBatis缓存、Spring三级缓存原理这些实际工程里绕不开的点把来龙去脉和避坑经验都捋一遍适合正在做后端开发、或者被缓存问题折磨过的同学收藏慢慢看。我默认你已经有了一定的后端基础知道Redis、MySQL大概是什么也写过简单的缓存读写代码。但即便你是刚入门的初级开发这篇文章也能帮你建立一套完整的判断框架——什么时候该用缓存、用了之后怎么保证数据不出错、出了错怎么排查。1. 缓存与数据库一致性到底在解决什么问题1.1 缓存不是多存了一份数据这么简单很多人理解缓存就是把数据库里的数据复制一份放到Redis里读的时候先读Redis读不到再读数据库。这个理解方向是对的但忽略了一个核心矛盾数据库里的数据是会变的而缓存里的数据不会自己变。你写了一条订单记录数据库里状态是待支付Redis里存的可能是几秒前缓存的旧状态。用户一刷新读到的是旧数据——对客户来说这就是bug对技术人来说这就是缓存与数据库不一致。这个问题的本质是两份数据之间缺少一个可靠的同步契约。数据库是数据的事实来源source of truth缓存是加速读取的副本副本和源头一旦脱节你的系统就在给用户讲两个版本的故事。听起来简单但实际情况比这复杂得多因为你的代码是并发执行的不是单线程顺序跑。1.2 不是所有数据都需要强一致我见过最极端的做法是有人为了绝对一致把所有缓存读写都加分布式锁结果性能还不如裸查数据库。这就是典型的没想清楚需求就上手。一致性和性能天生就是跷跷板。你要搞明白自己的业务到底需要什么级别的一致性强一致任何一个读操作读到的必须是最近一次写操作的结果。比如账户余额、库存扣减这种场景通常根本不应该用普通缓存或者说要用也是极其小心的。最终一致允许短时间内读到旧数据但最终会收敛到一致。绝大多数业务场景都是这个级别比如商品详情页、用户信息列表。弱一致读操作可能长时间读到旧数据甚至永远不会感知某次更新。比如某些统计类、排行榜类数据。在动手设计缓存方案之前第一件事就是给业务数据分个等级哪些能容忍几秒钟的延迟哪些一分一秒都不能差。80%的缓存一致性事故都源于把所有数据都当成强一致来做或者反过来当成弱一致来糊弄。2. 主流缓存模式与一致性取舍2.1 Cache Aside 模式最常用但最容易写错Cache Aside旁路缓存是应用最广的模式逻辑清晰读的时候先读缓存读不到就查数据库然后回填缓存写的时候先更新数据库然后删除缓存。这个模式的关键在于写后删缓存而不是写后更新缓存。很多新手不理解为什么不能直接把新值写进Redis。原因很简单你写进Redis的那个值可能是基于一个过期的数据计算出来的。举个例子库存初始100A线程扣了1个把99写进缓存B线程同时扣了2个数据库里显示98但它读缓存时读到了A刚写进去的99然后算了个新值写进缓存。缓存里最终可能是98也可能是别的乱七八糟的数但数据库的真实结果是对的——而你本可以删掉缓存让下一次读取去数据库拿最新的。Cache Aside还有一个经典陷阱更新数据库和删除缓存这两步天然就不是原子的。中间任何一个环节挂了缓存里就是旧数据。这没什么完美解法只能靠补偿机制把影响降到最低后面我会专门讲。2.2 Read Through / Write Through把缓存变成代理Read Through是让缓存自己负责从数据库加载数据应用层只跟缓存交互缓存不命中时由缓存组件去查数据库。Write Through则是写数据时也先写缓存由缓存组件同步写数据库。这两种模式的好处是一致性逻辑被收敛到了缓存组件内部业务代码简单了。缺点也很明显你依赖的缓存中间件得足够可靠而且先写缓存再写数据库如果失败缓存里就有了数据库没有的数据问题比Cache Aside更隐蔽。实际工程里纯Read Through/Write Through并不多见更多是作为某个组件内部策略存在比如某些ORM框架的缓存机制。做大型分布式系统时我建议还是老老实实用Cache Aside可控性最强。2.3 Write Behind性能极致但别轻易碰Write Behind也叫Write Back是先把数据写进缓存立即返回成功后台异步批量刷入数据库。性能拉满写操作吞吐极高但风险也最大一旦缓存服务宕机没来得及刷进数据库的数据就丢了。这种模式适合那些丢了也无所谓或者有重建路径的数据比如用户浏览记录、操作日志、点赞计数。对于订单、支付、库存这类核心数据除非你有非常完善的高可用架构和持久化方案否则我不建议用Write Behind哪怕是最终一致也不能拿核心数据开玩笑。顺带说一句很多人以为分布式缓存中间件都自带持久化就能放心用Write Behind但中间件的持久化保障的是缓存节点重启不丢数据跟你业务上缓存和数据库最终一致是两码事别混淆。3. 缓存失效与更新策略实操中的关键细节3.1 为什么删除缓存比更新缓存更靠谱前文说了要删缓存而不是更新缓存这里补充一个更底层的理由更新缓存是写操作写操作在并发下容易产生覆盖而删除缓存是让数据失效下一次读会触发回填天然带上最新的数据库值。这话说起来简单但真的有人在代码里用updateCache而不是deleteCache理由是少一次缓存穿透。前期确实没事等并发上来了、多个线程同时更新同一个缓存key你就会看到各种诡异的值。我在实际项目中踩过这个坑之后定了一条规范缓存里的数据一律只删除永不主动更新除了那些经过严格计算且幂等的场景。当然删除缓存也分先删后写和先写后删。缓存与数据库一致性问题的经典讨论就是这两者的取舍先删缓存、再更新数据库读请求可能在缓存删除之后、数据库更新之前进来直接查数据库查到旧值并回填缓存缓存里又变成旧数据了。先更新数据库、再删缓存如果删缓存失败缓存里是旧值同样不一致。从概率上讲先更新数据库再删缓存出问题的窗口更小所以业界主流选择是它。但更小不代表没有所以才需要延迟双删。3.2 延迟双删补偿不一致窗口的土办法延迟双删的思路是先删除缓存再更新数据库休眠一小段时间比如500ms再次删除缓存。为什么要第二次删因为第一次删完缓存后可能有并发读请求在数据库更新前读到了旧值并且回填了缓存第二次删除就是把这个回填的脏缓存再干掉。延迟双删不是什么高深理论但很实用。关键参数是休眠时间理论上要大于一次读请求从开始读到回填缓存的最长时间。你可以用下面的公式估算休眠时间 读请求平均耗时 × 2 数据库更新耗时 冗余比如读请求平均50ms数据库更新平均30ms那延迟双删的休眠时间可以设150ms~200ms取一个安全余量。设置太小起不到补偿作用设置太大又影响写接口的RT。实际工程里我一般取200ms然后压测调优。还要注意延迟双删的第二次删除也可能失败。所以更完善的做法是配合后面要讲的binlog订阅异步删除只是那套方案复杂度高一些适合核心链路。3.3 缓存过期时间不是随便填的很多人设置缓存过期时间习惯性写个3600秒哪天好心情改成1800秒毫无逻辑。但过期时间其实是缓存一致性的第一道防线即使你的更新后删缓存逻辑因为某种bug失效了只要过期时间合理脏数据也不会存活太久。我给团队定过一个估算公式缓存过期时间 ≈ 业务容忍不新鲜时长 × 1.5 ~ 2如果业务能容忍10分钟内数据是旧的那过期时间设15~20分钟。如果只能容忍1分钟那就设90~120秒。这个时间也不是越长越好太短了缓存命中率低数据库压力大要用实时监控数据说话。此外缓存过期时间还要考虑缓存雪崩的问题如果大量key在同一时刻过期请求会同时打到数据库瞬间把数据库压垮。解决方案无非是给过期时间加一个随机扰动比如基础过期时间随机0~300秒让过期时间自然分散。4. 分布式环境下的Redis缓存治理4.1 缓存穿透、击穿、雪崩三个必须处理的问题聊缓存与数据库一致性问题就不可能绕开分布式缓存领域的三大经典故障。它们虽然不完全等价于一致性但处理不好会导致缓存形同虚设甚至放大数据库压力间接搞出更多不一致。缓存穿透查询一个根本不存在的数据缓存和数据库都没有每次请求都直接打到数据库。解决思路是缓存空值设置短过期时间比如5分钟或者用布隆过滤器先拦截。缓存击穿某个热点key过期的一瞬间大量并发请求同时回源数据库数据库被打崩。解决思路是互斥锁/分布式锁让一个请求去回填缓存其他请求等待或者直接返回默认值。缓存雪崩大量key同时过期或者缓存节点集群整体宕机所有流量涌向数据库。解决思路是过期时间随机化、多级缓存、缓存集群高可用。这些名字听着唬人但本质上都是缓存治理的基础功课。我在Redis缓存治理实践中发现很多团队只关注了缓存命中率却忽略了穿透、击穿、雪崩对数据库带来的稳定性风险。说句不好听的缓存没治理好谈一致性就是空谈因为你的缓存本来就没在正确工作。4.2 多级缓存架构如何影响一致性大型互联网系统常用多级缓存本地缓存如Caffeine、Guava Cache→ 分布式缓存Redis→ 数据库。二级甚至三级缓存能把响应时间压到毫秒级但也带来一个新问题每一级缓存都有自己的过期时间层级越多数据不一致的概率和窗口越大。本地缓存和Redis之间的一致性通常靠版本号或者发布时间戳来校验。比如本地缓存里存了一份数据带版本号Redis因为某种原因更新了数据版本号变了本地缓存下次读的时候发现版本号对不上就丢弃本地旧值去Redis重新拉取。这个方案需要你在数据模型里加入版本字段有点侵入性但能解决多级缓存的大部分不一致问题。另外要提一嘴Spring三级缓存原理。很多同学把Spring的三级缓存和Redis这种分布式缓存混在一起其实完全是两码事。Spring的三级缓存是解决循环依赖问题的singletonObjects一级缓存存放完整对象、earlySingletonObjects二级缓存存放早期暴露的半成品对象、singletonFactories三级缓存存放对象工厂。它是为了在Bean创建过程中提前暴露对象引用让A依赖B、B依赖A这种循环能够完成注入。理解它的关键在于Spring故意让半成品对象暴露到二级缓存是为了避免提前执行AOP代理导致代理对象和原始对象不一致。这里的一致性是同一个Bean在容器中的身份一致性跟数据库数据的一致性完全是两个维度。如果你在面试中把Spring三级缓存讲成防止缓存不一致面试官基本可以确定你是在背八股文。4.3 分布式事务与最终一致性当多服务共享一份数据、并且各自都有缓存时缓存一致性问题就升级成了分布式事务一致性问题。比如订单服务改了订单状态库存服务需要扣减库存两个服务各有缓存一个成功一个失败两边数据就对不上了。解决思路没有银弹常用手段有事务消息用消息队列把数据变更事件广播出去下游服务收到事件后失效自己的缓存。数据库更新和发消息要保证最终一致通常用本地消息表定时补偿来实现。binlog订阅同步通过Canal这类工具监听数据库binlog拿到数据变更记录后程序自动执行缓存删除或更新。这个方案的好处是业务代码零侵入真正的数据库驱动缓存失效。分布式事务框架比如Seata这类AT/TCC方案能保证跨服务事务但代价是性能和复杂度只适合核心链路。我的建议是不要一上来就上分布式事务框架先用事务消息binlog订阅把90%的缓存最终一致性问题解决掉剩下10%硬骨头再考虑强一致方案。成本和收益要算清楚。5. 从MyBatis到Spring本地缓存里的那些坑5.1 MyBatis一级缓存和二级缓存的正确认知MyBatis的一级缓存是SqlSession级别的默认开启同一个SqlSession内重复查询相同SQL会命中缓存。二级缓存是namespace级别的多个SqlSession共享默认关闭。听起来很美好但MyBatis一级缓存的坑在于只要SqlSession里发生了一次增删改操作整个一级缓存就会被清空这是MyBatis为了粗粒度保证一致性做的处理。也就是说一个SqlSession里先查后改再查第二次查询会重新走数据库实际上缓存利用率很低。二级缓存的问题更绕不同namespace之间如果有表关联查询一个namespace的更新不会导致另一个namespace的缓存失效就会出现我改了订单表订单明细表缓存还是旧的这种典型的不一致。所以MyBatis官方文档也强调二级缓存适用于极少被修改的数据并且要非常小心多表关联场景。如果你在项目里用了MyBatis二级缓存我建议先检查一下是否有跨namespace的联合查询是否有高频更新如果有任何一项目关掉二级缓存可能比留着它更安全——省下的那点数据库压力不够你排查不一致bug的时间成本。5.2 Spring三级缓存原理和Bean一致性的误解前面简单提了一句Spring三级缓存原理这里再往深讲一点因为它确实是面试高频但也是被误解最深的点。Spring创建Bean A和Bean B假设A依赖BB依赖A。正常的流程是实例化A → 注入B发现B还不存在→ 去创建B → B需要注入A。如果没有特殊处理这里会死循环。Spring的解决方案是A实例化后先把A的工厂方法放进singletonFactories三级缓存这样B在注入A时可以通过工厂拿到A的引用——哪怕A此时还是个没完成属性注入的半成品。早期暴露对象可能有风险因为半成品对象一旦被拿到却被别的地方改了状态最终A完成初始化后对象状态可能不是预期的。Spring把这个风险控制在一定范围内并且用是否允许提前暴露allowCircularReferences开关来控制。如果你在配置里关了循环依赖支持三级缓存就不会介入。说这个是希望大家别把Spring三级缓存和缓存一致性硬扯在一起。一个是依赖注入的生命周期机制一个是数据读写的副本同步问题八竿子打不着。你在技术分享时如果能把这两个概念区分清楚比背一百遍八股文更能体现功底。5.3 本地缓存与分布式缓存的搭配策略本地缓存进程内缓存的速度比Redis快一到两个数量级因为连网络IO都省了。所以很自然的方案是本地缓存扛大头Redis兜底数据库最后兜底。但本地缓存有一个天然劣势每个应用实例的本地缓存是各自的无法像Redis那样全局共享。你更新了数据库删掉了Redis里的key但每个实例的本地缓存里还留着旧值直到各自的过期时间到了才会刷新。解决思路有两种第一种给本地缓存设非常短的过期时间比如30秒或60秒。这样即使缓存没被主动失效最多也就是扛一分钟的旧数据。对大多数非核心数据这个方案性价比最高。第二种引入消息广播机制。应用实例订阅一个缓存失效主题数据库变更后所有实例收到消息并删除各自的本地缓存。这套方案能做到近乎实时的本地缓存失效但需要引入消息队列或者Redis的Pub/Sub复杂度上了一个台阶。我个人的经验是本地缓存只放那些变更频率低、容忍度较高的数据比如系统配置项、字典表、商品分类树核心交易数据、强一致要求高的数据直接查Redis或者数据库不要为了炫技硬套多级缓存。6. 常见问题排查与避坑指南6.1 典型问题速查表实战中遇到缓存一致性问题可以按下面的表快速定位思路现象可能原因排查方向偶尔读到旧数据刷新后恢复更新后删缓存失败检查删除缓存key的异常处理逻辑缓存里存在数据库中已删除的数据删除缓存操作没执行就被返回看日志里是否有缓存删除异常被吞掉热点数据频繁不一致并发更新同一key评估是否需要加分布式锁新发布版本后数据混乱本地缓存未及时失效检查本地缓存过期时间和广播失效机制缓存服务重启后大量请求打到数据库缓存未做预热上线前主动加载热点数据跨服务数据不一致分布式事务未保证最终一致检查消息队列消费和补偿任务这张表是我在实际运维中总结出来的高频问题不一定覆盖所有场景但能帮你把大多数问题引到正确的排查路径上。6.2 排查缓存一致性问题的独门思路遇到缓存数据对不上不要上来就改代码。先判断脏数据是哪种来源第一步复现或采集找到一条脏数据的记录记录下它的缓存key、数据库里的值、缓存里的值、以及大概的出现时间。第二步反推时间线看那条脏数据出现的时间窗口内是否有发布、是否有大批量任务在写库、是否有缓存过期时间恰好落在那个点。第三步核对代码逻辑确认写路径的顺序到底是先库后删还是先删后库确认有没有在异常分支里跳过删除缓存确认有没有缓存更新和缓存删除混用。第四步如果你有binlog工具直接用binlog追那条数据的变更记录对比缓存里最后的值是哪个操作写进去的。这一步基本能锁定问题。这套排查方法看起来笨但非常有效。越是在复杂系统里越不能靠猜。把操作记录摆出来谁写的、什么时间写的、写的什么值一目了然问题自然浮出水面。6.3 几个值得长期坚守的缓存治理原则最后我不打算讲那些大而全的架构只分享几个我在Redis缓存治理过程中真正觉得重要、且能长期受益的原则第一删除缓存失败必须重试。删除缓存操作不要只写一行Redis调用要搭配重试机制。最简单的做法是捕获异常后放入本地延迟队列稍微等几秒钟再试一次条件允许的话用消息队列异步重试更可靠。第二缓存key的设计要留后路。尽量把版本号或时间戳放进key里比如product:detail:123:ver20250101。这样就算缓存逻辑出了bug你还能通过切换版本号快速恢复不用等缓存自然过期。这在线上事故处理时是非常实用的保命手段。第三监控要看到不一致层面。通常团队只监控缓存命中率、Redis内存使用量但很少监控数据库和缓存的值是否一致。你可以定时扫描一部分核心key对比两边数据把不一致的数量作为告警指标。这个指标一开始可能噪音比较大但一旦稳定运行它能帮你提前发现非常隐蔽的bug。第四能不缓存就不缓存能短缓存就不要长缓存。缓存是优化手段不是业务必需。数据量小、并发不高的场景直接查数据库反而简单可靠。引入缓存前先问自己三个问题这个数据的读多还是写多能容忍多久的旧数据如果缓存系统挂了有没有降级方案三个问题都答得上来再动手加缓存也不迟。写到这里我其实特别想强调一件事缓存与数据库一致性没有一劳永逸的标准答案它是一套根据业务场景不断权衡的动态策略。去年我在一个项目里以为延迟双删已经万无一失结果一场大促峰值流量下还是出了问题——后来复盘问题不出在策略上而出在删除缓存的重试机制没做好。从那以后我给自己定了一条规矩每个缓存写入路径都要有失败兜底的觉悟要么重试要么过期时间足够短。最后再分享一个小技巧当你真的把数据一致性卡得很死时可以在缓存里额外存一个lastUpdateTime字段读取时对比一下和数据库里最新修改时间差别超出阈值就重新加载。这个技巧用在那些既要性能又要一定准确性的场景里特别实用。