2026/10/24 5:03:44

二级缓存实战:从原理到落地,解决数据库读压力

二级缓存实战:从原理到落地,解决数据库读压力 1. 二级缓存到底是个什么东西第一次听到“二级缓存”这个词很多人脑子里冒出来的画面大概是一级缓存已经够用了为什么还要搞个二级是不是又多了一层要维护的东西我当初也是这么想的直到有一次做一个数据查询模块单表几百万行数据接口响应时间从几十毫秒一路飙到两秒多数据库的CPU直接拉满我才真正意识到——一级缓存那点容量和生命周期根本扛不住真实业务里的读压力。二级缓存说白了就是在应用层和数据库之间再架一层可以跨会话、跨请求共享的数据暂存区。一级缓存通常是跟着单个会话走的会话一关缓存就没了而二级缓存是跟着整个应用实例走的只要应用还活着缓存就在。这个区别听起来简单但它带来的影响是巨大的多个请求可以共享同一份数据不用每个请求都去数据库捞一遍。我拿生活里的场景打个比方。一级缓存就像你办公桌上的便签纸你自己写自己看换个人来就得重新写一张。二级缓存更像是办公室墙上的白板谁都能看谁都能往上写只要没人擦掉信息就一直在那儿。当然白板也有白板的麻烦——写的人多了怎么保证大家看到的是最新版本这就是二级缓存要解决的核心问题之一。这篇文章适合谁看如果你正在做后端开发、中间件选型或者单纯对“缓存”这个技术点感兴趣想搞清楚二级缓存的工作原理、适用场景和踩坑经验那接下来的内容应该能帮到你。我会从设计思路、核心细节、实操过程到问题排查把二级缓存这件事掰开揉碎讲清楚。2. 为什么需要二级缓存从一次性能瓶颈说起2.1 一级缓存的局限性在哪里一级缓存最典型的就是数据库连接级别的缓存比如ORM框架里的Session缓存。你在一个事务里查了某条记录再查一次框架不会再去数据库拿直接从内存里给你。这个机制很好用但它有两个硬伤。第一个硬伤是生命周期太短。会话结束缓存清空。这意味着下一个请求进来哪怕查的是完全相同的数据也得重新走一遍数据库。对于读多写少的场景这简直是巨大的浪费。我做过一个统计某个商品详情接口QPS大概在三千左右其中百分之九十五以上的请求查的是同一批热门商品。如果只有一级缓存每个请求都要打一次数据库数据库连接池分分钟被打满。第二个硬伤是作用范围太窄。一级缓存只在单个会话内有效多个会话之间无法共享。你可能会想那我能不能把会话设长一点可以但会带来内存泄漏和数据一致性的风险。会话越长缓存的数据越多内存占用越大而且如果数据在数据库里被改了会话里的缓存还是旧的读出来的就是脏数据。2.2 二级缓存解决了哪些实际问题二级缓存的出现直接针对的就是上面两个问题。它把缓存的生命周期从“会话级”提升到了“应用级”把作用范围从“单会话”扩展到了“多会话共享”。这样一来多个请求可以复用同一份缓存数据数据库的读压力会大幅下降。我拿一个实际场景来说明。假设有一个内容管理系统的文章列表页每页展示二十篇文章首页的访问量特别大。如果没有二级缓存每次刷新首页都要执行一条带分页的查询语句数据库要扫描、排序、返回结果。加了二级缓存之后第一次查询把结果存起来后续的请求直接命中缓存数据库只需要在文章有更新的时候才被访问。实测下来首页的响应时间从平均一百二十毫秒降到了十五毫秒左右数据库的QPS从两千多降到了不到两百。当然二级缓存不是银弹。它解决的是读多写少场景下的性能问题如果写操作非常频繁缓存的维护成本可能会超过它带来的收益。这一点在后面讲适用场景的时候会详细展开。2.3 二级缓存和分布式缓存的关系这里需要澄清一个容易混淆的概念。二级缓存和分布式缓存比如Redis、Memcached不是一回事但它们可以配合使用。二级缓存通常指的是应用进程内的缓存数据存在JVM的堆内存里访问速度极快纳秒级别。分布式缓存则是独立部署的缓存服务数据存在远程服务器上访问需要走网络速度是毫秒级别但容量更大、可以跨多个应用实例共享。那为什么还要用二级缓存因为进程内缓存的访问速度是分布式缓存比不了的。对于极热的数据走进程内缓存可以省掉网络开销。常见的做法是一级缓存会话级→ 二级缓存进程内→ 分布式缓存跨进程→ 数据库。每一层都有它的位置二级缓存处在中间承上启下。3. 二级缓存的核心机制与关键参数3.1 缓存的生命周期管理二级缓存的生命周期管理是它最核心的机制之一。和一级缓存跟着会话走不同二级缓存需要自己管理什么时候创建、什么时候销毁、什么时候失效。创建时机通常有两种一种是懒加载第一次查询的时候才把数据放进缓存另一种是预加载应用启动的时候就把热点数据加载进来。懒加载实现简单但第一次查询会有延迟预加载适合数据量可控、热点明确的场景但启动时间会变长。销毁时机更复杂。常见的有几种策略基于时间的过期策略TTL比如设置三十分钟过期基于容量的淘汰策略LRU、LFU缓存满了就淘汰最久未使用或最少使用的数据基于事件的失效策略当数据被修改时主动清除对应的缓存。我个人的经验是TTL和事件失效要结合使用。光靠TTL数据更新后要等过期才能生效实时性太差光靠事件失效如果某个更新事件没触发或者触发失败缓存就永远脏了。两者结合TTL作为兜底事件失效作为主要手段这样既保证了实时性又有容错。3.2 缓存键的设计原则缓存键的设计直接决定了缓存的命中率和维护成本。设计得不好要么命中率低要么容易冲突要么更新的时候找不到该删哪些键。最基本的原则是唯一性。同一个查询条件必须对应同一个键不同的查询条件必须对应不同的键。比如查询用户信息键可以是“user:123”其中123是用户ID。如果查询条件复杂一点比如“查询年龄大于十八岁的用户列表按注册时间排序第一页”那键就要把这些条件都编码进去。但键也不是越细越好。太细的键会导致缓存碎片化管理成本高而且容易漏掉某些组合。我见过一个项目缓存键设计得特别细结果更新一个用户信息的时候要删除十几种不同组合的键代码里到处是删除逻辑维护起来非常痛苦。一个实用的建议是对于列表查询可以按“维度分页”来设计键比如“article:list:category:1:page:1”。对于详情查询按“实体类型ID”来设计比如“article:detail:456”。更新的时候详情键直接删列表键可以按维度批量删或者等TTL过期。3.3 数据一致性的保证策略数据一致性是二级缓存最让人头疼的问题。数据库里的数据改了缓存里的数据还是旧的读出来就是脏数据。怎么保证一致性业界有几种常见的策略。第一种是“先更新数据库再删除缓存”。这是最常用的做法。更新数据库成功后把对应的缓存删掉下次查询的时候自然会从数据库加载最新数据。这个策略的优点是实现简单缺点是如果删除缓存失败就会有一段时间的脏数据。为了降低风险可以加上重试机制或者把删除操作放到消息队列里异步执行。第二种是“先删除缓存再更新数据库”。这个策略在并发场景下有问题删除缓存后、更新数据库前如果有请求进来会把旧数据重新加载到缓存里然后数据库才更新导致缓存里一直是旧数据。所以这个策略通常需要配合延迟双删也就是更新完数据库后延迟一段时间再删一次缓存。第三种是“更新数据库的同时更新缓存”。这个策略看起来最直接但实际用起来问题很多。如果更新缓存失败数据就不一致了如果并发更新缓存的写入顺序可能和数据库不一致。所以除非有特殊的业务需求一般不推荐这种做法。我自己的项目里用的最多的是第一种策略配合消息队列做异步删除和重试。实测下来在绝大多数场景下都能满足一致性要求极端情况下的短暂不一致也可以接受。4. 二级缓存的实操落地过程4.1 环境准备与依赖引入假设我们用的是Java技术栈ORM框架选MyBatis二级缓存用Ehcache或者Caffeine作为进程内缓存实现。这里以Caffeine为例因为它的性能和API都比较友好。首先在项目的依赖管理文件里引入Caffeine和MyBatis的Caffeine适配包。版本选择上Caffeine建议用2.x或3.xMyBatis用3.5以上。具体的依赖坐标这里不展开按照官方文档引入即可。然后在MyBatis的配置文件里开启二级缓存。默认情况下MyBatis的二级缓存是关闭的需要在映射文件里显式声明。比如在Mapper XML的顶部加上一个cache标签指定缓存实现类和相关参数。cache typecom.example.CaffeineCache evictionLRU flushInterval1800000 size1024 readOnlyfalse/这里的参数含义eviction是淘汰策略flushInterval是刷新间隔毫秒size是最大缓存对象数readOnly表示缓存是否只读。如果设为true缓存对象不能被修改性能更好但灵活性差设为false每次读取会返回缓存对象的副本安全但多一次序列化开销。4.2 缓存配置的参数调优参数调优是二级缓存落地过程中最需要经验的部分。配得太小命中率低配得太大内存占用高还可能触发频繁的Full GC。先说size。这个值取决于你的应用能拿出多少堆内存给缓存以及热点数据的规模。我的经验是先估算热点数据的条数和每条数据的大小然后留出百分之三十的余量。比如热点数据大概有一万条每条平均两KB那就是二十MB左右size设成一万三到一万五比较合适。再说flushInterval。这个值决定了缓存的最长存活时间。设得太短缓存频繁失效命中率下降设得太长数据更新后要等很久才能生效。一般建议设在十五分钟到一小时之间具体看业务对实时性的要求。如果业务要求秒级实时那flushInterval可以设短一点但更推荐用事件失效来保证实时性flushInterval作为兜底。还有一个容易被忽略的参数是缓存的序列化方式。如果readOnly设为false缓存对象需要序列化和反序列化。默认的Java序列化性能很差建议换成Protostuff或者Kryo。我实测过换成Protostuff之后序列化耗时从平均零点五毫秒降到了零点零五毫秒提升了一个数量级。4.3 代码层面的缓存读写配置好之后代码层面的读写其实很简单。MyBatis的二级缓存是透明的你不需要手动调用put和get框架会自动处理。查询的时候MyBatis会先查二级缓存命中就直接返回没命中再查数据库然后把结果放进缓存。但有几个细节需要注意。第一只有同一个命名空间下的查询才会共享二级缓存。如果你的Mapper有多个XML文件它们之间的缓存是不互通的。第二二级缓存默认使用SerializedCache要求缓存对象实现Serializable接口。如果你的实体类没有实现这个接口会报错。第三事务提交后缓存才会更新。在事务未提交之前缓存里的数据还是旧的这是为了保证事务的隔离性。如果你需要手动控制缓存比如在某些更新操作后主动清除缓存可以在Mapper接口里加一个方法然后在XML里配置flushCachetrue。这样每次调用这个方法都会清空当前命名空间下的所有缓存。update idupdateArticle flushCachetrue UPDATE article SET title #{title} WHERE id #{id} /update4.4 缓存命中率的监控缓存上线之后必须监控命中率。命中率太低说明缓存没起到作用白白浪费内存命中率突然下降可能是数据更新频繁或者缓存配置出了问题。MyBatis的二级缓存提供了统计功能可以在配置里开启。开启之后通过Cache的getHitRatio方法可以拿到命中率。但更实用的做法是接入监控系统把命中率、缓存大小、加载耗时等指标定期上报。我一般会关注三个指标命中率、平均加载耗时、缓存淘汰速率。命中率低于百分之七十就要考虑调整缓存策略了平均加载耗时突然变长可能是数据库慢了或者缓存序列化出了问题淘汰速率过高说明缓存容量不够需要扩容或者优化键的设计。5. 常见问题与排查技巧实录5.1 缓存穿透、击穿、雪崩的应对这三个问题是缓存领域的经典问题二级缓存同样会遇到。缓存穿透是指查询一个数据库里也不存在的键缓存里没有数据库里也没有每次请求都打到数据库。解决办法是在缓存里存一个空值并设置较短的过期时间。这样下次再查这个键缓存直接返回空不会打到数据库。缓存击穿是指某个热点键突然失效大量请求同时打到数据库。解决办法是加互斥锁只让一个请求去数据库加载其他请求等待。或者用逻辑过期缓存不设物理过期时间而是存一个逻辑过期时间发现过期后异步更新期间返回旧数据。缓存雪崩是指大量缓存键同时失效请求全部打到数据库。解决办法是给过期时间加随机值避免同时失效。比如原本统一设三十分钟改成二十五到三十五分钟之间的随机值。5.2 缓存与数据库不一致的排查不一致的表现是数据库里明明改了但接口返回的还是旧数据。排查的时候先确认更新操作有没有触发缓存清除。可以在更新方法里加日志看flushCache有没有执行。如果执行了再看缓存清除的范围对不对是不是漏了某些键。还有一种情况是并发导致的不一致。比如请求A更新数据库请求B同时查询。如果请求B在请求A删除缓存之前把旧数据加载到了缓存里那缓存里就是旧数据。这种问题比较难排查需要看日志的时间线。解决办法是延迟双删更新完数据库后等一小段时间再删一次缓存。5.3 内存溢出与GC问题二级缓存用的是堆内存如果缓存的数据太多或者对象太大很容易导致内存溢出。表现是应用运行一段时间后频繁Full GC甚至OOM。排查的时候先看缓存的实际大小和配置的size是否匹配。如果实际大小远超配置值可能是缓存实现有问题或者有地方在往缓存里塞数据但没走正常的淘汰流程。再看缓存对象的大小如果单个对象特别大比如包含大文本或者大列表可以考虑拆分缓存只缓存关键字段。调优的方向一是减小size二是换用堆外缓存比如Ehcache的offheap存储三是优化缓存对象的结构减少不必要的字段。5.4 常见问题速查表问题现象可能原因排查方法解决思路命中率持续偏低缓存键设计不合理、TTL太短、数据更新频繁查看缓存统计、分析查询日志优化键设计、延长TTL、评估是否适合用缓存数据不一致更新后未清缓存、并发导致旧数据回填检查flushCache配置、查看更新日志时间线加延迟双删、用消息队列保证清除可靠性频繁Full GC缓存对象太大、size配置过大分析堆转储、查看缓存实际大小减小size、换堆外缓存、优化对象结构缓存加载耗时变长序列化性能差、数据库慢监控加载耗时、检查序列化方式换高性能序列化、优化数据库查询应用启动变慢预加载数据量太大查看启动日志、统计预加载耗时改为懒加载、减少预加载数据量6. 二级缓存的适用边界与替代方案6.1 什么场景适合用二级缓存二级缓存最适合读多写少、数据量可控、对一致性要求不是特别苛刻的场景。比如内容管理系统的文章详情、电商平台的商品分类、配置管理系统的配置项。这些数据的特点是读的频率远高于写的频率数据量不会无限增长短暂的不一致可以接受。反过来如果写操作非常频繁比如实时聊天消息、秒杀库存那二级缓存就不太合适。写太频繁缓存刚放进去就失效了命中率极低还增加了维护成本。这种场景更适合用分布式缓存加更细粒度的一致性控制。6.2 什么时候该考虑替代方案如果你的应用是多实例部署的二级缓存只在单个实例内有效多个实例之间的缓存不互通。这时候可以考虑用分布式缓存替代或者用二级缓存加分布式缓存的组合方案。如果数据量特别大比如上亿条记录进程内缓存放不下那就只能用分布式缓存。如果对一致性要求极高比如金融交易数据那缓存可能就不适合直接读数据库更稳妥。6.3 我个人的选型经验我一般会按这个顺序来判断先看数据量和读写比如果读远大于写且数据量在单机内存可承受范围内优先考虑二级缓存如果数据量超出单机内存或者需要多实例共享那就上分布式缓存如果一致性要求极高那就放弃缓存直接读库用数据库的读写分离和索引优化来扛。还有一点二级缓存和分布式缓存不是互斥的。我做过一个项目用二级缓存扛住单实例内的热点读用分布式缓存做跨实例的数据共享效果很好。二级缓存的TTL设短一点比如五分钟分布式缓存的TTL设长一点比如三十分钟。这样即使二级缓存失效了还可以从分布式缓存拿不用直接打到数据库。7. 一些踩过的坑和实操心得二级缓存的配置里readOnly这个参数我踩过坑。一开始为了性能设成了true结果发现缓存里的对象被修改后其他请求读到的也是修改后的数据导致业务逻辑出错。后来改成false虽然多了一次序列化开销但数据安全了。如果你的缓存对象在业务代码里会被修改千万别设readOnly为true。还有一个坑是缓存的序列化。默认的Java序列化要求实体类实现Serializable接口而且serialVersionUID要一致。有一次我改了一个实体类的字段忘了更新serialVersionUID结果反序列化失败缓存全部失效数据库瞬间被打满。后来我养成了习惯每次改实体类都检查serialVersionUID或者干脆用不依赖serialVersionUID的序列化方案。事务和缓存的配合也要注意。在Spring管理的事务里如果事务还没提交缓存是不会更新的。这意味着在事务内查询拿到的可能是旧数据。如果业务逻辑依赖事务内的数据一致性要么把缓存操作放到事务提交后要么在事务内直接读数据库不走缓存。最后分享一个小技巧给缓存键加一个版本号。比如“user:123:v2”当实体结构发生变化时把版本号加一这样旧版本的缓存自然失效不用手动清理。这个技巧在灰度发布和回滚的时候特别有用。缓存这件事说到底是在性能和一致性之间找平衡。没有完美的方案只有适合当前业务的方案。多监控、多压测、多复盘慢慢就能找到那个平衡点。