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.x,MyBatis用3.5以上。具体的依赖坐标这里不展开,按照官方文档引入即可。
然后在MyBatis的配置文件里开启二级缓存。默认情况下,MyBatis的二级缓存是关闭的,需要在映射文件里显式声明。比如在Mapper XML的顶部加上一个cache标签,指定缓存实现类和相关参数。
<cache type="com.example.CaffeineCache" eviction="LRU" flushInterval="1800000" size="1024" readOnly="false"/>这里的参数含义: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里配置flushCache="true"。这样每次调用这个方法,都会清空当前命名空间下的所有缓存。
<update id="updateArticle" flushCache="true"> UPDATE article SET title = #{title} WHERE id = #{id} </update>4.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”,当实体结构发生变化时,把版本号加一,这样旧版本的缓存自然失效,不用手动清理。这个技巧在灰度发布和回滚的时候特别有用。
缓存这件事,说到底是在性能和一致性之间找平衡。没有完美的方案,只有适合当前业务的方案。多监控、多压测、多复盘,慢慢就能找到那个平衡点。