为什么需要两层缓存
响应式系统中,缓存不仅是性能优化,更是故障隔离手段。当数据库不可用时,缓存提供降级能力。
请求
→ L1 Caffeine Async(本地,微秒级)
→ L2 Redis Reactive(远程,亚毫秒级)
→ R2DBC(数据库,毫秒级)
| 层级 | 延迟量级(参考) | 容量 | 一致性 |
|---|---|---|---|
| Caffeine Async | 微秒级 | JVM 堆内存 | 进程级 |
| Redis Reactive | 亚毫秒到毫秒级 | 分布式 | 集群级 |
| R2DBC | 毫秒级或更高 | 磁盘 | 强一致 |
这里的延迟只是量级参考,实际值取决于硬件、网络、序列化方式和查询复杂度;做容量规划时必须以本机或目标环境的压测结果为准。
Caffeine Async
Caffeine 是高性能本地缓存,支持异步加载:
import com.github.benmanes.caffeine.cache.AsyncCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
public class LocalCacheConfig {
public AsyncCache<Long, User> userCache() {
return Caffeine.newBuilder()
.maximumSize(10_000) // 最多 1 万条
.expireAfterWrite(Duration.ofMinutes(10)) // 写入 10 分钟后过期
.recordStats() // 启用统计
.buildAsync(); // 加载器在 get(key, mappingFunction) 时传入
}
}
也可以给 buildAsync 传全局 AsyncCacheLoader<Long, User>,它的签名是 (key, executor) -> CompletableFuture;不能直接传 Function<Long, CompletableFuture<User>>,两者不是同一个函数式接口。
使用:
AsyncCache<Long, User> cache = userCache();
public Mono<User> getUser(Long id) {
return Mono.fromFuture(cache.get(id,
k -> userRepo.findById(k).toFuture())); // Mono → CompletableFuture
}
加载器返回 CompletableFuture。缓存未命中时 Caffeine 调用加载器,同一 key 的并发请求拿到的是同一个 future,加载只执行一次;线程不会阻塞,各调用方在 future 完成时各自被回调。
Redis Reactive
Lettuce 是 Redis 的响应式 Java 客户端:
import io.lettuce.core.RedisClient;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.reactive.RedisReactiveCommands;
import io.lettuce.core.codec.StringCodec;
@Configuration
public class RedisConfig {
@Bean(destroyMethod = "shutdown")
public RedisClient redisClient() {
return RedisClient.create("redis://localhost:6379");
}
@Bean(destroyMethod = "close")
public StatefulRedisConnection<String, String> redisConnection(RedisClient client) {
return client.connect(StringCodec.UTF8);
}
@Bean
public RedisReactiveCommands<String, String> redisCommands(
StatefulRedisConnection<String, String> connection) {
return connection.reactive();
}
}
RedisClient 和 StatefulRedisConnection 都是长生命周期资源,要像上面这样分别暴露成 Bean,让 Spring 在容器关闭时按 destroyMethod 释放。反例是在一个 @Bean 方法里写 RedisClient.create(...).connect(...).reactive() 然后只返回 commands:client 和连接都没有 Bean 引用,容器关闭时没有人调用 shutdown()/close()。实际项目更常用 Spring Data Redis 的 ReactiveStringRedisTemplate(底层同样是 Lettuce),连接生命周期由自动配置托管。
基本操作:
@Service
public class RedisCacheService {
private final RedisReactiveCommands<String, String> redis;
public RedisCacheService(RedisReactiveCommands<String, String> redis) {
this.redis = redis;
}
public Mono<String> get(String key) {
return redis.get(key);
}
public Mono<String> set(String key, String value, Duration ttl) {
return redis.setex(key, ttl.getSeconds(), value);
}
public Mono<Long> delete(String key) {
return redis.del(key);
}
}
Cache-Aside 模式
最常用的缓存模式:应用负责读写缓存,缓存不主动与数据库交互。
@Service
public class OrderService {
private final OrderRepository repo;
private final AsyncCache<Long, Order> localCache;
private final RedisCacheService redisCache;
public Mono<Order> getOrder(Long id) {
return Mono.fromFuture(localCache.get(id,
id_ -> getFromRedisOrDb(id_).toFuture()));
}
private Mono<Order> getFromRedisOrDb(Long id) {
return redisCache.get("order:" + id)
.flatMap(json -> Mono.justOrEmpty(deserialize(json)))
.switchIfEmpty(
repo.findById(id)
.flatMap(order ->
redisCache.set("order:" + id, serialize(order), Duration.ofMinutes(5))
.thenReturn(order))
);
}
}
读取流程:
getOrder(id)
→ Caffeine.get(id)
命中?→ 返回
未命中?→ 加载器执行
→ Redis.get("order:id")
命中?→ 返回并存入 Caffeine
未命中?→ R2DBC 查询
→ Redis.set("order:id", json, TTL)
→ 返回并存入 Caffeine
Write-Through 模式
写操作同时更新缓存:
public Mono<Order> createOrder(Order order) {
return repo.save(order)
.flatMap(saved -> {
String json = serialize(saved);
// 写 Redis(TTL 1 分钟,防永不过期)
return redisCache.set("order:" + saved.id(), json, Duration.ofMinutes(1))
.doOnSuccess(v -> localCache.synchronous().invalidate(saved.id())) // 清本地缓存
.thenReturn(saved);
});
}
注意:不直接写 Caffeine。因为 Caffeine 是本地缓存,分布式部署时其他节点的 Caffeine 不会感知更新。失效本地缓存,让下次读取从 Redis 重新加载。
优势是缓存里始终有较新的数据;劣势是写请求要等 Redis 返回,写延迟上升,适合读多写少。如果把 Redis 写入也丢给消息队列异步处理,语义就变成了 write-behind:主流程更快,但缓存与数据库之间存在延迟窗口,一致性等级下降,需要能接受丢消息或重复消费的补偿机制。
缓存问题防护
穿透(Cache Penetration)
查询一个不存在的 key,缓存和数据库都没有,每次请求都打到数据库。
解决:空值缓存。
public Mono<Order> getOrder(Long id) {
String cacheKey = "order:" + id;
return redisCache.get(cacheKey)
.flatMap(json -> "NULL".equals(json)
? Mono.error(new OrderNotFoundException(id))
: Mono.just(deserialize(json)))
.switchIfEmpty(Mono.defer(() ->
repo.findById(id)
.flatMap(order ->
redisCache.set(cacheKey, serialize(order), Duration.ofMinutes(5))
.thenReturn(order))
.switchIfEmpty(
redisCache.set(cacheKey, "NULL", Duration.ofMinutes(1))
.then(Mono.error(new OrderNotFoundException(id)))
)
));
}
数据库查询为空时,在 Redis 写入 "NULL"(短 TTL,如 1 分钟),防止重复穿透。这里必须用异常而不是 Mono.empty() 表示空值命中:switchIfEmpty 分不清「缓存未命中要查库」和「缓存命中空值应直接返回」,用空值哨兵又返回空 Mono,会让每次请求仍然落到数据库,空值缓存就白写了。
击穿(Cache Breakdown)
一个热点 key 过期的瞬间,大量并发请求同时打到数据库。
解决:互斥加载。
Caffeine Async 天然防止:同一个 key 的并发未命中只执行一次加载器。Redis 层需要额外处理:
import io.lettuce.core.ScriptOutputType;
import io.lettuce.core.SetArgs;
import java.util.UUID;
// 放在 RedisCacheService 中;redis 是 RedisReactiveCommands<String, String>
// 获取成功返回自己的 token;NX 未抢到时 Redis 回 nil,Lettuce 转成空 Mono
Mono<String> acquireLock(String lockKey, Duration ttl) {
return Mono.defer(() -> {
String token = UUID.randomUUID().toString();
// 必须用 map 而不是 thenReturn:set NX 未抢到锁时返回空 Mono,
// thenReturn 仍会发射 token,等于假装拿到了锁
return redis.set(lockKey, token, SetArgs.Builder.nx().px(ttl.toMillis()))
.map(ok -> token);
});
}
// 只有 value 仍等于自己的 token 才删除,避免锁过期后误删别人的锁
Mono<Long> releaseLock(String lockKey, String token) {
return redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) else return 0 end",
ScriptOutputType.INTEGER,
new String[] { lockKey }, token);
}
public Mono<Order> getOrderWithLock(Long id) {
String cacheKey = "order:" + id;
String lockKey = "lock:order:" + id;
Mono<Order> loadWithLock = Mono.usingWhen(
redisCache.acquireLock(lockKey, Duration.ofSeconds(10)),
token -> repo.findById(id)
.switchIfEmpty(Mono.error(new OrderNotFoundException(id)))
.flatMap(order -> redisCache.set(cacheKey, serialize(order),
Duration.ofMinutes(5)).thenReturn(order)),
token -> redisCache.releaseLock(lockKey, token));
return redisCache.get(cacheKey)
.flatMap(json -> Mono.justOrEmpty(deserialize(json)))
.switchIfEmpty(loadWithLock)
// 没抢到锁(空 Mono)才等待重试;数据库真查不到会以异常结束,不会走到这里
.switchIfEmpty(Mono.delay(Duration.ofMillis(100))
.then(Mono.defer(() -> getOrderWithLock(id))));
}
等待重试是递归的,生产代码应在调用方加 .timeout(...) 或最大重试次数,避免持锁方异常时请求无限等待。
雪崩(Cache Avalanche)
大量 key 同时过期,瞬间所有请求打到数据库。
解决:随机 TTL。不让缓存同时失效:
Duration baseTtl = Duration.ofMinutes(10);
Duration jitter = Duration.ofSeconds((long)(Math.random() * 60)); // 0-60 秒随机
Duration ttl = baseTtl.plus(jitter);
redisCache.set(key, value, ttl);
缓存统计
Caffeine 统计命中率:
CacheStats stats = localCache.synchronous().stats();
System.out.println("Hit rate: " + stats.hitRate());
System.out.println("Miss count: " + stats.missCount());
System.out.println("Load average: " + stats.averageLoadPenalty().toMillis() + "ms");
命中率 < 80% 时,需要排查:
- TTL 设置过短
- 缓存 key 设计不合理(频繁变更的参数)
- 预热不足(启动后大量冷数据)
eventd v2 缓存策略
eventd v2 的缓存配置:
L1 Caffeine Async(本地):
- 用户基础信息:TTL 5 分钟,max 1 万条
- 订单热点数据:TTL 1 分钟,max 5 万条
L2 Redis Reactive(集群):
- session/Token 黑名单:TTL = token 剩余有效期
- 全局配置:TTL 10 分钟
- 计数器(限流用):TTL 1 分钟
小结
| 模式 | 读 | 写 | 一致性 | 复杂度 |
|---|---|---|---|---|
| Cache-Aside | 先读缓存,未命中读 DB | 先写 DB,再失效/更新缓存 | 最终一致 | 低 |
| Write-Through | 同 Cache-Aside | 同时写缓存和 DB | 较高 | 中 |
| Write-Behind | 同 Cache-Aside | 只写缓存,异步刷 DB | 弱 | 高 |
| 问题 | 解决 |
|---|---|
| 穿透 | 空值缓存(短 TTL) |
| 击穿 | 互斥锁 / Caffeine Async 天然防护 |
| 雪崩 | 随机 TTL |
下一节 9.3 缓存一致性 :写穿透、写回、失效传播、最终一致性与强一致性选型。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。