探究Redis + Caffeine两级缓存架构

上一篇博客浅谈了MyBatis的二级缓存,但不符合工程实践的要求。本篇博客将探究更符合生产实践的缓存架构------Redis + Caffeine两级缓存架构

1、caffeine

Redis大家一定熟悉,Caffeine比较陌生,它是基于 JAVA 8 的高性能本地缓存库,使用了Java 8最新的StampedLock锁技术,属于内存级本地缓存。spring 官方使用了 Caffeine 作为默认缓存组件。可参考github官方

淘汰策略 :采用 Window-TinyLFU 算法,结合了 LRU(最近最少使用)和 LFU(最不经常使用)的优点。
滑动窗口分区 : 缓存分为一个 主区域(保护高频数据)和一个 窗口区域(接纳新数据),避免突发流量污染缓存。
频率素描 : 以极低的内存开销统计数据访问频率,替代传统 LFU 的哈希计数。
高性能并发设计:分段锁机制: 写操作分段加锁,减少线程竞争。无锁读优化: 通过 AtomicReference 实现并发读的高性能。

2、Redis+Caffeine实现两级缓存

2.1 依赖

java 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.2.2</version>
</dependency>

2.2 配置类

RedisConfig

java 复制代码
public classRedisConfig {
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = newRedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        Jackson2JsonRedisSerializer<Object> serializer = newJackson2JsonRedisSerializer<>(Object.class);
        ObjectMappermapper=newObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);
        serializer.setObjectMapper(mapper);

        redisTemplate.setKeySerializer(newStringRedisSerializer());
        redisTemplate.setValueSerializer(serializer);
        redisTemplate.setHashKeySerializer(newStringRedisSerializer());
        redisTemplate.setHashValueSerializer(serializer);
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }
}

CaffeineConfig

java 复制代码
public classCaffeineConfig {
    @Bean
    public Cache<String, Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(128)
                .maximumSize(1024)
                .expireAfterWrite(60, TimeUnit.SECONDS)
                .build();
    }

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManagercacheManager=newCaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .initialCapacity(128)
                .maximumSize(1024)
                .expireAfterWrite(60, TimeUnit.SECONDS));
        return cacheManager;
    }
}

2.3 注解实现

DoubleCache 注解

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DoubleCache {
    String cacheName();
    String[] key();
    longexpireTime() default 120;
    CacheType type() default CacheType.FULL;

    enumCacheType {
        FULL, PUT, DELETE
    }
}

DoubleCacheAspect

java 复制代码
@Slf4j
@Component
@Aspect
public class DoubleCacheAspect {
    @Resource
    private Cache caffeineCache;

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Pointcut("@annotation(com.test.redis.annotation.DoubleCache)")
    public void doubleCachePointcut() {}

    @Around("doubleCachePointcut()")
    public Object doAround(ProceedingJoinPoint point)throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        String[] paramNames = signature.getParameterNames();
        Object[] args = point.getArgs();
        TreeMap<String, Object> treeMap = newTreeMap<>();
        for (int i = 0; i < paramNames.length; i++) {
            treeMap.put(paramNames[i], args[i]);
        }
        Double Cacheannotation = method.getAnnotation(DoubleCache.class);
        String elResult = DoubleCacheUtil.arrayParse(Lists.newArrayList(annotation.key()), treeMap);
        String realKey = annotation.cacheName() + ":" + elResult;

        if (annotation.type() == DoubleCache.CacheType.PUT) {
            Object object = point.proceed();
            redisTemplate.opsForValue().set(realKey, object, annotation.expireTime(), TimeUnit.SECONDS);
            caffeineCache.put(realKey, object);
            return object;
        } elseif (annotation.type() == DoubleCache.CacheType.DELETE) {
            redisTemplate.delete(realKey);
            caffeineCache.invalidate(realKey);
            return point.proceed();
        }
        Object caffeineCacheObj = caffeineCache.getIfPresent(realKey);
        if (Objects.nonNull(caffeineCacheObj)) {
            log.info("get data from caffeine");
            return caffeineCacheObj;
        }
        Object redisCache = redisTemplate.opsForValue().get(realKey);
        if (Objects.nonNull(redisCache)) {
            log.info("get data from redis");
            caffeineCache.put(realKey, redisCache);
            return redisCache;
        }
        log.info("get data from database");
        Object object = point.proceed();
        if (Objects.nonNull(object)) {
            log.info("get data from database write to cache: {}", object);
            redisTemplate.opsForValue().set(realKey, object, annotation.expireTime(), TimeUnit.SECONDS);
            caffeineCache.put(realKey, object);
        }
        return object;
    }
}

切面中根据操作缓存的类型,分别处理更新、删除、二级缓存读取缓存操作。

更新缓存的操作,先执行原方法,再将value进行更新。

二级缓存读取缓存时,先读取caffeine本地缓存,再读取redis,若两者都没有,则执行原方法,执行后再将value进行更新,这就是第一次读取进行插入两级缓存的操作。

2.4 注解使用

service层的业务代码,可以加上@DoubleCache注解,进行缓存的查询、更新与删除操作。

java 复制代码
@DoubleCache(cacheName = "order", key = "#id", type = CacheType.FULL)
public Order getOrderById(Long id) {
    Order myOrder = orderMapper.selectOne(new LambdaQueryWrapper<Order>().eq(Order::getId, id));
    return myOrder;
}
@DoubleCache(cacheName = "order", key = "#order.id", type = CacheType.PUT)
public Order updateOrder (Order order) {
    orderMapper.updateById(order);
    return order;
}
@DoubleCache(cacheName = "order", key = "#id", type = CacheType.DELETE)
 public void deleteOrder (Long id){
    orderMapper.deleteById(id);
}

存入redis中的key示例为:

order:20260114000001234

其中的分隔符在YAML 格式 (application.yml)文件中可配置

java 复制代码
cache:
  prefix: "my_project"       # 全局前缀
  separator: ":"            # <--- 分隔符

4、JetCache实现多级缓存(阿里开源,推荐生产)

JetCache 内置支持多级缓存,代码更简洁。生产环境、快速开发,复杂度低

  1. 添加依赖
java 复制代码
<dependency>
    <groupId>com.alicp.jetcache</groupId>
    <artifactId>jetcache-starter-redis-lettuce</artifactId>
    <version>2.7.5</version>
</dependency>
  1. 配置 application.yml
java 复制代码
jetcache:
  statIntervalMinutes: 15
  areaInCacheName: false
  local:
    default:
      type: caffeine
      keyConvertor: fastjson
      limit: 1000
  remote:
    default:
      type: redis.lettuce
      keyConvertor: fastjson
      valueEncoder: java
      valueDecoder: java
      poolConfig:
        minIdle: 5
        maxIdle: 20
        maxTotal: 50
      host: localhost
      port: 6379
  1. 注解方式使用多级缓存
java 复制代码
@CreateCache(name = "order", expire = 300, cacheType = CacheType.BOTH)
private Cache<Long, User> userCache;

public User getUserById(Long id) {
		//只有当缓存中 key=id 的数据【不存在】(null) 时,才会执行这个 Lambda 表达式
    return userCache.computeIfAbsent(id, (key) -> {
        return userMapper.selectById(key);
        // 将查到的结果自动存入两级缓存 (本地 + 远程)
        // 返回结果
    });
    // 如果缓存【存在】,直接返回缓存值,根本不会执行上面的 Lambda,也不会查数据库
}

public void updateUser(User user) {
		// 步骤 1: 更新数据库
    userMapper.updateById(user);
    // 步骤 2: 删除缓存 (关键步骤!)
    userCache.remove(user.getId()); // 自动清除两级缓存
}

对于缓存使用中常见的问题,可以有如下建议:

缓存穿透:对空结果缓存短 TTL(如 60 秒),或者缓存value为null

缓存雪崩:Redis 设置随机 TTL(如 30±5 分钟)

缓存击穿:Caffeine 本身抗高并发;Redis 可用互斥锁

数据一致性,更新时先更新 DB,再删除缓存(Cache-Aside 模式),随后在查询的时候,就会更新最新的缓存

分布式环境,本地缓存不一致,多实例部署时,本地缓存无法同步, 可通过 MQ 广播失效消息;此种做法较为复杂,一般靠 TTL 自愈;或者通过提供接口,手动调用刷新各个实例的本地缓存。

5、Caffeine长周期本地缓存实现

对于一个不经常变动的参数数据,比如说省份地区信息等,黑名单渠道信息,数据量通常不超过10000条,大小不超过10M,如果采用两级缓存的做法,会频繁查询数据库进而更新缓存,导致一定程度上耗时上升,可以采用Caffeine长周期本地缓存实现,比如黑名单渠道,Caffeine本地缓存查询不到,即不是黑名单渠道,这将防止缓存穿透到数据库的情况,极大提升业务处理的效率。

java 复制代码
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 黑名单渠道长周期本地缓存实现
 * 适用于不常变动、小体量数据的缓存场景
 */
@Component
public class BlacklistChannelLocalCache {

    // Caffeine 缓存实例:Key-渠道ID,Value-渠道信息(此处简化为String)
    private Cache<String, String> blacklistChannelCache;

    private final BlacklistChannelDao blacklistChannelDao = new BlacklistChannelDao();

    /**
     * 初始化缓存:项目启动时加载全量数据,配置长周期缓存策略
     */
    @PostConstruct
    public void initCache() {
        // 缓存配置:
        // 1. 最大容量10000条(适配业务数据量,最大约6M)
        // 2. 过期时间7天(长周期,可根据业务调整)
        // 3. 不设置自动刷新(数据不常变动,避免无效开销)
        blacklistChannelCache = Caffeine.newBuilder()
                .maximumSize(10000)
                .expireAfterWrite(7, TimeUnit.DAYS)
                .build();

        // 项目启动时加载全量黑名单渠道到缓存
        loadAllBlacklistChannelsToCache();
    }

    /**
     * 核心查询方法:判断渠道是否在黑名单中
     * 缓存中查不到则直接判定为非黑名单,避免穿透到数据库
     * @param channelId 渠道ID
     * @return true-黑名单,false-非黑名单
     */
    public boolean isBlacklistChannel(String channelId) {
        // 从缓存查询,null则表示非黑名单
        String channelInfo = blacklistChannelCache.getIfPresent(channelId);
        return channelInfo != null;
    }

    /**
     * 手动刷新缓存(当数据变动时调用,如后台修改黑名单)
     * 避免长周期缓存导致数据更新不及时
     */
    public void refreshCache() {
        // 清空旧缓存
        blacklistChannelCache.invalidateAll();
        // 重新加载全量数据
        loadAllBlacklistChannelsToCache();
    }

    /**
     * 加载全量黑名单渠道到缓存
     */
    private void loadAllBlacklistChannelsToCache() {
        // 从数据源查询全量数据
        List<BlacklistChannel> allChannels = blacklistChannelDao.queryAllBlacklistChannels();
        // 批量放入缓存
        Set<String> channelIds = allChannels.stream()
                .map(BlacklistChannel::getChannelId)
                .collect(Collectors.toSet());
        for (BlacklistChannel channel : allChannels) {
            blacklistChannelCache.put(channel.getChannelId(), channel.getChannelInfo());
        }
        System.out.println("缓存初始化完成,加载黑名单渠道数量:" + channelIds.size());
    }

    // ===================== 业务类=====================
    /**
     * 黑名单渠道实体
     */
    static class BlacklistChannel {
        private String channelId; // 渠道ID
        private String channelInfo; // 渠道信息(如名称、类型等)

        // 构造器、getter/setter
        public BlacklistChannel(String channelId, String channelInfo) {
            this.channelId = channelId;
            this.channelInfo = channelInfo;
        }

        public String getChannelId() {
            return channelId;
        }

        public String getChannelInfo() {
            return channelInfo;
        }
    }

    /**
     * 数据库访问层Dao,用于测试
     */
    static class BlacklistChannelDao {
        /**
         * 查询全量黑名单渠道
         */
        public List<BlacklistChannel> queryAllBlacklistChannels() {
            return List.of(
                    new BlacklistChannel("channel_001", "违规渠道A"),
                    new BlacklistChannel("channel_002", "违规渠道B"),
                    new BlacklistChannel("channel_003", "违规渠道C")
            );
        }
    }

    // ===================== 测试方法 =====================
    public static void main(String[] args) {
        BlacklistChannelLocalCache cache = new BlacklistChannelLocalCache();
        cache.initCache();

        // 测试1:查询黑名单渠道
        System.out.println("channel_001 是否黑名单:" + cache.isBlacklistChannel("channel_001")); // true
        // 测试2:查询非黑名单渠道(缓存无数据,直接返回false,不查库)
        System.out.println("channel_999 是否黑名单:" + cache.isBlacklistChannel("channel_999")); // false
        // 测试3:刷新缓存
        cache.refreshCache();
    }
}

1. 缓存初始化(initCache):

使用 @PostConstruct 注解,项目启动时自动执行,避免运行时首次查询触发数据库访问;

若使用 Spring 框架,使用 @Component、@PostConstruct 注解能被扫描到;

配置 maximumSize(10000) 限制缓存容量,适配业务数据量;

expireAfterWrite(7, TimeUnit.DAYS) 设置 7 天过期,实现 "长周期" 缓存,减少更新频率。

此处通常配合一个批量,在7天的周期内进行刷新,比如批量设置6天定期刷新一次。
2. 核心查询方法(isBlacklistChannel):

仅从缓存查询(getIfPresent),不调用数据库;

缓存中查不到则直接返回 false,彻底避免缓存穿透到数据库,符合 "查询不到即非黑名单" 的业务逻辑。
3. 缓存刷新(refreshCache):

提供手动刷新入口,当数据变动(如后台修改黑名单)时调用,解决长周期缓存的 "数据新鲜度" 问题;

先清空旧缓存再重新加载,避免新旧数据混用。
4. 防穿透设计:

核心逻辑是 "缓存未命中 = 非黑名单",无需像传统缓存那样 "查库后更新缓存",从根源上杜绝穿透;

数据量小(≤10M),全量加载到内存无性能压力。

Caffeine长周期本地缓存实现总结

核心设计:长周期缓存 + 启动全量加载 + 缓存未命中直接判定,避免数据库访问和缓存穿透;

关键特性:配置合理的容量和过期时间,提供手动刷新入口平衡 "长周期" 和 "数据新鲜度";

性能优势:纯内存查询(Caffeine 性能接近 HashMap),查询耗时微秒级,远高于数据库查询。

该实现可直接适配省份地区、黑名单渠道等小体量、低频变动数据的缓存场景,极大提升业务处理效率。

4、总结

本文首先介绍了caffeine的原理,通过手写Redis+Caffeine实现两级缓存进一步理解了缓存的用法,在实际生产项目中,更推荐JetCache实现多级缓存,最后介绍了在一些特殊场景中推荐使用的本地缓存的做法。缓存是一把双刃剑,提升了查询效率。redis的无故障时间虽然很少,但也存在故障的可能性,因此在项目设计过程中,应采用冗余兜底策略,使查询的数据高效准确。

参考:

https://blog.csdn.net/weixin_43887285/article/details/147238521

https://www.jb51.net/database/347796681.htm

https://cloud.tencent.com/developer/article/2357414

https://blog.csdn.net/LearnerDL/article/details/138173159

相关推荐
C澒2 小时前
前端跨业务线代码复用标准化体系构建与实践
前端·架构
laozhao4322 小时前
阿里云213万中标兼容CUDA架构智能算力设备采购项目
阿里云·架构·云计算
檐下翻书1732 小时前
公司组织架构调整工具 在线可视化编辑平台
论文阅读·人工智能·信息可视化·架构·去中心化·流程图
星轨zb2 小时前
非遗AI对话系统架构升级实战
java·人工智能·redis·后端·系统架构
乾元2 小时前
Agent 模式: 构建能够自主调用工具的安全智能体
网络·人工智能·安全·网络安全·架构·安全架构
DisonTangor2 小时前
黑森林研究所提出KV缓存方式让生图模型能更好地多参考编辑
人工智能·缓存·ai作画·开源·aigc
ezreal_pan2 小时前
Redis SCAN 命令使用指南(华为云Redis版)
redis·scan
qq5680180763 小时前
HDFS的架构优势与基本操作
hadoop·hdfs·架构