Java Caffeine 高性能本地缓存

2025-07-22
4 min read

Caffeine 是 Guava Cache 作者的后续作品,基于 W-TinyLFU 淘汰算法,目前 Java 本地缓存里性能最好。

相比 Guava Cache:

  • 命中率更高,淘汰算法从 LRU 换成了 W-TinyLFU
  • 读写几乎无锁,并发性能好一个量级
  • 支持异步缓存、自动刷新、自定义过期

快速上手

  1. 依赖

    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>3.1.8</version>
    </dependency>
    
  2. 使用

    import com.github.benmanes.caffeine.cache.LoadingCache;
    import com.github.benmanes.caffeine.cache.Caffeine;
    
    import java.util.concurrent.TimeUnit;
    
    public class HelloCaffeine {
    
        public static void main(String[] args) {
            LoadingCache<String, String> cache = Caffeine.newBuilder()
                    .maximumSize(100)
                    .expireAfterWrite(10, TimeUnit.MINUTES)
                    .build(key -> {
                        System.out.println("缓存没命中,去数据库查 " + key);
                        return queryFromDB(key);
                    });
    
            cache.put("user:1", "张三");
    
            String name = cache.get("user:1");
            System.out.println(name); // 输出:张三(缓存命中,不查库)
    
            String name2 = cache.get("user:2");
            System.out.println(name2); // 输出:数据库返回的_user:2
        }
    
        private static String queryFromDB(String key) {
            return "数据库返回的_" + key;
        }
    }
    
    • get(key) — 缓存有就返回,没有就调 build 里的 loader 加载,存入缓存再返回
    • getIfPresent(key) — 纯查缓存,没有返回 null,不触发加载

常用功能

  1. 控制缓存大小

    // 按数量限制
    Cache<String, User> cache1 = Caffeine.newBuilder()
            .maximumSize(1000)
            .build();
    
    // 按权重限制
    Cache<String, byte[]> cache2 = Caffeine.newBuilder()
            .maximumWeight(100_000)
            .weigher((key, value) -> value.length)
            .build();
    
    // 软引用,内存不足时 GC 回收
    Cache<String, Object> cache3 = Caffeine.newBuilder()
            .softValues()
            .build();
    

    超过上限时淘汰低频数据,一般用 maximumSize 就够了。

  2. 设置过期时间

    // 写入后 10 分钟过期
    Cache<String, String> cache1 = Caffeine.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();
    
    // 最后一次访问后 5 分钟过期
    Cache<String, String> cache2 = Caffeine.newBuilder()
            .expireAfterAccess(5, TimeUnit.MINUTES)
            .build();
    
    // 组合使用
    Cache<String, String> cache3 = Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.HOURS)
            .expireAfterAccess(10, TimeUnit.MINUTES)
            .build();
    
    • expireAfterWrite :到时间就过期,不管有没有人用
    • expireAfterAccess :每次访问重置倒计时,一直有人用就不会过期
  3. LoadingCache:自动加载

    缓存 miss 时自动回调 loader 查库,不用每次手动传 Function:

    LoadingCache<Long, User> userCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(30, TimeUnit.MINUTES)
            .build(key -> userMapper.selectById(key));
    
    User user = userCache.get(1L);
    
    Map<Long, User> users = userCache.getAll(Arrays.asList(1L, 2L, 3L));
    
  4. 自动刷新

    LoadingCache<Long, User> userCache = Caffeine.newBuilder()
            .maximumSize(1000)
            .refreshAfterWrite(5, TimeUnit.MINUTES)   // 5 分钟后,下次访问时触发刷新
            .expireAfterWrite(10, TimeUnit.MINUTES)   // 10 分钟后强制过期
            .build(key -> userMapper.selectById(key));
    
    场景 expireAfterWrite 表现 refreshAfterWrite 表现
    数据到期后第一个请求 所有线程阻塞,等数据库返回 仅一个线程去查库,其他线程返回旧值
    数据库挂了 所有请求异常 返回旧值,不影响业务
    适用场景 数据必须准确 允许短暂延迟,但保证可用性

    refreshAfterWrite 是到时间标记需刷新,下次被访问才触发异步加载新值;*expireAfterWrite* 是超过时间直接删除。

    推荐写法:refreshAfterWrite 设短,expireAfterWrite 设长兜底。

    refreshAfterWrite 解决“数据新不新”,expireAfterWrite 解决“数据死不死”(防止内存泄露),
    两者分别负责“更新策略”和“生命周期上限”,不能互相替代。

  5. 缓存统计

    Cache<String, String> cache = Caffeine.newBuilder()
            .maximumSize(100)
            .recordStats()
            .build();
    
    CacheStats stats = cache.stats();
    System.out.println("命中次数: " + stats.hitCount());
    System.out.println("未命中次数: " + stats.missCount());
    System.out.println("命中率: " + stats.hitRate());
    System.out.println("被淘汰的数量: " + stats.evictionCount());
    System.out.println("平均加载耗时: " + stats.averageLoadPenalty() + "ns");
    

缓存三大问题及解法

  1. 缓存穿透

    查一条数据库里根本没有的数据,缓存永远不命中,每次请求直达数据库。

    解法: 缓存空值标记。

    LoadingCache<Long, User> cache = Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build(id -> {
                User user = userMapper.selectById(id);
                return user != null ? user : User.EMPTY;
            });
    
    User user = cache.get(userId);
    if (user == User.EMPTY) {
        throw new NotFoundException("用户不存在");
    }
    

    更彻底的方案是前面加布隆过滤器,Guava 的 BloomFilter 或 Redisson 的 RBloomFilter 都可以。

  2. 缓存击穿

    热点 key 过期瞬间,大量请求同时查同一个 key,全部打到数据库。

    Caffeine 自带防击穿。 当缓存 miss 时,内部为这个 key 创建一个 CompletableFuture 占位,只有第一个线程去查库,其余线程等同一个 Future 的结果。100 个并发请求只产生 1 次数据库查询。

    get(key, loader)LoadingCache.get(key) 都自动生效,不用额外配置。

  3. 缓存雪崩

    大量 key 同一时间点过期,数据库瞬间压力暴增。

    解法一:refreshAfterWrite 分散刷新

    LoadingCache<Long, Product> cache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .refreshAfterWrite(5, TimeUnit.MINUTES)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build(key -> productMapper.selectById(key));
    

    refreshAfterWrite 是逐个异步刷新,刷新时间取决于 key 什么时候被访问,天然分散。

    解法二:过期时间加随机偏移

    Cache<String, String> cache = Caffeine.newBuilder()
            .expireAfter(new Expiry<String, String>() {
                @Override
                public long expireAfterCreate(String key, String value, long currentTime) {
                    long base = TimeUnit.MINUTES.toNanos(10);
                    long random = (long) (Math.random() * TimeUnit.MINUTES.toNanos(5));
                    return base + random;
                }
    
                @Override
                public long expireAfterUpdate(String key, String value,
                                              long currentTime, long currentDuration) {
                    long base = TimeUnit.MINUTES.toNanos(10);
                    long random = (long) (Math.random() * TimeUnit.MINUTES.toNanos(5));
                    return base + random;
                }
    
                @Override
                public long expireAfterRead(String key, String value,
                                            long currentTime, long currentDuration) {
                    return currentDuration;
                }
            })
            .build();
    

    解法三:多级缓存

    请求 → Caffeine(本地)→ 未命中 → Redis(远程)→ 未命中 → 数据库
    
  4. 总结

    问题 现象 解法
    缓存穿透 查不存在的数据 缓存空值 / 布隆过滤器
    缓存击穿 热点 key 过期瞬间 Caffeine 内置 CompletableFuture 占位,自动防
    缓存雪崩 大量 key 同时过期 refreshAfterWrite 分散刷新 / 过期时间加随机值

Spring Boot 集成

Spring Boot 2.x 起内置 Caffeine 支持。

  1. yaml 配置

    spring:
      cache:
        type: caffeine
        caffeine:
          spec: maximumSize=500,expireAfterWrite=600s
    
  2. 注解使用

    @Configuration
    public class CacheConfig {
    
        @Bean
        public CacheManager cacheManager() {
            CaffeineCacheManager manager = new CaffeineCacheManager();
            manager.setCaffeine(Caffeine.newBuilder()
                    .maximumSize(1000)
                    .expireAfterWrite(10, TimeUnit.MINUTES)
                    .recordStats());
            return manager;
        }
    }
    
    @Service
    public class UserService {
    
        @Cacheable(value = "users", key = "#id")
        public User getUser(Long id) {
            return userMapper.selectById(id);
        }
    
        @CachePut(value = "users", key = "#user.id")
        public User updateUser(User user) {
            userMapper.updateById(user);
            return user;
        }
    
        @CacheEvict(value = "users", key = "#id")
        public void deleteUser(Long id) {
            userMapper.deleteById(id);
        }
    }
    
    • @Cacheable — 有缓存走缓存,没有就执行方法,结果存缓存
    • @CachePut — 总是执行方法,结果更新到缓存
    • @CacheEvict — 删除缓存