深入解析 Caffeine 缓存

207 阅读8分钟

相信大家对Caffeine都不陌生,Caffeine 作为高性能的 Java 缓存库,被广泛应用于各种 Java 框架、中间件、数据库访问层等场景,大部分高并发的项目都用到Caffeine本地缓存,以提升响应速度、降低数据库压力、优化高并发性能。下面我们深入了解下Caffeine......


前言

在高并发系统中,缓存是提升性能的核心组件之一。Caffeine 作为一款高性能 Java 缓存库,凭借其 Window-TinyLFU 淘汰算法 和 卓越的并发性能,成为许多系统的首选。本文将深入解析 Caffeine 的设计原理、核心架构、关键 API,并通过一个高并发场景的实战案例,展示其应用方法。


一、Caffeine 的核心原理

Caffeine架构图

  1. 淘汰策略:Window-TinyLFU,Caffeine 采用 Window-TinyLFU 算法,结合了 LRU(最近最少使用)和 LFU(最不经常使用)的优点: 滑动窗口分区: 缓存分为一个 主区域(保护高频数据)和一个 窗口区域(接纳新数据),避免突发流量污染缓存。 频率素描(Count-Min Sketch): 以极低的内存开销统计数据访问频率,替代传统 LFU 的哈希计数。
  2. 高性能并发设计 分段锁机制: 写操作分段加锁,减少线程竞争。 无锁读优化: 通过 AtomicReference 实现并发读的高性能。

关键数据结构设计

// 频率素描 (Count-Min Sketch)
class CountMinSketch {
    long[][] matrix = new long[4][]; // 4个哈希函数
    void increment(key) {
        for (int i = 0; i < 4; i++) {
            matrix[i][hash(i, key)]++;
        }
    }
}

// 存储节点 (Node)
class Node<K,V> {
    K key;
    V value;
    volatile long accessTime; // 访问时间,CAS更新
    int frequency; // TinyLFU计数
    Node<K,V> prev, next;    // LRU链表指针
}

下面我给出读请求和写请求两种模块内部工作交互

读请求处理流

  1. 用户调用 cache.get("key")
  2. 查 ConcurrentHashMap → 命中?返回数据&频率+1 : 继续
  3. 未命中 → 调用 CacheLoader.load("A") 加载数据
  4. 数据加载后:
    • 写入 ConcurrentHashMap
    • 更新 Count-Min Sketch(频率+1)
  5. 返回数据

写请求处理流

  1. 调用 cache.put(key, value)

  2. 锁获取: 根据 key.hashCode() 计算对应的 Segment 锁(默认16个分段)

  3. 锁内操作:

    步骤1: 检查 Key 是否已存在:存在 → 覆盖旧值;不存在 → 进入 Window-TinyLFU 淘汰逻辑

    步骤2: 检查 Window 区域是否已满:未满 → 直接写入 Window 区;已满 → 触发淘汰流程: 对比新数据与 Window 中最旧数据的频率素描计数。 新数据频率更高 → 淘汰旧数据,新数据进入 Main 区。 旧数据频率更高 → 丢弃新数据。

  4. 更新元数据: 更新 Count-Min Sketch 频率计数 记录访问时间戳(LRU 逻辑)

  5. 释放锁: 完成写入

下面给出伪代码

// 伪代码: TinyLFU淘汰
//1. 缓存达到 maximumSize
//2. 检查 Window 区域:
//   - 新数据进入 Window,若满则淘汰最旧数据
//3. 检查 Main 区域:
//   - 对比频率素描,淘汰低频数据
//   - 频率相近时,淘汰 LRU 数据
if (window.isFull()) {
    Candidate newEntry = new Candidate(key, value);
    Candidate victim = window.getOldestEntry();
    if (frequencySketch.compare(newEntry, victim) > 0) {
        mainRegion.admit(newEntry);  // 新数据胜出
        window.evict(victim);       // 淘汰旧数据
    }
}

二、Caffeine 的架构分层

模块功能描述
Cache Interface定义缓存的核心 API(get、put、invalidate 等)。
Eviction Policy实现 Window-TinyLFU 算法,管理缓存淘汰逻辑。
Concurrency Control通过分段锁和 CAS 操作保证线程安全。
Data Storage基于 ConcurrentHashMap 存储缓存条目,支持高效查找。

三、Caffeine 重点方法详解

1. 初始化方法:Caffeine.newBuilder()

Cache<String, Object> cache = Caffeine.newBuilder()
    .maximumSize(10_000)          // 最大条目数
    .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期
    .expireAfterAccess(5, TimeUnit.MINUTES) // 访问后过期
    .refreshAfterWrite(1, TimeUnit.MINUTES)  // 自动刷新(非阻塞)
    .weakKeys()                   // Key弱引用(GC敏感)
    .weakValues()                 // Value弱引用
    .recordStats()                // 开启命中率统计
    .removalListener((key, value, cause) -> 
        System.out.println("移除原因: " + cause)) // 淘汰监听
    .build();

关键参数:

  • maximumSize:触发淘汰的条目数(非字节大小,需结合 weigher 使用)。
  • expireAfterWrite vs expireAfterAccess: Write:严格一致性,适合配置类数据。 Access:更高命中率,适合热点数据。
  • refreshAfterWrite:异步刷新(旧值仍可用),避免缓存雪崩。

2. 数据写入:put(K key, V value)

cache.put("key", new Object());

底层逻辑:

  1. 计算 key.hashCode() 确定所属 Segment(默认16个分段)。
  2. 获取分段锁(ReentrantLock),保证线程安全。
  3. 执行写入: - 若 Key 存在,直接覆盖。 - 若 Key 不存在,进入 Window-TinyLFU 淘汰流程:
if (window.isFull()) {
    if (frequencySketch.compare(newData, oldestData) > 0) {
        mainRegion.admit(newData); // 新数据胜出
    }
}
  1. 更新 Count-Min Sketch 频率统计 (无锁CAS操作)
  2. 释放 分段锁

3. 数据读取:get(K key, Function loader)

Object value = cache.get("key", k -> loadFromDB(k));

执行流程:

  1. 无锁读:尝试从 ConcurrentHashMap 获取值(get() 为原子操作)。
  2. 缓存未命中: 同步调用 loader.apply(key) 加载数据(同一 Key 并发时仅加载一次)并写入缓存并更新频率素描。
  3. 缓存命中:更新 LRU 访问时间戳,返回缓存值。

高并发优化: 使用 AsyncLoadingCache 避免阻塞:

AsyncLoadingCache<String, Object> asyncCache = Caffeine.newBuilder()
    .buildAsync(key -> loadFromDB(key));
CompletableFuture<Object> future = asyncCache.get("key");

4. 数据淘汰:invalidate(key) 与自动淘汰

cache.invalidate("key"); // 主动移除单个Key
cache.invalidateAll();   // 清空缓存

自动淘汰触发条件:

  1. 基于大小(maximumSize):触发 Window-TinyLFU 淘汰赛。
  2. 基于时间(expireAfter*):后台线程定期清理(ForkJoinPool)。
  3. 基于引用(weakKeys/weakValues):依赖 GC 回收。
// 淘汰监听:我们可以监听不同淘汰触发条件做不同的处理。比如当缓存数据不是因为手工删除和超出容量限制而被删除的情况,就需要通知业务处理器做相应处理
.removalListener((key, value, cause) -> {
    // cause: EXPLICIT(手动移除), SIZE(容量淘汰), EXPIRED(过期)...
    if(cause != RemovalCause.EXPLICIT && cause != RemovalCause.REPLACED && cause != RemovalCause.SIZE)
       listener.notifyExpired(busId, (String)key);
})

5.统计与监控:recordStats()

recordStats()启用缓存统计功能,记录以下核心指标: CacheStats { hitCount; // 缓存命中次数 missCount; // 缓存未命中次数 loadSuccessCount; // 成功加载新值的次数 loadFailureCount; // 加载失败次数 totalLoadTime; // 总加载耗时(纳秒) evictionCount; // 因容量或过期导致的淘汰总数 evictionWeight; // 淘汰条目的总权重(仅在使用weigher时有效) }

CacheStats stats = cache.stats();
System.out.printf("命中率: %.1f%%, 加载次数: %d, 淘汰数: %d",
    stats.hitRate() * 100, 
    stats.loadCount(),
    stats.evictionCount());

关键指标:

  • hitRate():缓存命中率(0~1)。
  • loadSuccessCount():成功加载次数。
  • totalLoadTime():总加载耗时(纳秒)。

6. 异步方法:refresh(key)

cache.refresh("key"); // 异步重新加载值(旧值仍可用)

与 expire 的区别: expire:立即失效,请求需等待新值加载。 refresh:后台加载,旧值可继续服务(更平滑)。

源码性能优化技巧

频率素描(Count-Min Sketch): 使用 4 个 long[][] 矩阵统计频率,误差率 <1%。比传统 LFU 节省 90% 内存。 分段锁优化: 默认 16 个 Segment,并发写性能接近 O(1)。 无锁读路径: 所有读操作完全无锁(volatile 变量 + AtomicReference)。

四、高并发场景实战:商品信息缓存

场景: 每秒万级请求的读请求(商品查询,架构查询),要求 99.9% 的缓存命中率。防止缓存击穿、雪崩,支持平滑过期刷新。

import com.github.benmanes.caffeine.cache.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;

public class ProductCache {
    // 缓存实例 (单例模式)
    private static final LoadingCache<Long, Product> cache = Caffeine.newBuilder()
        .maximumSize(10000)                     // 容量控制
        .expireAfterWrite(30, TimeUnit.MINUTES)  // 基础过期时间
        .refreshAfterWrite(5, TimeUnit.MINUTES)  // 自动刷新防雪崩
        .executor(Executors.newFixedThreadPool(4)) // 刷新线程池
        .recordStats()                           // 监控
        .build(new ProductLoader());             // 自定义加载逻辑

    // 防击穿锁 (Key级别细粒度锁)
    private static final ConcurrentMap<Long, AtomicBoolean> loadingLocks = new ConcurrentHashMap<>();

    // 缓存加载器 (核心防击穿逻辑)
    private static class ProductLoader implements CacheLoader<Long, Product> {
        @Override
        public Product load(Long productId) throws Exception {
            // 模拟数据库查询
            return fetchFromDB(productId);
        }

        @Override
        public Product reload(Long productId, Product oldValue) throws Exception {
            // 异步刷新时触发 (防雪崩关键)
            return fetchFromDB(productId);
        }

        private Product fetchFromDB(Long productId) {
            // 1. 获取Key专属锁 (防击穿)
            AtomicBoolean lock = loadingLocks.computeIfAbsent(productId, k -> new AtomicBoolean(false));
            while (!lock.compareAndSet(false, true)) {
                Thread.yield(); // 自旋等待
            }

            try {
                // 2. 再次检查缓存 (可能其他线程已加载)
                Product cached = cache.getIfPresent(productId);
                if (cached != null) return cached;

                // 3. 模拟数据库查询 (核心业务逻辑)
                System.out.println("Loading from DB: " + productId);
                Thread.sleep(100); // 模拟IO延迟
                return new Product(productId, "Product-" + productId, ThreadLocalRandom.current().nextInt(100));

            } catch (Exception e) {
                throw new RuntimeException("DB error", e);
            } finally {
                lock.set(false); // 释放锁
                loadingLocks.remove(productId); // 防止内存泄漏
            }
        }
    }

    // 商品类
    public static class Product {
        private final Long id;
        private final String name;
        private final int stock;

        public Product(Long id, String name, int stock) {
            this.id = id;
            this.name = name;
            this.stock = stock;
        }
    }

    // 获取商品 (对外接口)
    public static Product getProduct(Long productId) {
        return cache.get(productId);
    }

    // 测试
    public static void main(String[] args) throws InterruptedException {
        // 模拟并发请求
        ExecutorService pool = Executors.newFixedThreadPool(20);
        for (int i = 0; i < 100; i++) {
            long productId = i % 10; // 10个商品模拟热点
            pool.execute(() -> {
                Product p = getProduct(productId);
                System.out.println("Get: " + p.name);
            });
        }
        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.SECONDS);

        // 打印统计
        System.out.println("缓存命中率: " + cache.stats().hitRate());
    }
}

设计思路 防击穿: 使用 Caffeine 的 CacheLoader + 同步锁(loadingLocks 的 AtomicBoolean CAS 操作),保证单个 Key 只加载一次 防雪崩: refreshAfterWrite 异步刷新 + 随机过期时间 热点保护: 基于 maximumSize 和 Window-TinyLFU 自动管理热点数据

实际大型项目中考虑到本地内存的局限性,大多数场景使用Caffeine和Redis多级缓存架构


总结

我们在使用Caffine时,根据内存压力调整 maximumSize,避免频繁淘汰。Caffeine 通过创新的 Window-TinyLFU 算法精细化并发控制,在缓存命中率和吞吐量之间取得平衡。本文的实战案例展示了如何通过异步加载、自动刷新等机制,构建稳定高效的缓存层。

适用场景: 读多写少、高并发、低延迟需求的业务(如电商、社交平台)。