一、 什么是 Guava Cache?
简单来说,Guava Cache 是一个全内存的、线程安全的、类似于 Map 的本地缓存。
如果你用过 HashMap 做缓存,你一定遇到过这些痛点:
- 内存溢出:Map 无限制增长,最终导致 OOM。
- 清理麻烦:需要自己写定时任务去清理过期数据。
- 并发安全:需要自己处理复杂的锁机制。
Guava Cache 就是为了解决这些问题而生的"增强版 Map"。它支持:
- 自动过期:支持写入后多久过期、访问后多久过期。
- 容量限制:支持最大缓存条数,基于 LRU(最近最少使用)算法淘汰。
- 自动加载:缓存不存在时,自动回调加载数据。
二、 核心原理与快速入门
1. 核心组件:LoadingCache
Guava Cache 最常用的模式是 LoadingCache(自动加载缓存)。它的核心思想是 "Read-Through" 策略:当调用方获取数据时,如果缓存中有,直接返回;如果没有,自动去数据源(DB/Redis)加载并回填。
2. 引入依赖
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
3. Hello World 示例
java
public class GuavaCacheDemo {
// 定义缓存:Key是用户ID,Value是用户名
private static final LoadingCache<Long, String> USER_CACHE = CacheBuilder.newBuilder()
.maximumSize(1000) // 最多存1000条
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入10分钟后过期
.build(new CacheLoader<Long, String>() {
@Override
public String load(Long key) {
// 模拟查数据库
return "User_" + key;
}
});
public static void main(String[] args) throws ExecutionException {
// 第一次:查库,存入缓存,返回
System.out.println(USER_CACHE.get(1L));
// 第二次:直接从内存返回,无需查库
System.out.println(USER_CACHE.get(1L));
}
}
三、 进阶:串行刷新 vs 全异步刷新
这是 Guava Cache 最精髓,也是面试和实战中最容易踩坑的地方。针对"高并发下的缓存过期",Guava 提供了两种完全不同的处理策略。
1. 串行刷新(同步阻塞)
使用 build(loader) 构建。
- 机制 :当缓存过期(refresh)时,当前请求线程 会被阻塞,亲自去执行
load方法加载数据。加载期间,其他线程返回旧值。 - 优点 :上下文安全 。因为是当前线程执行,
ThreadLocal(如用户ID、租户ID)依然可用。 - 缺点 :有卡顿。触发刷新的那个"倒霉"请求会变慢。
- 适用场景:用户维度的缓存(如个人信息),依赖 ThreadLocal 上下文。
java
// 同步构建方式
LoadingCache<K, V> syncCache = CacheBuilder.newBuilder()
.refreshAfterWrite(Duration.ofMinutes(1))
.build(new CacheLoader<K, V>() { ... }); // 普通 loader
2. 并行刷新(全异步非阻塞)
使用 build(CacheLoader.asyncReloading(loader, executor)) 构建。
- 机制 :当缓存过期时,当前请求线程不阻塞 ,直接返回旧值。同时,Guava 提交一个任务给后台线程池去加载新数据。
- 优点 :极致性能。也就是所谓的"最终一致性",用户永远感觉不到卡顿。
- 缺点 :ThreadLocal 丢失。因为是后台线程池执行,拿不到主线程的 ThreadLocal 变量。
- 适用场景 :全局配置、热点榜单、白名单等与具体用户无关的数据。
java
// 异步构建方式
// 这里的参数需要根据机器配置和业务量调整
private static final ExecutorService ASYNC_POOL = new ThreadPoolExecutor(
5, // 核心线程数:平时保留5个线程干活
20, // 最大线程数:忙的时候最多扩容到20个
60L, TimeUnit.SECONDS, // 空闲线程回收时间
new LinkedBlockingQueue<>(100), // 队列容量:最多排队100个刷新任务
// 给线程起个名字,方便出了 Bug 排查(强烈建议)
new ThreadFactoryBuilder().setNameFormat("guava-cache-refresh-%d").build(),
// 【关键策略】拒绝策略:DiscardPolicy (丢弃策略)
// 解释:如果线程池满了,队列也满了,说明系统负载极高。
// 此时直接丢弃这次刷新任务,不抛异常,也不阻塞主线程。
// 后果仅仅是缓存多旧了一会儿,等负载降下来下次请求再刷新即可。
new ThreadPoolExecutor.DiscardPolicy()
);
// 2. 构建缓存
public static <K, V> LoadingCache<K, V> buildAsyncCache(CacheLoader<K, V> originalLoader) {
return CacheBuilder.newBuilder().maximumSize(10000)
.refreshAfterWrite(Duration.ofMinutes(1))
.build(CacheLoader.asyncReloading(
originalLoader, // 原始的查库逻辑
ASYNC_POOL // 【修正】传入自定义的安全线程池
));
}
四、 架构演进:Guava + Redis 多级缓存
单用 Redis 很快,但在面对突发热点 Key(如微博热搜、秒杀活动)时,Redis 的网络 IO 和单节点吞吐量依然可能成为瓶颈。
此时,Guava (L1) + Redis (L2) 的多级缓存架构应运而生。
1. 架构流程
- 查询:先查本地 Guava -> 再查远程 Redis -> 最后查 DB。
- 命中率:通常 Guava 设置较短过期时间(如 5 秒),虽然短,但能拦截掉 99% 的瞬时高并发请求,保护 Redis。
2. 数据一致性挑战
本地缓存最大的痛点是各节点数据不一致。
- 场景:服务器 A 修改了配置,删除了 Redis,也清理了本地 Guava。但服务器 B 的 Guava 里还是旧配置。
- 解决方案 :引入 Redis Pub/Sub(发布订阅) 。
- 当数据更新时,发送一条消息到 Redis Channel。
- 所有应用服务器监听该 Channel。
- 收到消息后,各自清理本地的 Guava Cache。
五、 Guava Cache的异步刷新问题
1. 问题定义
Guava Cache 的 asyncReloading 特性虽然能极大提升接口响应速度(防止用户请求被数据库查询阻塞),但它引入了一个致命的上下文丢失问题。
1.1 场景还原
假设你的系统是多租户架构 ,使用 ThreadLocal 存储当前请求的 TenantId(租户ID)。
- 主线程(HTTP请求线程) :接收用户请求,拦截器将
TenantId = 1001放入ThreadLocal。 - 触发刷新 :Guava Cache 发现数据过期,且使用了
asyncReloading。 - 异步切换 :Guava 将"去数据库查询新值"的任务,提交给了一个独立的后台线程池。
- 灾难发生 :
- 后台线程池里的线程,和主线程不是同一个线程。
- Java 的
ThreadLocal是线程隔离的,后台线程里的TenantId是 null。 - 结果 :
CacheLoader.load()方法执行 SQL 时,因为取不到租户 ID,要么报错(空指针),要么查出了所有租户的数据(严重的数据泄露事故)。
1.2 为什么 JDK 自带的 InheritableThreadLocal (ITL) 不行?
JDK 其实有一个 InheritableThreadLocal,允许子线程继承父线程的变量。但在线程池场景下它会失效:
- ITL 的机制 :只有在创建线程(new Thread)的那一瞬间,才会拷贝父线程的数据。
- 线程池的机制 :线程是复用的。线程池里的线程可能早就创建好了。
- 第一次任务:线程创建,拷贝了上下文 A。
- 第二次任务 :线程复用,不再触发创建动作,上下文依然是 A,而不是当前主线程的 B。这就是所谓的"上下文污染"或"数据陈旧"。
2. TTL (TransmittableThreadLocal) 的原理是什么?
阿里开源的 TTL 专门为了解决在使用线程池等会池化复用线程的执行组件情况下,提供 ThreadLocal 值的传递。
它的核心原理可以概括为 CRR 模式 :Capture(捕获)、Replay(重放)、Restore(恢复)。
2.1 核心流程
TTL 将上下文传递的时机,从"线程创建时"推迟到了任务提交/执行时。
- Capture (捕获):
- 时机 :主线程将任务提交给线程池的那一刻(
execute/submit)。 - 动作 :TTL 会把主线程里所有的
TransmittableThreadLocal值捕获下来,保存在一个对象里。
- Replay (重放):
- 时机 :线程池里的工作线程即将开始执行任务的那一刻(
run方法运行前)。 - 动作 :TTL 会把刚才捕获的值,拷贝到当前的工作线程中,覆盖工作线程原本的上下文。
- Execute (执行):
- 动作 :执行真正的业务逻辑(比如 Guava 的
load方法)。此时代码里TTL.get()拿到的就是正确的主线程数据。
- Restore (恢复):
- 时机:任务执行结束后。
- 动作 :TTL 会把工作线程的上下文恢复成执行任务之前的样子。
- 目的:防止工作线程被"污染",保证它回到线程池待命时是干净的(或者保持原样)。
2.2 实现方式
TTL 通过装饰者模式 (Decorator Pattern)实现了对 Runnable / Callable 或者 ExecutorService 的包装,在 run() 方法外层包裹了上述的 CRR 逻辑。
3. 为什么能解决 Guava Cache 的问题?
结合 Guava Cache,通过 TTL 改造后的执行链路如下:
3.1 代码准备
必须做两件事:
- 容器替换 :把存储租户 ID 的容器从
ThreadLocal换成TransmittableThreadLocal。 - 线程池修饰 :把传给 Guava 的线程池用
TtlExecutors包装。
java
// 1. 使用 TTL 容器
static TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
// 2. 包装线程池
ExecutorService ttlExecutor = TtlExecutors.getTtlExecutorService(originalExecutor);
// 3. 构建 Guava Cache
CacheBuilder.newBuilder()
.refreshAfterWrite(Duration.ofMinutes(1))
.build(CacheLoader.asyncReloading(loader, ttlExecutor)); // 传入 TTL 线程池
3.2 执行步骤解析
| 步骤 | 执行线程 | 动作描述 | 上下文状态 |
|---|---|---|---|
| 1. 请求进入 | 主线程 | context.set("Tenant-A") |
Main: A |
| 2. 触发刷新 | 主线程 | Guava 调用 ttlExecutor.execute(Task) |
Main: A |
| 3. TTL 捕获 | 主线程 | Capture: TTL 拦截任务提交,将 "Tenant-A" 存入 Task 对象 | Task 携带 A |
| 4. 线程排队 | - | 任务在队列中等待... | - |
| 5. 线程捞取 | 工作线程-1 | 线程池分配 Thread-1 来执行任务 |
Thread-1: Null (或旧值) |
| 6. TTL 重放 | 工作线程-1 | Replay : 任务开始前,TTL 将 Task 里的 "Tenant-A" 写入 Thread-1 |
Thread-1: A |
| 7. 业务查库 | 工作线程-1 | 执行 loader.load(),SQL 获取 context.get() |
拿到 A,查库成功 |
| 8. TTL 恢复 | 工作线程-1 | Restore : 任务结束,清空 Thread-1 的上下文 |
Thread-1: Null |