微服务多级缓存方案:基于RuoYi-Cloud的Caffeine+Redis实战

发布时间:2026/10/21 2:17:05
微服务多级缓存方案:基于RuoYi-Cloud的Caffeine+Redis实战 微服务拆得越细缓存问题就越突出。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前面再兜一层本地缓存。另一个隐藏问题是缓存热点。比如一个运营位配置数据在某个时间点突然涌入几千个请求这些请求全部打到RedisRedis的QPS瞬间飙高虽然能扛住但是线程阻塞和网络重传会拖垮整体吞吐。本地缓存则恰好可以挡掉这部分流量只有本地没有时才回源到Redis。1.2 多级缓存的整体分层设计我最终采用的方案是经典的三级结构Caffeine本地缓存作为L1Redis作为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-TinyLFUCaffeine的淘汰策略要理解W-TinyLFU先要理解老牌淘汰算法的局限性。LRU的缺点是一个数据只要最近被访问过就认为它是热点但如果有一段突发流量涌入一批一次性数据这批冷数据就会把真正的热数据挤出去造成缓存污染。LFU的缺点则相反它统计历史访问频率但一个曾经很热、现在过气的key会一直占着空间无法让位给新晋热点。W-TinyLFU把缓存空间分成两段一段用来记录频率统计信息通过Count-Min Sketch这种概率数据结构只需要很小的内存就能近似统计海量key的访问频率一段是实际的缓存窗口。同时设计了一个“准入窗口”新数据先进这个小窗口用LRU方式管理如果它在窗口期内被访问多次才进入主缓存段。这样突发但真实的热点能快速上位一次性的冷流量又不会污染主缓存。这套机制放在RuoYi-Cloud这种业务场景里最直观的感受就是早高峰突然大量查询某个活动配置时这个key能在极短时间内进入主缓存段并稳定命中而那种偶发扫描全表的接口即使短时间内被打了几百次也不会把用户信息这种长期热点挤掉。2.2 常用参数实战maximumSize、expireAfterWrite与refreshAfterWriteCaffeine的配置参数看着简单真正设好需要结合业务形态。我当前项目里用的核心配置是这样的Configuration public class CaffeineConfig { Bean(localCache) public CacheString, 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 LoadingCacheString, SysConfig loadingCache() { return Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .refreshAfterWrite(Duration.ofMinutes(3)) .build(new CacheLoaderString, 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 CacheString, Object localCache; private final RedisTemplateString, 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 CacheString, 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 效果对比与数据解读在一个代表性接口上的实测数据如下该测试环境的参考值方案平均响应时间TP99QPS数据库QPSRedis QPS无缓存128ms420ms8508500仅Redis30ms86ms22001602200多级缓存(CaffeineRedis)9ms23ms320090650解读这三组数据的重点在于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里最适合多级缓存的就是字典、参数、菜单、角色权限、用户基础信息、低频但热门的业务配置这几类。我见过有同学把分页查询结果整个丢进Caffeinekey是页码和查询条件拼接的字符串。这种做法看起来优雅但查询条件稍微一变key的组合几乎是无限的命中率极低内存还白白占着。正确的做法是只缓存结果对象列表的公共部分或者干脆只在Redis层缓存并设置很短TTL。6. 最后再分享一点实战体会很多人把缓存看成性能优化兜底手段我做了这一轮改造之后反而觉得缓存是架构设计的一等公民。Caffeine和Redis组合的多级缓存核心不是把某个接口从120ms优化到9ms而是把整个系统的数据库压力降下来、把流量尖峰削掉让系统在突发流量下不会雪崩。实际推动这个方案落地时最有用的不是技术本身而是数据。你要用压测数据告诉团队Redis那层省了什么Caffeine这层又省了什么每层的命中率和回源量是多少。我建议上线后持续盯一周的缓存命中率和回源延迟第一周最能暴露配置参数是否合理。如果你正在RuoYi-Cloud上做类似改造我的建议很简单先统一封装CacheService再改造一两个高频接口验证效果确认数据达标后再全面铺开。同时一定保留一套可以一键关闭本地缓存的开关线上出问题时可以快速降级回到Redis方案。这套方案我已经稳定跑了半年后续如果再深入我会把缓存预热和热点预测做得更智能一些比如利用日志统计自动生成热点key清单让每个实例的本地缓存始终装的是真正被高频访问的数据。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询