Caffeine 缓存详解与 Redis 缓存一致性实践

29 阅读11分钟

Caffeine 缓存详解与 Redis 缓存一致性实践

一、Caffeine 缓存简介

Caffeine 是一个高性能的本地缓存库,广泛应用于 Java 应用程序中。它基于 Java 8 开发,提供了接近理论最优的缓存命中率,并且在并发场景下表现出色。Caffeine 是 Google Guava Cache 的升级替代品,优化了内存使用和性能。

1.1 Caffeine 的核心特性

  • 高性能:Caffeine 使用了 Window TinyLFU(W-TinyLFU)算法,结合了 LRU(最近最少使用)和 LFU(最少使用频率)的优点,提供更高的缓存命中率。

  • 多种淘汰策略

    • 基于大小:设置最大缓存条目数,超出时淘汰。
    • 基于时间:支持基于写入时间或访问时间的过期策略。
    • 基于引用:支持弱引用或软引用,便于垃圾回收。
  • 异步加载:支持异步缓存加载,适合高并发场景。

  • 统计功能:内置统计功能,可以监控缓存命中率、加载时间等指标。

  • 轻量级:占用内存小,适合本地缓存场景。

1.2 Caffeine 的基本使用

以下是一个简单的 Caffeine 缓存示例:

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;

import java.util.concurrent.TimeUnit;

public class CaffeineExample {
    public static void main(String[] args) {
        // 创建一个同步加载缓存
        LoadingCache<String, String> cache = Caffeine.newBuilder()
                .maximumSize(100) // 最大缓存条目
                .expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期
                .build(key -> loadFromDatabase(key)); // 缓存未命中时加载数据

        // 获取缓存
        String value = cache.get("key1");
        System.out.println(value);
    }

    private static String loadFromDatabase(String key) {
        // 模拟从数据库加载数据
        return "value-for-" + key;
    }
}

在这个例子中,Caffeine 缓存会:

  1. 缓存最多 100 条数据。
  2. 数据写入 10 分钟后过期。
  3. 如果缓存未命中,则从 loadFromDatabase 方法加载数据。

1.3 Caffeine 的适用场景

Caffeine 适合以下场景:

  • 本地缓存:需要快速访问数据的场景,例如用户会话、配置信息等。
  • 高并发:多线程环境下,Caffeine 的并发性能优于 Guava Cache。
  • 低延迟:本地缓存避免了网络调用,适合对延迟敏感的应用。

二、Caffeine 与 Redis 缓存一致性问题

在实际业务中,我们通常会结合=0A0A组合使用本地缓存(如 Caffeine)和分布式缓存(如 Redis),以兼顾性能和数据一致性。然而,Caffeine(本地缓存)和 Redis(分布式缓存)的数据可能出现不一致问题,例如:

  • 本地缓存的数据未及时更新,导致读取到旧数据。
  • 分布式缓存更新后,本地缓存未同步,导致数据不一致。

2.1 为什么需要缓存一致性?

在分布式系统中,数据可能存储在数据库(如 MySQL)、分布式缓存(如 Redis)和本地缓存(如 Caffeine)中。数据库通常是数据的最终来源,但查询数据库的成本较高,因此使用 Redis 和 Caffeine 缓存来提升性能。然而,如果缓存未及时更新,可能导致以下问题:

  • 用户读取到过期的优惠券状态。
  • 商品库存显示错误,导致超卖。
  • 配置信息未同步,影响业务逻辑。

因此,确保 Caffeine 和 Redis 之间的缓存一致性至关重要。

2.2 实现缓存一致性的方法

以下是几种常见的实现 Caffeine 和 Redis 缓存一致性的方法:

方法 1:发布-订阅机制(Pub/Sub)

利用 Redis 的发布-订阅功能,当 Redis 缓存更新时,发布消息通知所有应用节点更新本地 Caffeine 缓存。

实现步骤

  1. 在 Redis 中为每个需要同步的缓存键设置一个发布频道(例如 cache-update:product:123)。
  2. 当 Redis 中的数据更新时,发布一条消息到对应频道,消息内容包含缓存键和操作类型(更新或删除)。
  3. 每个应用节点订阅这些频道,收到消息后更新或删除本地的 Caffeine 缓存。

代码示例

import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;

public class CacheSyncExample {
    private static final LoadingCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .build(key -> null);

    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        // 订阅 Redis 频道
        jedis.subscribe(new JedisPubSub() {
            @Override
            public void onMessage(String channel, String message) {
                String[] parts = message.split(":");
                String key = parts[0];
                String operation = parts[1];
                if ("update".equals(operation)) {
                    cache.put(key, loadFromRedis(key));
                } else if ("delete".equals(operation)) {
                    cache.invalidate(key);
                }
            }
        }, "cache-update:*");
    }

    private static String loadFromRedis(String key) {
        // 从 Redis 获取最新数据
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            return jedis.get(key);
        }
    }
}

优点

  • 实时性高,更新传播快。
  • 适合分布式系统,节点间解耦。

缺点

  • 依赖 Redis 的 Pub/Sub 性能。
  • 网络抖动可能导致消息丢失。
方法 2:主动刷新

在每次访问 Caffeine 缓存时,检查 Redis 中的数据是否更新,若更新则同步到本地缓存。

实现步骤

  1. 为每个缓存键在 Redis 中存储一个版本号(例如 product:123:version)。

  2. 在 Caffeine 缓存中存储数据时,同时记录版本号。

  3. 每次读取 Caffeine 缓存时,比较本地版本号与 Redis 版本号:

    • 如果版本号一致,直接返回本地缓存。
    • 如果版本号不同,从 Redis 加载最新数据并更新 Caffeine 缓存。

代码示例

import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;

public class CacheVersionExample {
    private static final LoadingCache<String, CacheEntry> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .build(key -> new CacheEntry(null, -1));

    static class CacheEntry {
        String value;
        long version;

        CacheEntry(String value, long version) {
            this.value = value;
            this.version = version;
        }
    }

    public static String get(String key) {
        CacheEntry entry = cache.get(key);
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            String redisVersion = jedis.get(key + ":version");
            long version = redisVersion == null ? 0 : Long.parseLong(redisVersion);
            if (entry.version < version) {
                String value = jedis.get(key);
                cache.put(key, new CacheEntry(value, version));
                return value;
            }
            return entry.value;
        }
    }
}

优点

  • 实现简单,无需额外组件。
  • 适合读多写少的场景。

缺点

  • 每次读取都需要访问 Redis,增加延迟。
  • 不适合高并发场景。
方法 3:定时同步

定期检查 Redis 缓存中的数据,将更新的数据同步到 Caffeine 缓存。

实现步骤

  1. 启动一个定时任务(例如每分钟执行一次)。
  2. 扫描 Redis 中的缓存键,检查版本号或时间戳。
  3. 将有更新的键同步到 Caffeine 缓存。

代码示例

import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;

import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CacheSyncTimer {
    private static final LoadingCache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .build(key -> null);

    public static void startSync() {
        Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
            try (Jedis jedis = new Jedis("localhost", 6379)) {
                Set<String> keys = jedis.keys("product:*");
                for (String key : keys) {
                    String value = jedis.get(key);
                    cache.put(key, value);
                }
            }
        }, 0, 1, TimeUnit.MINUTES);
    }
}

优点

  • 实现简单,适合数据更新不频繁的场景。
  • 减少对 Redis 的频繁访问。

缺点

  • 存在同步延迟,可能读取到旧数据。
  • 扫描所有键可能影响性能。

2.3 推荐方案

在实际生产环境中,推荐结合 发布-订阅机制主动刷新

  • 使用 Redis 的 Pub/Sub 实现实时更新,覆盖大部分场景。
  • 对于可能的消息丢失或网络问题,辅以主动刷新机制,确保数据最终一致性。
  • 定时同步作为兜底方案,处理异常情况。

三、业务场景与面试模拟

3.1 业务场景

假设你在一个电商平台工作,负责实现商品详情页的缓存机制。商品信息(包括价格、库存、促销状态)存储在 MySQL 数据库中,为了提升性能,你使用了 Redis 作为分布式缓存,Caffeine 作为应用内的本地缓存。商品信息可能因促销活动或库存变化而频繁更新,需要保证用户看到的商品信息是最新的。

需求

  • 商品详情页的响应时间控制在 50ms 以内。
  • 缓存数据与数据库保持一致,更新延迟不超过 1 秒。
  • 系统支持高并发,每天处理千万级请求。

3.2 面试模拟拷问

面试官:请设计一个缓存方案,确保商品详情页的数据一致性,并应对高并发场景。

候选人
我会采用 Caffeine 和 Redis 结合的缓存架构,具体方案如下:

  1. 缓存结构

    • Caffeine 本地缓存:每个应用节点维护一个 Caffeine 缓存,存储热点商品的详细信息,设置最大条目为 10,000,写入后 5 分钟过期。
    • Redis 分布式缓存:存储所有商品信息,设置 1 小时过期时间,并为每个商品维护一个版本号(例如 product:123:version)。
    • MySQL:作为数据源,存储所有商品信息。
  2. 数据读取流程

    • 先查询 Caffeine 缓存:

      • 如果命中,检查缓存中的版本号与 Redis 中的版本号是否一致。
      • 如果版本号一致,返回缓存数据。
      • 如果版本号不同或缓存未命中,从 Redis 加载数据,更新 Caffeine 缓存。
    • 如果 Redis 未命中,从 MySQL 加载数据,更新 Redis 和 Caffeine 缓存。

  3. 数据更新流程

    • 当商品信息更新时(例如库存减少、价格调整):

      • 先更新 MySQL 数据库。
      • 更新 Redis 中的商品数据,并递增版本号。
      • 通过 Redis 的 Pub/Sub 发布更新消息(例如 cache-update:product:123:update)。
    • 各应用节点订阅消息,收到后更新或删除 Caffeine 缓存。

  4. 一致性保障

    • 使用 Pub/Sub 实现实时同步,更新延迟通常在 100ms 以内。
    • 每 30 秒执行一次定时任务,扫描 Redis 中的版本号,同步有变化的商品到 Caffeine 缓存,确保异常情况下数据一致。
    • 对于高并发,Caffeine 的高性能保证本地缓存的读取速度,Redis 的集群部署支持分布式扩展。
  5. 性能优化

    • 使用批量加载(cache.getAll)减少 Redis 查询次数。
    • 对热点商品设置更短的过期时间(例如 1 分钟),避免缓存过旧数据。
    • 监控 Caffeine 的命中率和 Redis 的 QPS,动态调整缓存大小和过期时间。

面试官:如果 Redis 宕机了,你的方案会受到什么影响?如何应对?

候选人
如果 Redis 宕机,会导致以下问题:

  • Caffeine 缓存无法验证版本号,可能返回过旧数据。
  • 新数据无法写入 Redis,影响其他节点的数据同步。

应对措施

  1. 降级处理:在 Redis 不可用时,直接从 MySQL 加载数据,更新 Caffeine 缓存。虽然性能下降,但保证数据正确性。
  2. 本地回退:Caffeine 缓存设置较短的过期时间(例如 30 秒),即使 Redis 宕机,旧数据也会很快失效。
  3. 哨兵机制:部署 Redis 哨兵或集群,确保高可用,降低宕机概率。
  4. 监控告警:实时监控 Redis 状态,一旦宕机立即通知运维团队,并切换到备用 Redis 实例。

面试官:如果某个商品的访问量突然激增,缓存可能会被击穿,怎么办?

候选人
缓存击穿是指热点数据因过期或未缓存,导致大量请求直接访问数据库。解决方法如下:

  1. 热点缓存:在 Caffeine 中为热点商品设置更长的过期时间(例如 10 分钟),并通过统计访问频率动态调整。
  2. 异步刷新:当缓存即将过期时,异步线程提前从 Redis 或 MySQL 刷新缓存,避免大量请求穿透。
  3. 布隆过滤器:在 Redis 中使用布隆过滤器,快速判断商品是否在缓存中,减少无效查询。
  4. 限流熔断:对数据库查询设置限流,防止高并发压垮数据库,同时返回默认值或提示用户稍后重试。

面试官:你的方案如何验证效果?有哪些监控指标?

候选人
我会通过以下方式验证方案效果,并监控关键指标:

  1. 验证方法

    • 功能测试:模拟商品更新,检查 Caffeine 和 Redis 是否同步,延迟是否小于 1 秒。
    • 压力测试:使用 JMeter 模拟高并发请求,验证响应时间是否在 50ms 以内。
    • 一致性测试:对比 MySQL、Redis 和 Caffeine 的数据,确保一致性。
  2. 监控指标

    • Caffeine 命中率:目标 95%以上,低命中率可能需要增加缓存大小。
    • Redis QPS:监控 Redis 请求量,防止过载。
    • 响应时间:统计商品详情页的 P99 响应时间,确保小于 50ms。
    • 一致性延迟:记录 Pub/Sub 消息从发布到 Caffeine 更新的时间。
    • 错误率:监控 Redis 连接失败、MySQL 查询超时等异常。

通过这些指标,我可以持续优化缓存方案,确保性能和一致性。

四、总结

Caffeine 是一个高性能的本地缓存库,适合需要低延迟和高并发的场景。与 Redis 结合使用,可以构建一个高效的缓存架构。通过发布-订阅、主动刷新和定时同步等方法,可以有效保证 Caffeine 和 Redis 的缓存一致性。在实际业务中,例如电商平台的商品详情页,合理的缓存设计能够显著提升性能,同时满足数据一致性要求。

在面试场景中,候选人需要清晰地阐述缓存方案的设计思路、应对异常的策略以及性能优化的方法,同时结合具体业务场景给出可落地的实现细节。希望这篇博客能为您提供实用的参考!