资讯动态

Caffeine + Redis 两级缓存方案详解

发布时间:2026/8/21 22:25:30 来源:尧图企业网站定制
Caffeine Redis 两级缓存方案详解在实际开发中单独使用 Redis 分布式缓存会面临网络开销大、高频访问场景下性能瓶颈等问题而单独使用 Caffeine 本地缓存JVM 内存缓存则存在数据不一致、内存有限、节点间数据不共享的问题。因此业界主流方案是结合两者Caffeine 作为一级缓存本地Redis 作为二级缓存分布式既利用 Caffeine 的高性能毫秒级→纳秒级又利用 Redis 的分布式一致性和数据持久化能力。一、核心设计思路缓存读写流程1. 读缓存流程核心先本地→再分布式→最后查数据库2. 写缓存流程核心保证数据一致性更新 / 删除数据先更新数据库 → 删除 Redis 缓存 → 删除 Caffeine 缓存避免脏读新增数据先写入数据库 → 写入 Redis → 可选写入 Caffeine按需。二、技术选型说明三、完整实现代码Spring Boot 示例前置条件引入依赖Maven!-- Spring Boot Redis 依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- Caffeine 缓存 -- dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency !-- JacksonRedis 序列化用 -- dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependency配置文件application.ymlspring: redis: host: 127.0.0.1 port: 6379 password: # 无密码则留空 timeout: 2000ms lettuce: pool: max-active: 20 # 最大连接数 max-idle: 10 # 最大空闲连接 min-idle: 5 # 最小空闲连接 # 自定义缓存配置 cache: caffeine: expire-after-write: 5m # Caffeine 写入后过期时间 maximum-size: 10000 # Caffeine 最大缓存条目数 redis: expire: 10m # Redis 缓存过期时间步骤 1缓存配置类初始化 Caffeine Redis 模板import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.caffeine.CaffeineCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.StringRedisSerializer; import java.time.Duration; import java.util.concurrent.TimeUnit; Configuration public class CacheConfig { // Caffeine 配置参数 Value(${cache.caffeine.expire-after-write}) private Duration caffeineExpire; Value(${cache.caffeine.maximum-size}) private long caffeineMaxSize; // Redis 配置参数 Value(${cache.redis.expire}) private Duration redisExpire; /** * 初始化 Caffeine 缓存配置 */ Bean public CaffeineObject, Object caffeineConfig() { return Caffeine.newBuilder() .maximumSize(caffeineMaxSize) // 最大缓存数 .expireAfterWrite(caffeineExpire) // 写入后过期 .recordStats(); // 开启统计可选用于监控命中率 } /** * Caffeine 缓存管理器 */ Bean public CacheManager caffeineCacheManager(CaffeineObject, Object caffeine) { CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(caffeine); cacheManager.setAllowNullValues(false); // 不允许缓存null值 return cacheManager; } /** * Redis 缓存管理器自定义序列化避免默认JDK序列化的问题 */ Bean public RedisCacheManager redisCacheManager(RedisConnectionFactory factory) { // key 序列化 StringRedisSerializer keySerializer new StringRedisSerializer(); // value 序列化JSON格式可读性好 GenericJackson2JsonRedisSerializer valueSerializer new GenericJackson2JsonRedisSerializer(); RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(redisExpire) // 默认过期时间 .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(keySerializer)) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer)) .disableCachingNullValues(); // 不缓存null值 return RedisCacheManager.builder(factory) .cacheDefaults(config) .build(); } }步骤 2通用缓存工具类封装两级缓存逻辑import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.concurrent.TimeUnit; Component public class TwoLevelCacheUtil { Autowired private RedisTemplateString, Object redisTemplate; private CacheString, Object caffeineCache; // 从配置中注入也可直接写死推荐配置化 Autowired private CaffeineObject, Object caffeineConfig; /** * 初始化 Caffeine 缓存实例 */ PostConstruct public void init() { this.caffeineCache caffeineConfig.build(); } /** * 读取两级缓存 * param cacheKey 缓存key * param loader 数据加载器查数据库的逻辑 * param T 数据类型 * return 缓存数据 */ SuppressWarnings(unchecked) public T T get(String cacheKey, DataLoaderT loader) { // 1. 先查 Caffeine 本地缓存 Object value caffeineCache.getIfPresent(cacheKey); if (value ! null) { return (T) value; } // 2. 再查 Redis 分布式缓存 value redisTemplate.opsForValue().get(cacheKey); if (value ! null) { // 将 Redis 数据回写到 Caffeine caffeineCache.put(cacheKey, value); return (T) value; } // 3. 缓存都未命中查数据库 T data loader.load(); if (data ! null) { // 写入 Redis设置过期时间这里用10分钟可配置化 redisTemplate.opsForValue().set(cacheKey, data, 10, TimeUnit.MINUTES); // 写入 Caffeine caffeineCache.put(cacheKey, data); } return data; } /** * 删除两级缓存更新/删除数据时调用 * param cacheKey 缓存key */ public void delete(String cacheKey) { // 先删 Redis分布式再删 Caffeine本地 redisTemplate.delete(cacheKey); caffeineCache.invalidate(cacheKey); } /** * 数据加载器接口用于封装查数据库的逻辑 */ FunctionalInterface public interface DataLoaderT { T load(); } }步骤 3业务层使用示例用户信息缓存import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class UserService { // 模拟数据库实际项目中替换为Mapper/Repository private final UserDB userDB new UserDB(); Autowired private TwoLevelCacheUtil twoLevelCacheUtil; /** * 根据用户ID查询用户带两级缓存 */ public User getUserById(Long userId) { String cacheKey user:info: userId; // 调用两级缓存工具类DataLoader 封装查库逻辑 return twoLevelCacheUtil.get(cacheKey, () - userDB.selectById(userId)); } /** * 更新用户信息删除缓存保证一致性 */ public void updateUser(User user) { // 1. 更新数据库 userDB.update(user); // 2. 删除两级缓存核心先更库再删缓存 String cacheKey user:info: user.getId(); twoLevelCacheUtil.delete(cacheKey); } /** * 模拟用户数据库 */ static class UserDB { public User selectById(Long userId) { // 实际项目中是 MyBatis/JPA 查库逻辑 System.out.println( 执行数据库查询userId userId ); return new User(userId, 用户名 userId, 20 userId.intValue()); } public void update(User user) { // 实际项目中是更新数据库逻辑 System.out.println( 执行数据库更新user user ); } } /** * 用户实体类 */ public static class User { private Long id; private String name; private Integer age; // 构造器、getter/setter、toString 省略 public User(Long id, String name, Integer age) { this.id id; this.name name; this.age age; } Override public String toString() { return User{id id , name name , age age }; } // getter/setter public Long getId() { return id; } public void setId(Long id) { this.id id; } public String getName() { return name; } public void setName(String name) { this.name name; } public Integer getAge() { return age; } public void setAge(Integer age) { this.age age; } } }步骤 4测试类验证缓存效果import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; SpringBootTest public class TwoLevelCacheTest { Autowired private UserService userService; Test public void testCache() { Long userId 1L; // 第一次查询缓存未命中执行数据库查询 User user1 userService.getUserById(userId); System.out.println(第一次查询结果 user1); // 第二次查询命中 Caffeine 本地缓存无数据库/Redis 查询 User user2 userService.getUserById(userId); System.out.println(第二次查询结果 user2); // 更新用户信息删除缓存 User updateUser new User(userId, 更新后的用户名, 25); userService.updateUser(updateUser); // 第三次查询缓存已删除重新查数据库 User user3 userService.getUserById(userId); System.out.println(更新后查询结果 user3); } }四、关键优化点生产环境必看1. 缓存穿透解决方案问题恶意请求不存在的 key导致每次都查数据库压垮数据库方案缓存空值/null或使用布隆过滤器提前拦截不存在的 key。2. 缓存击穿解决方案问题热点 key 过期瞬间大量请求同时穿透到数据库方案Caffeine设置expireAfterAccess访问后过期refreshAfterWrite自动刷新分布式锁查数据库时加锁保证同一时间只有一个请求查库。3. 缓存雪崩解决方案问题大量缓存同时过期导致数据库压力骤增方案给 Redis 过期时间加随机值如 10m ± 1mCaffeine 和 Redis 过期时间错开如 Caffeine 5 分钟Redis 10 分钟Redis 集群保证高可用。4. 本地缓存一致性问题多实例部署时某实例更新数据后其他实例的本地缓存还是旧数据方案短过期时间如 5 分钟让旧数据自动失效使用 Redis Pub/Sub 发布缓存删除事件所有实例监听并删除本地缓存。总结核心逻辑读缓存先查 Caffeine本地→ 再查 Redis分布式→ 最后查数据库写缓存先更数据库 → 删 Redis → 删 Caffeine保证最终一致性优势结合 Caffeine 的高性能无网络开销和 Redis 的分布式一致性兼顾性能与数据共享生产要点必须处理缓存穿透 / 击穿 / 雪崩问题多实例部署时注意本地缓存的一致性。五、spring cache简化redis开发在SpringCache中提供了很多缓存操作的注解常见的是以下的几个注解说明EnableCaching开启缓存注解功能通常加在启动类上Cacheable在方法执行前先查询缓存中是否有数据如果有数据则直接返回缓存数据如果没有缓存数据调用方法并将方法返回值放到缓存中CachePut将方法的返回值放到缓存中CacheEvict将一条或多条数据从缓存中删除EnableCaching 开启缓存支持即可放在启动类之上CachePut 说明一般在增改方法之上修饰作用: 将方法返回值放入缓存value: 缓存的名称, 每个缓存名称下面可以有很多keykey: 缓存的key ---------- 支持Spring的表达式语言SPEL语法Cacheable 说明:作用: 在方法执行前spring先查看缓存中是否有数据如果有数据则直接返回缓存数据若没有数据调用方法并将方法返回值放到缓存中一般在查询方法之上修饰value: 缓存的名称每个缓存名称下面可以有多个keykey: 缓存的key ---------- 支持Spring的表达式语言SPEL语法CacheEvict 说明作用: 清理指定缓存value: 缓存的名称每个缓存名称下面可以有多个keykey: 缓存的key ---------- 支持Spring的表达式语言SPEL语法六、集群场景下的问题问题分析通过前面的解决似乎可以完成一级、二级缓存中数据的同步如果在单节点项目中是没有问题的但是在分布式场景下是有问题的看下图说明部署了2个transport-info微服务节点每个微服务都有自己进程级的一级缓存都共享同一个Redis作为二级缓存假设所有节点的一级和二级缓存都是空的此时用户通过节点1查询运单物流信息在完成后节点1的caffeine和Redis中都会有数据接着系统通过节点2更新了物流数据此时节点2中的caffeine和Redis都是更新后的数据用户还是进行查询动作依然是通过节点1查询此时查询到的将是旧的数据也就是出现了一级缓存与二级缓存之间的数据不一致的问题问题复现第一步我们启动两个节点的transport-info微服务节点第二步我们在MQ中发布消息给sl.queue.transportInfo.append这个队列发布消息第三步我们发现第一次可能给18088节点发布的那么这个时候我们可以查询18087节点的swagger查询操作我们发现18087没有更新之后的数据拿到的仍然是旧的数据问题解决如何解决该问题呢可以通过消息的方式解决就是任意一个节点数据更新了数据发个消息出来通知其他节点其他节点接收到消息后将自己caffeine中相应的数据删除即可。关于消息的实现可以采用RabbitMQ也可以采用Redis的消息订阅发布来实现在这里为了应用技术的多样化所以采用Redis的订阅发布来实现。关于Redis的消息订阅发布参考官网地址 https://redis.io/docs/interact/pubsub/中文网站https://www.redis.net.cn/tutorial/3514.htmlRedis 发布订阅(pub/sub)是一种消息通信模式发送者(pub)发送消息订阅者(sub)接收消息。当有新消息通过 publish 命令发送给频道 channel1 时 这个消息就会被发送给订阅它的三个客户端。Redis的订阅发布功能与传统的消息中间件如RabbitMQ相比相对轻量一些针对数据准确和安全性要求没有那么高的场景可以直接使用。第一步在com.sl.transport.info.config.RedisConfig增加订阅的配置public static final String CHANNEL_TOPIC sl-express-ms-transport-info-caffeine; /** * 配置订阅用于解决Caffeine一致性的问题 * * param connectionFactory 链接工厂 * param listenerAdapter 消息监听器 * return 消息监听容器 */ Bean public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listenerAdapter, new ChannelTopic(CHANNEL_TOPIC)); return container; }第二步编写RedisMessageListener用于监听消息删除caffeine中的数据。package com.sl.transport.info.mq; import cn.hutool.core.convert.Convert; import com.github.benmanes.caffeine.cache.Cache; import com.sl.transport.info.domain.TransportInfoDTO; import org.springframework.data.redis.connection.Message; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.stereotype.Component; import javax.annotation.Resource; /** * redis消息监听解决Caffeine一致性的问题 */ Component public class RedisMessageListener extends MessageListenerAdapter { Resource private CacheString, TransportInfoDTO transportInfoCache; Override public void onMessage(Message message, byte[] pattern) { //获取到消息中的运单id String transportOrderId Convert.toStr(message); //将本jvm中的缓存删除掉 this.transportInfoCache.invalidate(transportOrderId); } }第三步更新数据后发送消息Resource private StringRedisTemplate stringRedisTemplate; Override CachePut(value transport-info, key #p0) public TransportInfoEntity saveOrUpdate(String transportOrderId, TransportInfoDetail infoDetail) { //省略代码 //清除缓存中的数据 // this.transportInfoCache.invalidate(transportOrderId); //发布订阅消息到redis this.stringRedisTemplate.convertAndSend(RedisConfig.CHANNEL_TOPIC, transportOrderId); //保存/更新到MongoDB return this.mongoTemplate.save(transportInfoEntity); }测试我们发现可以接收到Redis订阅的消息最终可以解决多级缓存间的一致性的问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价