跳到主内容

从击穿到自愈:Caffeine+Redis多级缓存失效避坑实录 ​

问题背景:亿级读为什么不能只靠 Redis ​

在商品详情、用户画像、配置中心等读多写少场景中,Redis 通常能扛住每秒数十万次查询,但业务继续增长后,瓶颈会从 Redis 本身转移到三个地方:网络往返、连接池排队、热点 Key 集中访问。只靠 Redis,意味着每次请求都要跨网络访问一次缓存;一旦 Redis 抖动或某个热点 Key 过期,应用线程会同时回源数据库。

多级缓存的目标不是简单“再加一层 Map”,而是把高频热点挡在应用进程内,把跨实例共享数据放在 Redis,把最终一致性交给明确的失效机制。典型读路径为:先查 Caffeine 本地缓存,未命中再查 Redis,仍未命中才访问数据库,并按策略回填两层缓存。写路径则在更新数据库后删除或刷新 Redis,再通过 Redis Pub/Sub 广播失效各节点的 Caffeine。

客户端请求CaffeineRedis数据库写请求更新DB删除Redis广播失效所有Caffeine

方案对比:四种缓存失效策略怎么选 ​

1. TTL 被动过期 ​

给 Caffeine 和 Redis 都设置过期时间,实现最简单,适合一致性要求不高的字典类数据。缺点是过期瞬间容易产生缓存击穿,且多层 TTL 如果配置相同,会放大同一时刻的回源压力。

2. 写后删除加消息广播 ​

数据库更新成功后删除 Redis,再发布失效消息,各应用节点收到消息后删除本地 Caffeine。这是大多数业务的首选:一致性比单纯 TTL 更好,实现成本可控。需要注意消息丢失、乱序和重复投递。

3. 逻辑过期加异步刷新 ​

缓存值中携带 expireAt 字段,发现逻辑过期后仍先返回旧值,同时由后台线程刷新。适合商品详情等“可用性优先、短时间旧值可接受”的场景,但不能用于库存、余额等强一致读。

4. 分布式锁防击穿 ​

对未命中的 Key 加 Redis 锁,只有一个线程回源,其他线程短暂等待或返回旧值。热点 Key 场景建议保留该兜底,但锁粒度必须是单个 Key,避免把普通请求串行化。

实操步骤:Java 实现 Caffeine 与 Redis 两级缓存 ​

下面示例使用 Java 17、Caffeine、Spring Data Redis 和 Redisson。代码保留核心配置,可直接改造到 Spring Boot 项目中。

Maven 依赖 ​

xml
<dependencies>
  <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
  </dependency>
  <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.27.2</version>
  </dependency>
</dependencies>

缓存对象与服务接口 ​

java
// 缓存值必须支持版本号,便于处理失效消息乱序
public record ProductCache(
    Long id,
    String name,
    Long price,
    long version,
    long logicalExpireAt
) {}

public interface ProductLoader {
    ProductCache loadFromDb(Long id);
}

Caffeine 配置与读取逻辑 ​

java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.*;

public class LocalCacheFactory {
    public static Cache<String, ProductCache> create() {
        return Caffeine.newBuilder()
                // maximumSize 按热点数量估算,不能无脑设置为 Integer.MAX_VALUE
                .maximumSize(20_000)
                // 本地 TTL 应短于 Redis TTL,避免大量节点同一时间回源
                .expireAfterWrite(Duration.ofMinutes(2))
                .recordStats()
                .build();
    }
}

两级缓存读取与空值保护 ​

java
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.StringRedisTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;

public class ProductCacheService {
    private static final String NULL_HOLDER = "{\"id\":null}";
    private final Cache<String, ProductCache> local = LocalCacheFactory.create();
    private final StringRedisTemplate redis;
    private final RedissonClient redisson;
    private final ProductLoader loader;
    private final ObjectMapper mapper = new ObjectMapper();

    public ProductCacheService(StringRedisTemplate redis,
                               RedissonClient redisson,
                               ProductLoader loader) {
        this.redis = redis;
        this.redisson = redisson;
        this.loader = loader;
    }

    public Optional<ProductCache> get(Long id) throws Exception {
        String key = "product:" + id;
        ProductCache localValue = local.getIfPresent(key);
        if (localValue != null) {
            return Optional.of(localValue);
        }

        String json = redis.opsForValue().get(key);
        if (NULL_HOLDER.equals(json)) {
            return Optional.empty(); // 命中空值缓存,防止穿透
        }
        if (json != null) {
            ProductCache value = mapper.readValue(json, ProductCache.class);
            local.put(key, value);
            return Optional.of(value);
        }

        return loadWithLock(id, key);
    }

    private Optional<ProductCache> loadWithLock(Long id, String key) throws Exception {
        String lockKey = "lock:product:" + id;
        RLock lock = redisson.getLock(lockKey);
        boolean locked = lock.tryLock(50, 3, TimeUnit.SECONDS);
        if (!locked) {
            Thread.sleep(50);
            String json = redis.opsForValue().get(key);
            return json == null || NULL_HOLDER.equals(json)
                    ? Optional.empty()
                    : Optional.of(mapper.readValue(json, ProductCache.class));
        }
        try {
            // 双重检查:拿到锁后可能已被前一个线程回填
            String json = redis.opsForValue().get(key);
            if (json != null) {
                return NULL_HOLDER.equals(json) ? Optional.empty()
                        : Optional.of(mapper.readValue(json, ProductCache.class));
            }

            ProductCache value = loader.loadFromDb(id);
            if (value == null) {
                // 空值缓存设置短 TTL,避免脏空值长期存在
                redis.opsForValue().set(key, NULL_HOLDER, Duration.ofMinutes(2));
                return Optional.empty();
            }

            String newValue = mapper.writeValueAsString(value);
            redis.opsForValue().set(key, newValue, Duration.ofMinutes(10));
            local.put(key, value);
            return Optional.of(value);
        } finally {
            lock.unlock();
        }
    }
}

写后失效与本地广播 ​

java
public void updateProduct(ProductCache newValue) throws Exception {
    String key = "product:" + newValue.id();
    // 推荐先更新数据库,再删除缓存;删除失败由短 TTL 与重试任务兜底
    redis.delete(key);
    local.invalidate(key);

    // 广播给其他应用实例,消息中带版本号
    InvalidMessage msg = new InvalidMessage(key, newValue.version());
    redis.convertAndSend("cache:invalidate", mapper.writeValueAsString(msg));
}

// 订阅端只处理版本更新的消息,避免旧消息覆盖新数据
public void onMessage(String body) throws Exception {
    InvalidMessage msg = mapper.readValue(body, InvalidMessage.class);
    ProductCache old = local.getIfPresent(msg.key());
    if (old == null || old.version() < msg.version()) {
        local.invalidate(msg.key());
    }
}

逻辑过期的使用边界 ​

如果热点商品不能接受重建期间阻塞,可以把 TTL 改成“物理不过期、逻辑过期”:读到 logicalExpireAt < now 时立即返回旧值,并提交异步刷新任务。但必须限制同一个 Key 只提交一个刷新任务,否则过期瞬间仍可能造成数百个线程同时回源。强一致数据不要使用该模式。

监控指标:没有观测就不要上线多级缓存 ​

至少采集 Caffeine 的 hitRate、loadTime、evictionCount、size,Redis 的命中率、慢查询、连接等待时间,以及数据库按 Key 维度的回源 QPS。命中率下降并不一定是坏事,发布后本地缓存批量失效会导致短时下降;真正需要告警的是 Redis 未命中与数据库 QPS 同时飙升。

建议按以下阈值建立基线:本地缓存命中率低于 80% 排查热点集合和 maximumSize;Redis 命中率低于 95% 检查 TTL 与删除频率;单 Key 回源 QPS 接近实例数说明锁或广播失效未生效。

避坑总结:实战中最容易踩的 5 个坑 ​

  1. 两层缓存 TTL 完全相同,导致整点雪崩。 现象是每 10 分钟数据库出现尖刺。修正方法是 Redis TTL 加 10%~20% 随机抖动,Caffeine TTL 短于 Redis,并让不同业务 Key 使用不同过期窗口。

  2. 更新后先删缓存再更新数据库,产生旧值回填。 典型时序是删除缓存后,读线程在数据库提交前查询到旧数据并写回 Redis。修正为“先更新数据库,再删除缓存”,关键数据增加版本号或延迟双删。

  3. 只删 Redis,不通知 Caffeine,节点本地脏读。 现象是部分实例持续返回旧值直到本地 TTL 结束。修正为写后发布失效消息,订阅端按版本号失效本地缓存,并处理订阅断线后的全量兜底刷新。

  4. 缓存未命中不加锁,热点 Key 过期瞬间打满数据库。 日志常表现为同一个商品 ID 大量并发查询。修正为 Redisson 单 Key 锁、双重检查、等待线程短暂重读 Redis;不要用一个全局锁保护所有 Key。

  5. 空结果不缓存或缓存永久有效。 前者会被不存在 ID 持续穿透,后者会在数据新建后长期读不到。修正为对 null 设置短 TTL,例如 1~3 分钟;写请求创建数据成功后主动删除空值占位。

多级缓存的本质是用复杂度换读取延迟和容量弹性。Caffeine 负责进程内极低延迟,Redis 负责跨节点共享,失效策略负责两者之间的一致性契约。只有把 TTL、删除、广播、锁和监控一起设计,才能在亿级读下做到热点不穿、故障不抖、数据不过分陈旧。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。