← 返回 Java核心图解

🗄️ 缓存方案选型与实战

结果导向 · 怎么选 · 为什么 · 生产上长啥样 · 有什么坑

Caffeine Redis @Cacheable Guava 多级缓存 缓存雪崩 缓存穿透

1. 直接给结论

新项目无脑选 Caffeine(本地)+ Redis(分布式)

本地缓存用 Caffeine(Java 本地缓存事实标准,比 Guava 快 30%-80%);多台机器共享用 Redis;不想写缓存代码就用 Spring @Cacheable 注解。HashMap 做缓存 = 定时炸弹,生产环境绝对不要用。

你的需求选什么一句话原因
单机本地缓存(配置字典/热点数据) Caffeine Java 本地缓存王者,W-TinyLFU 淘汰算法,自带过期/统计/异步刷新
多台机器共享缓存 Redis 独立进程,所有机器访问同一份缓存,数据一致
不想写缓存代码 Spring @Cacheable 注解一行搞定,底层可切换 Caffeine/Redis
既要快又要一致 Caffeine + Redis 多级缓存 L1 本地(纳秒级)→ L2 Redis(毫秒级)→ DB
HashMap 做缓存 ❌ 别用 没有淘汰策略 → OOM;线程不安全 → 并发死循环
分水岭就一个:要不要多台机器共享同一份缓存
• 不要 → Caffeine(本地内存,纳秒级,不耗网络)
• 要 → Redis(独立进程,毫秒级,所有机器共享)
• 两个都要 → Caffeine + Redis 多级缓存(L1 本地 + L2 共享)

2. 这些技术分别是什么

每个技术一句话说清楚,不用记原理,知道它是干啥的就够了。

技术是什么一句话定位
Caffeine Java 本地缓存库 Guava Cache 的继任者,W-TinyLFU 淘汰算法,性能比 Guava 高 30%-80%,Java 本地缓存事实标准
Redis 内存数据库(独立进程) 独立于应用进程运行,所有机器访问同一份数据,支持持久化、过期、发布订阅
Spring @Cacheable 注解化缓存抽象 不是缓存实现,是封装层 — 注解一行搞定缓存,底层可切换 Caffeine/Redis/Ehcache
Guava Cache Google 的本地缓存库 Caffeine 的前辈,已停更,新项目用 Caffeine 替代
Ehcache 老牌 Java 缓存库 支持本地+分布式,但配置繁琐,新项目基本不用了
HashMap JDK 自带的 Map 不是缓存库 — 没淘汰策略(OOM)、没线程安全(并发问题),生产环境别拿来当缓存
核心对比记住两条
Caffeine vs Redis:Caffeine 在应用进程内(纳秒级,单机),Redis 是独立进程(毫秒级,共享)
@Cacheable 不是缓存:它只是注解封装,底层还得选 Caffeine 或 Redis 来真正存数据

3. Caffeine vs HashMap vs Redis:核心差别

HashMapCaffeineRedis
在哪运行 JVM 进程内 JVM 进程内 独立进程(单独的服务器)
速度 纳秒级(最快) 纳秒级(最快) 毫秒级(要走网络)
多机共享 ❌ 各自一份 ❌ 各自一份 ✅ 所有机器同一份
淘汰策略 ❌ 没有 → OOM ✅ W-TinyLFU(最优) ✅ LRU/LFU/随机/TTL
过期时间 ❌ 没有 ✅ 写后过期/访问后过期 ✅ TTL
线程安全 ❌ 不安全 ✅ 安全 ✅ 安全(单线程模型)
断电丢失 有持久化(RDB/AOF)
适合场景 ❌ 别当缓存用 单机热点数据/配置字典 多机共享/分布式缓存
结论 HashMap 不是缓存库 — 没淘汰策略会 OOM,没线程安全会并发死循环。Caffeine 解决了这两个问题还加了 W-TinyLFU 淘汰算法。Redis 则解决了"多台机器共享"的问题。生产环境从 HashMap 升级到 Caffeine,是避免缓存事故的第一步。

4. Caffeine 能做什么(能力全景 + 界面演示)

Caffeine 不只是"存个 Map",它是个专业缓存库,自带淘汰、过期、刷新、统计等能力。

① 自动淘汰(W-TinyLFU)
设最大容量,满了自动淘汰最不常用的。W-TinyLFU 比传统 LRU 命中率高 30%+,能抗"扫描污染"(一次性大批量查询不会冲掉热点)。
② 过期策略
两种过期:写后过期(写入 10 分钟后失效)、访问后过期(最后一次访问 5 分钟后失效)。可组合使用。
③ 异步刷新
缓存快过期时,后台异步加载新数据,旧数据继续返回。用户无感知,不会因为缓存刷新而卡顿。
④ 统计监控
自带命中率、加载时间、驱逐数量等统计。recordStats() 一行开启,接 Spring Boot Actuator 暴露到监控面板。

能力一览表:

能力能做什么类比
自动淘汰设 maxSize,满了自动淘汰冷数据书架满了自动收回最久没看的书
写后过期expireAfterWrite(10, MINUTES) — 写入 10 分钟后失效牛奶上的保质期,过了就扔
访问后过期expireAfterAccess(5, MINUTES) — 最后访问 5 分钟后失效长时间没翻的书收回柜子
异步刷新refreshAfterWrite — 后台刷新,旧值继续返回换班时老员工先顶着,新员工后台接手
统计监控命中率/加载耗时/驱逐数/缓存大小书架上的统计表:这周借了多少本、退了多少本
线程安全基于 ConcurrentHashMap,并发安全多人同时取书不会打架

💻 Caffeine 基本用法

CacheConfig.java
📁 config/
CacheConfig.java
RedisConfig.java
📁 service/
UserService.java
@Configuration public class CacheConfig { @Bean public Cache<String, User> userCache() { return Caffeine.newBuilder() .maximumSize(10_000) // 最多 1 万条 .expireAfterWrite(10, TimeUnit.MINUTES) // 写入 10 分钟后过期 .expireAfterAccess(5, TimeUnit.MINUTES) // 访问后 5 分钟无访问则过期 .recordStats() // 开启统计(命中率等) .build(); } } // 使用 @Autowired Cache<String, User> userCache; public User getUser(String userId) { return userCache.get(userId, k -> { // 缓存没有则加载 return userMapper.selectById(userId); // 查数据库 }); }

🖥️ Caffeine 监控面板长这样(命中率/驱逐/加载耗时)

开启 recordStats() 后,通过 Spring Boot Actuator 可以看到缓存统计。下面模拟一个生产环境的缓存监控面板:

🗄️ 缓存监控 userCache productCache configCache 实时 · 自动刷新 10s
命中率
94.2%
✅ 健康(>80% 为佳)
缓存大小
8,234
/ 10,000 上限
加载次数
1,421
未命中时查 DB
驱逐次数
32
因容量/过期淘汰
平均加载耗时
23ms
查 DB 平均时间
命中耗时
0.02ms
内存读取,极快
总请求数
24,568
最近 1 小时
节省 DB 查询
23,147
94.2% 请求没打 DB
命中率趋势(最近 1 小时)
80% 阈值
关键看点
  • 命中率 94.2%:说明 94% 的请求直接从内存返回,没查 DB。低于 80% 说明缓存策略有问题
  • 命中耗时 0.02ms vs 加载耗时 23ms:命中缓存比查 DB 快 1000 倍,这就是缓存的价值
  • 驱逐次数 32:因容量满或过期被淘汰的条目数。如果持续飙升说明 maxSize 设太小
  • 节省 DB 查询 23,147 次:1 小时内帮 DB 挡了 2.3 万次查询,DB 压力大幅降低
这就是缓存的核心价值:用内存换速度,用命中率换 DB 压力。

5. 本地缓存 vs 分布式缓存:什么时候用哪个

维度本地缓存(Caffeine)分布式缓存(Redis)
在哪JVM 进程内存里独立进程(单独服务器)
速度纳秒级(最快)毫秒级(要走网络)
多机共享❌ 每台机器各自一份✅ 所有机器同一份
数据一致❌ 各机器可能不一致✅ 一致
容量受 JVM 堆内存限制(GB 级)可独立扩展(TB 级)
断电丢失有持久化(RDB/AOF)
适合配置字典/元数据/极少变更/单机热点用户会话/商品信息/多机共享数据

什么时候必须用 Redis(多机共享场景)

  • 用户登录 Session:用户请求可能打到任何一台机器,Session 必须共享 → Redis
  • 商品详情:3 台机器都有同一商品的缓存,如果商品改价了,只有一台更新 → 数据不一致 → Redis
  • 限流计数器:API 限流"每分钟 100 次",3 台机器各自计数就没意义了 → Redis 集中计数
  • 分布式锁:多台机器不能同时执行某任务 → Redis SETNX

什么时候 Caffeine 够用(本地缓存场景)

  • 配置字典:省市区列表、商品分类树 — 几乎不变,各机器各自缓存没问题
  • 元数据:枚举值、错误码映射 — 变更极少
  • 计算结果:复杂的内存计算结果,短期不变
  • 热点数据加速:Redis 的前面加一层 Caffeine,最热的数据本地命中,不走网络
最佳实践:多级缓存。L1 = Caffeine(本地,纳秒级)→ L2 = Redis(分布式,毫秒级)→ DB。
请求先查 L1(最快),没命中查 L2(稍慢但共享),还没命中查 DB(最慢)。90% 请求在 L1 命中,9% 在 L2 命中,1% 查 DB。
请求 L1: Caffeine 纳秒级 · 命中率 90%
↓ 未命中
L2: Redis 毫秒级 · 命中率 9% DB: MySQL 十毫秒级 · 1%

6. 通常和什么搭配使用

Caffeine + Redis 多级缓存(最常见)

搭配是啥为啥搭配
Caffeine + RedisL1 本地 + L2 分布式90% 请求 L1 秒回(不走网络),9% L2 共享,1% 查 DB。兼顾速度和一致性
@Cacheable + Caffeine注解 + 本地实现不想写缓存代码,@Cacheable 注解搞定,底层用 Caffeine 存
@Cacheable + Redis注解 + 分布式实现同上,底层切到 Redis,多机共享
Redis + 分布式锁SETNX 实现锁防止缓存击穿 — 大量请求同时查 DB,用锁只让一个查
Redis + MQ缓存更新通知数据变更时发 MQ 消息,所有机器清本地 Caffeine 缓存

💻 多级缓存配置(Spring Boot)

CacheConfig.java — 多级缓存
@Configuration public class CacheConfig { // L1: 本地缓存(Caffeine) @Bean public Cache<String, Object> localCache() { return Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(2, TimeUnit.MINUTES) // L1 过期快一点 .build(); } // L2: 分布式缓存(Redis)— 通过 Spring Data Redis @Bean public RedisTemplate<String, Object> redisTemplate() { RedisTemplate<String, Object> tpl = new RedisTemplate<>(); tpl.setConnectionFactory(connectionFactory); tpl.setKeySerializer(new StringRedisSerializer()); tpl.setValueSerializer(new GenericJackson2JsonRedisSerializer()); return tpl; } } // 多级缓存查询:L1 → L2 → DB public User getUser(String userId) { // L1: 先查本地 Caffeine User user = localCache.getIfPresent(userId); if (user != null) return user; // 90% 在这里返回 // L2: 查 Redis user = redisTemplate.opsForValue().get("user:" + userId); if (user != null) { localCache.put(userId, user); // 回填 L1 return user; // 9% 在这里返回 } // L3: 查 DB user = userMapper.selectById(userId); if (user != null) { redisTemplate.opsForValue().set("user:" + userId, user, 10, TimeUnit.MINUTES); localCache.put(userId, user); // 回填 L1+L2 } return user; // 1% 在这里返回 }
一句话:Caffeine(快但单机)+ Redis(共享但稍慢)= 多级缓存,兼顾速度和一致性。Spring @Cacheable 是注解封装层,底层可切换 Caffeine 或 Redis。

7. 生产上怎么用(3 个典型场景,带界面演示)

🎬 场景 1:缓存雪崩 — 大量 key 同时过期,DB 被打爆

现象:某天 10:00 整,系统突然响应变慢,DB CPU 飙到 100%。排查发现 10:00 有大批缓存 key 同时过期(因为设置了相同的过期时间),所有请求穿透到 DB。

Redis 监控面板:看到大量 key 同时过期

🔴 Redis 监控 expired keys memory connected clients 10:00 - 10:05 ▾
过期 key 数量
12,438
⚠️ 1 分钟内集中过期
DB 查询量
+820%
缓存穿透到 DB
缓存命中率
12%
断崖式下跌(正常 94%)
Redis 内存
↓ 3.2GB
大批 key 过期后释放
expired keys / min — 10:00 前后对比
10:00 雪崩
解法过期时间加随机值,避免大批 key 同时过期。
// ❌ 雪崩写法:所有 key 同样过期时间 redis.set("user:1001", data, 600); // 都是 10 分钟 redis.set("user:1002", data, 600); // 同时过期 → 雪崩 // ✅ 正确写法:过期时间加随机偏移 int ttl = 600 + ThreadLocalRandom.current().nextInt(120); // 10~12 分钟随机 redis.set("user:1001", data, ttl); // 过期时间分散开
其他措施:① 热点数据永不过期,后台异步刷新 ② DB 限流降级,雪崩时只放一部分请求过去 ③ Redis 集群高可用,别单点

🎬 场景 2:缓存穿透 — 查不存在的数据,每次都打 DB

现象:有人恶意请求 /api/user/-1(不存在的 ID),缓存里当然没有 → 每次都查 DB → DB 被打爆。Caffeine 监控面板显示命中率断崖式下跌。

Caffeine 监控面板:命中率异常下跌

🗄️ 缓存监控 userCache 10:30 - 10:35 ▾
命中率
8.3%
⚠️ 断崖下跌(正常 94%)
加载次数
28,431
几乎全查 DB
缓存大小
421
只存了正常数据
DB 查询
26,073
91% 请求穿透
原因:查 userId=-1,DB 里没有 → 返回 null → 不放缓存 → 下次又查 DB → 循环穿透。

解法 1:缓存空值(最简单)
public User getUser(String userId) { User user = cache.get(userId, k -> { User dbUser = userMapper.selectById(userId); // ✅ DB 查不到也放缓存(空值缓存 5 分钟) return dbUser != null ? dbUser : User.EMPTY; }); return user == User.EMPTY ? null : user; }
解法 2:布隆过滤器(更优雅)— 请求前先过一遍布隆过滤器,不存在的 ID 直接拒绝,不查 DB 不查缓存。

🎬 场景 3:热点 key — 一个 key 扛住 80% 流量,Redis 单点瓶颈

现象:大促时某个爆款商品 product:10086 的 QPS 占了 80%,Redis 单 key 所在的分片 CPU 打满,其他分片闲着。

Redis 监控面板:热点 key 一目了然

🔴 Redis 监控 hot keys memory 大促期间 ▾
热点 key 排行(按访问量)
product:10086 STRING 2.3 KB 82,341 ops/s 🔥
product:10087 STRING 2.1 KB 3,221 ops/s
product:10088 STRING 2.5 KB 891 ops/s
config:provinces HASH 15 KB 42 ops/s
问题:product:10086 扛了 82,341 ops/s,所在 Redis 分片 CPU 98%,其他分片 <10%。单点瓶颈。
解法:本地缓存兜底热点 key
public Product getProduct(Long productId) { // L1: Caffeine 本地缓存(纳秒级,不走网络) // 热点 key 90% 在这里命中,Redis 压力直接降 90% Product p = caffeineCache.get(productId, k -> { // L2: 只有 L1 没命中才查 Redis return redisTemplate.opsForValue().get("product:" + productId); }); return p; }
效果:加了 L1 Caffeine 后,82,341 ops/s 中 74,000 在本地命中,Redis 只剩 8,000 ops/s → CPU 降到 12%,瓶颈解除。
其他措施:① 热点 key 拆分(product:10086 → product:10086_v1/v2/v3,分散到不同分片)② 限流降级

8. 生产坑与事故

坑 1:HashMap 做缓存 → OOM 挂掉

现象:某服务用 static HashMap 缓存用户数据,运行 3 天后 OOM 重启。

原因:HashMap 没有淘汰策略,数据只进不出,内存持续增长直到 OOM。

解法:换 Caffeine,设 maxSize + expireAfterWrite。

教训HashMap 不是缓存库。没有淘汰策略 = 定时炸弹。

坑 2:@Cacheable 自调用失效 → 缓存没生效

现象:加了 @Cacheable,但缓存命中率 0%,每次都查 DB。

原因:@Cacheable 基于 Spring AOP 代理。同一个类内部方法调用,不经过代理 → 注解失效。

// ❌ 自调用失效 public User getUser(Long id) { return getUserFromDb(id); // 内部调用,不经过代理,@Cacheable 失效! } @Cacheable("users") public User getUserFromDb(Long id) { return userMapper.selectById(id); // 每次都查 DB }

解法:把缓存方法拆到另一个类,或者注入自己(self.getUserFromDb)。

教训:@Cacheable 必须跨类调用才生效。这是 Spring AOP 的通病。

坑 3:Redis 大 key → 阻塞其他请求

现象:某接口偶发超时,Redis 延迟从 1ms 飙到 200ms。

原因:有人存了一个 50MB 的 JSON 到一个 Redis key(all_products)。Redis 是单线程的,读写这个大 key 会阻塞所有其他操作。

解法:大 key 拆分 — all_productsproducts:category:1 / products:category:2... 每个 key 控制在 10KB 以内。

教训:Redis 单 key 不要超过 10KB(String)或 1万个元素(Hash/List/Set)。大 key 是 Redis 性能杀手。

坑 4:缓存与 DB 不一致 → 卖了 100 块钱的东西收了 1 块

现象:商品改价 1 元 → 100 元,但用户下单还是 1 元。缓存里还是旧价格。

原因:先改 DB 再删缓存,但删缓存失败了 → 缓存还是旧值 → 用户看到 1 元 → 下单 → DB 里已经是 100 元 → 价格不一致。

解法

  • 缓存更新策略:先删缓存再改 DB(但有可能并发读到旧值回填)
  • 更可靠:延迟双删 — 先删缓存 → 改 DB → sleep 500ms → 再删一次缓存
  • 最终一致:改 DB 后发 MQ 消息 → 异步删缓存,失败重试

教训:缓存一致性是最终一致,不是强一致。核心交易数据不要缓存,或用极短 TTL(如 5 秒)。

9. FAQ

Caffeine 和 Guava Cache 选哪个?

选 Caffeine。Guava Cache 已停更,Caffeine 是它的精神继承者,API 几乎一样(迁移成本低),但性能高 30%-80%(W-TinyLFU 算法)。新项目无脑 Caffeine,老项目有空就迁移。

@Cacheable 和手动写缓存代码选哪个?

简单场景用 @Cacheable(注解一行搞定,代码干净)。复杂场景手动写(多级缓存、条件缓存、异步刷新等 @Cacheable 不好搞)。

@Cacheable 的坑:自调用失效(同类内部调用不经过代理)、不支持多级缓存(需要自定义 CacheManager)、条件不好控制。

缓存命中率多少算正常?
  • > 90%:健康,缓存策略合理
  • 80%-90%:一般,可优化(调 TTL/maxSize)
  • < 80%:有问题,排查原因(数据太冷?TTL 太短?缓存 key 设计不合理?)
  • < 50%:缓存基本没用,可能还不如直接查 DB
缓存雪崩、穿透、击穿有什么区别?
雪崩穿透击穿
是什么大量 key 同时过期查不存在的数据热点 key 过期
结果DB 被大量请求打爆每次都查 DB一个 key 过期,大量请求同时查 DB
解法过期时间加随机值缓存空值/布隆过滤器热点 key 永不过期 + 分布式锁

记忆口诀:雪崩是"一批过期",穿透是"查没有的",击穿是"一个热点过期"

本地缓存要不要设置过期时间?

必须设。即使数据很少变更,也要设一个较长的 TTL(如 30 分钟)作为兜底。原因:

  • 防止数据变更后本地缓存长时间不更新(一致性兜底)
  • 防止内存泄漏(某些异常情况下数据只进不出)
  • 配合 maxSize 淘汰策略,双重保险

配置字典这类极少变更的数据:TTL 30 分钟 + maxSize 5000。
热点商品这类变更频繁的数据:TTL 5 分钟 + maxSize 10000。

🗄️ 缓存方案选型与实战 · 全栈资料库

← 返回 Java核心图解