上一篇博客浅谈了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 内置支持多级缓存,代码更简洁。生产环境、快速开发,复杂度低
- 添加依赖
java
<dependency>
<groupId>com.alicp.jetcache</groupId>
<artifactId>jetcache-starter-redis-lettuce</artifactId>
<version>2.7.5</version>
</dependency>
- 配置 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
- 注解方式使用多级缓存
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