微服务拆得越细,缓存问题就越突出。RuoYi-Cloud这套框架把认证、网关、系统管理、业务模块全拆成独立服务后,服务之间的调用链路变长,数据库压力没降反升的情况很常见。我把Caffeine本地缓存和Redis二级缓存组合起来,做了一套多级缓存方案,压测数据从接口平均耗时120ms降到20ms左右,QPS提升接近4倍。这篇内容会把这套方案的选型逻辑、配置细节、封装方式和排坑过程完整拆开讲,适合正在做微服务性能优化、或者想在RuoYi-Cloud基础上改造缓存的同学参考。
1. 微服务下的缓存困局与破局思路
1.1 从调用链路看为什么Redis还不够快
一个典型的RuoYi-Cloud请求链路可能是这样的:网关网关层做签名校验和路由转发,然后请求打到认证服务校验令牌,再转发到系统服务查用户权限,最后落到业务服务查业务数据。如果业务服务再去查数据库,这条链路里数据库就是最大的瓶颈。很多人第一反应是上Redis,把热点数据丢进集中式缓存,确实能缓解数据库压力。
但Redis不是银弹。关键在于,Redis再快也是网络IO,本地回环还好,如果是跨机房、跨交换机,单次读Redis的RT可能在1ms到3ms之间。每次请求在链路里查个三五次Redis,光网络开销就达到5ms以上。而在进程内直接读本地缓存,纳秒级到微秒级,差距根本不是同一个数量级。这就是为什么我在看了压测报告之后,决定在Redis前面再兜一层本地缓存。
另一个隐藏问题是缓存热点。比如一个运营位配置数据,在某个时间点突然涌入几千个请求,这些请求全部打到Redis,Redis的QPS瞬间飙高,虽然能扛住,但是线程阻塞和网络重传会拖垮整体吞吐。本地缓存则恰好可以挡掉这部分流量,只有本地没有时才回源到Redis。
1.2 多级缓存的整体分层设计
我最终采用的方案是经典的三级结构:Caffeine本地缓存作为L1,Redis作为L2,数据库作为兜底存储。
读请求的路径是:先查L1 Caffeine,命中直接返回;未命中查L2 Redis,命中则回填L1并返回;仍未命中则查数据库,回填L2和L1,再返回。写请求的路径是:先更新数据库,然后删除Redis中的对应key,同时通过消息广播通知其他服务实例失效自己本地的Caffeine缓存。
这样设计的原因很直接。L1解决的是进程内高频访问的速度问题,L2解决的是多实例间的缓存共享与一致性底线问题,数据库则是所有数据最终一致性的保障。没有L2的纯本地缓存方案在分布式环境里会出大问题,没有L1的纯Redis方案又解决不了网络开销和热点击穿,所以两级必须同时存在。
关于一致性,这里必须强调一点:多级缓存永远做不到强一致。只要本地缓存存在,就会有某个实例上是旧数据而其他实例是新数据的窗口期。我们要做的是把窗口期缩到业务可以接受的范围内,比如秒级甚至百毫秒级。这是我的核心设计原则。
1.3 为什么选用Caffeine作为本地缓存层
本地缓存选型时,我把当时的几个主流方案都过了一遍:Guava Cache、Caffeine、Ehcache、自己写Map加定时清理。
淘汰掉Guava Cache的原因很简单:Caffeine就是它的升级版,基于Java 8重写了内核,命中率、并发性能都更好,而且API风格几乎一致,迁移成本极低。Ehcache功能很全但偏重,支持磁盘存储、分布式同步这些,但在这个场景里我们根本不需要,反而增加维护成本。自己写Map加定时任务纯粹是不归路,并发控制、淘汰算法、统计监控都得自己造轮子,不值得。
Caffeine的真正核心优势是它在淘汰算法上的实现。默认采用W-TinyLFU,这是一种结合了LRU和LFU思想的近似频率统计算法,既能识别突发热点(LRU擅长的事),又能保护长期高热数据不被短时间大量冷数据挤出(LFU擅长的事)。在真实业务流量下,Caffeine的命中率通常比Guava Cache高出不少。
2. Caffeine核心要点解析与配置
2.1 W-TinyLFU:Caffeine的淘汰策略
要理解W-TinyLFU,先要理解老牌淘汰算法的局限性。LRU的缺点是,一个数据只要最近被访问过就认为它是热点,但如果有一段突发流量涌入一批一次性数据,这批冷数据就会把真正的热数据挤出去,造成缓存污染。LFU的缺点则相反,它统计历史访问频率,但一个曾经很热、现在过气的key会一直占着空间,无法让位给新晋热点。
W-TinyLFU把缓存空间分成两段,一段用来记录频率统计信息(通过Count-Min Sketch这种概率数据结构,只需要很小的内存就能近似统计海量key的访问频率),一段是实际的缓存窗口。同时设计了一个“准入窗口”,新数据先进这个小窗口,用LRU方式管理,如果它在窗口期内被访问多次,才进入主缓存段。这样突发但真实的热点能快速上位,一次性的冷流量又不会污染主缓存。
这套机制放在RuoYi-Cloud这种业务场景里,最直观的感受就是:早高峰突然大量查询某个活动配置时,这个key能在极短时间内进入主缓存段并稳定命中;而那种偶发扫描全表的接口,即使短时间内被打了几百次,也不会把用户信息这种长期热点挤掉。
2.2 常用参数实战:maximumSize、expireAfterWrite与refreshAfterWrite
Caffeine的配置参数看着简单,真正设好需要结合业务形态。我当前项目里用的核心配置是这样的:
@Configuration public class CaffeineConfig { @Bean("localCache") public Cache<String, Object> localCache() { return Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(); } }maximumSize建议根据实例内存和业务key总量来定。比如一个系统管理服务里有10万左右的字典、参数、用户基础信息key,单实例堆内存4GB,那么2万个key的缓存总量比较合理,每个value对象平均几千字节的话,总占用在100MB以内。设得太大会挤占业务堆内存,设得太小命中率上不去。
expireAfterWrite设的是写入后固定过期时间,这是本地缓存的“安全底线”,保证即使没有收到失效通知,数据也会在10分钟后自动过期,防止内存里堆积永久脏数据。refreshAfterWrite配合异步加载后,行为会变成:5分钟之后第一个请求触发刷新,但返回的是旧值,后台重新加载新值替换。这样做的最大好处是没有阻塞,不会出现缓存过期瞬间大量线程同时回源导致击穿。
这里有一个非常重要的点:refreshAfterWrite只有在构建缓存时指定了CacheLoader(加载函数)才会生效。如果只是用get(key, function)这种手动方式,刷新机制是不会触发的。所以我通常单独定义带CacheLoader的LoadingCache用于热点数据预热:
@Bean("loadingCache") public LoadingCache<String, SysConfig> loadingCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(3)) .build(new CacheLoader<String, SysConfig>() { @Override public SysConfig load(String key) { return sysConfigMapper.selectByKey(key); } }); }2.3 从数据统计看缓存健康度
只用缓存不看统计,等于蒙眼开车。Caffeine内置了recordStats指标,打开之后可以拿到命中率、加载耗时、淘汰数量等一系列数据。我封装了一个缓存监控接口,每五分钟抓一次统计数据存入Redis,再用定时任务汇总到日志或监控面板:
CacheStats stats = localCache.stats(); long hitCount = stats.hitCount(); long missCount = stats.missCount(); double hitRate = stats.hitRate();判断缓存是否健康的几个经验阈值:命中率低于50%说明配置太小或者缓存粒度不对;平均加载时间忽然变高说明回源数据库开始变慢,需要考虑SQL优化;淘汰数突然暴涨说明流量模式变化,可能出现了瞬时热点冲击。我在过程中就靠这些指标定位过一次字典缓存配置过小导致命中率只有40%的问题。
注意:recordStats会带来少量额外内存和计算开销,但相比它提供的可观测性,这点消耗完全值得。生产环境建议必须打开。
3. RuoYi-Cloud场景下的落地实战
3.1 缓存层的工具封装与统一入口
在微服务架构里,我不建议每个业务代码直接注入Caffeine或RedisTemplate,那样缓存调用会散落各处,改一个策略要动几十个文件。我的做法是做一个统一缓存服务CacheService,内部封装多级缓存的读写逻辑。
@Service public class CacheService { private final Cache<String, Object> localCache; private final RedisTemplate<String, Object> redisTemplate; public Object get(String key) { Object value = localCache.getIfPresent(key); if (value != null) { return value; } value = redisTemplate.opsForValue().get(key); if (value != null) { localCache.put(key, value); return value; } return null; } public void set(String key, Object value, Duration ttl) { redisTemplate.opsForValue().set(key, value, ttl); localCache.put(key, value); } public void invalidate(String key) { redisTemplate.delete(key); localCache.invalidate(key); } }这套封装代码虽然不复杂,但把整个缓存策略收敛到了一个类里,后续无论是调整本地缓存过期时间,还是加缓存空值保护,都只需要改这一个地方。业务代码只依赖CacheService,测试也好做。
3.2 字典、参数配置的缓存改造实例
RuoYi-Cloud里最典型的缓存场景就是字典数据和系统参数。原来的逻辑是每次调用都从数据库查,接口并发一高,数据库连接池就被拖垮。我把这个场景做了拆解改造。
改造后读取逻辑:先从Caffeine读字典列表,没有则查Redis,还没有则查数据库并回填。由于字典表的数据量通常只有几百条,我会在服务启动时做一次全量预热,把常用字典组直接加载到Caffeine中,让线上请求几乎没有冷启动。
参数配置类数据有一个特点,就是读多写极少,但修改后台配置后用户期望立即生效。这时就需要主动失效。RuoYi-Cloud有配置修改接口,在这个接口里加上CacheService.invalidate(dictKey)的调用即可。如果配置在多个服务中被引用,则通过发送Redis消息或MQ广播,所有实例收到消息后各自失效本地缓存。
@PostMapping("/update") public AjaxResult update(@RequestBody SysConfig config) { sysConfigService.updateConfig(config); cacheService.invalidate("sys_config:" + config.getConfigKey()); redisTemplate.convertAndSend("cache.invalidate", "sys_config:" + config.getConfigKey()); return AjaxResult.success(); }收消息的监听器长这样:
@Component public class CacheInvalidateListener { @Resource private Cache<String, Object> localCache; @EventListener public void handleMessage(String key) { localCache.invalidate(key); } }3.3 缓存失效与一致性保障的取舍
做多级缓存的过程中,最费心思的不是怎么缓存,而是怎么失效。这里我有三层保障:第一层是Redis消息触发主动失效,这是最快的方式,目标在毫秒级内把所有实例的本地缓存清掉;第二层是Caffeine的expireAfterWrite到期自动失效,这是兜底机制,即使消息丢失最多也只会让脏数据存活10分钟;第三层是针对关键数据配置的定时对账任务,每隔一段时间扫描Redis中缓存数据与数据库是否一致,发现不一致就强制刷新。
主动失效的可靠性依赖消息通道,如果MQ挂了,数据不一致窗口期会拉长。我遇到过Redis不可用时消息发不出去的情况,所以对于用户昵称、头像这类脏数据容忍度极低的数据,干脆不走本地缓存,只使用Redis,或者设置极短的本地过期时间如30秒。而对菜单、字典这类容忍度高的数据,才放心使用10分钟过期。
缓存数据粒度的选择同样影响一致性。我建议对单个key做细粒度拆分,不要把一个用户的所有信息放在一个大JSON里。例如用户权限,按userId为维度缓存,不要缓存整个角色集合的权限树。否则角色一改,关联的所有用户缓存都要刷新,刷新成本成倍增加。
3.4 缓存预热与多实例流量分布策略
服务刚启动时Caffeine是空的,如果直接把流量切进来,所有请求同时回源Redis和数据库,可能造成瞬间压力。我做了两类预热。
启动预热:在服务启动完成并在注册中心上线前,用一个ApplicationRunner加载核心配置、字典、常用用户信息到Caffeine。一次性加载几百个key,耗时毫秒级,但省去了启动初期的穿透压力。
周期预热:配合定时任务,在低峰期把统计出的热点key列表(通过redisTemplate的keys或日志埋点收集)预热到各实例。这个操作在RuoYi-Cloud多实例部署时很有效,因为每个实例的本地缓存是独立的,如果负载均衡策略设置了ip_hash,甚至可以让每个实例只预热自己被路由到的热点数据。
多实例部署时还有一个细节:不同实例的Caffeine大小尽量一致。如果一台实例内存大配了5万上限,另一台只有2万,那么命中率不一致会造成响应时间抖动明显。尽量保持实例配置统一,后续排查问题会简单很多。
4. 性能压测与优化效果
4.1 压测方案与指标确定
我用的压测工具是JMeter,模拟的典型场景是并发200线程、持续压测10分钟,请求一个经过网关和认证服务的业务查询接口。压测环境是3台业务实例(每台4核8G)+ 1台Redis + 1台数据库,数据量为字典500条、系统参数200条、用户基础信息10万条。
基线指标先测一遍无缓存的情况,再逐步加Redis缓存,最后加Caffeine本地缓存形成多级结构。每轮都需要观察平均响应时间、TP99、QPS、数据库连接池使用率、Redis每秒操作数这五项数据。这套对比数据能清楚回答一个关键问题:多级缓存的性能收益到底来自哪一层。
4.2 效果对比与数据解读
在一个代表性接口上的实测数据如下(该测试环境的参考值):
| 方案 | 平均响应时间 | TP99 | QPS | 数据库QPS | Redis QPS |
|---|---|---|---|---|---|
| 无缓存 | 128ms | 420ms | 850 | 850 | 0 |
| 仅Redis | 30ms | 86ms | 2200 | 160 | 2200 |
| 多级缓存(Caffeine+Redis) | 9ms | 23ms | 3200 | 90 | 650 |
解读这三组数据的重点在于Redis QPS的大幅下降。多级缓存方案下Redis操作量从2200降到650,说明大约70%的请求直接在本地Caffeine命中,根本没走到Redis。这部分请求的平均延迟从30ms降到9ms,正是消除了网络IO的开销。
从数据库侧看,QPS从850降到90,连接池压力完全释放。在业务高峰期,数据库CPU从85%降到了30%以下,这才是缓存改造最核心的收益。数据库不堵了,整个链路的稳定性和扩展性才有保障。
4.3 参数调优记录
第一轮压测时maximumSize我设的是5000,结果命中率只有62%。分析发现用户基础信息这个key时,热点用户比较分散,5000容量装不下10万用户数据里的高频访问集。调整到20000后命中率提升到86%,TP99从35ms降到23ms。
第二轮压测发现refreshAfterWrite设为1分钟时,后台刷新任务触发频繁,数据库压力有所上升。改到3分钟后,既保证了数据不会太陈旧,又明显降低了回源频率。所以刷新间隔并不是越短越好,需要结合数据变更频率和数据库承载能力来权衡。
第三轮是给Redis里的key设置差异化TTL。热点字典设10分钟,用户信息设30分钟,一次性读取的业务参数设1分钟。这样即使用户信息因为异常原因没有主动失效,最多也就脏30分钟,而低频参数能快速过期释放Redis内存。
5. 常见问题与排查实录
5.1 本地缓存数据不一致
多级缓存最常被问到的就是:本地缓存里是旧数据怎么办。我排查过这样一个案例:管理员改了系统参数,数据库和Redis都更新成功,但页面数据还是老的。查日志发现消息通知组件在Redis故障重连期间丢失了一条缓存失效消息,导致部分实例的Caffeine一直缓存旧值直到过期窗口结束。
解决思路分两层。第一层是消息可靠性的增强,把缓存失效动作也记录为一条数据库操作日志,由定时任务重放,确保可靠性;第二层是缩短危险窗口,对这类配置类数据把本地过期时间从10分钟缩到3分钟,脏数据存活期也就控制在3分钟内。
实际过程中还会遇到一个更隐蔽的不一致:服务A修改了共享数据,但它只发消息,服务B在消息到达前自己又读了一次旧数据回填到本地Caffeine,结果反而把旧值缓存起来了。这个问题的处理办法是在回填之前再做一次Redis判断,如果Redis里已有新值就直接读取新值,不要用数据库的旧值回填。
5.2 缓存击穿、雪崩与穿透防护
多级缓存降低的不只是延迟,它还天然分担了部分风险。Redis缓存过期瞬间,如果没有本地缓存,大量请求会同时穿透到数据库;有了Caffeine之后,由于本地缓存过期时间通常比Redis短,而且各实例的过期时刻错开,数据库面临的冲击会小很多。
但还要手工做三重防护。空值缓存防穿透:查询数据库结果为null的key,也要在Redis里放一个空值占位,TTL设置60秒,防止恶意请求反复穿透。随机TTL防雪崩:Redis的key过期时间不要设成整点整分,在基础TTL上随机加几十秒,让过期时刻散开。互斥锁防击穿:单机内用Caffeine自带的同步加载天然带锁,跨实例则用Redis分布式锁限制同一时刻只能有一个请求回源数据库。
我构造过一次最坏的场景测试:删掉所有缓存,同时发5000个请求打同一个key。无任何防护时数据库瞬间收到5000个并发查询;加上互斥锁后,实际回源到数据库的请求只有2个,其他全部等待缓存回填后读取。效果非常显著。
5.3 序列化与内存占用的坑
Redis缓存value的序列化方案是个容易被忽略的坑。默认的JDK序列化虽然能用,但存储体积大、有安全漏洞风险,而且跨语言不友好。我在RuoYi-Cloud里统一改成了Jackson序列化,配置RedisTemplate的valueSerializer为GenericJackson2JsonRedisSerializer。
这里有一个细节:如果value序列化JSON后,反序列化时泛型信息会丢失,直接从Redis取出一个List 会变成List edhashmap> ,强转时报类型异常。我的解决办法是封装一个带TypeReference的读取方法,或者在缓存对象的顶层包一层统一Result对象,避免复杂的泛型结构。
另一个内存占用问题是本地缓存的对象引用。Caffeine缓存持有了对象引用,如果业务代码把缓存对象直接修改了,会发生连锁脏数据。我实践时的习惯是:存入缓存前做一次深拷贝,或者要求业务对象只读。尤其注意,从Caffeine取出的对象不要直接把它当成待更新对象传给Mapper,否则改完数据库后,本地缓存里的对象也被改了,而这个修改不会被追踪到。
5.4 复盘:什么样的数据适合多级缓存
不是所有数据都能无脑放Caffeine。我总结了一套筛选标准:访问频率高、数据量可控、一致性容忍度适中、单条数据序列化后体积不大。满足这些条件的数据才能真正发挥多级缓存的优势。
不适合多级缓存的典型数据包括:频繁更新的库存数据,一致性要求过高,本地缓存反而成为负担;单条超过1MB的大文本内容,占用本地堆内存太多,性价比极低;一次性访问的临时数据,缓存了也只是占空间。RuoYi-Cloud里最适合多级缓存的就是字典、参数、菜单、角色权限、用户基础信息、低频但热门的业务配置这几类。
我见过有同学把分页查询结果整个丢进Caffeine,key是页码和查询条件拼接的字符串。这种做法看起来优雅,但查询条件稍微一变,key的组合几乎是无限的,命中率极低,内存还白白占着。正确的做法是只缓存结果对象列表的公共部分,或者干脆只在Redis层缓存并设置很短TTL。
6. 最后再分享一点实战体会
很多人把缓存看成性能优化兜底手段,我做了这一轮改造之后反而觉得,缓存是架构设计的一等公民。Caffeine和Redis组合的多级缓存,核心不是把某个接口从120ms优化到9ms,而是把整个系统的数据库压力降下来、把流量尖峰削掉,让系统在突发流量下不会雪崩。
实际推动这个方案落地时最有用的不是技术本身,而是数据。你要用压测数据告诉团队,Redis那层省了什么,Caffeine这层又省了什么,每层的命中率和回源量是多少。我建议上线后持续盯一周的缓存命中率和回源延迟,第一周最能暴露配置参数是否合理。
如果你正在RuoYi-Cloud上做类似改造,我的建议很简单:先统一封装CacheService,再改造一两个高频接口验证效果,确认数据达标后再全面铺开。同时一定保留一套可以一键关闭本地缓存的开关,线上出问题时可以快速降级回到Redis方案。这套方案我已经稳定跑了半年,后续如果再深入,我会把缓存预热和热点预测做得更智能一些,比如利用日志统计自动生成热点key清单,让每个实例的本地缓存始终装的是真正被高频访问的数据。