继续Hibernate这个系列,今天聊查询缓存(Query Cache)。这算是Hibernate里比较尴尬的组件——用得好是性能利器,用不好就是内存黑洞外加莫名其妙的N+1查询。我最早把hibernate.cache.use_query_cache打开后,以为所有查询都自动缓存了,结果看日志发现一条都没命中;后来又把它和二级缓存混为一谈,排查了大半天才分清楚俩的职责。这篇文章我会从查询缓存到底缓存了什么说起,逐步讲清楚开启配置、失效机制、适用场景和踩坑经验。想上查询缓存的、或者开了之后命中率不理想的同学,可以按这个思路重新梳理一遍。
1. 先搞清楚查询缓存到底缓存了什么:命中的不是实体,而是ID清单
1.1 查询缓存的“产物”:参数化SQL对应的主键列表
很多人对查询缓存的第一印象是:它会把查询结果里的实体对象整体放到某个缓存里,下次同样的查询直接从缓存里把对象掏出来。实际上不是这样。
Hibernate查询缓存的存储粒度是:一条查询在某个参数组合下,返回的结果集标识。如果查询返回的是实体,缓存里存的就是这批实体的主键ID列表;如果查询返回的是标量值(比如聚合结果、投影字段),那才直接缓存标量值本身。
举个最简单的例子:
List<Book> books = session.createQuery( "select b from Book b where b.author = :author", Book.class) .setParameter("author", "lost") .setCacheable(true) .getResultList();这条查询被缓存后,缓存里放的不是一堆Book对象,而是类似[12, 34, 56]这样的ID数组。缓存的key则由查询语句、参数值、缓存区域名、分页参数共同拼出来。
这里就引出了查询缓存最核心的特性:命中了查询缓存,并不代表不用访问数据库。如果这些实体ID对应的实体不在二级缓存里,Hibernate仍然会按ID逐个加载实体,每个ID都可能触发一条SQL。你可以理解为,查询缓存只负责告诉你“符合条件的都是谁”,不负责把“这些人本身”给你。
1.2 一次查询缓存命中的完整执行链路
把执行链路展开看会更清楚。假设上面那条查询已经执行过一次并写入了缓存,第二次执行同样的查询时,Hibernate走的是:
- 解析HQL、生成SQL,这一步不管有没有缓存都会做。
- 根据SQL、参数值、缓存区域拼出缓存key。
- 去查询缓存区域里查这个key。
- 命中后拿到主键ID列表。
- 逐个ID去二级缓存里找实体,二级缓存命中的直接用;没命中的发SQL到数据库按ID查询。
- 因为拿到的是一批ID,Hibernate会尽量采取批量加载的方式减少SQL条数。
看到第5步你就明白了:查询缓存是建立在二级缓存之上的“目录层”。它本身不装实体数据,只是把“结果集有哪些ID”这件事记住了。如果二级缓存没开或没配置好,查询缓存命中后的表现很可能是:原来一条带条件的主查询变成了N条按主键查的SQL,性能不升反降。这也是“开了查询缓存反而变慢”最常见的元凶。
1.3 一级缓存、二级缓存、查询缓存的分工
把三层缓存放一起对照,边界一下子就清楚了:
| 缓存类型 | 作用范围 | 缓存内容 | 默认开启 |
|---|---|---|---|
| 一级缓存 | 单个Session内 | 实体对象,按ID | 是 |
| 二级缓存 | 整个SessionFactory | 实体、集合,按ID | 否 |
| 查询缓存 | 整个SessionFactory | 查询结果的ID列表或标量值 | 否 |
一句话概括三者的关系:一级缓存管“当前会话里用过的对象”,二级缓存管“整个应用进程里被共享的实体”,查询缓存管“某条查询匹配了哪些ID”。查询缓存能发挥作用,很大程度上依赖二级缓存已经把对应的实体装好了。
2. 开启查询缓存的完整步骤:属性配置、Java API、缓存区域三件套
2.1 核心配置项逐个说清楚
最基础的配置是这几个,我以Hibernate 5.x配合Ehcache为例:
# 开启二级缓存 hibernate.cache.use_second_level_cache=true # 开启查询缓存 hibernate.cache.use_query_cache=true # 缓存工厂 hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory # 开启统计,方便看命中率 hibernate.generate_statistics=true如果你在Spring Boot项目里,通常这么写:
spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.use_query_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory有两个容易被忽视的点。
第一,use_query_cache=true只是“允许”查询缓存,并不会把所有查询自动变成可缓存。每一条查询必须自己在代码里调用setCacheable(true),Hibernate才会尝试缓存它。这个设计有点像“全局开关 + 单条查询开关”的双重确认,好处是不会误伤那些不该缓存的查询。
第二,查询缓存强烈建议和二级缓存一起开。前面已经解释过,实体查询的缓存结果只是一堆ID,没有二级缓存就得逐条回表。所以实际项目中我还没见过只开查询缓存不开二级缓存还能有好效果的案例。
Hibernate 6.x 的配置路径稍有调整,如果用的是JCache标准,区域工厂类可以配置成jcache,再引入对应的JCache Provider依赖。配置思想一样,只是类名和依赖换了。
2.2 在代码端把查询标记为可缓存
配置层做完之后,代码里只需要三步:
Query<Book> query = session.createQuery( "from Book b where b.author = :author", Book.class) .setParameter("author", "lost") .setCacheable(true) // 1. 标记可缓存 .setCacheRegion("query.book.byAuthor"); // 2. 指定缓存区域 List<Book> books = query.getResultList(); // 3. 执行setCacheRegion不是必须的,但强烈建议设置。不设置的话,所有可缓存查询会挤到默认区域org.hibernate.cache.internal.StandardQueryCache里,大家共享一套淘汰策略和内存上限,很容易互相干扰。设置了独立区域之后,每个业务查询可以单独控制缓存大小和过期时间,排查问题也更方便。
对应地,你还需要在缓存配置文件里给这个区域做好声明。
2.3 ehcache.xml里为查询缓存单独规划区域
Ehcache的配置文件大致像下面这样:
<cache name="query.book.byAuthor" maxElementsInMemory="5000" eternal="false" timeToIdleSeconds="120" timeToLiveSeconds="300" overflowToDisk="false" memoryStoreEvictionPolicy="LRU"/>这里maxElementsInMemory我习惯按“不同参数组合的数量”来估算,而不是按查询次数。查询缓存的一个key对应一组参数,比如author=lost是一个缓存项,author=another又是另一个缓存项。如果查询天然是“千人千面”的,参数组合数量会大得惊人,内存上限必须卡死。
另外还有一个特别需要注意的区域:更新时间戳缓存,通常叫org.hibernate.cache.spi.UpdateTimestampsCache。这个区域我建议设置成永不过期:
<cache name="org.hibernate.cache.spi.UpdateTimestampsCache" maxElementsInMemory="10000" eternal="true" overflowToDisk="false"/>道理后面讲失效机制时会详细说,这里先记住:时间戳区域一旦丢失条目,查询缓存可能读到脏数据,安全起见不要给它设TTL。
3. 失效机制的核心:UpdateTimestampsCache时间戳是怎么把脏数据挡住的
3.1 时间戳对比的判定逻辑
查询缓存一个很有意思的设计是:当相关表的数据发生变化时,Hibernate并不会立刻去把缓存里的查询结果删掉,而是用一套“时间戳对比”的软失效机制。
Hibernate在SessionFactory级别维护了一个特殊的缓存区域,记录着每张表最后一次被更新(增删改)的时间戳。查询结果被放入缓存时,会顺便记录当时涉及表的最新时间戳。之后每次查询缓存命中,Hibernate会做一次比较:如果查询涉及的某张表的时间戳比缓存条目的时间戳更新,就认为这个缓存结果已经过期,按未命中处理,重新执行查询并刷新缓存。
用时间线来模拟:
t0时刻执行查询,Hibernate把结果放进查询缓存,同时记录Book表最新更新时间是t0。t1时刻有一条Book记录被更新,Hibernate把Book表的时间戳改成t1。t2时刻同样的查询再次执行,命中缓存,但一比对发现Book表时间戳t1比缓存条目时间戳t0新,于是判定缓存过期,放弃缓存结果,重新查库。
这套机制的好处是,Hibernate不用像很多应用级缓存那样,在每次数据更新后精准定位并删除相关的查询缓存条目。它只需要在每个表上维护一个“最后修改时间”,查询时做一次比较即可。代价就是失效粒度非常粗。
3.2 为什么一次小更新会让整张表的查询缓存全部失效
时间戳是表级别的,不是行级别的。只要Book表里任何一行数据被改动,所有涉及Book表的查询缓存都会在下次访问时被判死,哪怕你改的行跟某个缓存查询的结果毫无关系。
这个特性是查询缓存命中率低的最主要原因,也是很多人开了缓存之后发现“缓存好像一点用都没有”的根本原因。你可以把它想象成:一个货架上任何一个商品改了价格,整排货架都要重新盘一次库存。
所以判断一个查询适不适合走查询缓存,不能只看它“被调用得多不多”,还要看它涉及的表“更新得勤不勤”。表更新越频繁,时间戳就越容易被刷新,缓存条目就越容易失效。一个极端场景是:一条查询每秒被调用几百次,但它涉及的表每秒也更新几十次,那时间戳判定几乎每次都不通过,查询缓存变成纯摆设,还得倒贴缓存写内存的消耗。
3.3 原生SQL和跨库更新:失效机制管不住的地方
时间戳机制能正常工作,前提是数据更新必须经过Hibernate的实体操作或者HQL更新。比如:
session.createQuery("update Book b set b.price = b.price * 0.9") .executeUpdate();HQL的update/delete,Hibernate能识别出目标实体类型,会自动把对应表的时间戳更新掉,查询缓存随之失效。
但如果你用的是原生SQL,情况就变了。比如:
session.createNativeQuery("update book set price = 0 where category = 'tool'") .executeUpdate();Hibernate的SQL解析能力再强,也不会去猜测这条原生SQL改了哪些表。这种情况下,查询缓存里可能还留着旧数据,继续命中返回已经过时的结果。
解决办法是显式告诉Hibernate这条SQL会影响哪些实体:
session.createNativeQuery("update book set price = 0 where category = 'tool'") .addSynchronizedEntityClass(Book.class) .executeUpdate();另外提醒一句,如果项目里混合使用了JdbcTemplate、MyBatis这类框架直接操作同一套数据库表,Hibernate同样感知不到这些更新,查询缓存一样会返回脏数据。这部分属于「跨框架写数据」的经典坑,遇到这种情况,要么放弃查询缓存,要么在写操作后手动清理相关查询缓存区域:
sessionFactory.getCache().evictQueryRegion("query.book.byAuthor");4. 什么业务适合开查询缓存:先画清楚边界再动手
4.1 适合的画像:低频更新、高频查询、结果集有限
根据前面两节的机制分析,适合查询缓存的场景画像其实非常清晰:表数据极少变化、查询量很大、结果集有边界。
我实际项目里最典型的例子是一张配置表。整张表几千行,业务高峰期每秒钟被同一类查询读取几十次,但表数据只由后台管理员偶尔手工维护一次。这种场景下,查询缓存命中率高得吓人,基本能长期稳定在90%以上。因为数据更新频率极低,时间戳几乎不会被刷新,缓存条目可以长期存活。
适合的场景还有:商品类目、标签字典、地区数据、版本号配置这类“读多写少”的主数据查询。这类查询通常结果集也不大,缓存占用的内存可控。
4.2 坚决不用的场景:频繁更新的业务表和千人千面的参数
反过来,下面这些场景我基本不会开查询缓存。
第一类是更新频繁的业务表。比如订单表,状态流转非常快,只要有一行订单状态变化,整个订单相关的查询缓存就会失效。你刚把结果缓存进去,下一秒就被时间戳判死,缓存几乎起不到作用。
第二类是参数组合特别分散的“千人千面”查询。比如“查某个用户最近收藏的图书”,每个用户一个参数组合,一万个用户就是一万个缓存条目。如果这些条目还经常失效,内存占用高,收益又低,纯粹是给缓存服务器添负担。
第三类是热点行不断更新的表。比如点击量计数器,每次请求都会更新某一行,表时间戳一直被顶上去,查询缓存在这种环境里只能持续“写入即失效”,没有任何正面收益。
判断一个查询适不适合,我自己有个简单粗暴的自检:问自己两个问题——这个查询涉及的表,一天内被更新几次?这个查询的参数组合,业务上有多少个?表一天更新一次、参数组合几十个,放心开;表一分钟更新十次、参数组合成百上千,趁早别开。
4.3 非确定性查询、分页查询和组合缓存的注意点
还有一种容易踩坑的情况:查询里带了now()、current_timestamp()、random()这类非确定性函数。因为时间戳失效机制只在乎表数据有没有变,不关心时间流逝,这些查询一旦被缓存,返回的“当前时间”“随机数”就会被固定住,直到涉及的表发生更新才会刷新。
比如“查询今天创建的订单”这种HQL,如果开了查询缓存,很可能在第二天、第三天继续返回第一天的结果,因为Order表根本没有新写入时时间戳不会动。这种查询千万不要标setCacheable(true)。
分页查询也可以用查询缓存,setFirstResult和setMaxResults会参与缓存key的拼接,每一页算一个缓存条目。但要注意,分页查询依赖“结果集顺序稳定”,一旦数据插入顺序发生变化,靠时间戳失效判定虽然能兜底,但所有页面的缓存几乎同时失效,挫败感会很强。
另外,实体查询开了查询缓存,最好确认实体本身有合理的二级缓存配置,或者给实体加上批量抓取:
@Entity @BatchSize(size = 20) public class Book { // ... }这样查询缓存命中后,ID列表转化成实体对象时,Hibernate可以一次性发批量SQL,而不是一条条查。否则就会变成我之前说的“缓存命中反而N+1”的尴尬局面。
5. 命中率与收益验证:把统计指标装进监控里再上线
5.1 打开统计开关,让数据说明一切
查询缓存到底有没有用,不能靠感觉,要靠统计数字。配置里加上:
hibernate.generate_statistics=true之后就能在代码里拿到各种缓存指标:
Statistics stats = sessionFactory.getStatistics(); long hit = stats.getQueryCacheHitCount(); long miss = stats.getQueryCacheMissCount(); long put = stats.getQueryCachePutCount(); double hitRate = (double) hit / (hit + miss);查询缓存命中率就是hit / (hit + miss)。在监控系统里把命中率、二级缓存命中率、数据库查询次数这几个指标一起看,才能判断缓存配置到底有没有生效。命中率长期在60%以下,基本可以断定这个查询选错了缓存对象,不如关掉。
5.2 一个小实验:同一查询用缓存前后的对比
我用一个最简单的实验验证过查询缓存的收益。假设有一条查询稳定返回100条图书记录,我连续执行它100次,观察数据库实际查询次数。
不开任何缓存的时候,100次执行对应100条主查询SQL,数据库查询次数就是100。
只开二级缓存、不开查询缓存,由于每次查询执行时Hibernate都要重新解析HQL、重新查询“结果集是哪些ID”,主查询SQL仍然是100条,二级缓存只是省掉了部分按ID查实体的SQL。
二级缓存和查询缓存都打开后,第一次执行会查数据库,后面99次全部命中查询缓存,Hibernate直接拿到ID列表,再从二级缓存里取实体。数据库查询次数会从100骤降到个位数。
这个对比非常直观,也解释了为什么查询缓存必须配合二级缓存使用。单独开二级缓存解决的是“同一实体反复读取”的问题,而查询缓存解决的是“同一查询反复执行”的问题,两者解决的问题维度不一样。
5.3 落地时的几个建议
最后分享几个我落地查询缓存时的习惯。
第一,小步上线。不要一次性给所有查询加setCacheable(true),先挑两三类“读多写少、结果集稳定”的查询试点,跑一两天看命中率和数据库压力变化。
第二,为每个业务查询分配独立缓存区域,单独设置内存上限和TTL。这样某个查询的参数组合爆炸时,只影响它自己,不会拖垮其他查询的缓存。
第三,给更新频繁的表“留一条后路”。如果确实需要在更新后及时清理查询缓存,可以在更新操作后调用清缓存接口,但要小心清理成本——全区域清理的瞬间会有大量查询同时回源,压力反而更大。
第四,读写并发都很高的表,设置再大的TTL也没意义,因为时间戳失效机制会跳过TTL,直接把缓存判死。这种表就别在查询缓存上浪费时间了,从SQL索引、主查询结构这些更本质的方向去优化。
最后说说我的实际体会。查询缓存不是银弹,它更像一把需要“捏准时机”用的刀。我的习惯是:先把二级缓存配置扎实,保证实体读取这一层稳定,再挑几个确实低频更新的查询开查询缓存,跑一天看命中率,低于60%就关掉。因为查询缓存带来的问题——N+1、内存占用、陈旧数据——往往比它省下的那点SQL更贵。希望这篇能把查询缓存的边界讲清楚,让大家少踩我之前踩过的坑。