Guava Cache 原理与实战

一、 什么是 Guava Cache?

简单来说,Guava Cache 是一个全内存的、线程安全的、类似于 Map 的本地缓存

如果你用过 HashMap 做缓存,你一定遇到过这些痛点:

  1. 内存溢出:Map 无限制增长,最终导致 OOM。
  2. 清理麻烦:需要自己写定时任务去清理过期数据。
  3. 并发安全:需要自己处理复杂的锁机制。

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. 架构流程

  1. 查询:先查本地 Guava -> 再查远程 Redis -> 最后查 DB。
  2. 命中率:通常 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)。

  1. 主线程(HTTP请求线程) :接收用户请求,拦截器将 TenantId = 1001 放入 ThreadLocal
  2. 触发刷新 :Guava Cache 发现数据过期,且使用了 asyncReloading
  3. 异步切换 :Guava 将"去数据库查询新值"的任务,提交给了一个独立的后台线程池
  4. 灾难发生
    • 后台线程池里的线程,和主线程不是同一个线程
    • Java 的 ThreadLocal 是线程隔离的,后台线程里的 TenantIdnull
    • 结果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 将上下文传递的时机,从"线程创建时"推迟到了任务提交/执行时

  1. Capture (捕获)
  • 时机 :主线程将任务提交给线程池的那一刻(execute/submit)。
  • 动作 :TTL 会把主线程里所有的 TransmittableThreadLocal 值捕获下来,保存在一个对象里。
  1. Replay (重放)
  • 时机 :线程池里的工作线程即将开始执行任务的那一刻(run 方法运行前)。
  • 动作 :TTL 会把刚才捕获的值,拷贝到当前的工作线程中,覆盖工作线程原本的上下文。
  1. Execute (执行)
  • 动作 :执行真正的业务逻辑(比如 Guava 的 load 方法)。此时代码里 TTL.get() 拿到的就是正确的主线程数据。
  1. Restore (恢复)
  • 时机:任务执行结束后。
  • 动作 :TTL 会把工作线程的上下文恢复成执行任务之前的样子。
  • 目的:防止工作线程被"污染",保证它回到线程池待命时是干净的(或者保持原样)。
2.2 实现方式

TTL 通过装饰者模式 (Decorator Pattern)实现了对 Runnable / Callable 或者 ExecutorService 的包装,在 run() 方法外层包裹了上述的 CRR 逻辑。

3. 为什么能解决 Guava Cache 的问题?

结合 Guava Cache,通过 TTL 改造后的执行链路如下:

3.1 代码准备

必须做两件事:

  1. 容器替换 :把存储租户 ID 的容器从 ThreadLocal 换成 TransmittableThreadLocal
  2. 线程池修饰 :把传给 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
相关推荐
yangminlei2 小时前
Spring 事务探秘:核心机制与应用场景解析
java·spring boot
Yuer20253 小时前
什么是 Rust 语境下的“量化算子”——一个工程对象的最小定义
开发语言·后端·rust·edca os·可控ai
记得开心一点嘛3 小时前
Redis封装类
java·redis
短剑重铸之日3 小时前
《7天学会Redis》Day 5 - Redis Cluster集群架构
数据库·redis·后端·缓存·架构·cluster
lkbhua莱克瓦243 小时前
进阶-存储过程3-存储函数
java·数据库·sql·mysql·数据库优化·视图
计算机程序设计小李同学3 小时前
基于SSM框架的动画制作及分享网站设计
java·前端·后端·学习·ssm
鱼跃鹰飞3 小时前
JMM 三大特性(原子性 / 可见性 / 有序性)面试精简版
java·jvm·面试
+VX:Fegn08954 小时前
计算机毕业设计|基于springboot + vue小型房屋租赁系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
该怎么办呢4 小时前
基于cesium的三维不动产登记系统的设计与实现(毕业设计)
java·毕业设计