Redis + Caffeine 实现高效的两级缓存架构

Redis + Caffeine 实现高效的两级缓存架构

引言

在现代高并发系统中,缓存是提升系统性能的关键组件之一。传统的单一缓存方案往往难以同时满足高性能和高可用性的需求。本文将介绍如何结合 Redis 和 Caffeine 构建一个高效的两级缓存系统,并通过三个版本的演进展示如何逐步优化代码结构。

项目源代码:github地址gitee地址

两级缓存架构概述

两级缓存通常由本地缓存(如 Caffeine)和分布式缓存(如 Redis)组成:

  1. 本地缓存(Caffeine):基于内存,访问速度极快,但容量有限且无法跨进程共享
  2. 分布式缓存(Redis):可跨进程共享,容量更大,但访问速度相对较慢

通过结合两者优势,我们可以构建一个既快速又具备一致性的缓存系统。

两级缓存的优势

一、性能优势

缓存类型 平均延迟 延迟波动范围
本地缓存 0.05-1ms 稳定
远程缓存 1-10ms 受网络影响大
数据库查询 10-100ms 取决于SQL复杂度

典型案例:某电商平台商品详情页采用两级缓存后:

  • 单纯Redis方案:P99响应时间8ms
  • 两级缓存方案:P99响应时间降至2ms

本地缓存的延迟是最低的,远远低于redis等远程缓存,而且本地缓存不受网络的影响,所以延迟的波动范围也是最稳定的。所以,二级缓存在性能上有极大的优势。

二、系统稳定性

2.1 抗流量洪峰能力

假如电商环境中出现了秒杀场景,或者促销活动。会有大量的访问到同一个商品或者优惠券,以下是两种情景:

markdown 复制代码
纯Redis方案,所有请求直达Redis,容易导致:

- 连接池耗尽
- 带宽被打满
- Redis CPU飙升
markdown 复制代码
两级缓存方案:

- 80%以上请求被本地缓存拦截
- Redis负载降低5-10倍
- 系统整体更平稳

2.2 故障容忍度

由于Redis等远程缓存需要通过网络连接,如果网络出现异常,很容易出现访问不到数据的情况。本地缓存则不存在网络问题,所以对故障的容忍度是非常高的。

网络分区场景测试

matlab 复制代码
模拟机房网络抖动(丢包率30%):
- 纯Redis方案:错误率飙升到85%
- 两级缓存方案:核心接口仍保持92%成功率

Caffeine简介

Caffeine 是一个高性能的 Java 本地缓存库,可以理解为 Java 版的"内存临时储物柜"。它的核心特点可以用日常生活中的例子来理解:

就像一个智能的文件柜:

  1. 自动整理 - 会自己清理不常用的文件(基于大小或时间)
  2. 快速查找 - 比去档案室(数据库)找资料快100倍
  3. 空间管理 - 只保留最常用的1000份文件(可配置)

技术特点:

  • 基于 Google Guava 缓存改进而来

  • 读写性能接近 HashMap(O(1)时间复杂度)

  • 提供多种淘汰策略:

    java 复制代码
    // 按数量淘汰(保留最近使用的1000个)
    Caffeine.newBuilder().maximumSize(1000)
    
    // 按时间淘汰(数据保存1小时)
    Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.HOURS)

典型使用场景:

java 复制代码
// 创建缓存(相当于准备一个储物柜)
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));

优势对比:

  • 比 HashMap:支持自动清理和过期
  • 比 Redis:快100倍(无需网络IO)
  • 比 Guava Cache:内存效率更高,并发性能更好

注意事项:

  • 仅适用于单机(不同服务器间的缓存不共享)
  • 适合缓存不易变的数据(如系统配置)
  • JVM重启后数据会丢失(如需持久化需配合Redis)

版本演进

版本1:直接侵入Service代码

在第一个版本中,我们直接在 Service 层实现了两级缓存逻辑:

java 复制代码
@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;
    });
}

优点

  • 实现简单直接
  • 缓存逻辑清晰可见

缺点

  • 缓存代码与业务代码高度耦合
  • 难以复用缓存逻辑
  • 代码重复率高

版本2:使用Spring Cache注解

在spring项目中,提供了CacheManager接口和一些注解,允许让我们通过注解的方式来操作缓存。先来看一下常用的几个注解说明:

1. @Cacheable - 缓存查询

作用:将方法的返回值缓存起来,下次调用时直接返回缓存数据,避免重复计算或查询数据库。

适用场景

  • 查询方法(如 getUserByIdfindProduct
  • 计算结果稳定的方法

示例

java 复制代码
@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"

2. @CachePut - 更新缓存

作用 :方法执行后,更新缓存(通常用于 insertupdate 操作)。

适用场景

  • 新增或修改数据后同步缓存
  • 避免缓存与数据库不一致

示例

java 复制代码
@CachePut(value = "users", key = "#user.id")  
public User updateUser(User user) {
    return userRepository.save(user); // 更新数据库后,自动更新缓存
}

注意

  • @Cacheable 不同,@CachePut 一定会执行方法,并更新缓存。

3. @CacheEvict - 删除缓存

作用 :方法执行后,删除缓存(适用于 delete 操作)。

适用场景

  • 数据删除后清理缓存
  • 缓存失效策略

示例

java 复制代码
@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的操作。

java 复制代码
@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 缓存
  • 减少了部分重复代码
  • 缓存配置更加集中

遗留问题

  • Redis 操作仍需手动编写
  • 两级缓存的同步逻辑仍需在业务代码中处理

版本3:自定义注解+AOP实现

如果单纯只是使用Cache注解进行缓存,还是无法把Redis功能实现从server模块中剥离出去。如果按照spring对cache注解的思路,我们可以自定义注解再利用AOP切片操作,把对应的缓存功能切入到service的代码中,就能实现二者之间的解耦。

首先,需要定义一个注解:

java 复制代码
/**
 * 双缓存注解,用于标记需要使用双缓存(通常为本地缓存和远程缓存)的方法
 */
@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;
}

定义一个枚举类型的变量,表示缓存操作的类型:

java 复制代码
public enum CacheType {
    FULL,   //存取
    PUT,    //只存
    DELETE  //删除
}

如果要支持springEL的表达式,还需要一个工具类来解析springEI的表达式:

java 复制代码
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的缓存操作:

java 复制代码
@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;
        }
    }
}

以上操作的主要工作总结下来是:

  1. 定义切点:匹配使用 @DoubleCache 注解的方法
  2. 参数解析与键生成:提取方法参数,解析 SpEL 表达式生成缓存键
  3. 缓存更新策略:PUT 类型执行方法后同步更新 Redis 和本地缓存
  4. 缓存删除策略:DELETE 类型先删除 Redis 和本地缓存,再执行方法
  5. 多级查询策略:优先查本地缓存 → Redis → 数据库,查询结果写入两级缓存

执行操作流程,以查询操作为例:

  1. 拦截被 @DoubleCache 标记的目标方法

  2. 生成缓存键 realKey

  3. 依次查询Caffeine → Redis → 数据库

  4. 将数据库结果写入两级缓存并返回

  5. 若触发更新/删除操作,则同步清理或更新缓存

java 复制代码
/**
* 根据订单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实现要点

  1. 解析注解参数
  2. 根据操作类型执行不同的缓存逻辑
  3. 处理缓存穿透、雪崩等问题
  4. 保证两级缓存的一致性

优势

  • 业务代码完全专注于业务逻辑
  • 缓存逻辑集中管理,便于维护
  • 注解配置灵活,可适应不同场景
  • 代码简洁,可读性高

关键配置

Caffeine 配置

java 复制代码
@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;
    }
}

Redis 配置

java 复制代码
@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;
}

性能优化建议

  1. 合理设置缓存过期时间
    • 本地缓存过期时间应短于 Redis 缓存
    • 根据数据更新频率调整过期策略
  2. 缓存穿透防护
    • 对空结果也进行缓存
    • 使用布隆过滤器
  3. 缓存雪崩防护
    • 设置随机过期时间
    • 实现熔断机制
  4. 一致性保证
    • 考虑使用消息队列同步多节点本地缓存
    • 对于关键数据,可采用"先更新数据库,再删除缓存"策略

总结

通过三个版本的演进,我们实现了一个从强耦合到完全解耦的两级缓存系统。最终版本利用自定义注解和 AOP 技术,既保持了代码的简洁性,又提供了强大的缓存功能。这种架构特别适合读多写少、对性能要求较高的场景。

在实际应用中,还需要根据具体业务特点调整缓存策略,并做好监控和指标收集,以便持续优化缓存效果。Redis + Caffeine 实现高效的两级缓存架构

相关推荐
DKPT1 天前
JVM中如何调优新生代和老生代?
java·jvm·笔记·学习·spring
phltxy1 天前
JVM——Java虚拟机学习
java·jvm·学习
seabirdssss1 天前
使用Spring Boot DevTools快速重启功能
java·spring boot·后端
喂完待续1 天前
【序列晋升】29 Spring Cloud Task 微服务架构下的轻量级任务调度框架
java·spring·spring cloud·云原生·架构·big data·序列晋升
benben0441 天前
ReAct模式解读
java·ai
轮到我狗叫了1 天前
牛客.小红的子串牛客.kotori和抽卡牛客.循环汉诺塔牛客.ruby和薯条
java·开发语言·算法
Volunteer Technology1 天前
三高项目-缓存设计
java·spring·缓存·高并发·高可用·高数据量
栗子~~1 天前
bat脚本- 将jar 包批量安装到 Maven 本地仓库
java·maven·jar
Mr.Entropy1 天前
ecplise配置maven插件
java·maven