《Java 编程实战》14.1 本地缓存:Caffeine 与失效策略

同一本图书在一分钟内被查询几百次,每次都打 PostgreSQL 既慢又浪费。本节用 Caffeine 3.3.1 给 libraryd 加本地缓存,讲清 maximumSize、expireAfterWrite、refreshAfterWrite 与 recordStats 的取舍,并用真实输出解释 estimatedSize 为什么暂时偏大、刷新为什么先返回旧值。

本节目标:让 libraryd 的图书查询在进程内命中缓存,减少重复的 PostgreSQL 查询;能根据容量、写入过期、刷新和统计指标解释缓存行为,并知道本地缓存什么时候会返回旧数据。
适用版本:JDK 25(Temurin 25.0.5)、Caffeine 3.3.1、PostgreSQL 17、Javalin 7.2.4。

从「同一本书被查了 800 次」说起

libraryd 的图书详情页有一个很典型的访问模式:热门图书在首页、搜索结果、借阅确认页都会出现,同一本《Effective Java》一分钟内可能被查询几百次。每次 GET /api/books/{isbn} 都走 JdbcBookRepository.findByIsbn,发一条 SELECT ... WHERE isbn = ?,再反序列化成 Book 记录。

单次查询 1 到 2 毫秒看起来不多,但乘以流量就很可观:1000 次/分钟就是 1000 次数据库往返,其中 999 次的结果完全一样。数据库连接池只有 10 个连接,热门图书的查询会把连接占住,其他写操作只能排队。更糟的是,网络抖动或数据库慢查询会直接放大到用户请求上。

缓存的直觉很简单:第一次查数据库,把结果放进内存;第二次直接读内存。真正难的是后面四个问题:

  • 内存放多少条?放满了淘汰谁?
  • 数据什么时候过期?写入后 5 分钟,还是最后一次访问后 5 分钟?
  • 过期后要不要阻塞等待新值?能不能先返回旧值、后台刷新?
  • 多实例部署时,A 实例更新了数据,B 实例的本地缓存怎么办?

Caffeine 3.3.1 解决前三个问题,第四个问题要靠 Redis 或失效广播,14.3 再展开。这一节先把单机缓存做对。

Caffeine 的四个旋钮

Caffeine.newBuilder() 返回一个 builder,常用配置可以归成四组:

旋钮作用不配的后果
maximumSize / maximumWeight限制条目数或总权重无界增长,最终 OOM
expireAfterWrite / expireAfterAccess控制条目寿命数据永久驻留,越来越旧
refreshAfterWrite到期后异步刷新,先返回旧值要么阻塞加载,要么一直过期
recordStats记录命中、未命中、淘汰、加载无法判断缓存是否有效

四个旋钮不是越多越好。maximumSize 和 maximumWeight 二选一;expireAfterWrite 和 expireAfterAccess 也通常二选一;refreshAfterWrite 需要配合加载器或手动 refresh;recordStats 有少量计数开销,排查和调参阶段应该开,稳定后可以保留,因为命中率是判断缓存价值的核心指标。

Caffeine 的淘汰策略不是简单的 LRU。它使用 Window TinyLFU:新条目先进入一个小窗口,命中频率高的条目更容易留下。对「少数热门图书被反复查询」的访问模式,这比纯 LRU 更抗扫描。你不需要手动实现 LFU 计数器,但要知道 maximumSize 满的时候,刚放进去的条目不一定马上被淘汰,estimatedSize 也可能暂时超过上限。

最小可用缓存:getIfPresent 与 put

先不接数据库,用一个 Cache<String, String> 看基本行为:

Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(2)
        .expireAfterWrite(Duration.ofMillis(200))
        .recordStats()
        .build();
cache.put("isbn-1", "Effective Java");
cache.put("isbn-2", "Java Concurrency in Practice");
cache.put("isbn-3", "Designing Data-Intensive Applications");
System.out.println("size after 3 puts (max=2)=" + cache.estimatedSize());
System.out.println("keys=" + cache.asMap().keySet());

Cache<String, String> ttl = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofMillis(200))
        .recordStats()
        .build();
ttl.put("isbn-1", "Effective Java");
System.out.println("before ttl=" + ttl.getIfPresent("isbn-1"));
Thread.sleep(300);
System.out.println("after ttl=" + ttl.getIfPresent("isbn-1"));
ttl.getIfPresent("isbn-1");
System.out.printf("hits=%d misses=%d hitRate=%.2f%n",
        ttl.stats().hitCount(), ttl.stats().missCount(), ttl.stats().hitRate());
ttl.invalidate("isbn-1");
System.out.println("after invalidate=" + ttl.getIfPresent("isbn-1"));

本机输出:

size after 3 puts (max=2)=3
keys=[isbn-3, isbn-2, isbn-1]
before ttl=Effective Java
after ttl=null
hits=1 misses=2 hitRate=0.33
after invalidate=null

两个输出容易让人误判。

第一,maximumSize(2) 之后放了 3 条,estimatedSize() 仍然是 3。这不是配置没生效,而是 Caffeine 的淘汰和维护是摊还执行的:写入路径先接受条目,清理和淘汰在后续操作或 cleanUp() 时完成。estimatedSize() 本身也是估算值,不保证精确。

第二,keys 的顺序是 [isbn-3, isbn-2, isbn-1],看起来像「新的在前」,但 asMap().keySet() 不承诺顺序。不要依赖这个顺序做业务判断,也不要在测试里断言完整顺序;要断言「包含哪些键」或「大小是多少」。

getIfPresent 未命中返回 null,不会抛异常,也不会自动加载。命中率计算里,getIfPresent 的每次调用都会计入 hit 或 miss;put 不计入。上面 TTL 例子中,第一次 getIfPresent 命中,过期后一次未命中,invalidate 后又一次未命中,所以 hits=1 misses=2 hitRate=0.33。

getIfPresent 适合「缓存是加速手段,miss 时我自己决定怎么加载」的场景。它的缺点是检查和加载分两步,高并发下多个线程可能同时发现 miss、同时查数据库。Caffeine 提供 get(key, mappingFunction) 把两步合并,同一个键的加载只执行一次:

AtomicInteger loads = new AtomicInteger();
Cache<String, String> cache = Caffeine.newBuilder().maximumSize(100).build();
String value = cache.get("isbn-1", key -> {
    loads.incrementAndGet();
    return "Effective Java";
});

本机用 8 个线程同时 get("isbn-1", loader),输出 loads=1。这说明 Caffeine 对同一个键做了单飞(single-flight):一个线程加载,其他线程等待结果。这个特性在 14.2 讲缓存击穿时会展开;这里先记住,getIfPresent + 手动查库不具备这个保证。

淘汰与过期是摊还的:estimatedSize 与 cleanUp

把淘汰和过期分开看:

Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(2)
        .recordStats()
        .build();
cache.put("isbn-1", "Effective Java");
cache.put("isbn-2", "Java Concurrency in Practice");
cache.put("isbn-3", "Designing Data-Intensive Applications");
System.out.println("estimated before cleanUp=" + cache.estimatedSize());
cache.cleanUp();
System.out.println("estimated after cleanUp=" + cache.estimatedSize());
System.out.println("keys after cleanUp=" + cache.asMap().keySet());
System.out.println("evictionCount=" + cache.stats().evictionCount());

Cache<String, String> ttl = Caffeine.newBuilder()
        .expireAfterWrite(Duration.ofMillis(100))
        .recordStats()
        .build();
ttl.put("isbn-1", "Effective Java");
Thread.sleep(200);
System.out.println("estimated expired before cleanUp=" + ttl.estimatedSize());
ttl.cleanUp();
System.out.println("estimated expired after cleanUp=" + ttl.estimatedSize());
System.out.println("get after expiry=" + ttl.getIfPresent("isbn-1"));

本机输出:

estimated before cleanUp=3
estimated after cleanUp=2
keys after cleanUp=[isbn-3, isbn-2]
evictionCount=1
estimated expired before cleanUp=1
estimated expired after cleanUp=0
get after expiry=null

cleanUp() 触发一次维护,之后 estimatedSize() 才反映真实条目数。evictionCount=1 说明有一条因为容量被淘汰;注意它不告诉你淘汰的是哪一条,keys 里剩下 isbn-3 和 isbn-2,isbn-1 被淘汰了。这个结果符合 Window TinyLFU 的倾向,但不要把它当成严格保证。

过期条目的行为更微妙:expireAfterWrite(100ms) 后睡 200ms,estimatedSize() 仍是 1,cleanUp() 后变 0,getIfPresent 返回 null。读取过期条目会触发失效判断,所以即使不 cleanUp(),业务读取也不会拿到过期值;estimatedSize 只是暂时把「尚未清理的尸体」算进去。

这也解释了为什么不要在请求路径里调用 cleanUp()。它是显式维护操作,会扫描缓存,代价不小。生产代码让 Caffeine 自己按访问和写入摊还清理;只有在测试、运维诊断或导出指标前,才需要主动 cleanUp() 让数字稳定。

invalidate(key) 是精确失效:只移除指定键,不扫描全表。写路径删除或更新一条图书时用它,代价远低于 invalidateAll()。invalidateAll() 适合配置整体变更、租户切换这类需要全量清空的场景,不要在每个请求里调用。

刷新不是过期:refreshAfterWrite 的 stale-while-refresh

expireAfterWrite 到期后,下一次读取会同步加载新值:在加载完成前,读线程是阻塞的。如果加载要 500 毫秒,到期后的第一个请求就要等 500 毫秒。refreshAfterWrite 提供另一种选择:到期后先返回旧值,后台异步加载新值,加载完成后替换。

AtomicInteger version = new AtomicInteger(1);
AtomicInteger loads = new AtomicInteger();
LoadingCache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(100)
        .refreshAfterWrite(Duration.ofMillis(200))
        .build(key -> {
            loads.incrementAndGet();
            Thread.sleep(500);
            return "title-v" + version.get();
        });
System.out.println("first=" + cache.get("isbn-1"));
version.set(2);
Thread.sleep(250);
long start = System.nanoTime();
System.out.println("stale-while-refresh=" + cache.get("isbn-1"));
System.out.printf("stale get took %d ms%n", (System.nanoTime() - start) / 1_000_000);
Thread.sleep(700);
System.out.println("after-refresh=" + cache.get("isbn-1"));
System.out.println("loads=" + loads.get());

本机输出:

first=title-v1
stale-while-refresh=title-v1
stale get took 19 ms
after-refresh=title-v2
loads=2

第一次加载耗时 500 毫秒,返回 title-v1。把版本改成 2,等 250 毫秒超过刷新窗口,再读:19 毫秒就返回了旧值 title-v1,后台同时开始加载。再等 700 毫秒,读到新值 title-v2,总加载次数是 2。

这里的关键取舍是「新鲜度换延迟」:

  • expireAfterWrite:到期后同步加载,读请求等加载完成;保证读到新值,但有延迟尖刺。
  • refreshAfterWrite:到期后先返回旧值,后台刷新;延迟平滑,但存在一段旧值窗口。
  • 两者可以同时配:refreshAfterWrite 通常比 expireAfterWrite 短,刷新失败时旧值还能继续服务;超过 expireAfterWrite 后条目真正过期,下一次读取必须同步加载或返回 miss。

refreshAfterWrite 需要加载器。用 Caffeine.newBuilder().build(key -> ...) 得到 LoadingCache;如果只有 Cache,可以调用 cache.refresh(key) 手动触发异步刷新,但不会自动按时间触发。refreshAfterWrite 的加载异常不会删除旧值,Caffeine 会保留旧值并记录日志/统计;这一点对「下游短暂故障时继续服务」很有用,但也意味着故障期间可能一直返回旧数据。

还有一点:刷新是异步的,但默认使用 ForkJoinPool.commonPool()。如果加载器阻塞很久,可能占满公共池,影响其他并行任务。生产环境可以传一个专用的 Executor:

Caffeine.newBuilder()
        .refreshAfterWrite(Duration.ofMinutes(5))
        .executor(Executors.newFixedThreadPool(2))
        .build(key -> loadFromDatabase(key));

线程池大小不需要很大,因为 Caffeine 对同一个键的刷新会合并;但不要让加载器无限阻塞,加载器内部要有自己的超时。

libraryd 的三层读路径与写路径

CachingBookRepository 把 Caffeine 包在 JdbcBookRepository 前面,构造参数是两个依赖:

public CachingBookRepository(BookRepository delegate, RedisBookCache redis) {
    this.delegate = Objects.requireNonNull(delegate, "delegate");
    this.redis = Objects.requireNonNull(redis, "redis");
    this.local = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats()
            .build();
}

delegate 是真正的数据库仓储,redis 是可选二级缓存。本地缓存的配置是:最多 10,000 条,写入后 5 分钟过期,开启统计。没有配 refreshAfterWrite,所以过期后的第一个请求会同步走 Redis/数据库,存在一次延迟尖刺;这是有意的简化,先保证行为可预测。

读路径:

@Override
public Optional<Book> findByIsbn(String isbn) {
    Book cached = local.getIfPresent(isbn);
    if (cached != null) {
        return Optional.of(cached);
    }
    Optional<Book> fromRedis = redis.get(isbn);
    if (fromRedis.isPresent()) {
        local.put(isbn, fromRedis.get());
        return fromRedis;
    }
    Optional<Book> loaded = delegate.findByIsbn(isbn);
    loaded.ifPresent(book -> {
        local.put(isbn, book);
        redis.put(book);
    });
    return loaded;
}

顺序是本地 → Redis → PostgreSQL。本地命中直接返回,连 Redis 都不访问;Redis 命中会回填本地;数据库命中会回填 Redis 和本地。数据库未命中时不做负缓存,直接返回 Optional.empty(),同一个不存在的 ISBN 每次都会打到数据库——14.2 会专门处理这个穿透问题。

写路径:

@Override
public Book save(Book book) {
    Book saved = delegate.save(book);
    local.put(saved.isbn(), saved);
    redis.put(saved);
    return saved;
}

@Override
public Book update(Book book) {
    Book updated = delegate.update(book);
    local.put(updated.isbn(), updated);
    redis.put(updated);
    return updated;
}

@Override
public boolean deleteByIsbn(String isbn) {
    boolean deleted = delegate.deleteByIsbn(isbn);
    if (deleted) {
        local.invalidate(isbn);
        redis.invalidate(isbn);
    }
    return deleted;
}

写入的顺序是「先写数据库,再更新缓存」。如果数据库写失败,异常向上抛,缓存保持旧值;不会出现「缓存已更新、数据库没写成」的脏数据。更新时用 put 而不是 invalidate,是为了让下一次读直接命中新值,避免一次额外的数据库查询。

删除时只在 deleted == true 时失效缓存。这有一个边界:如果数据库里已经不存在这条记录,但本地还残留旧值(例如数据被外部工具删除),deleteByIsbn 返回 false,缓存不会被清理,旧值会一直服务到 5 分钟过期。对一致性要求高的场景,可以在 false 时也执行 invalidate,用一次多余的失效换取确定性。

search 和 existsByIsbn 直接委托给数据库:

@Override
public PageResult<Book> search(String query, int page, int size) {
    return delegate.search(query, page, size);
}

@Override
public boolean existsByIsbn(String isbn) {
    return delegate.existsByIsbn(isbn);
}

搜索结果通常带分页、排序、查询词,键的维度多、命中率低,放进本地缓存容易撑大内存;existsByIsbn 可以基于 findByIsbn 的结果推导,但直接查数据库更简单,也避免缓存穿透语义变复杂。不是所有查询都值得缓存,这个取舍本身就是设计的一部分。

本机用一个内存版 BookRepository 加真实 Redis 运行 CacheRepoDemo:

after save localSize=1
first get=Effective Java
second get=Effective Java
delegate reads=0
after update=Effective Java 3rd
delegate reads after update=0
after delete present=false
delegate reads after delete=1

save 之后本地已经有值,所以两次 get 都没有打到 delegate,delegate reads=0。update 之后再读,仍然是本地命中,读到新标题,delegate 计数不变。delete 之后本地失效,再读时本地 miss、Redis 也已经被删除,只能查 delegate;delegate 返回空,计数变成 1。这条输出把「写后本地热、删后回源」的行为完整串了起来。

注意 localCacheSize() 返回的是 estimatedSize(),和前面的例子一样是估算值。用它做监控可以看趋势,不要用它做精确的容量断言:

public long localCacheSize() {
    return local.estimatedSize();
}

容量、TTL 与命中率:怎么用数据定参数

maximumSize(10_000) 和 expireAfterWrite(5min) 不是拍脑袋的数字,应该从访问数据推出来。至少要回答三个问题。

第一,工作集有多大? 统计一段时间内被访问的不同 ISBN 数量。如果每天只有 2,000 本图书被访问,10,000 条足够覆盖;如果有 200,000 本长尾图书,10,000 条只能覆盖头部,命中率会低,需要 Redis 承担更多。maximumSize 应该略大于工作集,而不是等于全库大小。

第二,单条值有多大? Book 是 record,字段是 ISBN、标题、作者、年份,粗算几百字节;10,000 条约几 MB,加上 Caffeine 的索引和统计开销,通常可以接受。如果缓存的是大对象、列表或搜索结果,就不能按条数算,要用 maximumWeight + weigher:

Caffeine.newBuilder()
        .maximumWeight(64 * 1024 * 1024)
        .weigher((String key, Book book) -> book.title().length() * 2 + 256)
        .build();

weigher 返回的是相对权重,不要求精确字节数,但要和 maximumWeight 的单位一致。上面的写法把标题字符按 UTF-16 两字节估算,再加固定开销;真实项目可以用序列化后的长度或对象图估算。

第三,能接受多旧的数据? 图书的标题、作者、年份变化很少,5 分钟可以接受;借阅状态、库存数量变化频繁,5 分钟可能太久。TTL 不是越短越好:太短会让缓存失效,数据库压力回升;太长会让旧值窗口变大。对 libraryd 的图书元数据,5 分钟是保守值;对借阅记录,应该单独设计,不要复用这个缓存。

命中率用 recordStats() 后的 cache.stats() 观察:

CacheStats stats = local.stats();
double hitRate = stats.hitRate();
long evictions = stats.evictionCount();
long loads = stats.loadSuccessCount();

hitRate 低不一定是缓存没用。可能工作集本来就大,也可能 TTL 太短导致频繁过期,还可能请求模式是「每个键只查一次」。先看 evictionCount:如果淘汰很多、命中率低,说明容量不足;如果淘汰很少、命中率低,说明访问太分散,加容量也未必有用。再看 loadSuccessCount 和加载耗时,判断回源成本是否值得缓存。

一个常见误区是「命中率越高越好」。缓存的价值是「节省的回源成本 × 命中次数」,不是命中率本身。缓存一个几乎不查的键,命中率可能很高,但节省的成本接近零;缓存一个每次都要查但极慢的查询,命中率不高,收益也可能很大。用回源耗时和数据库负载一起判断,而不是只看百分比。

常见错误与边界

错误一:不设上限。 Caffeine.newBuilder().build() 没有任何容量限制,键不断增长就是内存泄漏。即使加了 TTL,过期条目在清理前仍占内存;高流量下可能先 OOM 再谈过期。生产缓存必须有 maximumSize 或 maximumWeight。

错误二:每个请求创建一个 Cache。 在方法里 Caffeine.newBuilder().build(),请求结束缓存就没了,命中率恒为零,还多了一次对象分配。Cache 应该是应用级单例,通过构造函数注入或在装配层创建,不能跟着请求生命周期走。

错误三:把 expireAfterAccess 当成 expireAfterWrite。 expireAfterAccess 在每次读或写后重置计时,热门条目可能永远不过期,旧值一直服务。图书元数据通常要 expireAfterWrite;只有「访问频繁就说明还有价值」的场景才用 expireAfterAccess,并且要接受旧值窗口。

错误四:以为 estimatedSize 是精确值。 前面的输出已经展示:容量 2 放了 3 条,estimatedSize() 仍是 3;过期条目清理前仍被计入。不要用它做业务判断,也不要在测试里断言精确值;要断言行为(读不到、淘汰后大小下降),必要时显式 cleanUp()。

错误五:把 null 放进缓存。 Caffeine 不允许 null key 或 null value。数据库查不到时返回 Optional.empty(),不要 local.put(isbn, null);要么不缓存,要么用一个明确的占位对象(负缓存),并给它更短的 TTL。14.2 会讲负缓存的边界。

错误六:用 getIfPresent 做高并发加载。 检查和加载分离,多个线程会同时回源。低并发下没问题,高并发下就是缓存击穿。用 get(key, loader) 获得单飞保证,或者用 14.2 的分布式锁/逻辑过期方案。

错误七:缓存可变对象。 Book 是 record,不可变,缓存安全。如果缓存的是 List<Book> 或可变 DTO,调用方修改了对象,缓存里的值也跟着变,其他请求会读到被污染的数据。缓存值应该是不可变的,或者在返回前做防御性拷贝。

错误八:忽略多实例的一致性。 本地缓存只属于当前 JVM。A 实例更新图书后,B 实例的本地缓存仍然是旧值,直到 TTL 过期。CachingBookRepository 的写路径只更新本实例的 local 和共享的 Redis,没有通知其他实例;这在 14.3 会作为一致性问题专门讨论。单实例部署可以先接受 5 分钟窗口,多实例必须设计失效广播或缩短 TTL。

边界:本地缓存不适合存会话和大对象。 会话需要跨实例共享,本地缓存做不到;大对象(图片、文件、长列表)会迅速吃满堆内存。本地缓存适合「小而热、可容忍短期不一致」的只读数据。选错数据比配错参数更致命。

边界:缓存不是数据库的替代品。 缓存命中率高的时候,数据库压力下降,容易让人误以为数据库可以缩容。缓存冷启动、过期风暴、实例重启时,流量会重新打到数据库;容量规划要按「缓存全失效」的最坏情况留余量,而不是按稳态命中率算。

小结

  • 本地缓存解决「同一份数据被反复查询」的问题,代价是内存和一致性窗口。
  • Caffeine 的核心旋钮是容量、过期、刷新和统计;maximumSize 防 OOM,expireAfterWrite 控制旧值窗口,refreshAfterWrite 用旧值换平滑延迟。
  • 淘汰和过期是摊还执行的,estimatedSize() 是估算值;cleanUp() 只用于测试和诊断,不要放在请求路径。
  • getIfPresent 适合手动加载,get(key, loader) 提供同键单飞;高并发下不要用前者做回源。
  • libraryd 的 CachingBookRepository 按本地 → Redis → PostgreSQL 读取,写后更新本地和 Redis,删除时失效两层。
  • 容量按工作集定,TTL 按数据变化频率定,命中率要结合淘汰数和回源成本看,不要只追求百分比。
  • 多实例下本地缓存天然不一致,需要 Redis 或失效广播;这是下一节和 14.3 的主题。

本地缓存只解决单机重复查询。真正棘手的是「查不到的数据」「同时失效的热点」「Redis 也挂了」这些模式问题。下一节把 Cache-Aside、穿透、雪崩和单飞拆开,逐个给出可运行的改写。

阅读导航:上一节:13.3 健康检查:依赖探测与就绪/存活 · 下一节:14.2 缓存模式:Cache-Aside、穿透与雪崩 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发