9.2 缓存策略

响应式缓存三层架构:Caffeine Async 本地缓存异步加载、Redis Reactive Lettuce 客户端操作、cache-aside 与 write-through 模式在 Mono/Flux 链中的实现,以及缓存穿透/击穿/雪崩防护策略。

为什么需要两层缓存

响应式系统中,缓存不仅是性能优化,更是故障隔离手段。当数据库不可用时,缓存提供降级能力。

请求
    → 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 缓存一致性 :写穿透、写回、失效传播、最终一致性与强一致性选型。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南