Spring Boot 多级缓存实战:Caffeine + Redis 架构设计与性能调优
2026/9/18 10:05:23 网站建设 项目流程

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 之后的默认本地缓存实现就是 Caffeine,Spring 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 的用法非常直观,最基础的一段构建代码是这样:

Cache<String, ProductInfo> productCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .build();

但生产环境我一般不用这个基础写法,而是用 LoadingCache 配合 refreshAfterWrite,这样既能拿到自动加载的便利,又能避免缓存集中失效的问题:

LoadingCache<String, 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 用 StringRedisSerializer,value 用 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 没命中,如果每个线程都去查 Redis,Redis 会被打爆;更严重的是,如果 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 本地缓存不依赖 Redis,Redis 挂了之后,本地缓存还能继续抗住一部分流量。但要注意,本地缓存的数据也有过期时间,Redis 挂了之后如果本地缓存也刚好全部过期,请求依然会穿透到数据库。我在生产上做了一个降级策略:检测到 Redis 不可用时,自动延长 Caffeine 的过期时间,比如从 5 分钟延长到 30 分钟,优先保证核心接口可用,数据延迟等 Redis 恢复后再追平。这种做法牺牲了一点一致性,但换来了系统整体的可用性,在大促这种场景下非常值得。

6. 实战落地:Spring Boot 集成 Caffeine + Redis

6.1 依赖引入与基础配置

我用的是 Spring Boot 2.7.x,对应的 Redis 客户端是 Lettuce,缓存框架是 Caffeine。Maven 依赖如下:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency>

application.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: maximumSize=10000,expireAfterWrite=600s

注意 Lettuce 的连接池配置。很多人不配置连接池,默认是直连模式,高并发下会创建大量连接。我这边压测发现,不配连接池时 Redis 连接数能冲到几千,配置之后稳定在几十。生产环境一定要配置连接池参数。

6.2 核心代码:两级缓存的管理器

我自己更喜欢编程式地封装一个两级缓存管理器,而不是在业务代码里直接操作两个缓存对象。因为多级缓存的读写策略是有共性的——先查一级、再查二级、回填、失效通知,这些逻辑抽出来复用,业务方只需要关心自己的加载函数。

核心管理器代码如下:

@Component public class MultiLevelCacheManager { private final LoadingCache<String, 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, Class<T> clazz, Function<String, 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 缓存21008ms25ms210
Caffeine + Redis 多级缓存72002ms6ms300

数据可以看出,多级缓存比单 Redis 方案在 QPS 上提升了约 2.4 倍,TP999 从 25ms 降到了 6ms。核心原因是:请求先走本地缓存,本地命中时完全不走网络,单机就能扛住很高的 QPS。这时候 Redis 和数据库的 QPS 被压得非常低,意味着系统的瓶颈从外部分布式组件转移到了应用进程内部,这其实是更好的状态——因为我们还可以靠横向扩容应用节点来提升整体吞吐,而不是受限于一个独立的 Redis 节点。

7.3 调参过程与经验

压测中我发现了几个需要调优的点:

  • 最大容量设置:一开始 localCache 的 maximumSize 设了 10 万,结果压测 10 分钟后,JVM 老年代占用从 500MB 涨到了 1.2GB,GC 时间明显变长。我通过 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); // 删除 Redis(Redis 本身是共享的,删一次即可) 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改为 StringRedisSerializer

8.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,排障效率会高很多。多级缓存的落地不是一个一次性工程,而是一个持续观测、持续调优的过程,希望这篇文章能帮你在设计阶段少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询