首页 / 资讯中心 / 文章详情

Hibernate查询缓存详解:命中逻辑、失效机制与配置实践

Hibernate查询缓存详解:命中逻辑、失效机制与配置实践 ★ FEATURED ARTICLE
继续Hibernate这个系列今天聊查询缓存Query Cache。这算是Hibernate里比较尴尬的组件——用得好是性能利器用不好就是内存黑洞外加莫名其妙的N1查询。我最早把hibernate.cache.use_query_cache打开后以为所有查询都自动缓存了结果看日志发现一条都没命中后来又把它和二级缓存混为一谈排查了大半天才分清楚俩的职责。这篇文章我会从查询缓存到底缓存了什么说起逐步讲清楚开启配置、失效机制、适用场景和踩坑经验。想上查询缓存的、或者开了之后命中率不理想的同学可以按这个思路重新梳理一遍。1. 先搞清楚查询缓存到底缓存了什么命中的不是实体而是ID清单1.1 查询缓存的“产物”参数化SQL对应的主键列表很多人对查询缓存的第一印象是它会把查询结果里的实体对象整体放到某个缓存里下次同样的查询直接从缓存里把对象掏出来。实际上不是这样。Hibernate查询缓存的存储粒度是一条查询在某个参数组合下返回的结果集标识。如果查询返回的是实体缓存里存的就是这批实体的主键ID列表如果查询返回的是标量值比如聚合结果、投影字段那才直接缓存标量值本身。举个最简单的例子ListBook 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查询。因为拿到的是一批IDHibernate会尽量采取批量加载的方式减少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_cachetrue # 开启查询缓存 hibernate.cache.use_query_cachetrue # 缓存工厂 hibernate.cache.region.factory_classorg.hibernate.cache.ehcache.EhCacheRegionFactory # 开启统计方便看命中率 hibernate.generate_statisticstrue如果你在Spring Boot项目里通常这么写spring.jpa.properties.hibernate.cache.use_second_level_cachetrue spring.jpa.properties.hibernate.cache.use_query_cachetrue spring.jpa.properties.hibernate.cache.region.factory_classorg.hibernate.cache.ehcache.EhCacheRegionFactory有两个容易被忽视的点。第一use_query_cachetrue只是“允许”查询缓存并不会把所有查询自动变成可缓存。每一条查询必须自己在代码里调用setCacheable(true)Hibernate才会尝试缓存它。这个设计有点像“全局开关 单条查询开关”的双重确认好处是不会误伤那些不该缓存的查询。第二查询缓存强烈建议和二级缓存一起开。前面已经解释过实体查询的缓存结果只是一堆ID没有二级缓存就得逐条回表。所以实际项目中我还没见过只开查询缓存不开二级缓存还能有好效果的案例。Hibernate 6.x 的配置路径稍有调整如果用的是JCache标准区域工厂类可以配置成jcache再引入对应的JCache Provider依赖。配置思想一样只是类名和依赖换了。2.2 在代码端把查询标记为可缓存配置层做完之后代码里只需要三步QueryBook query session.createQuery( from Book b where b.author :author, Book.class) .setParameter(author, lost) .setCacheable(true) // 1. 标记可缓存 .setCacheRegion(query.book.byAuthor); // 2. 指定缓存区域 ListBook books query.getResultList(); // 3. 执行setCacheRegion不是必须的但强烈建议设置。不设置的话所有可缓存查询会挤到默认区域org.hibernate.cache.internal.StandardQueryCache里大家共享一套淘汰策略和内存上限很容易互相干扰。设置了独立区域之后每个业务查询可以单独控制缓存大小和过期时间排查问题也更方便。对应地你还需要在缓存配置文件里给这个区域做好声明。2.3 ehcache.xml里为查询缓存单独规划区域Ehcache的配置文件大致像下面这样cache namequery.book.byAuthor maxElementsInMemory5000 eternalfalse timeToIdleSeconds120 timeToLiveSeconds300 overflowToDiskfalse memoryStoreEvictionPolicyLRU/这里maxElementsInMemory我习惯按“不同参数组合的数量”来估算而不是按查询次数。查询缓存的一个key对应一组参数比如authorlost是一个缓存项authoranother又是另一个缓存项。如果查询天然是“千人千面”的参数组合数量会大得惊人内存上限必须卡死。另外还有一个特别需要注意的区域更新时间戳缓存通常叫org.hibernate.cache.spi.UpdateTimestampsCache。这个区域我建议设置成永不过期cache nameorg.hibernate.cache.spi.UpdateTimestampsCache maxElementsInMemory10000 eternaltrue overflowToDiskfalse/道理后面讲失效机制时会详细说这里先记住时间戳区域一旦丢失条目查询缓存可能读到脏数据安全起见不要给它设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/deleteHibernate能识别出目标实体类型会自动把对应表的时间戳更新掉查询缓存随之失效。但如果你用的是原生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而不是一条条查。否则就会变成我之前说的“缓存命中反而N1”的尴尬局面。5. 命中率与收益验证把统计指标装进监控里再上线5.1 打开统计开关让数据说明一切查询缓存到底有没有用不能靠感觉要靠统计数字。配置里加上hibernate.generate_statisticstrue之后就能在代码里拿到各种缓存指标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%就关掉。因为查询缓存带来的问题——N1、内存占用、陈旧数据——往往比它省下的那点SQL更贵。希望这篇能把查询缓存的边界讲清楚让大家少踩我之前踩过的坑。
阅读完成 · 觉得有帮助?
咨询建站