Redis 多级缓存:一致性、失效与性能优化
本文讲解多级缓存的完整设计:Caffeine 本地缓存(L1)、Redis 分布式缓存(L2)、Spring Cache 注解抽象,以及二级缓存联动、缓存一致性与更新策略的工程实践。
目录
| 章节 | 说明 |
|---|---|
| 为什么需要多级缓存 | 本地缓存 vs 分布式缓存的定位 |
| L1 本地缓存 Caffeine | W-TinyLFU 算法、核心 API、配置 |
| Spring Cache 抽象 | @Cacheable / @CacheEvict / @CachePut |
| 二级缓存联动设计 | L1+L2 手动实现与 Layering Cache |
| 缓存更新策略 | Cache Aside / Write Through / Write Behind |
| 缓存一致性 | 双写不一致根因与解法 |
| 最佳实践 | 选型决策、Key 设计、监控 |
为什么需要多级缓存
单一缓存层的局限:
| 问题 | 纯本地缓存(单机) | 纯 Redis(分布式) |
|---|---|---|
| 网络开销 | 无 | 每次请求 ~1ms 网络往返 |
| 集群一致性 | 多实例数据不同步 | 天然共享 |
| 容量 | 受 JVM 堆限制(通常 < 1GB) | 可水平扩展 |
| 单点故障 | 服务重启缓存丢失 | Redis 集群保障 |
多级缓存的访问链路:
graph LR
Req["请求"] --> L1["L1: Caffeine<br/>本地内存,纳秒级"]
L1 -->|"未命中"| L2["L2: Redis<br/>分布式,毫秒级"]
L2 -->|"未命中"| DB["数据库 / 数据源"]
DB -->|"回填"| L2
L2 -->|"回填"| L1
style L1 fill:#cfc,stroke:#060
style L2 fill:#d1ecf1,stroke:#0c5460
style DB fill:#f8d7da,stroke:#721c24
选型原则:高频读、数据量小、实时性要求不高 → L1(本地);需要集群共享、数据量大 → L2(Redis);两者结合 → 多级缓存。
L1 本地缓存 Caffeine
Caffeine 是基于 W-TinyLFU 淘汰算法的高性能本地缓存,读写吞吐量接近 ConcurrentHashMap。
依赖
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<!-- Spring Boot 已管理版本,无需指定 -->
</dependency>
核心构建与 API
Cache<Long, User> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存 1 万条
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.expireAfterAccess(5, TimeUnit.MINUTES) // 最后访问后 5 分钟过期
.refreshAfterWrite(1, TimeUnit.MINUTES) // 写入后 1 分钟后台刷新(不阻塞读)
.recordStats() // 开启统计(命中率等)
.build();
// 获取,不存在返回 null
User user = cache.getIfPresent(1L);
// 获取,不存在时执行加载函数
User user = cache.get(1L, id -> userRepository.findById(id).orElse(null));
// 手动写入 / 删除
cache.put(1L, user);
cache.invalidate(1L);
cache.invalidateAll();
// 统计信息
CacheStats stats = cache.stats();
log.info("命中率: {}, 加载次数: {}", stats.hitRate(), stats.loadCount());
LoadingCache(自动加载)
LoadingCache<Long, User> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.refreshAfterWrite(1, TimeUnit.MINUTES) // 配合 CacheLoader 异步刷新
.build(id -> userRepository.findById(id).orElse(null)); // CacheLoader
// get() 自动触发加载,无需判断 null
User user = loadingCache.get(1L);
// 批量获取
Map<Long, User> users = loadingCache.getAll(List.of(1L, 2L, 3L));
关键参数选择
| 场景 | 推荐策略 |
|---|---|
| 数据更新后需立即失效 | expireAfterWrite + 主动 invalidate |
| 容忍短暂旧数据,避免缓存击穿 | refreshAfterWrite(后台刷新,不阻塞读) |
| 热点数据(访问不均匀) | maximumSize + W-TinyLFU 自动淘汰冷数据 |
| 软引用(内存不足自动释放) | softValues()(但会被 GC 突然回收,慎用) |
Spring Cache 抽象
Spring Cache 提供注解层,屏蔽底层缓存实现(Caffeine / Redis / Ehcache 均可)。
配置(Spring Boot 3.x)
spring:
cache:
type: caffeine # 或 redis、composite 等
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
@Configuration
@EnableCaching
public class CacheConfig {
// 细粒度配置(多个不同策略的 Cache)
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.recordStats());
return manager;
}
}
核心注解
@Service
public class UserService {
// 读缓存:先查缓存,未命中执行方法并写入
@Cacheable(cacheNames = "users", key = "#id",
condition = "#id > 0", // 满足条件才缓存
unless = "#result == null") // 结果为 null 时不缓存
public UserDTO getUser(Long id) {
return userRepository.findById(id).map(UserConverter::toDTO).orElse(null);
}
// 更新缓存:方法执行后更新缓存(不影响方法执行)
@CachePut(cacheNames = "users", key = "#result.id")
public UserDTO updateUser(UpdateUserRequest req) {
User user = userRepository.save(UserConverter.fromRequest(req));
return UserConverter.toDTO(user);
}
// 删除缓存:方法执行后删除指定 key
@CacheEvict(cacheNames = "users", key = "#id")
public void deleteUser(Long id) {
userRepository.deleteById(id);
}
// 删除整个 Cache(allEntries = true)
@CacheEvict(cacheNames = "users", allEntries = true)
public void refreshAllUsers() { }
// 组合多个缓存操作
@Caching(
put = @CachePut(cacheNames = "users", key = "#result.id"),
evict = @CacheEvict(cacheNames = "user-list", allEntries = true)
)
public UserDTO createUser(CreateUserRequest req) {
User user = userRepository.save(UserConverter.fromRequest(req));
return UserConverter.toDTO(user);
}
}
二级缓存联动设计
Spring Cache 的 @Cacheable 只支持单一缓存层。手动实现 L1+L2 联动更可控:
手动实现
@Service
@RequiredArgsConstructor
public class ProductCacheService {
private final LoadingCache<Long, Product> localCache;
private final RedisTemplate<String, Product> redisTemplate;
private final ProductRepository productRepository;
private static final Duration L2_TTL = Duration.ofMinutes(30);
private static final String KEY_PREFIX = "product:";
public Product getProduct(Long id) {
// 1. 查 L1(本地 Caffeine)
Product p = localCache.getIfPresent(id);
if (p != null) return p;
// 2. 查 L2(Redis)
String redisKey = KEY_PREFIX + id;
p = redisTemplate.opsForValue().get(redisKey);
if (p != null) {
localCache.put(id, p); // 回填 L1
return p;
}
// 3. 查数据库
p = productRepository.findById(id).orElse(null);
if (p != null) {
redisTemplate.opsForValue().set(redisKey, p, L2_TTL); // 回填 L2
localCache.put(id, p); // 回填 L1
}
return p;
}
public void evict(Long id) {
localCache.invalidate(id); // 清 L1
redisTemplate.delete(KEY_PREFIX + id); // 清 L2
}
}
集群 L1 同步问题
多个服务实例各自有独立 Caffeine,更新数据后其他实例的 L1 缓存仍是旧值。解决方式:
graph LR
App1["实例 1<br/>更新数据"] -->|"发布失效消息"| Redis["Redis Pub/Sub<br/>channel: cache.evict"]
Redis -->|"通知"| App1
Redis -->|"通知"| App2["实例 2<br/>收到消息→清 L1"]
Redis -->|"通知"| App3["实例 3<br/>收到消息→清 L1"]
// 发布失效消息
redisTemplate.convertAndSend("cache.evict", "product:" + id);
// 监听失效消息,清本地缓存
@Component
public class CacheEvictListener implements MessageListener {
private final LoadingCache<Long, Product> localCache;
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
if (key.startsWith("product:")) {
Long id = Long.parseLong(key.substring("product:".length()));
localCache.invalidate(id);
}
}
}
缓存更新策略
Cache Aside(旁路缓存,最常用)
读:先读缓存 → 未命中 → 读 DB → 写入缓存
写:先更新 DB → 再删除缓存(不是更新缓存!)
删而不是更新的原因:更新操作(先写 DB 再写缓存)在并发下可能用旧值覆盖新值;删除是幂等操作,下次读时自然回填。
Write Through(同步直写)
写:同时写 DB 和缓存(事务保证一致)
读:先读缓存,未命中读 DB 并回填
适合强一致性要求,但写性能下降。
Write Behind(异步回写)
写:先写缓存,异步批量写 DB
读:从缓存读,始终最新
写性能最高,但缓存宕机可能丢失数据。适合日志、访问计数等允许少量丢失的场景。
缓存一致性
双写不一致根因
Cache Aside 删除缓存后、下次读回填前,存在短暂不一致窗口。极端并发下还存在:
线程 A:更新 DB(新值)→ 删除缓存
线程 B:读缓存未命中 → 读 DB(旧值,A 还未提交)→ 写入缓存(旧值)
线程 A:DB 提交完成
→ 结果:缓存存的是旧值,DB 是新值
解法:延迟双删
// 更新 DB
userRepository.save(user);
// 第一次删除缓存
cache.delete(key);
// 延迟后再删一次(等待上面窗口期内的回填完成)
scheduler.schedule(() -> cache.delete(key), 500, TimeUnit.MILLISECONDS);
延迟时间:略大于"读 DB + 写缓存"的时间,通常 200~500ms。
解法:基于 Canal 的 CDC 同步
MySQL binlog → Canal → MQ → 缓存更新服务 → 删除/更新 Redis
业务代码只写 DB,缓存失效由异步 CDC 驱动,彻底解耦。适合一致性要求较高的场景。
最佳实践
Key 设计规范
格式:{业务}:{实体}:{id}[:字段]
示例:
user:profile:1001
product:detail:sku:20240101
order:status:2024010100001
user:list:status:ACTIVE:page:1
避免:Key 过长(增加内存和网络开销)、Key 随机(难以运维)、Key 包含特殊字符。
缓存监控
// Caffeine 统计
@Scheduled(fixedDelay = 60_000)
public void reportCacheStats() {
CacheStats stats = cache.stats();
metrics.gauge("cache.hit.rate", stats.hitRate());
metrics.counter("cache.miss.count", stats.missCount());
metrics.timer("cache.load.avg", stats.averageLoadPenalty(), TimeUnit.NANOSECONDS);
}
// Redis 命中率
// INFO stats 命令中的 keyspace_hits / keyspace_misses
选型速查
| 需求 | 推荐方案 |
|---|---|
| 单机,读热点数据 | Caffeine(L1) |
| 集群,共享数据 | Redis(L2) |
| 高频读 + 集群部署 | Caffeine(L1) + Redis(L2) 多级缓存 |
| 需要注解简化代码 | Spring Cache + CaffeineCacheManager |
| 集群 L1 一致性 | Redis Pub/Sub 通知 + Caffeine invalidate |
| 强一致性(不能有脏数据) | 不用本地缓存,仅 Redis + 延迟双删 |
参考资料
- Caffeine 官方文档
- Spring Cache 官方文档
- Redis 缓存设计与问题处理(Redis 缓存穿透、击穿、雪崩)
- Redis 高可用与集群(Redis 集群模式)
评论 (0)