Spring Boot 实战:@PostConstruct + Caffeine 缓存初始化与定时刷新

1. 引言

在 Spring Boot 应用中,缓存是提升性能的利器。Caffeine 作为一款高性能的 Java 缓存库,因其卓越的性能和丰富的特性,已成为 Spring Boot 默认的本地缓存实现。而 @PostConstruct 注解则允许我们在 Bean 初始化完成后执行自定义逻辑,非常适合用来初始化缓存。

本文将通过一个实际案例,总结如何在 Spring Boot 中使用 @PostConstruct 初始化 Caffeine 缓存,并实现定时主动刷新,确保缓存数据的新鲜度。

2. 核心概念

2.1 @PostConstruct 注解

@PostConstruct 是 Java EE 引入的一个注解,被它修饰的方法会在依赖注入完成后、Bean 初始化阶段被自动调用。在 Spring Boot 中,它常用于执行一些初始化操作,如加载配置、初始化缓存、建立连接等。

2.2 Caffeine 缓存

Caffeine 是一个基于 Java 8 的高性能缓存库,提供了近乎最优的命中率。它支持多种缓存策略,包括:

  • 基于时间的过期策略expireAfterWriteexpireAfterAccess
  • 基于大小的淘汰策略maximumSize
  • 异步加载buildAsyncLoadingCache
  • 手动刷新refresh

3. 实战案例:离线菜单缓存

下面我们通过一个「离线菜单缓存」的案例,展示 @PostConstruct 与 Caffeine 的典型用法。

3.1 场景描述

假设我们有一个离线菜单数据,需要根据 品牌门店 两个维度进行查询。由于数据量大且查询频繁,我们决定使用 Caffeine 的异步加载缓存来存储这些数据,并在应用启动时通过 @PostConstruct 初始化缓存,同时启动一个定时任务每分钟检查并刷新过期缓存。

3.2 代码实现

java 复制代码
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.AsyncLoadingCache;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class OfflineMenuCacheManager {

    private AsyncLoadingCache<String, Object> offlineMenuCache;

    /**
     * 构建异步加载缓存
     * @param expireAfterWriteDuration 写入后过期时间
     * @param expireAfterWriteUnit     时间单位
     * @param refreshAfterWriteDuration 写入后刷新时间
     * @param refreshAfterWriteUnit     时间单位
     * @param maximumSize               最大缓存条目数
     * @param loader                    异步加载函数
     * @return AsyncLoadingCache
     */
    public static <K, V> AsyncLoadingCache<K, V> buildAsyncLoadingCache(
            long expireAfterWriteDuration, TimeUnit expireAfterWriteUnit,
            long refreshAfterWriteDuration, TimeUnit refreshAfterWriteUnit,
            long maximumSize,
            AsyncLoadingCacheLoader<K, V> loader) {
        return Caffeine.newBuilder()
                .expireAfterWrite(expireAfterWriteDuration, expireAfterWriteUnit)
                .refreshAfterWrite(refreshAfterWriteDuration, refreshAfterWriteUnit)
                .maximumSize(maximumSize)
                .buildAsync(loader);
    }

    @FunctionalInterface
    public interface AsyncLoadingCacheLoader<K, V> {
        V load(K key, Executor executor) throws Exception;
    }

    @PostConstruct
    public void init() {
        // 初始化异步加载缓存
        offlineMenuCache = buildAsyncLoadingCache(
                25, TimeUnit.MINUTES,   // 写入后25分钟过期
                30, TimeUnit.MINUTES,   // 写入后30分钟刷新(实际refreshAfterWrite应小于expireAfterWrite)
                100,                    // 最大缓存100条
                (key, executor) -> {
                    String[] parts = key.split("\\|");
                    return getOfflineMenuData(parts[0], parts[1]);
                }
        );

        // 定时主动更新缓存(每分钟检查一次)
        scheduler.scheduleAtFixedRate(() -> {
            try {
                offlineMenuCache.synchronous().policy().expireAfterWrite().ifPresent(expiration -> {
                    long refreshThreshold = TimeUnit.MINUTES.toNanos(25);

                    offlineMenuCache.synchronous().asMap().forEach((key, value) -> {
                        expiration.ageOf(key, TimeUnit.NANOSECONDS).ifPresent(age -> {
                            if (age >= refreshThreshold) {
                                offlineMenuCache.synchronous().refresh(key);
                                log.info("Refreshed offlineMenu cache for key: {}", key);
                            }
                        });
                    });
                });
            } catch (Exception e) {
                log.error("Scheduled refresh menu cache failed", e);
            }
        }, 1, 1, TimeUnit.MINUTES);
    }

    /**
     * 模拟从数据库或其他数据源获取离线菜单数据
     */
    private Object getOfflineMenuData(String brand, String store) {
        // 实际业务逻辑:查询数据库或调用远程服务
        log.info("Loading offline menu data for brand: {}, store: {}", brand, store);
        return "MenuData{" + brand + "|" + store + "}";
    }
}

3.3 代码解析

  1. @PostConstruct init() :在 Bean 初始化完成后,自动调用 init() 方法,完成缓存的构建和定时任务的启动。

  2. buildAsyncLoadingCache:这是一个工具方法,用于构建 Caffeine 的异步加载缓存。参数包括:

    • expireAfterWrite:写入后过期时间(25分钟),超过此时间缓存条目将被自动删除。
    • refreshAfterWrite:写入后刷新时间(30分钟),注意此值应小于 expireAfterWrite,否则刷新将无意义。
    • maximumSize:最大缓存条目数,防止内存溢出。
    • loader:异步加载函数,当缓存未命中时自动调用。
  3. 定时刷新机制

    • 使用 ScheduledExecutorService.scheduleAtFixedRate 每分钟执行一次。
    • 通过 expiration.ageOf(key) 获取每个缓存条目的存活时间。
    • 如果存活时间超过 25 分钟,则调用 refresh(key) 主动刷新缓存。
    • 这种机制确保了缓存数据在过期前被提前刷新,避免大量请求同时穿透到后端。
3.3.1 buildAsyncLoadingCache 方法能力详解

buildAsyncLoadingCache 是一个泛型工具方法,封装了 Caffeine 异步加载缓存的构建逻辑,具备以下核心能力:

  1. 泛型支持 :通过 <K, V> 泛型参数,支持任意类型的 Key 和 Value,复用性极强。本例中 Key 为 String(品牌|门店组合),Value 为 Object(菜单数据对象)。

  2. 过期策略配置

    • expireAfterWrite:写入后指定时间自动过期,适用于对数据新鲜度有要求的场景(如菜单数据 25 分钟后失效)。
    • `
3.3.3 定时刷新任务代码逐行解析

下面逐行拆解 init() 方法中的定时刷新任务,重点讲解 Caffeine 提供的核心方法。

java 复制代码
scheduler.scheduleAtFixedRate(() -> {
    try {
        // 获取过期时间策略
        offlineMenuCache.synchronous().policy().expireAfterWrite().ifPresent(expiration -> {
            // 25分钟的纳秒数
            long refreshThreshold = TimeUnit.MINUTES.toNanos(25);

            offlineMenuCache.synchronous().asMap().forEach((key, value) -> {
                // 获取每个 entry 的存活时间信息
                expiration.ageOf(key, TimeUnit.NANOSECONDS).ifPresent(age -> {
                    // 如果存活时间超过25分钟,则主动刷新
                    if (age >= refreshThreshold) {
                        offlineMenuCache.synchronous().refresh(key);
                        log.info("Refreshed offlineMenu cache for key: {}", key);
                    }
                });
            });
        });
    } catch (Exception e) {
        log.error("Scheduled refresh menu cache failed", e);
    }
}, 1, 1, TimeUnit.MINUTES); // 每分钟检查一次
1. scheduler.scheduleAtFixedRate(..., 1, 1, TimeUnit.MINUTES)

这是 ScheduledExecutorService 的定时调度方法,含义是:初始延迟 1 分钟后开始执行,之后每隔 1 分钟执行一次。无论任务执行耗时多久,下一次执行都会按固定频率触发(如果任务耗时超过间隔,则等任务完成后立即开始下一次)。

2. offlineMenuCache.synchronous()

AsyncLoadingCache 是异步缓存,其 get() 返回 CompletableFuture。但定时任务中需要同步遍历和操作缓存,因此调用 .synchronous() 获取一个同步视图。该视图暴露了与异步缓存相同的数据,但所有方法都是同步阻塞的,方便在定时任务中直接使用。

3. .policy().expireAfterWrite()

policy() 返回 Caffeine 的策略视图 ,允许访问缓存的底层配置和运行时信息。expireAfterWrite() 返回一个 Optional<Expiration<K, V>>,其中包含:

  • 如果缓存配置了 expireAfterWrite,则返回该过期策略对象。
  • 如果未配置,则返回 Optional.empty()

Expiration 接口提供了 ageOf(key, unit) 方法,用于查询指定 key 的存活时间。

4. .ifPresent(expiration -> ...)

Java 8 的 Optional.ifPresent():只有当 expireAfterWrite 策略存在时(即缓存确实配置了写入后过期),才执行内部的 lambda 逻辑。这是一种防御性编程 ,避免在未配置过期策略时抛出 NullPointerException

5. long refreshThreshold = TimeUnit.MINUTES.toNanos(25);

将 25 分钟转换为纳秒。Caffeine 内部的时间精度是纳秒级,ageOf() 返回的存活时间也是纳秒,因此阈值也需要统一为纳秒进行比较。

6. offlineMenuCache.synchronous().asMap()

asMap() 返回缓存底层数据的 ConcurrentMap 视图,允许遍历所有已缓存的 key-value 对。注意:

  • 返回的是实时视图 ,遍历期间其他线程的 put/remove 操作会反映在遍历中(但 forEach 本身不保证强一致性)。
  • 遍历的是当前缓存中所有未过期的条目 (已过期的条目已被自动清理,不会出现在 asMap 中)。
7. .forEach((key, value) -> ...)

遍历 asMap 中的每一个缓存条目,key 是缓存的键(如 "KFC|SH001"),value 是对应的值。

8. expiration.ageOf(key, TimeUnit.NANOSECONDS)

这是 Caffeine 的 Expiration.ageOf() 方法,用于查询指定 key 的存活时间 (从写入/刷新完成开始计算)。返回 OptionalLong

  • 如果 key 存在且未过期,返回存活时间的纳秒数。
  • 如果 key 不存在或已过期,返回 OptionalLong.empty()
9. .ifPresent(age -> ...)

同样使用 Optional.ifPresent():只有当 ageOf 成功返回存活时间时,才执行内部逻辑。

10. if (age >= refreshThreshold)

比较存活时间是否达到或超过 25 分钟。如果存活时间 >= 25 分钟,说明该缓存条目即将过期(expireAfterWrite 设为 25 分钟),需要主动刷新。

11. offlineMenuCache.synchronous().refresh(key)

refresh(key) 是 Caffeine 提供的手动刷新方法:

  • 它会异步重新调用 loader 加载最新数据。
  • 刷新期间,旧值仍然可用 ,调用方通过 get() 获取到的是旧值,不会阻塞。
  • 加载完成后,新值自动替换旧值。
  • 如果同一个 key 在短时间内被多次调用 refresh(),Caffeine 会合并请求,只触发一次实际加载。
12. log.info("Refreshed offlineMenu cache for key: {}", key);

记录刷新日志,方便排查问题。

13. catch (Exception e) { log.error(...) }

捕获整个定时任务执行过程中的所有异常,防止因一次异常导致定时任务终止。ScheduledExecutorServicescheduleAtFixedRate 有一个重要特性:如果任务抛出未捕获的异常,后续的任务将不再执行 。因此这里的 try-catch 是必须的,确保即使某次刷新失败,定时任务仍能继续运行。

整体设计思路

这段代码的核心思路是:在缓存过期之前主动刷新 。由于 expireAfterWrite 设为 25 分钟,定时任务每分钟检查一次,对存活超过 25 分钟的 key 调用 refresh()。这样,缓存条目在 25 分钟时被刷新,永远不会真正过期,从而避免了缓存穿透。

为什么需要定时任务? 因为 refreshAfterWrite 是惰性的(只在有 get() 请求时触发),如果某个 key 在 25 分钟内没有被访问过,它就不会被刷新,最终在 25 分钟时过期。定时任务弥补了这一缺陷,确保所有 key 都能被主动刷新。

3.3.2 refreshAfterWrite 的自动更新逻辑

refreshAfterWrite 的自动更新逻辑是 Caffeine 内部在缓存读取路径上触发的,不需要额外的定时任务。具体机制如下:

核心原理

当配置了 refreshAfterWrite 后,Caffeine 会在每次 get() 请求时检查该缓存条目的存活时间:

  1. 存活时间 < refreshAfterWrite:直接返回当前缓存值,不做任何额外操作。
  2. 存活时间 >= refreshAfterWrite :Caffeine 会立即返回当前旧值 (不阻塞调用方),同时在后台异步触发 loader 重新加载。加载完成后,新值替换旧值,后续请求自动获取新值。

关键特性

  • 非阻塞:调用方拿到的是旧值,不会等待 loader 完成,因此响应速度不受影响。
  • 异步触发:刷新操作在后台线程池中执行,由 Caffeine 内部调度。
  • 仅在有请求时触发 :如果某个 key 在 refreshAfterWrite 之后再也没有被 get() 过,Caffeine 不会主动刷新它------刷新是惰性的,由读取请求驱动。

与 expireAfterWrite 的配合

  • refreshAfterWrite 必须在 expireAfterWrite 之前触发才有意义,即 refreshAfterWrite < expireAfterWrite
  • 如果 refreshAfterWrite >= expireAfterWrite,则条目会在刷新之前就被过期删除了,刷新永远不会发生。

对比代码中的定时刷新

你代码中的定时任务(每分钟遍历 asMap 并调用 refresh(key))是一种主动刷新 策略,与 refreshAfterWrite惰性刷新不同:

特性 refreshAfterWrite(惰性) 定时任务(主动)
触发条件 get() 请求且存活时间达标 定时器周期性执行
无请求时是否刷新 ❌ 不刷新 ✅ 会刷新
是否阻塞调用方 ❌ 不阻塞(返回旧值) ❌ 不阻塞(异步 refresh)
适用场景 读多写少,允许短暂旧数据 对数据新鲜度要求高,需提前预热

一句话总结refreshAfterWrite 是 Caffeine 内置的惰性异步刷新机制,在每次 get() 时检查并触发后台刷新,返回旧值不阻塞;而代码中的定时任务则是主动遍历所有 key 进行刷新,两者可以互补使用。

refreshAfterWrite`:写入后指定时间自动刷新,在过期前提前异步加载新数据,避免缓存雪崩。

  1. 内存控制maximumSize 限制最大缓存条目数,防止内存溢出,适合数据量不可控的场景。

  2. 异步加载器loader 参数是一个函数式接口 AsyncLoadingCacheLoader<K, V>,当缓存未命中时自动异步调用,返回 CompletableFuture,不阻塞主线程。

  3. 链式构建 :内部通过 Caffeine.newBuilder() 链式调用,将配置参数传递给 Caffeine 的 Builder,最终调用 buildAsync(loader) 生成 AsyncLoadingCache 实例。

一句话总结:该方法将 Caffeine 缓存的过期策略、大小限制、异步加载等核心配置封装为一个可复用的构建器,开发者只需传入业务参数和加载函数即可快速获得一个高性能的异步加载缓存实例。

3.3.1 offlineMenuCache 的赋值时刻

理解 offlineMenuCache 被赋值的时刻,需要先了解 Spring Bean 的生命周期。具体流程如下:

  1. 实例化 :Spring 通过反射创建 OfflineMenuCacheManager 实例,此时 offlineMenuCachenull
  2. 依赖注入 :Spring 注入 @Autowired@Resource 等标注的依赖(本例中没有额外依赖)。
  3. @PostConstruct init() 执行 :Spring 自动调用 init() 方法,执行到 offlineMenuCache = buildAsyncLoadingCache(...) 这一行时,offlineMenuCache 被赋值为新构建的 AsyncLoadingCache 实例。
  4. Bean 就绪init() 方法执行完毕后,该 Bean 完全初始化完成,可以被其他 Bean 使用。

关键点

  • 赋值发生在 @PostConstruct 方法内部,而不是在 Bean 实例化时。
  • 赋值后,缓存对象已经创建完毕,但此时缓存中还没有任何数据 (因为 buildAsyncLoadingCache 只是构建了缓存结构,不会自动加载数据)。数据是在后续调用 get()refresh() 时,由 loader 按需加载的。
  • 定时任务也是在 init() 方法中启动的,所以赋值完成后,定时任务也开始运行。

3.4 Key-Value 传入流程详解

很多同学会好奇:代码中并没有显式调用 put(key, value),那 Key 和 Value 是怎么传入缓存的?下面通过流程图和源码分析来拆解。

3.4.1 整体流程

3.4.1.1 get() 请求流程

下面通过 ASCII 流程图展示一次 get() 请求的完整生命周期:

text 复制代码
                        ┌─────────────────────────────────────┐
                        │  调用方发起请求                       │
                        │  offlineMenuCache.get("KFC|SH001")   │
                        └──────────────────┬──────────────────┘
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │     缓存中是否存在该 key?            │
                        │     key = "KFC|SH001"                │
                        └──────────────────┬──────────────────┘
                                           │
                    ┌──────────────────────┼──────────────────────┐
                    │                      │                      │
                    ▼                      │                      ▼
        ┌───────────┴───────────┐          │          ┌───────────┴───────────┐
        │  命中(未过期)        │          │          │  未命中 / 已过期       │
        │  直接返回缓存中的      │          │          │  触发异步加载器        │
        │  Value                │          │          │  loader(key, executor) │
        └───────────────────────┘          │          └───────────┬───────────┘
                                           │                      │
                                           │                      ▼
                                           │          ┌───────────┴───────────┐
                                           │          │  loader 内部拆解 key  │
                                           │          │  key.split("\\|")     │
                                           │          │  → ["KFC", "SH001"]   │
                                           │          └───────────┬───────────┘
                                           │                      │
                                           │                      ▼
                                           │          ┌───────────┴───────────┐
                                           │          │  调用数据源            │
                                           │          │  getOfflineMenuData   │
                                           │          │  ("KFC", "SH001")     │
                                           │          └───────────┬───────────┘
                                           │                      │
                                           │                      ▼
                                           │          ┌───────────┴───────────┐
                                           │          │  loader 返回 Value,   │
                                           │          │  Caffeine 自动存入缓存 │
                                           │          └───────────┬───────────┘
                                           │                      │
                                           └──────────┬───────────┘
                                                      │
                                                      ▼
                        ┌─────────────────────────────────────┐
                        │  返回 CompletableFuture<Object>      │
                        │  调用方通过 future.get() 获取结果     │
                        └─────────────────────────────────────┘

流程说明

  • 入口 :调用 offlineMenuCache.get(key),而非直接调用 getOfflineMenuData
  • 缓存命中 :直接返回缓存中的 Value,不触发 loader,响应速度最快。
  • 缓存未命中:loader 自动拆解 key 并调用数据源方法,返回值由 Caffeine 自动存入缓存。
  • 透明性 :整个过程对调用方完全透明,无需手动 put 或关心缓存内部逻辑。
3.4.1.2 定时任务自动刷新流程

下面通过 ASCII 流程图展示定时任务每分钟自动检查并刷新过期缓存的完整流程:

text 复制代码
                        ┌─────────────────────────────────────┐
                        │  scheduler.scheduleAtFixedRate       │
                        │  初始延迟 1 分钟,之后每 1 分钟执行  │
                        └──────────────────┬──────────────────┘
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │  定时任务开始执行                     │
                        │  try { ... } catch (Exception e)    │
                        └──────────────────┬──────────────────┘
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │  获取过期策略对象                     │
                        │  offlineMenuCache.synchronous()      │
                        │  .policy().expireAfterWrite()        │
                        └──────────────────┬──────────────────┘
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │  策略是否存在?                       │
                        │  .ifPresent(expiration -> ...)       │
                        └──────────────────┬──────────────────┘
                                           │
                                    (存在,继续)
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │  计算刷新阈值:25 分钟 → 纳秒        │
                        │  refreshThreshold = 25min in nanos  │
                        └──────────────────┬──────────────────┘
                                           │
                                           ▼
                        ┌──────────────────┴──────────────────┐
                        │  遍历所有已缓存的 key                 │
                        │  .asMap().forEach((key, value) ->)   │
                        └──────────────────┬──────────────────┘
                                           │
                    ┌──────────────────────┼──────────────────────┐
                    │                      │                      │
                    ▼                      │                      ▼
        ┌───────────┴───────────┐          │          ┌───────────┴───────────┐
        │  获取该 key 的         │          │          │  下一个 key           │
        │  存活时间              │          │          │  (继续遍历)           │
        │  expiration.ageOf(key) │          │          │                       │
        └───────────┬───────────┘          │          └───────────────────────┘
                    │                      │
                    ▼                      │
        ┌───────────┴───────────┐          │
        │  ageOf 是否返回有效值?│          │
        │  .ifPresent(age ->)   │          │
        └───────────┬───────────┘          │
                    │                      │
            (有效,继续)                    │
                    │                      │
                    ▼                      │
        ┌───────────┴───────────┐          │
        │  存活时间 >= 25 分钟? │          │
        │  age >= refreshThreshold         │
        └───────────┬───────────┘          │
                    │                      │
            ┌───────┴───────┐              │
            │               │              │
            ▼               ▼              │
    ┌───────┴───────┐  (否,跳过)          │
    │  是,主动刷新   │                     │
    │  .refresh(key) │                     │
    │  异步重新加载   │                     │
    │  旧值仍可用     │                     │
    └───────┬───────┘                      │
            │                              │
            ▼                              │
    ┌───────┴───────┐                      │
    │  记录日志      │                      │
    │  log.info(...) │                     │
    └───────┬───────┘                      │
            │                              │
            └──────────────┬───────────────┘
                           │
                           ▼
            ┌──────────────┴──────────────┐
            │  遍历结束,本次定时任务完成   │
            │  等待下一分钟再次执行         │
            └─────────────────────────────┘

流程说明

  • 触发方式ScheduledExecutorService.scheduleAtFixedRate 每 1 分钟自动触发一次。
  • 防御性检查 :通过 ifPresent 确保缓存确实配置了 expireAfterWrite 策略,避免空指针。
  • 遍历检查 :遍历 asMap() 中所有未过期的缓存条目,逐一检查存活时间。
  • 刷新条件 :存活时间 >= 25 分钟(即即将达到 expireAfterWrite 阈值)时触发刷新。
  • 异步刷新refresh(key) 是异步操作,刷新期间旧值仍然可用,调用方无感知。
  • 异常保护:整个定时任务被 try-catch 包裹,防止单次异常导致定时任务永久终止。

3.4.2 Key 的传入方式

Key 是由调用方 在查询缓存时传入的字符串,格式为 "品牌|门店"。例如:

java 复制代码
// 调用方传入 key
String brand = "KFC";
String store = "SH001";
String cacheKey = brand + "|" + store;  // "KFC|SH001"

// 通过 get 方法触发缓存加载
CompletableFuture<Object> future = offlineMenuCache.get(cacheKey);
Object menuData = future.get();  // 阻塞等待结果

当调用 offlineMenuCache.get("KFC|SH001") 时,Caffeine 会:

  1. 检查缓存中是否存在 key = "KFC|SH001" 的条目。
  2. 如果不存在,自动调用我们在 @PostConstruct 中传入的 loader 函数。
  3. loader 内部将 key 按 "|" 拆分为 ["KFC", "SH001"],然后调用 getOfflineMenuData("KFC", "SH001") 获取数据。

3.4.3 Value 的自动存入

Value 的存入是自动完成 的,无需手动 put

java 复制代码
// loader 函数(在 @PostConstruct 中定义)
(key, executor) -> {
    String[] parts = key.split("\\|");          // 拆解 key
    return getOfflineMenuData(parts[0], parts[1]); // 获取数据
}
  • loader 的返回值就是 Value,Caffeine 会自动将其与 key 关联并存入缓存。
  • 后续相同 key 的请求,只要缓存未过期,都会直接返回该 Value,不再调用 loader。

3.4.4 定时刷新时的 Key 复用

在定时刷新任务中,我们遍历缓存的 asMap(),拿到所有已有的 key:

java 复制代码
offlineMenuCache.synchronous().asMap().forEach((key, value) -> {
    // key 就是 "KFC|SH001" 这样的字符串
    expiration.ageOf(key, TimeUnit.NANOSECONDS).ifPresent(age -> {
        if (age >= refreshThreshold) {
            offlineMenuCache.synchronous().refresh(key); // 用同一个 key 刷新
        }
    });
});

refresh(key) 会重新调用 loader,用相同的 key 获取最新数据并覆盖旧值,整个过程对调用方透明。

3.4.5 小结

环节 说明
Key 来源 调用方传入,格式为 `"品牌
Key 拆解 loader 内部用 split("|") 拆成品牌和门店
Value 获取 调用 getOfflineMenuData(brand, store) 从数据源加载
Value 存入 loader 返回值自动被 Caffeine 缓存,无需手动 put
刷新复用 定时任务用相同 key 调用 refresh(),自动更新 Value

4. 关键点总结

4.1 @PostConstruct 的优势

  • 自动执行:无需手动调用初始化方法,Spring 容器会自动执行。
  • 时机准确:在依赖注入完成后执行,确保所有依赖都已就绪。
  • 代码简洁:将初始化逻辑集中在一个方法中,便于维护。

4.2 Caffeine 缓存的最佳实践

  • 合理设置过期时间 :根据业务数据的更新频率设置 expireAfterWrite
  • 使用异步加载buildAsyncLoadingCache 可以避免缓存未命中时阻塞主线程。
  • 定时主动刷新:对于热点数据,使用定时任务提前刷新,减少缓存穿透。
  • 监控缓存命中率 :通过 Caffeine 提供的统计功能,监控缓存性能。

4.3 注意事项

  • refreshAfterWrite 必须小于 expireAfterWrite,否则刷新操作永远不会触发。
  • 定时刷新任务的时间间隔不宜过短,避免对后端造成过大压力。
  • 对于分布式环境,需要考虑缓存一致性问题,可以使用 Redis 等分布式缓存。

5. 总结

通过 @PostConstruct 结合 Caffeine 缓存,我们可以在 Spring Boot 应用中轻松实现高性能的本地缓存。本文的案例展示了如何初始化异步加载缓存,并通过定时任务主动刷新过期数据,确保缓存数据的新鲜度。这种模式适用于配置信息、字典数据、菜单数据等读多写少的场景。

希望本文能帮助你更好地在 Spring Boot 项目中使用 Caffeine 缓存,提升应用性能。

6. 常见问题:get() 的触发时机

很多同学会问:offlineMenuCache.get(cacheKey) 是在第一次调用时触发加载,还是需要手动调用 loader?

6.1 触发时机详解

是的,第一次调用 get() 就会自动触发 loader。 具体流程如下:

  1. 第一次调用 :当调用 offlineMenuCache.get("KFC|SH001") 时,Caffeine 检查缓存中不存在该 key,立即触发 你在 @PostConstruct 中定义的 loader 函数:

    java 复制代码
    (key, executor) -> {
        String[] parts = key.split("\\|");
        return getOfflineMenuData(parts[0], parts[1]);
    }

    loader 内部拆解 key 并调用 getOfflineMenuData 获取数据,返回值自动存入缓存

  2. 后续调用(缓存未过期) :直接返回缓存中的 Value,不再触发 loader

  3. 缓存过期后再次调用 :如果缓存条目已过期(超过 25 分钟),再次 get()重新触发 loader 加载最新数据。

  4. 定时刷新 :代码中的定时任务每分钟检查一次,对存活超过 25 分钟的 key 主动调用 refresh(key),这也会触发 loader 重新加载,但不会阻塞调用方(异步刷新)。

6.2 代码验证

java 复制代码
// 第一次调用:触发 loader,打印 "Loading offline menu data for brand: KFC, store: SH001"
CompletableFuture<Object> future1 = offlineMenuCache.get("KFC|SH001");
Object data1 = future1.get();  // 阻塞等待加载完成

// 第二次调用(缓存未过期):直接返回,不触发 loader
CompletableFuture<Object> future2 = offlineMenuCache.get("KFC|SH001");
Object data2 = future2.get();  // 瞬间返回,无日志打印

6.3 总结

场景 是否触发 loader 说明
第一次 get() 自动调用 loader 加载并缓存
缓存未过期时 get() 直接返回缓存值
缓存过期后 get() 重新调用 loader
定时任务 refresh(key) 异步刷新,不阻塞调用方

所以一句话总结:get() 是触发加载的入口,第一次调用或过期后再次调用时,loader 自动执行并缓存结果,无需手动 put

相关推荐
swipe5 小时前
从本地开发到生产部署:用 Docker Compose 跑通 NestJS、MySQL 与 Milvus
后端·langchain·llm
码事漫谈6 小时前
SenseNova Skills Studio:为商汤SenseNova U1打造的本地办公技能包
后端
zhangxingchao6 小时前
AI应用开发七:可以替代 RAG 的技术
前端·人工智能·后端
Java面试题总结6 小时前
java高频面试题(2026最新)
java·开发语言·jvm·数据库·spring·缓存
excel7 小时前
🧠 Prisma 表名大写 vs SQL 导出小写问题深度解析(附踩坑与解决方案)
前端·后端
GetcharZp8 小时前
Hermes Agent:一个真正“会成长”的开源 AI Agent,正在改变 AI 自动化玩法
后端
Gopher_HBo8 小时前
Go依赖管理
后端
ltl8 小时前
Layer Normalization:为什么 Transformer 用 LN,不用 BN
后端
ltl8 小时前
title: 【Transformer 与注意力机制】24|
后端