☰
ORM一级缓存失效场景解析:从会话边界到CacheKey设计
2026/10/20 7:00:55 网站建设 项目流程

1. 先搞清楚“一级缓存”在ORM框架里到底是什么

使一级缓存失效这个话题,我在面试题和实际排查记录里都见过太多次。很多人能一字不差背出四种情况:换了会话、查询条件不同、中间执行了写操作、手动清理缓存。可真到线上遇到“同一条SQL查了两次数据库”“一个事务里反复读到旧数据”这类问题时,背答案的人往往还是抓瞎。原因是“失效”这个词,本身就把好几种不同的机制混在了一起:有的是缓存根本没被命中,有的是缓存被主动清空,有的是作用域压根就不共享。不把这层拆开,就等于只知道症状,不知道病理。

ORM框架里的一级缓存,本质上是一个绑定在“会话”上的本地缓存。这里的会话,你可以理解成一次与数据库交互的上下文,它通常对应一个数据库连接,也对应一次事务的边界。在这个上下文内部,第一次执行某条SQL时,框架会把查询结果按照一个可比较的key存到内存里;后续再执行相同SQL时,框架会先在这个内存里寻找,找到了就直接返回,不再向数据库发送查询语句。这就是一级缓存的核心价值:在同一个会话内,避免重复查询,减少数据库压力,也减少网络和结果映射的开销。

这个缓存有几个特点,值得先记在脑子里:

  • 默认开启,通常不需要额外配置;
  • 作用域是会话级别,会话结束缓存就随之销毁;
  • 命中条件非常严格,SQL语句、参数、分页信息、环境标识等关键信息必须完全一致;
  • 只要会话内发生写操作,整个缓存内容都会无效。

不少人会混淆一级缓存和二级缓存。一句话区分:一级缓存解决的是“同一个会话里反复查询同一条数据”的问题,二级缓存解决的是“跨会话共享查询结果”的问题。两者在命中范围、生命周期、存储位置上差别很大。本文专注一级缓存,但很多结论在理解二级缓存时同样成立。

我在刚开始接触框架底层时,总觉得一级缓存是个很“弱”的存在,作用范围这么小,能省多少性能?后来被现实教育了:在一个重型批处理任务里,同一个事务循环上千次,如果每次循环都重复查询配置表,一级缓存可以把这类重复查询直接抵消掉;反过来,如果会话边界没控制好,缓存根本不会被命中,日志里几百条相同的SQL刷屏,数据库压力成倍增长。所以这个“弱缓存”其实很关键。

2. 面试答案里最常见的四种失效场景:逐一拆开看

2.1 场景一:换了会话,缓存各玩各的

第一种情况最好理解:一级缓存挂在会话上,A会话查询过的结果,B会话不可能拿到。你在代码里写两次相同的查询,如果每次查询都是通过新建会话执行的,那么第二次查询必然重新打到数据库,走完整的SQL执行流程。

不少新手写批量处理代码时都会踩这个点:循环体内每次查询都获取一个新会话,循环一千次,同一条SQL就真实执行了一千次。他们第一反应是“缓存为什么失效了”,其实一级缓存压根就没有参与进来——两个会话之间本来就不共享任何缓存数据。

验证方法很简单:打开SQL日志,看同一条语句是不是出现了两次。如果出现了,基本可以断定这两次查询没有落在同一个会话的缓存范围内。我在实际项目里好几次排查重复查询,最后都定位到业务代码在一个for循环里频繁创建新会话,把循环体改成共享一个会话之后,重复查询立刻消失,性能变化非常明显。

2.2 场景二:查询条件变了,缓存Key对不上

严格说,这种情况不能叫缓存“失效”,应该叫“未命中”。一级缓存存储结果时,会构建一个CacheKey,里面通常包含映射语句的ID、最终解析出来的SQL文本、参数值、分页偏移量、每页大小以及当前环境标识。第二个查询只要任何一个参数值不同,构建出来的key就不同,框架拿着新key去旧缓存里找,自然找不到,于是老老实实走数据库查询。查完再把新key对应的结果放进去,旧key对应的结果并不会被删除,只是在缓存里继续躺着等后续命中。

举个例子,同一个会话里先查“状态为1的客户列表”,再查“状态为2的客户列表”,这两次不会命中。这不是一级缓存失效,而是缓存里的key和查询对不上。面试里把这个列为失效情况之一,更多是为了提醒你:别以为会话内所有查询结果都会被缓存,缓存本质上是键值对,键对不上,值就取不到。

不过这里有个容易忽略的坑:有时候你觉得两次查询“差不多”,实际CacheKey差得很远。比如一个SQL里传了null值,另一个SQL直接没有这个查询条件,解析后的SQL文本就不一样;又比如分页参数一个传了0,一个传了1,key也不一样。我排查过一个接口,第一次调用耗时200毫秒,第二次调用依然200毫秒,完全没有任何缓存命中迹象,反复看日志才发现两次查询的分页条件不同。这种“缓存没生效”的错觉,本质上都是对Key粒度理解不够。

2.3 场景三:两次查询之间执行了写操作,缓存整体作废

这才是真正意义上的一级缓存失效。在同一个会话里,第一次查询之后,只要执行任何增删改操作,整个一级缓存都会被清空。为什么不是只删除受影响的那一条缓存?因为框架无法精确判断数据库的变更到底影响了哪些查询结果。与其做复杂的关联分析和精细删除,不如让整个缓存都失效,下次查询重新加载,简单、安全、不会出错。

这里有个细节特别值得注意:不仅是增删改语句会清缓存,调用事务提交或者回滚同样会触发缓存清理。很多资料只写“执行了增删改会使一级缓存失效”,把提交和回滚这两个边界漏了。实际上框架会在事务边界上做清理动作,目的就是保证缓存里的数据始终和当前事务看到的数据是一致的。如果你在一个事务里先查询,再更新,再查询,第二次查询会重新查数据库,这样后续查询才能看到前面写操作的效果。

我模拟一下这个流程,帮助理解:

同一个会话: 第一次查询 findUser(1) -> 缓存未命中,执行SQL,结果放入缓存 第二次查询 findUser(1) -> 缓存命中,直接返回 执行 updateUser(1) -> 缓存被整体清空 第三次查询 findUser(1) -> 缓存为空,重新执行SQL

可以看到,写操作之后的那次查询,无论如何都会打数据库。这不是性能问题,而是为了保证数据一致性。

2.4 场景四:手动调用清理方法,强制缓存失效

框架通常都提供手动清空缓存的入口,在会话上调用类似clearCache的方法,当前会话的一级缓存就会被清空。这个操作在特定场景下非常有用:比如同一个会话里先查询了旧数据,随后通过别的方式(不经过ORM框架本身的写操作路径)修改了数据库某张表,再查询同一条记录时如果不手动清理,拿到的还是第一次缓存的旧值。

我遇到过一个典型的线上故障:某个定时任务在同一个长会话里做轮询,不停地查用户表,看某个状态字段是否变化。问题是这个状态字段是另一个线程通过底层数据库操作改的,没有走会话里的写操作路径,所以会话内的一级缓存一直保留着第一次查询的结果,任务整整跑了一个小时都在读旧值。后来在每次轮询查询前主动调用一次缓存清理,问题立刻消失。

这种问题很容易被误判成“数据没有提交”或者“缓存穿透”,其实根源是一级缓存没有更新策略,只有主动清空机制。它不像分布式缓存那样可以设置过期时间,也不感知数据库外部变更。所以遇到“同一个会话反复读到旧数据”的诡异现象,先想想是不是一级缓存的老实本分在作怪。

把四种场景归个类,你会看得更清楚:

  • 换了会话:缓存作用域问题,不属于失效;
  • 参数不同:缓存Key匹配问题,属于未命中;
  • 写操作和事务边界:真正的主动失效;
  • 手动清理:主动失效的另一种途径。

这么一看,面试题问的“四种情况”,真正够得上“缓存失效”的,只有后两种。前两种更像是“缓存没发挥作用”。能把这一层讲清楚,面试时就不只是在背答案了。

3. 从源码执行顺序看缓存命中与清理的边界条件

ORM框架之所以能做到“先查缓存,查不到再查数据库,写操作时清空缓存”,是因为所有SQL执行都走同一个执行器,而执行器内部持有当前会话的缓存实例。正常情况下,一次查询的执行顺序大致是这样的:

第一次查询: 1. 根据SQL语句、参数等生成CacheKey 2. 从当前会话缓存中查找 3. 未命中,执行真正的数据库查询 4. 拿到结果后放入缓存 5. 返回结果 第二次相同查询: 1. 生成同样的CacheKey 2. 命中缓存,直接返回 3. 不再执行数据库SQL

用代码表达就是:

Map<CacheKey, Object> localCache = new HashMap<>(); CacheKey key = buildKey(statementId, sql, params); Object value = localCache.get(key); if (value == null) { value = queryDatabase(statementId, sql, params); localCache.put(key, value); } return value;

非常简单,没有任何复杂的淘汰策略。所以一级缓存的命中率,基本取决于你构建CacheKey的稳定性和使用会话的方式。

写操作的执行顺序完全不同:

写操作执行前: 1. 先清空当前会话缓存 2. 再执行真正的INSERT/UPDATE/DELETE

留意这里的顺序:先清空,后执行。为什么不是执行完SQL再清空?如果先执行SQL再清缓存,那么在SQL执行过程中,缓存里的数据已经和数据库不一致了;而且如果清空动作本身在SQL执行后失败了,残留的脏数据会被后续查询继续命中。反过来,先清空再执行,哪怕写操作最终失败,缓存里最多是没有数据,后续查询多走一次数据库而已,不会读到错误结果。这个“先失效,再更新”的思想,在分布式缓存的更新策略里也经常出现,目的都是尽量缩小不一致窗口。

再补充一个容易忽略的点:事务提交和回滚。会话提交时,框架会把一级缓存里尚未同步的数据先发送给二级缓存,然后清空一级缓存;回滚时则直接清空,不做任何同步。所以当你开启一个事务,里面先查询再更新再查询,第二次查询必然会重新查询数据库。这是为了确保事务内部对数据的可见性——你刚改了数据,下一次查询当然要看到改动后的结果,而不是旧缓存。

有些读者会问:既然一级缓存是会话级的,为什么“同一条SQL在两个会话里执行”也能被当成失效情况?原因在于,在很多主流容器管理事务的框架里,一次业务方法可能被拆分成多个会话,或者每次查询都会通过代理对象获取新的会话。表面上看你写的是“查两次”,底层却落在两个不同会话上,一级缓存自然各查各的。这不能怪框架,是会话边界和你的直觉不一致。

4. 真正影响功能的不是“失效”,而是会话边界与Key设计

聊到这里你会发现,一级缓存是否命中,归根到底取决于两件事:第一,缓存的Key是怎么设计的;第二,缓存实例的生命周期边界在哪里。这两个问题想明白了,很多线上的“缓存失效”现象都能自己定位。

先看Key设计。一个典型的CacheKey会包含这些要素:

CacheKey组成说明
语句ID定位到具体映射语句
SQL文本动态SQL解析后的最终形态
参数值查询传入的完整参数对象
分页信息偏移量和每页大小
环境标识区分不同数据源或环境

这意味着两个看起来差不多的查询,只要其中一个参数不同,key就不同。我在排查过程中发现,最容易忽略的是分页参数。接口第一次查询传offset=0,第二次传offset=1,虽然目标数据几乎一样,但key完全不同,缓存自然命不中。

还有一类很隐蔽的坑:同一个SQL,第一次调用把某个参数设为null,第二次调用没传这个参数,两条SQL文本可能就不一样,CacheKey也不一样。这些差异在日志里很难看出,需要断点看生成的key对象才能发现。所以我做缓存调优时,第一件事永远是确认key的构成,而不是急着改配置。

再看会话边界。一级缓存的生命周期和会话绑定,会话又往往和连接、事务绑定。在一个长事务里,一级缓存可以积累很多查询结果,内存占用会超出你的直觉。我处理过一次内存告警:某个批量任务在同一个事务里循环处理上万条数据,每个循环都要查询配置表、查询业务单据、查询关联用户,最终这个会话的缓存里堆积了数万个查询结果,直到事务结束才释放。这其实包含了一个双向的问题——会话复用好,但缓存规模也容易失控。

除了内存,还有一个更隐蔽的副作用值得提醒:一级缓存命中时返回的,通常是同一个对象引用。也就是说,同一个会话里,你第一次查询拿到一个实体对象,然后代码顺手修改了它的某个字段,之后任何通过一级缓存命中同样结果的地方,拿到的都是被你修改过的对象。我真实遇到过这种情况:一个方法里先查了客户对象,为了方便后续判断,给客户对象加了一个临时标记字段,结果另一个查询通过缓存命中了同一个对象,业务逻辑读到了这个标记,整体行为立刻变得诡异。排查了半天才发现原来是一级缓存返回了同一个引用。解决办法也不复杂:不要随意修改查询返回的对象,或者在做复杂业务判断时用新对象包装。

所以你看,一级缓存的实际影响,远不止“命中/不命中”这么简单。它的边界和Key设计,决定了你在复杂业务场景里会不会踩坑。

5. 工程实践里如何利用和规避一级缓存的两个方向

理解了原理之后,就要说说工程上怎么用了。我的态度是:能用好尽量用好,而不是一遇到问题就想着全局关掉。

如果想让一级缓存发挥价值,核心思路是稳定地复用会话。比如一个业务方法里,先查询基础数据做校验,后面多次引用,这种场景一级缓存能给到实打实的收益。批量任务里则要注意会话边界:能共用一个会话的就不要每次重新获取。但是,长事务里要控制缓存规模,不要在一个会话里无限制地堆积查询结果。对于会查询大量分页数据的批量场景,我会在循环中定时手动清一下缓存,保持内存平稳。

如果一级缓存带来了麻烦,也不要盲目全局关掉。大部分框架允许把缓存作用域调整为“语句级”,也就是每次查询执行完后,缓存条目立即丢弃,相当于只有这一次查询内部能复用,跨查询完全不生效。这样做能规避长会话的脏读和内存堆积问题,代价是后续相同查询都会重新打数据库。应当根据业务特征取舍:如果同一个会话内对同一条数据的重复查询频率很低,语句级缓存也不会有太大损失;如果频率很高,关掉反而是浪费。

还有一个原则必须强调:先诊断,再调整。判断缓存是否生效最直接的手段就是看SQL日志。开启框架的SQL日志后,一条SQL如果只出现一次,说明后续相同查询都命中了缓存;如果同一条SQL反复出现,就从三个方向排查:

  1. 会话边界对不对——两次查询是否真的在同一个会话里;
  2. 参数是否完全一致——CacheKey是否牢牢匹配;
  3. 中间有没有写操作或事务边界——缓存是否被主动清空。

这三个方向对应完全不同的修复手段。会话不对就调整代码结构,参数不一致就优化key设计或复用查询对象,中间有写操作则要思考是否真的需要绕过缓存。先定位再动手,效率会高很多。

我自己的经验里,九成以上的“缓存失效”问题,最终都落在会话边界和参数不一致上,真正需要手动清理的很少。所以动手改配置之前,务必先确认问题出在哪一层。全局关闭一级缓存是一件很容易的事情,但省下那点内存,可能会带来更多的重复查询和新的性能黑洞,这个账要算清楚。

6. 最后关于缓存排查的一点个人体会

一级缓存平时不出问题的时候,很少有人注意到它的存在;一旦出了数据“变旧”、SQL重复狂暴这类问题,它又经常成为第一背锅对象。我排查过的几个线上异常,最后都指向同一个本质:对缓存生命周期和缓存Key的理解不够精确。如果你能把“失效的四种情况”拆成“未命中、作用域隔离、主动清空”这三类,其实很多问题不用看源码也能预判。

我在实际项目里最常用、也最推荐别人试的一个排查技巧,就是怀疑缓存问题时先看SQL日志里同一条语句出现的次数。出现两次,先查会话边界;会话没问题,再看参数是不是一样的;参数也一样,就查中间有没有写操作。按这个顺序往下走,大多数缓存问题都能很快定位。

至于那些“看似缓存有问题,实际怎么查都查不出来”的场景,我的建议是断点打开看一次CacheKey对象。它会把SQL文本、参数值、分页信息等字段全部列出来,和数据库日志一对比,差异一目了然。这个办法虽然麻烦一点,但对理解框架行为很有帮助。看得多了,你自然就知道哪些代码习惯会让缓存形同虚设,哪些改动能够真正踩到缓存的红利。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询