Spring Boot 多级缓存实战:Caffeine + Redis 架构设计与性能调优

Spring Boot 多级缓存实战:Caffeine + Redis 架构设计与性能调优 1. 为什么需要多级缓存一次接口超时引发的思考先聊一个真实场景。之前我维护过一个商品详情接口QPS 高峰能冲到 3000 左右业务逻辑本身不算复杂就是从库里查商品信息、库存、优惠券再拼装返回。最开始图省事所有查询都走 Redis缓存命中率也确实挺高大部分请求都能在 5ms 内返回。但到了大促压测的时候问题就暴露了——接口的 TP99 从 8ms 一路涨到 60ms 左右偶尔还会出现一批 200ms 以上的尖刺请求。排查下来发现瓶颈不在数据库而在 Redis。每秒钟几千次请求每次都通过网络走一遍 Redis 的读写光序列化和网络往返的开销就非常可观。再加上有的热点 key 会被大量线程同时命中Redis 单线程模型下虽然单次操作微秒级但高并发下也会出现排队。更麻烦的是一旦某个 Redis 节点发生抖动所有依赖缓存的接口就像多米诺骨牌一样跟着遭殃。这时候我才意识到单靠 Redis 这一层分布式缓存是不够的。Redis 再快它也是一次网络 IO再稳定的网络也不可能做到进程内的内存访问速度。于是我开始研究多级缓存——把数据按访问频率和成本分成不同的层级热数据放在离应用最近的地方冷一点的数据放在 Redis数据库永远是兜底。多级缓存的思路其实不复杂就是一句话能不进网络的绝不进网络能不进数据库的绝不进数据库。具体到落地就是 Caffeine 做 JVM 进程内一级缓存Redis 做二级分布式缓存数据库做最后一道防线。这篇文章我把自己从设计到落地再到压测调优的完整过程写出来包括每一步为什么这么选、配置参数怎么定、哪些坑一定要避开应该能帮到正在做缓存方案选型或改造的朋友。2. Caffeine进程内缓存的关键角色2.1 Caffeine 是什么为什么选它很多做 Java 开发的朋友对本地缓存并不陌生——早期用 ConcurrentHashMap 手写过后来用 Guava Cache再后来 Hashmap Timer 自己搞过期策略的也见过不少。但 Caffeine 的出现基本把这些方案都替代掉了。Caffeine 是基于 Java 8 开发的高性能缓存库它的核心优势可以总结为三点第一底层数据结构和淘汰算法都做了深度优化性能比 Guava Cache 高出一截第二提供了基于容量的淘汰、基于时间的过期、异步刷新、统计指标等功能开箱即用第三它采用了一种叫 W-TinyLFU 的淘汰算法这个后面展开讲。我选 Caffeine 还有一层考量——Spring Boot 2.x 之后的默认本地缓存实现就是 CaffeineSpring Cache 抽象对它做了很好的适配。这意味着我既可以通过编程式 API 精细控制缓存行为也可以直接用 Cacheable 注解的方式快速接入两条路都通。2.2 W-TinyLFU 淘汰策略到底解决了什么问题传统缓存淘汰算法里最常见的是 LRU最近最少使用和 LFU最不经常使用。LRU 实现简单但有个明显问题——一次偶发的批量扫表操作可能把真正高频的数据全部挤出缓存这叫“缓存污染”。LFU 能记录访问频率但需要额外维护计数器而且如果数据访问模式发生了变化旧的频率统计会拖慢新热点数据的晋升速度。W-TinyLFU 的做法是结合两者把缓存空间分成 window 区和 main 区window 区比较小用 LRU 策略接纳新数据main 区则是核心存储区用 LFU 思路管理。新数据先进入 window 区如果在 window 区里被频繁访问才有机会晋升到 main 区main 区里频率太低的数据会被淘汰。这么一来高频数据能稳定留在缓存里新来的热点数据也能快速上位不会因为历史统计被卡住。这个算法对多级缓存的意义很大。因为一级缓存的空间是有限的JVM 堆就那么大如果淘汰策略不聪明容易出现“缓存里存的不是真正热门的数据”这种尴尬情况。Caffeine 在这一点上的表现实测下来确实比 Guava Cache 要好。2.3 Caffeine 的构建参数与推荐配置Caffeine 的用法非常直观最基础的一段构建代码是这样CacheString, ProductInfo productCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .build();但生产环境我一般不用这个基础写法而是用 LoadingCache 配合 refreshAfterWrite这样既能拿到自动加载的便利又能避免缓存集中失效的问题LoadingCacheString, ProductInfo productCache Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .refreshAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(key - loadFromRedisAndDb(key));这里有几个参数值得认真说maximumSize本地缓存最多放多少个 key。这个值不是越大越好要根据 JVM 堆内存来评估。比如服务堆 2GB一条数据平均 2KB那 1 万个 key 大约占 20MB可以接受。如果数据量特别大可以考虑 maximumWeight 结合 weigher 按数据大小来约束。expireAfterWrite写入后多久过期。这是兜底策略防止数据无限期停留在本地缓存里。对于一致性要求不高的读多场景比如商品详情、用户信息一般设 10 到 30 分钟比较合适。注意它和 refreshAfterWrite 不能设置成一样的时间否则刷新逻辑不会生效。refreshAfterWrite写入后多久触发异步刷新。这个参数非常关键——当缓存项到了刷新时间Caffeine 会让一个线程去加载新值加载期间其他线程拿到的是旧值而不是像 expireAfterWrite 那样直接干掉缓存。这个机制天然做到了“缓存永远不空”对防止缓存击穿很有用。recordStats开启统计配合 hitRate、missCount 这些指标可以实时观察本地缓存的命中情况这对调参非常重要。3. Redis 侧的准备数据结构与序列化方案3.1 数据结构选型能简单就别复杂多级缓存里 Redis 是二级缓存它面对的请求量级比本地缓存少一个数量级但仍然扛着很大压力。我在设计 Redis key 和数据结构时遵循一个原则**能用 String 就别用 Hash能用 Hash 就别用 ZSet。**越简单的数据结构读写开销越小出问题的概率也越低。拿商品信息举例。商品信息有几十个字段最自然的想法是存成 Hash按字段存取。这样确实灵活但有个代价——每次查询要取多个字段至少要一次 hgetall而且如果业务方每次需要的字段不完全一样还得在代码里组合。另一个方案是直接序列化成 JSON 字符串用 String 存储。我的经验是如果数据是一次性整体读出的场景比如详情页展示直接存 JSON 字符串最简单高效。如果字段频繁单独修改比如库存、价格这时候才考虑 Hash用 hget/hset 做局部更新。Redis 的 key 设计也要考虑清晰和可控。我常用的格式是业务名:领域名:ID比如mall:product:1001这样在 Redis Desktop Manager 里看一目了然排查问题时 grep 起来也方便。3.2 序列化方案对比与避坑Redis 存数据离不开序列化。Spring Boot 项目中默认的 JdkSerializationRedisSerializer 虽然能用但序列化出来的字节非常臃肿而且存进去的 key 会带 xxx 前缀肉眼完全没法读排障的时候很痛苦。另外 JDK 序列化还有安全漏洞的历史包袱生产环境我基本不会用它。我在项目中实际对比过三种方案序列化方案序列化速度可读性压缩比推荐度JDK Serialization慢差二进制前缀最差不推荐Jackson/Fastjson2中等好纯 JSON中等常规推荐Kryo快差二进制高高性能场景大部分业务系统推荐直接上 JSON 序列化而且我强烈建议把 key 序列化和 value 序列化分开配置key 用 StringRedisSerializervalue 用 Jackson 或 Fastjson2。原因是 key 是给开发人员看的必须可读value 是给机器解析的重点是稳定和高效。踩过的一个坑是——Jackson 序列化 LocalDateTime 会出现异常需要额外注册 JavaTimeModule或者统一用 Long 时间戳代替。还有一点如果 value 里存的是 List 这种泛型类型反序列化的时候如果没有传递 TypeReference会抛 ClassCastException。这些细节看起来小但都会在联调阶段咬你一口。4. 多级缓存的读写流程与一致性设计4.1 读请求的链路设计多级缓存读流程核心是解决“先查哪一层、怎么回填、失败了怎么办”的问题。我的标准读链路是这样的请求到达 - 查 Caffeine 本地缓存 命中 - 直接返回开销 0 未命中 - 查 Redis 命中 - 回填 Caffeine - 返回 未命中 - 查数据库 查到 - 回填 Caffeine Redis - 返回 查不到 - 缓存空值可选 - 返回 null这个流程看起来简单但有几个关键点值得展开。第一**Caffeine 未命中时去查 Redis 这一步需要考虑并发控制。**假如商品详情是热点数据本地缓存刚过期一瞬间 100 个线程同时发现 Caffeine 没命中如果每个线程都去查 RedisRedis 会被打爆更严重的是如果 Redis 也没命中100 个线程会同时打到数据库。这个场景叫缓存击穿。我的应对方案是针对 key 加 JVM 级别的锁同一个 key 只有一个线程去查下游其他线程阻塞等待结果。用 Caffeine 的 LoadingCache 天然具备这个能力——多个线程请求同一个未加载的 key 时内部会保证只有一个加载线程。第二**要区分业务异常和缓存未命中。**加载数据时如果数据库抛了 SQL 异常这时候不应该把异常吞掉然后返回 null更不能把 null 写进缓存——否则等缓存过期前的这段时间所有请求都会拿到空数据而数据库其实是有数据的。我的处理是业务异常直接抛出只有查询结果真的为空时才缓存空值并且给空值设置一个较短的过期时间比如 3 到 5 分钟。第三**本地缓存回填要考虑缓存大小。**如果短时间内有大量不同的 key 穿过 Redis 到达数据库回填 Caffeine 时可能导致本地缓存频繁淘汰出现“缓存抖动”。这时候可以通过配置 Caffeine 的 maximumSize 来控制本地缓存容量或者对写入本地缓存的 key 做筛选——比如只允许访问次数超过阈值的 key 进入本地缓存。这个属于高级玩法数据量特别大时很有用。4.2 写请求与缓存更新策略多级缓存最难的部分不是读而是写。因为写入 DB 之后Redis 和 Caffeine 里的旧数据都要处理。业界最常用的模式是 Cache-Aside旁路缓存我的处理方式分为三层数据库更新成功后先删除 Redis 缓存再删除 Caffeine 缓存。为什么用“删除”而不是“更新”因为更新缓存在并发场景下存在竞态——两个请求同时更新后写入的旧数据可能覆盖前一个请求写入的新数据导致缓存里存了旧值。而删除缓存的操作是幂等的即使删晚了下一次读取也会触发重建缓存。但这里有个经典问题**先更新数据库还是先删缓存**我只能说没有绝对完美的顺序只有相对安全的妥协。我在生产上是“先更新数据库再删 Redis 缓存再删本地缓存”。这个顺序下最坏情况是一个读请求在更新事务提交前读到了旧值并回填了缓存导致短时间内的脏读。为了缩小这个时间窗口常规做法是延迟双删——在第一次删除缓存后休眠几百毫秒再删一次确保已经回填旧值的缓存也被清掉。延迟双删的代码如下public void updateProduct(ProductInfo info) { // 1. 更新数据库 productMapper.updateById(info); // 2. 删除 Redis 缓存 redisTemplate.delete(CACHE_KEY_PREFIX info.getId()); // 3. 延迟再删一次防止读请求在步骤2和步骤3之间回填旧数据 cacheDeleteExecutor.schedule(() - { redisTemplate.delete(CACHE_KEY_PREFIX info.getId()); caffeineCache.invalidate(info.getId()); }, 500, TimeUnit.MILLISECONDS); }注意这里“本地缓存”的删除必须和 Redis 删除在同一个延迟任务里保证两级的最终一致。如果项目的并发量还没到必须牺牲一致性的程度其实可以简化成同步删除流程更可控。4.3 Redis 和 Caffeine 之间的一致性取舍很多人问本地缓存和 Redis 缓存怎么保证强一致我的答案是**多级缓存本身就做不到强一致也不该去追求强一致。**本地缓存活在 JVM 里Redis 活在独立进程中任何跨进程的数据更改都不可能原子通知到所有节点。靠 MQ 广播删除本地缓存已经是很务实的做法了但如果某个消费者处理失败本地缓存还是会存在一段时间内的旧值。所以设计多级缓存时必须想清楚业务上能容忍多久的数据延迟。以我的经验商品详情、文章内容、用户资料这类读多写少的数据30 秒甚至几分钟的延迟完全可以接受。但像库存、余额这种强一致要求的数据根本不应该放多级缓存甚至不要放 Redis直接查数据库或者走专门的分布式锁方案。做架构的人一定要有取舍观不是所有数据都适合缓存。5. 防护策略穿透、击穿、雪崩的三座大山5.1 缓存穿透查询不存在的数据缓存穿透是指查询一个必然不存在的数据。比如用不存在的商品 ID 去查详情Redis 和数据库都查不到请求直接穿透到数据库。如果攻击者循环构造不存在的 ID数据库压力会被瞬间拉满。我的应对是双管齐下第一道关卡是参数校验在 Controller 层对 ID 格式、范围做前置校验明显不合法的请求直接拒绝第二道是缓存空值查询结果为空时在 Redis 里写入一个特殊标记的短过期 key比如值写成空字符串或 null过期时间设 3 分钟。第三道是布隆过滤器在缓存层之前加一个布隆过滤器把存在的数据 ID 预加载到过滤器里查询前先判断 ID 是否存在不存在直接返回。布隆过滤器有误判率但能挡住绝大多数非法请求。布隆过滤器在 Redis 里可以用 Redisson 的 RBloomFilter 实现也可以用 Guava 的 BloomFilter 做本地布隆后者在分布式多节点下需要每台机器都初始化一份我一般用 Redisson 的方案数据一致性更容易管理。5.2 缓存击穿热点 key 失效的瞬间缓存击穿是指某个热点 key 在缓存过期的那一瞬间大量请求同时打进来全部穿透到数据库。这和穿透的区别在于击穿访问的 key 是真实存在且热度极高的数据比如微博热搜、爆款商品的详情。解决击穿我常用的方案有三个层次互斥锁Mutex Key在缓存重建时加锁只让一个线程去查数据库其他线程阻塞等待。实现上可以使用 Redis 的 SETNX 命令做分布式锁或者用 Caffeine LoadingCache 的底层机制做 JVM 内单机锁。但注意如果服务是集群部署单机锁解决不了跨节点的击穿问题必须上分布式锁。逻辑过期缓存数据里额外存一个过期时间字段比如缓存值里放ProductInfo expireTime超过 expireTime 就认为逻辑过期此时只允许一个线程去加载新数据其他线程返回旧数据。这个方案的好处是缓存永远不会真的消失适合极端热点数据。缺点是每个请求都要多一次逻辑判断。refreshAfterWrite前面提到过Caffeine 的异步刷新机制可以做到“旧值顶上后台更新”这个能力对我防守击穿非常有用。Redis 层面也可以配合每台机器错开过期时间避免集群里所有节点同时失效。5.3 缓存雪崩批量 key 同时失效缓存雪崩是指大量 key 在同一时间过期或者 Redis 集群宕机导致请求全部落到数据库。和击穿相比雪崩是面不是点破坏力大得多。批量 key 同时过期的场景非常常见——比如批量导入了一批数据过期时间统一设成 10 分钟那 10 分钟后这些 key 就会集体消失所有请求同时打向数据库。应对方法很简单过期时间加随机抖动。比如基础过期时间 10 分钟实际过期时间 600 秒 random(0, 300) 秒让过期时间散落在 10 到 15 分钟之间就不会出现齐刷刷失效的情况。Redis 集群整体不可用是另一种雪崩。多级缓存架构在这里有一个巨大的优势——Caffeine 本地缓存不依赖 RedisRedis 挂了之后本地缓存还能继续抗住一部分流量。但要注意本地缓存的数据也有过期时间Redis 挂了之后如果本地缓存也刚好全部过期请求依然会穿透到数据库。我在生产上做了一个降级策略检测到 Redis 不可用时自动延长 Caffeine 的过期时间比如从 5 分钟延长到 30 分钟优先保证核心接口可用数据延迟等 Redis 恢复后再追平。这种做法牺牲了一点一致性但换来了系统整体的可用性在大促这种场景下非常值得。6. 实战落地Spring Boot 集成 Caffeine Redis6.1 依赖引入与基础配置我用的是 Spring Boot 2.7.x对应的 Redis 客户端是 Lettuce缓存框架是 Caffeine。Maven 依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependencyapplication.yml 里的配置spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 cache: type: caffeine caffeine: spec: maximumSize10000,expireAfterWrite600s注意 Lettuce 的连接池配置。很多人不配置连接池默认是直连模式高并发下会创建大量连接。我这边压测发现不配连接池时 Redis 连接数能冲到几千配置之后稳定在几十。生产环境一定要配置连接池参数。6.2 核心代码两级缓存的管理器我自己更喜欢编程式地封装一个两级缓存管理器而不是在业务代码里直接操作两个缓存对象。因为多级缓存的读写策略是有共性的——先查一级、再查二级、回填、失效通知这些逻辑抽出来复用业务方只需要关心自己的加载函数。核心管理器代码如下Component public class MultiLevelCacheManager { private final LoadingCacheString, Object localCache; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public MultiLevelCacheManager(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; this.localCache Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(30)) .refreshAfterWrite(Duration.ofMinutes(10)) .recordStats() .build(key - loadFromRedis(key)); } public T T get(String key, ClassT clazz, FunctionString, T dbLoader) { // 1. 查本地缓存 Object localValue localCache.getIfPresent(key); if (localValue ! null) { return (T) localValue; } // 2. 查 Redis通过 LoadingCache 的 loadFromRedis 加载 Object redisValue localCache.get(key); if (redisValue ! null) { return (T) redisValue; } // 3. 查数据库 T dbValue dbLoader.apply(key); if (dbValue null) { return null; } // 4. 回填 Redis 本地缓存 redisTemplate.opsForValue().set(key, toJson(dbValue), 30, TimeUnit.MINUTES); localCache.put(key, dbValue); return dbValue; } private Object loadFromRedis(String key) { String json redisTemplate.opsForValue().get(key); if (StringUtils.hasText(json)) { return parseJson(json, Object.class); } return null; } public void invalidate(String key) { localCache.invalidate(key); redisTemplate.delete(key); } }注意这里有个细节localCache.get(key)和getIfPresent的区别。getIfPresent 只查一级缓存不触发加载get 会触发 LoadingCache 的 load 方法也就是去 Redis 加载。这正好符合我们的链路先看本地有没有没有再走 Redis。而 Redis 也没命中时get 返回 null此时再去数据库加载。这个顺序天然地实现了多级缓存。6.3 业务层使用示例业务方使用时只需要传入业务 key、返回类型和数据库加载函数Service public class ProductService { Autowired private MultiLevelCacheManager cacheManager; public ProductInfo getProduct(Long productId) { String cacheKey mall:product: productId; return cacheManager.get(cacheKey, ProductInfo.class, id - { // 这里执行数据库查询 ProductInfo info productMapper.selectById(id); return info; }); } Transactional public void updateProduct(ProductInfo info) { productMapper.updateById(info); String cacheKey mall:product: info.getId(); cacheManager.invalidate(cacheKey); } }这段代码的逻辑很直白——查询走多级缓存更新后删缓存。如果产品数据更新的频率不高这套写法已经能应付绝大多数读多写少的场景。7. 压测数据与调优实录7.1 压测环境我用了 4 台 4C8G 的云服务器搭建压测环境应用部署 2 台Redis 单节点MySQL 单实例模拟商品详情接口。压测工具用 JMeter线程数从 100 逐步加到 1000观察不同缓存方案下的表现。7.2 压测结果对比方案QPSTP99TP999数据库 QPS无缓存直查数据库42045ms110ms420单 Redis 缓存21008ms25ms210Caffeine Redis 多级缓存72002ms6ms300数据可以看出多级缓存比单 Redis 方案在 QPS 上提升了约 2.4 倍TP999 从 25ms 降到了 6ms。核心原因是请求先走本地缓存本地命中时完全不走网络单机就能扛住很高的 QPS。这时候 Redis 和数据库的 QPS 被压得非常低意味着系统的瓶颈从外部分布式组件转移到了应用进程内部这其实是更好的状态——因为我们还可以靠横向扩容应用节点来提升整体吞吐而不是受限于一个独立的 Redis 节点。7.3 调参过程与经验压测中我发现了几个需要调优的点最大容量设置一开始 localCache 的 maximumSize 设了 10 万结果压测 10 分钟后JVM 老年代占用从 500MB 涨到了 1.2GBGC 时间明显变长。我通过 recordStats 观察命中率发现 5 万容量就能覆盖 98% 的访问 key于是把 maximumSize 降到了 5 万内存占用降下来GC 也平稳了。过期时间与一致性的平衡refreshAfterWrite 设得太短比如 1 分钟会导致频繁触发刷新任务刷新线程吃 CPU设得太长比如 1 小时数据更新后要很久才能看到新值。结合业务容忍度我最终定为 10 分钟刷新 30 分钟过期。连接池调整压测到 500 并发时Lettuce 连接池 max-active 如果只有 8会出现获取连接超时的报错。我把 max-active 调到 32同时观察 Redis 服务端连接数保持在 50 以内问题解决。调优的核心思路是用数据说话别靠直觉。Caffeine 的 recordStats 提供了 hitCount、missCount、loadSuccessCount、loadFailureCount 等指标我在代码里加了一个定时打印 CacheStats 的定时任务每 30 秒输出一次。压测期间观察命中率曲线命中率低于 95% 就检查是不是容量不够高于 99% 就看看内存占用是否偏高、能不能降容量。8. 常见问题与排查技巧实录8.1 热点 key 失效瞬间打爆 Redis有一次上线后发现 Redis 的 CPU 时不时飙到 90%排查发现是每晚零点的定时任务会更新一批热卖商品的价格更新完删除了缓存导致第二天早上一波集中访问时所有缓存都处于空置状态大量请求同时回源数据库。这个问题的本质是缓存击穿 雪崩的叠加。我当时的解法是定时任务更新完数据后不直接删除缓存而是预先把更新后的数据写入 Redis提前“预热”缓存。同时本地缓存的 refreshAfterWrite 也起到缓冲作用——Redis 虽然短暂击穿但本地缓存还能扛一会儿。另外把批量更新任务的执行时间错开几分钟避免几百个商品同时失效。8.2 本地缓存和 Redis 数据不一致运营同事反馈改完商品标题后前台有时候要过很久才能看到变化。查下来发现是代码里更新完数据库只删了 Redis没有删本地缓存。本地缓存最长 30 分钟过期所以最坏情况下用户要等 30 分钟才能看到新标题。修复方法就是在 invalidate 方法里同时删除两级缓存。但这里要注意如果服务是多节点部署每个节点的本地缓存都要删。原来的代码只删了当前节点的 Caffeine 缓存其他节点的本地缓存没删。我的处理是引入 Redis Pub/Sub 广播删除通知——某个节点更新数据后往 Redis 的 channel 发一条消息其他节点订阅这个 channel 收到消息后删除本地缓存。这样最多有毫秒级延迟但能保证所有节点最终一致。广播删除的代码片段public void invalidate(String key) { // 删除当前节点本地缓存 localCache.invalidate(key); // 删除 RedisRedis 本身是共享的删一次即可 redisTemplate.delete(key); // 广播通知其他节点删除本地缓存 redisTemplate.convertAndSend(cache:invalidate, key); } EventListener(ApplicationReadyEvent.class) public void subscribe() { redisTemplate.getConnectionFactory().getConnection().subscribe( (message, pattern) - { String key new String(message.getBody()); localCache.invalidate(key); }, cache:invalidate.getBytes() ); }8.3 常见问题速查表问题现象可能原因排查方法解决方案缓存命中率低maximumSize 太小看 CacheStats 中的 hitRate增大容量或按数据权重限制接口偶发超时Redis 连接池耗尽查 Lettuce 连接等待时间调整 max-active / min-idle数据更新后看不到新值本地缓存未删除观察本地缓存 key 是否存在使用广播删除或缩短过期时间缓存穿透导致 DB 压力大非法 ID 请求过多查 DB 日志中不存在的 ID参数校验 缓存空值大批 key 同时失效过期时间无随机抖动查看缓存 key 的 TTL 分布过期时间加随机偏移序列化后 JSON 乱码使用 JDK 序列化用 RDM 查看 key 的 value改为 StringRedisSerializer8.4 监控与告警多级缓存不能黑盒运行多级缓存上线之后最怕的就是“感觉正常一压测就崩”。我的经验是把关键指标全部接入监控Caffeine 的 CacheStats每台机器上报 hitRate、missCount、loadFailureCount低于阈值告警。Redis 的慢查询日志latency 超过 20ms 的命令输出到单独日志文件定期分析。JVM GC 指标本地缓存越大GC 压力越大需要关注老年代的增长趋势。回源数据库的 QPS这是最直接的兜底指标一旦超过阈值说明缓存已经没起到应有的保护作用。9. 写在最后的经验与建议多级缓存这套架构我从最初为了解决一个接口超时问题开始研究到后来完整落地到多个核心服务前后踩了不少坑也总结出了几条比较实用的经验。第一多级缓存不是银弹不要一上来就用。如果你的 QPS 不到几百单机数据库完全扛得住那引入多级缓存就是过度设计。多级缓存带来的问题——一致性、容量规划、分布式广播、系统复杂度——每一项都需要额外的维护成本。适合用多级缓存的场景是读多写少、热点数据集中、QPS 在几千以上、对响应时延敏感。第二参数一定要基于压测和线上数据来调。我见过很多人配置 Caffeine 时随便写个 maximumSize 10000过期时间 10 分钟根本没有观察过命中率和内存占用。正确的做法是开启 recordStats把命中率、加载次数这些指标上报到监控系统根据实际情况逐步调整。宁可花两周时间做精细化调优也不要上线后靠拍脑袋救火。第三一致性方案要越简单越好。Redis 和 Caffeine 之间的一致性问题很多团队一上来就想搞复杂的分布式事务、消息队列、版本号方案。但我个人的经验是先想清楚业务能接受多长时间的缓存延迟——如果是 10 秒以内延迟双删就够如果是 1 分钟以内设置一个合理的过期时间配合主动失效也能接受如果几秒钟都不能等那这个数据根本不应该走缓存。架构越简单生产环境越好维护。第四缓存一定要有降级预案。Redis 挂了怎么办本地缓存也没有了怎么办我在生产上做的降级策略是检测到 Redis 不可用时自动切换为本地缓存 数据库直查模式同时把 Caffeine 的过期时间临时调大。这样核心接口还能继续提供弱化但可用的服务不会全链路雪崩。这块逻辑在压测中一定要专门演练一次别等到大促时才去验证。最后再分享一个小技巧上线多级缓存之后你会经常遇到“本地缓存和 Redis 不一致”的排查问题。我给所有缓存 key 设计了一个统一的前缀规范并且给 Caffeine 的 key 加了一个单独的 namespace这样在查看日志和监控时一眼就能分清一个 key 是来自本地缓存还是 Redis排障效率会高很多。多级缓存的落地不是一个一次性工程而是一个持续观测、持续调优的过程希望这篇文章能帮你在设计阶段少走几步弯路。