Java Caffeine 高性能本地缓存
Caffeine 是 Guava Cache 作者的后续作品,基于 W-TinyLFU 淘汰算法,目前 Java 本地缓存里性能最好。
相比 Guava Cache:
- 命中率更高,淘汰算法从 LRU 换成了 W-TinyLFU
- 读写几乎无锁,并发性能好一个量级
- 支持异步缓存、自动刷新、自定义过期
快速上手
-
依赖
<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency> -
使用
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,不触发加载
常用功能
-
控制缓存大小
// 按数量限制 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 就够了。
-
设置过期时间
// 写入后 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 :每次访问重置倒计时,一直有人用就不会过期
-
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)); -
自动刷新
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 解决“数据死不死”(防止内存泄露),
两者分别负责“更新策略”和“生命周期上限”,不能互相替代。
-
缓存统计
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");
缓存三大问题及解法
-
缓存穿透
查一条数据库里根本没有的数据,缓存永远不命中,每次请求直达数据库。
解法: 缓存空值标记。
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 都可以。
-
缓存击穿
热点 key 过期瞬间,大量请求同时查同一个 key,全部打到数据库。
Caffeine 自带防击穿。 当缓存 miss 时,内部为这个 key 创建一个 CompletableFuture 占位,只有第一个线程去查库,其余线程等同一个 Future 的结果。100 个并发请求只产生 1 次数据库查询。
get(key, loader) 和 LoadingCache.get(key) 都自动生效,不用额外配置。
-
缓存雪崩
大量 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(远程)→ 未命中 → 数据库 -
总结
问题 现象 解法 缓存穿透 查不存在的数据 缓存空值 / 布隆过滤器 缓存击穿 热点 key 过期瞬间 Caffeine 内置 CompletableFuture 占位,自动防 缓存雪崩 大量 key 同时过期 refreshAfterWrite 分散刷新 / 过期时间加随机值
Spring Boot 集成
Spring Boot 2.x 起内置 Caffeine 支持。
-
yaml 配置
spring: cache: type: caffeine caffeine: spec: maximumSize=500,expireAfterWrite=600s -
注解使用
@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 — 删除缓存