前言:为什么我们需要本地缓存?
在构建高性能、高可用的Web应用时,缓存是绕不开的关键技术。它能够将热点数据存储在距离计算更近的地方,极大地减少数据访问延迟,提高系统吞吐量。
Redis、Memcached等分布式缓存因其强大的功能和共享特性而广受欢迎。然而,在面对极高并发请求时,即使是Redis也可能成为性能瓶颈。每一次网络往返带来的毫秒级延迟,在海量请求下累积起来,足以拖慢整个系统的响应速度。此外,热点Key的集中访问也可能瞬间压垮Redis实例。
此时,Java 世界中,Caffeine 已经是事实上的本地缓存首选,它不仅性能优秀,还在内存管理和淘汰策略方面设计得极为精巧。
- 性能层面:官方基准测试表明,Caffeine 的命中率在大多数真实业务场景中已经接近理论最优。
- 生态层面:Spring Boot 2.x 之后,spring-boot-starter-cache 默认就集成了 Caffeine,你可以几乎零成本启用它。
一个现实例子:京东开源的 JD-HotKey 中间件在探测到 Redis 热 Key 时,可以将其直接推入客户端的 Caffeine 缓存,避免 Redis 热点访问引发的网络 IO 风暴。
而在众多Java本地缓存框架中,Caffeine 凭借其出色的性能表现和先进的缓存淘汰算法(W-TinyLFU),脱颖而出,被誉为“新一代高性能本地缓存之王”。本文将深入探讨Caffeine的各项特性和最佳实践,帮助你掌握这一利器,为你的Java应用性能优化提供强大助力。
一、快速上手
1.依赖引入
xml
<!-- pom.xml --> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency>
2.配置Bean(声明式配置)
java
@Configuration public class CacheConfig { @Value("${cache.caffeine.spec:maximumSize=100000,expireAfterWrite=1h,recordStats}") private String cacheSpec; @Bean("localUrlCache") public Cache<String, String> localUrlCache() { return Caffeine.from(cacheSpec).build(); } }
配置参数拆解:
- maximumSize=100000:最大容量10万条目
- expireAfterWrite=1h:写入后1小时过期
- recordStats:开启统计功能(命中率、加载时间)
3.业务使用(注入 + API调用)
java
@Service public class ShortUrlService { @Autowired @Qualifier("localUrlCache") private Cache<String, String> localCache; public String getLongUrl(String shortCode) { // 查询缓存 String cached = localCache.getIfPresent(shortCode); if (cached != null) return cached; // 未命中,查DB后回填 String longUrl = db.query(shortCode); localCache.put(shortCode, longUrl); return longUrl; } }
二、解决的核心痛点
痛点1:Redis网络IO成为瓶颈
场景:QPS 5000时,每次查Redis需要1-2ms网络延迟
plaintext
┌─────────┐ 1-2ms ┌──────┐ │ Service │ ────────▶│ Redis│ ← 网络开销 └─────────┘ └──────┘
解决:本地缓存命中率90%,延迟降至微秒级
java
// 统计数据:90%请求在L1本地缓存命中 localCache.getIfPresent(code); // < 1μs
痛点2:热点Key打爆Redis
场景:爆款短链1秒被点击1000次,Redis连接池耗尽
plaintext
高并发 ────┬──▶ Redis连接1 ├──▶ Redis连接2 ← 连接池耗尽 └──▶ Redis连接N
解决:L1本地缓存承载热点流量
ini
java // 热点短链直接从JVM堆内存读取,不占用Redis连接 String url = localCache.getIfPresent("hot-code");
痛点3:冷启动缓存穿透
场景:应用重启后缓存为空,大量请求打到DB
plaintext
重启 ──▶ 缓存空 ──▶ 1000并发 ──▶ MySQL崩溃
解决:启动时预热Top热点数据
java
@PostConstruct public void warmupCache() { // 启动时加载Top 1000热点链接 List<ShortUrl> hotUrls = repo.findAll(PageRequest.of(0, 1000)); hotUrls.forEach(url -> localCache.put(url.getShortCode(), url.getLongUrl()) ); log.info("预热完成:{} 条热点数据", hotUrls.size()); }
三、缓存淘汰原理(W-TinyLFU算法)
1. 为什么不用LRU?
LRU问题:扫描攻击会淘汰真正的热点数据
plaintext
正常访问:A(100次) B(90次) C(80次) 攻击场景:D E F G ... Z(各1次) LRU结果:A B C被淘汰 ← 灾难! 正确结果:应保留A B C,淘汰D-Z
2. W-TinyLFU核心机制
Window Cache(窗口缓存)
新数据先进入窗口区(1%容量),防止扫描攻击污染主缓存
java
// 新访问的shortCode先进Window Window[1000] ──过滤──▶ Main[99000] ↑ 新数据 ↑ 热点数据
** Frequency Sketch(频率统计)**
使用Count-Min Sketch算法,4字节记录百万级访问频率
java
// 空间复杂度:O(1),时间复杂度:O(1) hash1(key) ──▶ counter[1234] += 1 hash2(key) ──▶ counter[5678] += 1 hash3(key) ──▶ counter[9012] += 1 // 查询时取最小值:min(3个counter)
淘汰决策(Admission Policy)
java
// 伪代码 if (cache.isFull()) { victim = findVictim(); // 找到频率最低的旧数据 newFreq = sketch.estimate(newKey); victimFreq = sketch.estimate(victim); if (newFreq > victimFreq) { cache.remove(victim); cache.put(newKey, newValue); } else { // 拒绝新数据入缓存 } }
四、过期策略详解
1. 三种过期模式
java
// 方式1:写入后过期(适合读多写少) Caffeine.newBuilder() .expireAfterWrite(1, TimeUnit.HOURS) // 写入1小时后过期 // 方式2:访问后过期(适合会话数据) Caffeine.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) // 30分钟不访问就过期 // 方式3:自定义过期策略 Caffeine.newBuilder() .expireAfter(new Expiry<String, String>() { public long expireAfterCreate(String key, String value, long currentTime) { // VIP链接缓存24小时 return isVip(key) ? TimeUnit.HOURS.toNanos(24) : TimeUnit.HOURS.toNanos(1); } })
2. 懒过期机制
java
// 不是定时扫描删除(节省CPU),而是: localCache.getIfPresent(key); // ↓ 触发检查 if (isExpired(entry)) { remove(entry); // 懒删除 return null; }
3. 定时清理(后台线程)
java
// Caffeine内部Scheduler每隔一段时间清理过期数据 Caffeine.newBuilder() .scheduler(Scheduler.systemScheduler()) // 默认使用ForkJoinPool
五、监控与调优
1. 开启统计
java
Cache<String, String> cache = Caffeine.newBuilder() .recordStats() // 开启统计 .build(); // 查看缓存指标 CacheStats stats = cache.stats(); log.info("命中率: {}", stats.hitRate()); log.info("加载次数: {}", stats.loadCount()); log.info("驱逐次数: {}", stats.evictionCount());
2. 集成Micrometer(暴露给Prometheus)
java
@Bean public Cache<String, String> monitoredCache(MeterRegistry registry) { Cache<String, String> cache = Caffeine.newBuilder() .maximumSize(100000) .recordStats() .build(); // 绑定到Prometheus CaffeineCacheMetrics.monitor(registry, cache, "url_cache"); return cache; }
Grafana监控面板:
plaintext
caffeine_cache_hit_total / (hit + miss) → 命中率 caffeine_cache_eviction_total → 驱逐速率 caffeine_cache_load_duration_seconds → 加载耗时
六、多级缓存架构
完整查询链路
java
public String getLongUrl(String shortCode) { // L1: 本地Caffeine(微秒级) String url = localCache.getIfPresent(shortCode); if (url != null) { log.debug("L1命中: {}", shortCode); return url; } // L2: Redis(毫秒级) url = redisTemplate.opsForValue().get("url:" + shortCode); if (url != null) { localCache.put(shortCode, url); // 回填L1 log.debug("L2命中,回填L1"); return url; } // L3: MySQL(十毫秒级) ShortUrl entity = repository.findByShortCode(shortCode); if (entity != null) { url = entity.getLongUrl(); redisTemplate.set("url:" + shortCode, url, 24, HOURS); // 回填L2 localCache.put(shortCode, url); // 回填L1 log.debug("DB命中,回填L2+L1"); return url; } return null; }
缓存更新策略(Cache Aside模式)
java
public void updateShortUrl(String shortCode, String newAlias) { // 1. 更新数据库 repository.updateShortCode(shortCode, newAlias); // 2. 删除缓存(而非更新,避免并发问题) localCache.invalidate(shortCode); redisTemplate.delete("url:" + shortCode); // 下次查询会触发缓存回填 }
八、最佳实践清单
java
Cache<String, String> cache = Caffeine.newBuilder() // 1. 设置合理容量(根据JVM堆内存) .maximumSize(100000) // 每条约100字节,共10MB // 2. 过期时间要比Redis短(避免数据不一致) .expireAfterWrite(1, TimeUnit.HOURS) // Redis是24小时 // 3. 开启统计监控 .recordStats() // 4. 弱引用Value(内存紧张时GC可回收) .softValues() // 可选 // 5. 异步加载(避免阻塞) .buildAsync(key -> loadFromRedis(key));
总结:Caffeine = W-TinyLFU算法(94%命中率) + 微秒级延迟 + 零网络开销 + 自动淘汰过期,是Java生态最强本地缓存方案,配合Redis形成黄金二级缓存架构。
Caffeine本地缓存优化实战
886

被折叠的 条评论
为什么被折叠?



