1 需求背景
系统读多写少场景,大量热点字典、基础业务信息,请求全部打到 Redis,Redis CPU / 带宽压力高。 引入本地内存缓存,缩短访问链路;同时解决多实例本地缓存脏数据问题。
非目标
- 不用于强一致性业务(库存、余额),这类数据禁止使用 L1 本地缓存
- 不替代数据库,只做查询加速
2 总体架构
2.1 层级定义
- L1:Caffeine JVM 堆内存缓存,纳秒级读取,单机隔离;弱一致性,TTL 兜底;热点只读数据
- L2:Redis 分布式缓存,毫秒级,多实例共享
- L3:MySQL,源数据
2.2 读写流程
读流程
- 查询 L1 本地缓存,命中直接返回
- L1 未命中,查询 Redis;命中则回填 L1 并返回
- Redis 未命中,查询数据库;DB 结果回填 Redis+L1,返回结果
- DB 不存在数据,写入空标记到 Redis,短 TTL,防止缓存穿透
写流程(Cache Aside)
- 更新数据库
- 删除 Redis 对应 key
- Redis 发布失效广播消息
- 所有应用实例收到消息,删除本机 L1 缓存
原则:更新 DB,删缓存,不更新缓存。
2.3 一致性策略
- 一致性模型:最终一致性,允许短暂脏数据
- 保障手段:失效广播 + L1 短 TTL 兜底;消息丢失时,脏数据最多存活 L1 TTL 时长
- 强一致业务:禁用 L1 本地缓存,只使用 Redis
3 缓存数据分类与准入规范
表格
| 数据类型 | 是否启用 L1 | 示例 |
|---|---|---|
| 静态基础字典 | ✅ | 地区、枚举、配置 |
| 低频变更热点业务数据 | ✅ | 商品基础信息 |
| 高频变更业务数据 | ❌ | 订单状态、库存 |
| 冷数据 | ❌ | 历史归档数据 |
4 缓存策略设计
4.1 TTL 设计
- L1 Caffeine:写入过期,推荐 2~5min
- Redis:基础 TTL + 随机偏移,防止雪崩;推荐 30min + 0~300s 随机
- 空值缓存:短 TTL,30s
4.2 缓存问题防护方案
- 缓存穿透:DB 不存在数据写入空标记;超大 key 集合场景增加布隆过滤器
- 缓存击穿:热点 key 采用后台异步刷新;或使用 Redis 分布式锁,单请求加载 DB
- 缓存雪崩:TTL 随机;Redis 集群;服务熔断降级;本地缓存容量上限防 OOM
4.3 本地缓存失效方案
- 方案:Redis Pub/Sub 广播失效消息
- 缺点:PubSub 消息不可靠,消息丢失会产生脏数据;必须搭配 L1 短 TTL 兜底
- 高可靠场景:替换为 RocketMQ 消息做失效通知
5 组件设计
5.1 MultiCacheManager 核心组件
能力:多级 get、缓存失效 evict、缓存统计埋点、本地缓存开关降级
不直接复用 Spring Cache 注解,自定义封装,原生 Spring Cache 不支持多级联动失效广播。
5.2 缓存 Key 规范
格式:cache:{业务域}:{key} 示例:cache:user:1
5.3 序列化方案
使用 FastJSON2 序列化对象,禁止 JDK 序列化。
6 监控指标设计
表格
| 指标 | 说明 | 告警阈值 |
|---|---|---|
| caffeine.hitRate | L1 缓存命中率 | 低于 70% 告警 |
| redis.hitRate | Redis 缓存命中率 | 低于 60% 告警 |
| cache.pass.through.count | 缓存穿透次数 | 突增告警 |
| cache.invalid.msg.fail | 失效消息消费失败 | >0 告警 |
| redis.cpu | Redis CPU 使用率 | >80% 告警 |
7 实施与上线流程
- 业务梳理:识别热点数据,划分是否允许 L1 缓存
- 代码开发:接入 MultiCacheManager;埋点监控
- 单元测试:验证命中、失效、穿透场景
- 压测:高并发验证击穿、雪崩
- 灰度上线:小流量开启;配置开关可一键关闭 L1
- 全量上线,持续监控指标
8 风险与应急预案
风险清单
- 本地缓存脏数据:PubSub 消息丢失;预案:配置中心一键关闭 L1 本地缓存,降级为纯 Redis
- Caffeine 内存溢出:预案:强制配置 maximumSize,限制缓存条目上限
- Redis 故障:预案:熔断,直接访问 DB(限流保护)
- 大 key:禁止大对象存入缓存;大 key 拆分
9 使用约束
- 高频更新数据禁止开启 L1 本地缓存
- 强一致性业务禁用 L1
- L1 缓存必须设置 maxSize,防止 OOM
- 不使用缓存更新策略,统一删缓存
- L1 TTL 不能设置过长
10 后续优化方向
- 热点 key 异步刷新
- 布隆过滤器接入,拦截不存在 key
- MQ 替换 PubSub,实现可靠失效通知
- 接入 Prometheus 指标埋点
- 缓存预热组件
1. SpringBoot 完整可运行多级缓存 Demo
技术栈:SpringBoot 2.7.x/ 3.x,Caffeine,Spring Data Redis,Redisson(可选分布式锁),Redis PubSub 做本地缓存失效广播 能力:L1=Caffeine 本地缓存,L2=Redis 分布式缓存;Cache Aside;空值防穿透;缓存埋点;开关控制本地缓存;Redis PubSub 失效广播;可一键关闭 L1 降级。 注意:SpringBoot3 需要使用
jakarta包,下面代码默认 SpringBoot2,按需替换。
Maven pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.15</version>
<relativePath/>
</parent>
<groupId>com.cache</groupId>
<artifactId>multi-level-cache-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>multi-level-cache-demo</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.github.benmanes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
application.yml
spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
multi-cache:
# 本地缓存开关,生产故障时一键关闭L1
enable-local-cache: true
# Caffeine 最大缓存条目
caffeine-max-size: 10000
# Caffeine 写入后过期时间,单位秒
caffeine-expire-write: 120
# Redis基础过期时间,单位秒
redis-base-ttl: 1800
# Redis随机偏移,防止雪崩,单位秒
redis-random-ttl: 300
# 空值缓存TTL,防穿透
null-cache-ttl: 30
cache-topic: multi:cache:invalidate:topic
配置类:MultiCacheConfig.java
package com.cache.config;
import com.alibaba.fastjson2.JSON;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.util.concurrent.TimeUnit;
@Slf4j
@Configuration
public class MultiCacheConfig {
@Value("${multi-cache.enable-local-cache}")
private boolean enableLocalCache;
@Value("${multi-cache.caffeine-max-size}")
private long caffeineMaxSize;
@Value("${multi-cache.caffeine-expire-write}")
private long caffeineExpireWrite;
@Value("${cache-topic}")
private String cacheInvalidTopic;
/**
* Caffeine L1本地缓存
*/
@Bean
public com.github.benmanes.caffeine.cache.Caffeine<Object, Object> caffeineBuilder() {
return Caffeine.newBuilder()
.maximumSize(caffeineMaxSize)
.expireAfterWrite(caffeineExpireWrite, TimeUnit.SECONDS)
.recordStats();
}
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(stringSerializer);
redisTemplate.setValueSerializer(stringSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
/**
* Redis Pub/Sub 消息容器,监听缓存失效消息
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory factory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listenerAdapter, new ChannelTopic(cacheInvalidTopic));
return container;
}
@Bean
public MessageListenerAdapter messageListenerAdapter(CacheInvalidMessageHandler messageHandler) {
return new MessageListenerAdapter(messageHandler, "onMessage");
}
}
失效消息处理器:CacheInvalidMessageHandler.java
package com.cache.config;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class CacheInvalidMessageHandler {
@Autowired
private Cache<Object, Object> caffeineCache;
/**
* 收到广播消息,清除本机L1缓存
*/
public void onMessage(String key) {
log.info("收到缓存失效广播,清除本地缓存key:{}", key);
caffeineCache.invalidate(key);
}
}
多级缓存核心工具类:MultiCacheManager.java
package com.cache.manager;
import com.alibaba.fastjson2.JSON;
import com.github.benmanes.caffeine.cache.Cache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.Random;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
@Slf4j
@Component
public class MultiCacheManager {
private static final String NULL_FLAG = "NULL_CACHE";
@Autowired
private Cache<Object, Object> caffeineCache;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Value("${multi-cache.enable-local-cache}")
private boolean enableLocalCache;
@Value("${multi-cache.redis-base-ttl}")
private long redisBaseTtl;
@Value("${multi-cache.redis-random-ttl}")
private long redisRandomTtl;
@Value("${multi-cache.null-cache-ttl}")
private long nullCacheTtl;
@Value("${cache-topic}")
private String cacheInvalidTopic;
private final Random random = new Random();
/**
* 多级缓存查询
* @param key 缓存key
* @param clazz 返回对象类型
* @param dbLoader 数据库查询逻辑
* @return 业务对象
*/
public <T> T get(String key, Class<T> clazz, Supplier<T> dbLoader) {
//1.优先读取L1本地缓存
if (enableLocalCache) {
Object localVal = caffeineCache.getIfPresent(key);
if (localVal != null) {
log.debug("L1缓存命中 key={}", key);
return (T) localVal;
}
}
//2.读取Redis L2缓存
String redisStr = redisTemplate.opsForValue().get(key);
if (redisStr != null) {
if (NULL_FLAG.equals(redisStr)) {
log.debug("Redis命中空缓存 key={}", key);
return null;
}
T val = JSON.parseObject(redisStr, clazz);
//回填本地缓存
if (enableLocalCache) {
caffeineCache.put(key, val);
}
log.debug("Redis缓存命中 key={}", key);
return val;
}
log.debug("缓存全部未命中,查询DB key={}", key);
//3.查询数据库
T dbData = dbLoader.get();
if (dbData == null) {
//写入空值,防穿透
redisTemplate.opsForValue().set(key, NULL_FLAG, nullCacheTtl, TimeUnit.SECONDS);
return null;
}
//4.回填Redis,增加随机TTL,防止雪崩
long realTtl = redisBaseTtl + random.nextLong(redisRandomTtl);
redisTemplate.opsForValue().set(key, JSON.toJSONString(dbData), realTtl, TimeUnit.SECONDS);
//回填本地缓存
if (enableLocalCache) {
caffeineCache.put(key, dbData);
}
return dbData;
}
/**
* 更新/删除数据后,失效缓存
* @param key 缓存key
*/
public void evict(String key) {
// 删除Redis缓存
redisTemplate.delete(key);
// 删除本机本地缓存
if (enableLocalCache) {
caffeineCache.invalidate(key);
}
//广播失效消息,通知其他应用实例清本地缓存
redisTemplate.convertAndSend(cacheInvalidTopic, key);
log.info("缓存失效完成,广播消息 key={}", key);
}
/**
* 获取缓存统计信息,用于监控
*/
public String getCacheStats() {
if (!enableLocalCache) {
return "local cache disabled";
}
return caffeineCache.stats().toString();
}
}
测试实体:User.java
package com.cache.entity;
import lombok.Data;
@Data
public class User {
private Long id;
private String username;
private String phone;
}
Service 测试层:UserService.java
package com.cache.service;
import com.cache.entity.User;
import com.cache.manager.MultiCacheManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class UserService {
private final MultiCacheManager multiCacheManager;
//模拟DB
public User getById(Long id) {
String cacheKey = "cache:user:" + id;
return multiCacheManager.get(cacheKey, User.class, () -> {
//模拟数据库查询逻辑
if (id == 1L) {
User user = new User();
user.setId(1L);
user.setUsername("test");
user.setPhone("13800138000");
return user;
}
return null;
});
}
public void updateUser(Long id) {
//1.更新DB(省略)
//2.失效缓存
String cacheKey = "cache:user:" + id;
multiCacheManager.evict(cacheKey);
}
}
Controller 测试入口:CacheTestController.java
package com.cache.controller;
import com.cache.entity.User;
import com.cache.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class CacheTestController {
private final UserService userService;
@GetMapping("/user/get")
public User getUser(@RequestParam Long id) {
return userService.getById(id);
}
@PostMapping("/user/update")
public String updateUser(@RequestParam Long id) {
userService.updateUser(id);
return "ok";
}
@GetMapping("/cache/stats")
public String stats() {
return userService.getCacheStats();
}
}
Demo 说明:
- 多实例启动时,更新接口触发
evict,Redis 发布消息,所有实例收到消息清除本机 Caffeineenable-local-cache配置项可以关闭 L1,降级为纯 Redis 缓存- 空值缓存防止穿透;Redis TTL 随机防雪崩
- 没有内置分布式锁;热点 key 击穿场景,可自行在 get 方法增加 Redisson 锁