在现代高并发系统中,缓存是提升系统性能的关键组件之一。传统的单一缓存方案往往难以同时满足高性能和高可用性的需求。本文将介绍如何结合 Redis 和 Caffeine 构建一个高效的两级缓存系统,并通过三个版本的演进展示如何逐步优化代码结构。
项目源代码:github地址、gitee地址
两级缓存通常由本地缓存(如 Caffeine)和分布式缓存(如 Redis)组成:
通过结合两者优势,我们可以构建一个既快速又具备一致性的缓存系统。
缓存类型 | 平均延迟 | 延迟波动范围 |
---|---|---|
本地缓存 | 0.05-1ms | 稳定 |
远程缓存 | 1-10ms | 受网络影响大 |
数据库查询 | 10-100ms | 取决于SQL复杂度 |
典型案例:某电商平台商品详情页采用两级缓存后:
本地缓存的延迟是最低的,远远低于redis等远程缓存,而且本地缓存不受网络的影响,所以延迟的波动范围也是最稳定的。所以,二级缓存在性能上有极大的优势。
假如电商环境中出现了秒杀场景,或者促销活动。会有大量的访问到同一个商品或者优惠券,以下是两种情景:
纯Redis方案,所有请求直达Redis,容易导致:
- 连接池耗尽
- 带宽被打满
- Redis CPU飙升
两级缓存方案:
- 80%以上请求被本地缓存拦截
- Redis负载降低5-10倍
- 系统整体更平稳
由于Redis等远程缓存需要通过网络连接,如果网络出现异常,很容易出现访问不到数据的情况。本地缓存则不存在网络问题,所以对故障的容忍度是非常高的。
网络分区场景测试:
模拟机房网络抖动(丢包率30%):
- 纯Redis方案:错误率飙升到85%
- 两级缓存方案:核心接口仍保持92%成功率
Caffeine 是一个高性能的 Java 本地缓存库,可以理解为 Java 版的"内存临时储物柜"。它的核心特点可以用日常生活中的例子来理解:
就像一个智能的文件柜:
技术特点:
基于 Google Guava 缓存改进而来
读写性能接近 HashMap(O(1)时间复杂度)
提供多种淘汰策略:
// 按数量淘汰(保留最近使用的1000个)
Caffeine.newBuilder().maximumSize(1000)
// 按时间淘汰(数据保存1小时)
Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.HOURS)
典型使用场景:
// 创建缓存(相当于准备一个储物柜)
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(100) // 最多存100个用户
.expireAfterWrite(10, TimeUnit.MINUTES) // 10分钟不用就清理
.build();
// 存数据(往柜子里放东西)
cache.put("user101", new User("张三"));
// 取数据(从柜子拿东西)
User user = cache.getIfPresent("user101");
// 取不到时自动加载(柜子没有就去仓库找)
User user = cache.get("user101", key -> userDao.getUser(key));
优势对比:
注意事项:
在第一个版本中,我们直接在 Service 层实现了两级缓存逻辑:
@Override
public Order getOrderById(Integer id) {
String key = CacheConstant.ORDER + id;
return (Order) orderCache.get(key, k -> {
// 先查询 Redis
Object obj = redisTemplate.opsForValue().get(key);
if (obj != null) {
log.info("get data from redis");
if (obj instanceof Order) {
return (Order) obj;
} else {
log.warn("Unexpected type from Redis, expected Order but got {}", obj.getClass());
}
}
// Redis没有或类型不匹配则查询 DB
log.info("get data from database");
Order myOrder = orderMapper.getOrderById(id);
redisTemplate.opsForValue().set(key, myOrder, 120, TimeUnit.SECONDS);
return myOrder;
});
}
优点:
缺点:
在spring项目中,提供了CacheManager接口和一些注解,允许让我们通过注解的方式来操作缓存。先来看一下常用的几个注解说明:
@Cacheable
- 缓存查询作用:将方法的返回值缓存起来,下次调用时直接返回缓存数据,避免重复计算或查询数据库。
适用场景:
getUserById
、findProduct
)示例:
@Cacheable(value = "users", key = "#userId")
public User getUserById(Long userId) {
// 如果缓存中没有,才执行此方法
return userRepository.findById(userId).orElse(null);
}
参数说明:
value
/ cacheNames
:缓存名称(如 "users"
)key
:缓存键(支持 SpEL 表达式,如 #userId
)condition
:条件缓存(如 condition = "#userId > 100"
)unless
:排除某些返回值(如 unless = "#result == null"
)@CachePut
- 更新缓存作用:方法执行后,更新缓存(通常用于 insert
或 update
操作)。
适用场景:
示例:
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
return userRepository.save(user); // 更新数据库后,自动更新缓存
}
注意:
@Cacheable
不同,@CachePut
一定会执行方法,并更新缓存。@CacheEvict
- 删除缓存作用:方法执行后,删除缓存(适用于 delete
操作)。
适用场景:
示例:
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId); // 删除数据库数据后,自动删除缓存
}
参数扩展:
allEntries = true
:清空整个缓存(如 @CacheEvict(value = "users", allEntries = true)
)beforeInvocation = true
:在方法执行前删除缓存(避免方法异常导致缓存未清理)第二个版本利用了 Spring 的缓存注解来简化代码,如果要使用上面这几个注解管理缓存的话,我们就不需要配置V1版本中的那个类型为Cache的Bean了,而是需要配置spring中的CacheManager的相关参数,具体参数的配置和之前一样。
注意,在改进更新操作的时,这里和V1版本的代码有一点区别,在之前的更新操作方法中,是没有返回值的void类型,但是这里需要修改返回值的类型,否则会缓存一个空对象到缓存中对应的key上。当下次执行查询操作时,会直接返回空对象给调用方,而不会执行方法中查询数据库或Redis的操作。
@Cacheable(value = "order", key = "#id")
@Override
public Order getOrderById(Integer id) {
String key = CacheConstant.ORDER + id;
// 先查询 Redis
Object obj = redisTemplate.opsForValue().get(key);
if (obj != null) {
log.info("get data from redis");
if (obj instanceof Order) {
return (Order) obj;
} else {
log.warn("Unexpected type from Redis, expected Order but got {}", obj.getClass());
}
}
// Redis没有或类型不匹配则查询 DB
log.info("get data from database");
Order myOrder = orderMapper.getOrderById(id);
redisTemplate.opsForValue().set(key, myOrder, 120, TimeUnit.SECONDS);
return myOrder;
}
@Override
@CachePut(cacheNames = "order",key = "#order.id")
public Order updateOrder(Order order) {
log.info("update order data");
orderMapper.updateOrderById(order);
//修改 Redis
redisTemplate.opsForValue().set(CacheConstant.ORDER + order.getId(),
order, 120, TimeUnit.SECONDS);
return order;
}
@Override
@CacheEvict(cacheNames = "order",key = "#id")
public void deleteOrderById(Integer id) {
log.info("delete order");
orderMapper.deleteOrderById(id);
redisTemplate.delete(CacheConstant.ORDER + id);
}
改进点:
@Cacheable
注解管理 Caffeine 缓存遗留问题:
如果单纯只是使用Cache注解进行缓存,还是无法把Redis功能实现从server模块中剥离出去。如果按照spring对cache注解的思路,我们可以自定义注解再利用AOP切片操作,把对应的缓存功能切入到service的代码中,就能实现二者之间的解耦。
首先,需要定义一个注解:
/**
* 双缓存注解,用于标记需要使用双缓存(通常为本地缓存和远程缓存)的方法
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCache {
/**
* 指定缓存的名称
* @return 缓存名称
*/
String cacheName();
/**
* 指定缓存的键,支持Spring EL表达式
* @return 缓存键
*/
String key(); //支持springEl表达式
/**
* 指定二级缓存的超时时间,单位默认根据实现确定(通常为秒)
* 默认值为120
* @return 二级缓存超时时间
*/
long l2TimeOut() default 120;
/**
* 指定缓存类型
* 默认值为 CacheType.FULL
* @return 缓存类型
*/
CacheType type() default CacheType.FULL;
}
定义一个枚举类型的变量,表示缓存操作的类型:
public enum CacheType {
FULL, //存取
PUT, //只存
DELETE //删除
}
如果要支持springEL的表达式,还需要一个工具类来解析springEI的表达式:
public class SpelExpressionUtils {
/**
* 解析 SpEL 表达式并替换变量
* @param elString 表达式(如 "user.name")
* @param map 变量键值对
* @return 解析后的字符串
*/
public static String parse(String elString, TreeMap<String, Object> map) {
// 将输入的表达式包装为 SpEL 表达式格式
elString = String.format("#{%s}", elString);
// 创建 SpEL 表达式解析器
ExpressionParser parser = new SpelExpressionParser();
// 创建标准的评估上下文,用于存储变量
EvaluationContext context = new StandardEvaluationContext();
// 将传入的变量键值对设置到评估上下文中
map.forEach(context::setVariable);
// 使用解析器解析表达式,使用模板解析上下文
Expression expression = parser.parseExpression(elString, new TemplateParserContext());
// 在指定上下文中计算表达式的值,并将结果转换为字符串返回
return expression.getValue(context, String.class);
}
}
定义切片,在切片操作中来实现Caffeine和Redis的缓存操作:
@Slf4j
@Component
@Aspect
@AllArgsConstructor
public class CacheAspect {
private final Cache<String, Object> cache;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 定义切点,匹配使用了 @DoubleCache 注解的方法
*/
@Pointcut("@annotation(com.example.redis_caffeine.annonation.DoubleCache)")
public void cacheAspect() {}
/**
* 环绕通知,处理缓存的读写、更新和删除操作
*
* @param point 切入点对象,包含方法执行的相关信息
* @return 方法执行的返回结果
* @throws Throwable 方法执行过程中可能抛出的异常
*/
@Around("cacheAspect()")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
try {
// 获取方法签名和方法对象
MethodSignature signature = (MethodSignature) point.getSignature();
Method method = signature.getMethod();
// 解析参数,将参数名和参数值存入 TreeMap 中
String[] paramNames = signature.getParameterNames();
Object[] args = point.getArgs();
TreeMap<String, Object> treeMap = new TreeMap<>();
for (int i = 0; i < paramNames.length; i++) {
treeMap.put(paramNames[i], args[i]);
}
// 获取方法上的 @DoubleCache 注解
DoubleCache annotation = method.getAnnotation(DoubleCache.class);
// 解析 SpEL 表达式,得到最终的 key 片段
String elResult = SpelExpressionUtils.parse(annotation.key(), treeMap);
// 拼接完整的缓存 key
String realKey = annotation.cacheName() + CacheConstant.ORDER + elResult;
// 处理强制更新操作
if (annotation.type() == CacheType.PUT) {
// 执行目标方法
Object object = point.proceed();
// 将结果存入 Redis,并设置过期时间
redisTemplate.opsForValue().set(realKey, object, annotation.l2TimeOut(), TimeUnit.SECONDS);
// 将结果存入 Caffeine 缓存
cache.put(realKey, object);
return object;
}
// 处理删除操作
if (annotation.type() == CacheType.DELETE) {
// 从 Redis 中删除缓存
redisTemplate.delete(realKey);
// 从 Caffeine 缓存中删除缓存
cache.invalidate(realKey);
return point.proceed();
}
// 优先从 Caffeine 缓存中获取数据
Object caffeineCache = cache.getIfPresent(realKey);
if (caffeineCache != null) {
log.info("get data from caffeine");
return caffeineCache;
}
// 其次从 Redis 中获取数据
Object redisCache = redisTemplate.opsForValue().get(realKey);
if (redisCache != null) {
log.info("get data from redis");
// 将从 Redis 中获取的数据存入 Caffeine 缓存
cache.put(realKey, redisCache);
return redisCache;
}
// 最后查询数据库
log.info("get data from database");
Object object = point.proceed();
if (object != null) {
// 将数据库查询结果存入 Redis,并设置过期时间
redisTemplate.opsForValue().set(realKey, object, annotation.l2TimeOut(), TimeUnit.SECONDS);
// 将数据库查询结果存入 Caffeine 缓存
cache.put(realKey, object);
}
return object;
} catch (Exception e) {
// 记录缓存切面处理过程中的错误
log.error("Cache aspect error", e);
throw e;
}
}
}
以上操作的主要工作总结下来是:
@DoubleCache
注解的方法执行操作流程,以查询操作为例:
拦截被 @DoubleCache 标记的目标方法
生成缓存键 realKey
依次查询Caffeine → Redis → 数据库
将数据库结果写入两级缓存并返回
若触发更新/删除操作,则同步清理或更新缓存
/**
* 根据订单ID获取订单信息
* 使用 @DoubleCache 注解,类型为 FULL,会执行完整的缓存操作逻辑
* @param id 订单ID
* @return 订单对象
*/
@Override
@DoubleCache(cacheName = "order", key = "#id",
type = CacheType.FULL)
public Order getOrderById(Integer id) {
return orderMapper.getOrderById(id);
}
/**
* 更新订单信息
* 使用 @DoubleCache 注解,类型为 PUT,会执行缓存更新操作
* @param order 订单对象
*/
@Override
@DoubleCache(cacheName = "order", key = "#id",
type = CacheType.PUT)
public void updateOrder(Order order) {
orderMapper.updateOrderById(order);
}
/**
* 根据订单ID删除订单信息
* 使用 @DoubleCache 注解,类型为 DELETE,会执行缓存删除操作
* @param id 订单ID
*/
@Override
@DoubleCache(cacheName = "order", key = "#id",
type = CacheType.DELETE)
public void deleteOrderById(Integer id) {
orderMapper.deleteOrderById(id);
}
核心注解:
@DoubleCache
:自定义注解,用于标记需要两级缓存的方法CacheType
:枚举,定义缓存操作类型(FULL, PUT, DELETE)AOP实现要点:
优势:
@Configuration
@EnableCaching
public class CaffeineConfig {
//-----------------------------V1------V3-----------------------------------
@Bean
public Cache<String, Object> orderCache() {
return Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(1024)
.expireAfterWrite(60, TimeUnit.SECONDS)
.build();
}
//-----------------------------V2------------------------------------------
@Bean
public CacheManager cacheManager(){
CaffeineCacheManager cacheManager=new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(128)
.maximumSize(1024)
.expireAfterWrite(60, TimeUnit.SECONDS));
return cacheManager;
}
}
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// 创建 ObjectMapper 实例,用于 JSON 序列化和反序列化
ObjectMapper objectMapper = new ObjectMapper();
// 注册 JavaTimeModule,用于支持 Java 8 日期时间类型的序列化和反序列化
objectMapper.registerModule(new JavaTimeModule());
// 禁用将日期写成时间戳的功能
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 启用默认类型信息,用于处理多态类型的序列化和反序列化
objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL);
// 创建 GenericJackson2JsonRedisSerializer 实例,使用配置好的 ObjectMapper
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
通过三个版本的演进,我们实现了一个从强耦合到完全解耦的两级缓存系统。最终版本利用自定义注解和 AOP 技术,既保持了代码的简洁性,又提供了强大的缓存功能。这种架构特别适合读多写少、对性能要求较高的场景。
在实际应用中,还需要根据具体业务特点调整缓存策略,并做好监控和指标收集,以便持续优化缓存效果。Redis + Caffeine 实现高效的两级缓存架构