news 2026/9/30 15:21:22

缓存与数据库一致性实战:从Redis到MyBatis的避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓存与数据库一致性实战:从Redis到MyBatis的避坑指南

缓存这玩意儿,刚工作那会儿我以为是性能优化的万能药,后来在线上被扇了好几次耳光才明白,缓存跟数据库之间那点"账",算不清楚是会出事的。尤其是那种看起来没什么技术含量的"先更新数据库再删缓存",和"先删缓存再更新数据库",用错了顺序,脏数据能让你排查到怀疑人生。这篇文章就把缓存与数据库一致性问题掰开了讲,从最基础的模式选择,到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的那个值,可能是基于一个过期的数据计算出来的。举个例子,库存初始100,A线程扣了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依赖B,B依赖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:ver=20250101。这样就算缓存逻辑出了bug,你还能通过切换版本号快速恢复,不用等缓存自然过期。这在线上事故处理时是非常实用的保命手段。

第三,监控要看到"不一致"层面。通常团队只监控缓存命中率、Redis内存使用量,但很少监控"数据库和缓存的值是否一致"。你可以定时扫描一部分核心key,对比两边数据,把不一致的数量作为告警指标。这个指标一开始可能噪音比较大,但一旦稳定运行,它能帮你提前发现非常隐蔽的bug。

第四,能不缓存就不缓存,能短缓存就不要长缓存。缓存是优化手段,不是业务必需。数据量小、并发不高的场景,直接查数据库反而简单可靠。引入缓存前,先问自己三个问题:这个数据的读多还是写多?能容忍多久的旧数据?如果缓存系统挂了,有没有降级方案?三个问题都答得上来,再动手加缓存也不迟。

写到这里,我其实特别想强调一件事:缓存与数据库一致性没有一劳永逸的"标准答案",它是一套根据业务场景不断权衡的动态策略。去年我在一个项目里以为延迟双删已经万无一失,结果一场大促峰值流量下还是出了问题——后来复盘,问题不出在策略上,而出在删除缓存的重试机制没做好。从那以后我给自己定了一条规矩:每个缓存写入路径都要有"失败兜底"的觉悟,要么重试,要么过期时间足够短。

最后再分享一个小技巧:当你真的把数据一致性卡得很死时,可以在缓存里额外存一个"lastUpdateTime"字段,读取时对比一下和数据库里最新修改时间,差别超出阈值就重新加载。这个技巧用在那些"既要性能又要一定准确性"的场景里,特别实用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 15:17:02

Flink StateMigrationException排查与状态迁移实战指南

1. 一场升级引发的线上事故:先看报错现场 凌晨两点,值班手机把我从梦里拽出来。告警说的是线上一个Flink SQL作业连续重启失败,作业已经进入FAILED状态。登录平台一看日志,罪魁祸首是这一行: Caused by: org.apache.…

作者头像 李华
网站建设 2026/9/30 15:16:04

ArcGIS属性查询公式大全:数值日期文本与字段计算器避坑指南

做数据这行久了,最怕听到的一句话就是“这个字段帮我筛一下”。ArcGIS 属性查询公式看着简单,无非是字段加运算符,可真到了几十万条记录的图层上,写错一个引号、漏掉一个空值判断,结果就是差之毫厘谬以千里。这篇东西整…

作者头像 李华
网站建设 2026/9/30 15:09:37

AOA优化随机森林超参数:从原理到代码实战的完整调参指南

随机森林(RF)分类算法在工程里的地位不用多说,从遥感到社区服务需求分类,这类表格数据任务里它基本是最稳的开局模型。但你可能也体会过它的另一面:超参数一多,调起来是真的磨人。这次我做了一组完整实验&a…

作者头像 李华
网站建设 2026/9/30 15:09:21

公众号历史文章数据采集与Excel分析实战:以罗辑思维为例

1. 项目全景:从“看文章”到“看数据” 1.1 这个项目到底在做什么 罗辑思维这个号,是我公众号观察系列里第一批纳入样本的。前后筹备了一周,把2025年发布的874篇文章全部抓下来,整理成Excel,包含标题、发布时间、链接…

作者头像 李华
网站建设 2026/9/30 15:07:38

新闻文本分类实战:BERT与朴素贝叶斯双模型对比与融合

简介:这是一份面向高校学生与机器学习初学者的新闻文本分类项目资源,基于BERT预训练模型与朴素贝叶斯算法完成新闻文本分类任务,适用于期末大作业、课程设计及NLP实战练习。压缩包共23个文件,包含8个Jupyter Notebook源码、2个CSV…

作者头像 李华
网站建设 2026/9/30 15:07:25

ANSYS Workbench网格划分全指南:从报错排查到质量优化

1. WB环境下网格划分的整体思路与模块连接逻辑 1.1 为什么明明画了网格,求解器却说“Domain 3没有划分网格” 很多人第一次在ANSYS Workbench里跑静力分析时,都会遇到一个莫名其妙的报错:带有标记“geom1”的几何中的“domain 3”没有划分网…

作者头像 李华