《Java 编程实战》14.2 缓存模式:Cache-Aside、穿透与雪崩

查不到的 ISBN 为什么每次都能打到数据库?热点 key 过期的一瞬间,八个线程会不会同时回源?本节用 libraryd 的真实输出拆解缓存穿透、击穿与雪崩,给出负缓存、Caffeine 单飞、TTL 抖动和 Cache-Aside 写路径的取舍,并指出多实例下仍会漏掉的并发窗口。

本节目标:能区分缓存穿透、击穿和雪崩,知道 libraryd 当前实现在哪一处会放大数据库压力,并用负缓存、单飞加载、TTL 抖动和正确的写路径把问题收敛到可接受的窗口。
适用版本:JDK 25(Temurin 25.0.5)、Caffeine 3.3.1、Redis 7、PostgreSQL 17。

三种失效场景:穿透、击穿与雪崩

缓存出问题的时候,现象都像「数据库突然很忙」,但根因完全不同。先把三个词分清,后面的代码才有针对性。

场景触发条件典型现象主要对策
穿透查询的键在数据库里也不存在每次请求都 miss,缓存形同虚设参数校验、负缓存、布隆过滤器
击穿一个热点键过期或失效瞬间大量线程回源同一行单飞加载、逻辑过期、分布式锁
雪崩大量键同时过期,或缓存层整体不可用数据库 QPS 陡增,连接池打满TTL 抖动、多级缓存、限流降级

穿透的关键是「数据不存在」。攻击者用随机 ISBN 请求,缓存永远不会命中,因为数据库也没有这条记录;每次 miss 都穿过缓存打到数据库。击穿的关键是「同一个热点键」。某个秒杀商品或热门图书的缓存过期,几千个请求同时发现 miss,同时查同一行。雪崩的关键是「同时」。应用启动时批量加载、TTL 统一设成 5 分钟,到期时间自然聚在同一秒;或者 Redis 整体挂掉,所有请求一起涌向数据库。

三者可能叠加:一个不存在的热点键既穿透,又会在负缓存过期时击穿;Redis 故障时,所有键一起雪崩。对策也要分层,不能指望一个开关解决全部问题。

Cache-Aside 的标准读路径与写路径

CachingBookRepository 用的是 Cache-Aside(旁路缓存):业务代码自己管缓存,读的时候先查缓存,miss 再查数据库并回填;写的时候先写数据库,再处理缓存。14.1 已经看过完整代码,这里把关键路径再压缩一遍。

读路径:

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;

写路径:

Book updated = delegate.update(book);
local.put(updated.isbn(), updated);
redis.put(updated);
return updated;

删除路径同样先操作数据库,成功后再 local.invalidate(isbn) 与 redis.invalidate(isbn)。

顺序的原则是「数据库是事实来源,缓存是派生数据」。先写数据库,成功后再改缓存;数据库写失败时,缓存保持旧值,至少不会出现「缓存里有、数据库里没有」的脏数据。

写缓存有两种常见选择:更新(put)和失效(invalidate)。参考工程用 put,让下一次读直接命中新值,少一次回源;代价是并发写时可能把旧值写回缓存。失效策略让下一次读回源,能拿到更新后的值,但会多一次 miss。两种都不是绝对安全,后面会讲并发窗口。

删除时「先删缓存还是先删数据库」是经典问题。如果先删缓存,再删数据库,中间有一个窗口:另一个请求发现缓存 miss,读数据库拿到旧值,回填缓存,然后数据库删除完成,缓存里留下了已删除的数据。正确顺序是先操作数据库,成功后再失效缓存;窗口仍然存在,但概率和持续时间都小得多。要彻底消除,需要版本号、延迟双删或订阅数据库变更,成本和复杂度都更高。

穿透:查不到的数据每次都打库

CachingBookRepository 的读路径只在 loaded.isPresent() 时回填缓存。数据库返回空时,什么都不写。于是同一个不存在的 ISBN 每次都会走完整条链路。

用一个总是返回空的 BookRepository 和一个连不上的 Redis 运行 CachePenetrationDemo,连续查三次 missing-isbn:

lookup 0 present=false
lookup 1 present=false
lookup 2 present=false
delegate reads=3

这是本机输出,环境为 JDK 25.0.5 + Caffeine 3.3.1。Redis 不可用时 RedisBookCache 会自动降级为本地缓存,但本地缓存也没有存空结果,所以三次查询全部落到 delegate。delegate reads=3 就是穿透的直接证据:查询次数等于数据库访问次数,缓存没有起到任何削峰作用。

真实攻击比这更糟。攻击者不需要知道有效的 ISBN,只要每次换一个随机字符串,就能让缓存永远 miss。LibraryService.requireText 目前只校验非空和长度不超过 20,不校验 ISBN 的格式或校验位,所以 abc、123、随机 20 字符都能进入查询路径。

穿透的防线应该从外到内:

  1. 格式校验:ISBN-10 或 ISBN-13 有固定格式和校验位,格式不对直接在 Web 层返回 400,不进入缓存和数据库。这一层成本最低,能挡掉大部分随机字符串。
  2. 负缓存:对「格式合法但数据库确实没有」的键,缓存一个空结果,给较短的 TTL。下一节展开。
  3. 布隆过滤器:在缓存前放一个位图,判断「这个键是否可能存在」。不存在直接返回,存在才查缓存/数据库。它对「不存在」有 100% 的拦截率,对「存在」有假阳性;位图需要随数据更新重建,适合键集合相对稳定的场景。
  4. 限流与鉴权:对高频随机查询做速率限制,避免攻击者用负缓存本身撑爆内存。

四层不是必须全上。先做格式校验和负缓存,通常足够;布隆过滤器在键空间很大、攻击明显时再引入。

负缓存:把「没有」也缓存起来

负缓存(negative cache)把「查不到」也当成一种结果缓存起来。Caffeine 不允许 null value,所以用一个独立的 Cache<String, Optional<Book>> 承载空结果:

Cache<String, Optional<Book>> misses = Caffeine.newBuilder()
        .maximumSize(5_000)
        .expireAfterWrite(Duration.ofSeconds(30))
        .build();
for (int i = 0; i < 3; i++) {
    String isbn = "missing-isbn";
    Book found = misses.get(isbn, key -> {
        Optional<Book> loaded = delegate.findByIsbn(key);
        return loaded.isPresent() ? loaded : Optional.empty();
    }).orElse(null);
    System.out.println("lookup " + i + " found=" + found);
}
System.out.println("delegate reads=" + reads.get());
System.out.println("negative entries=" + misses.estimatedSize());

本机输出:

lookup 0 found=null
lookup 1 found=null
lookup 2 found=null
delegate reads=1
negative entries=1

三次查询只回源一次,后两次命中「空结果」缓存。Optional.empty() 是一个非 null 的对象,Caffeine 可以正常存储;get 返回这个 Optional,调用方 orElse(null) 得到 null,业务语义仍然是「没找到」。

负缓存的参数和边界比正缓存更敏感:

  • TTL 要短。正缓存 5 分钟,负缓存 30 秒。新书入库后,最坏 30 秒内可以被查到;如果负缓存也设 5 分钟,用户会看到「刚上架的书查不到」。
  • 只缓存确认不存在。数据库连接失败、超时、SQL 异常不能写成空结果,否则一次故障会让所有查询在 TTL 内都返回「不存在」,把可用性问题伪装成数据问题。delegate.findByIsbn 抛异常时,Caffeine 的 loader 不会缓存异常结果,这是对的;不要用 catch 把异常转成 Optional.empty()。
  • 必须有容量上限。负缓存是攻击者最容易填充的缓存:随机 ISBN 都能留下一条空结果。maximumSize(5_000) 是底线,配合格式校验和限流,避免内存被撑爆。
  • 写路径要主动失效。新增、更新、删除同一 ISBN 时,除了正缓存,也要 negative.invalidate(isbn);否则新书入库后,负缓存还会挡住它 30 秒。
  • 区分「不存在」和「暂不可用」。前者可以负缓存,后者应该快速失败或降级,不要把故障状态写进缓存。

把负缓存接进 CachingBookRepository,读路径可以改成先查正缓存、再查负缓存、最后回源:

private final Cache<String, Book> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .recordStats()
        .build();
private final Cache<String, Optional<Book>> negative = Caffeine.newBuilder()
        .maximumSize(5_000)
        .expireAfterWrite(Duration.ofSeconds(30))
        .build();

@Override
public Optional<Book> findByIsbn(String isbn) {
    Book hit = local.getIfPresent(isbn);
    if (hit != null) {
        return Optional.of(hit);
    }
    Optional<Book> knownMissing = negative.getIfPresent(isbn);
    if (knownMissing != null) {
        return knownMissing;
    }
    Optional<Book> fromRedis = redis.get(isbn);
    if (fromRedis.isPresent()) {
        local.put(isbn, fromRedis.get());
        return fromRedis;
    }
    Optional<Book> loaded = delegate.findByIsbn(isbn);
    if (loaded.isPresent()) {
        local.put(isbn, loaded.get());
        redis.put(loaded.get());
    } else {
        negative.put(isbn, Optional.empty());
    }
    return loaded;
}

这段代码解决了穿透,但仍然是「检查再加载」:并发请求可能同时发现正缓存和负缓存都 miss,然后一起回源。要同时解决击穿,需要把加载合并成一次,下一节处理。

击穿:热点 key 过期瞬间的并发回源

14.1 的 CaffeineLoadDemo 已经展示过 get(key, loader) 的单飞行为:8 个线程同时加载同一个键,loads=1。CachingBookRepository 没有用这个 API,而是用 getIfPresent 加手动回源,所以并发 miss 时会重复查库。

用 8 个线程同时调用 repository.findByIsbn("isbn-1"),delegate 每次查询睡 100 毫秒模拟慢查询,运行 StampedeProblemDemo:

databaseReads=8

这是本机输出。8 个线程全部在 local.getIfPresent 看到 null,全部走 Redis(演示里 Redis 不可用,直接降级),全部调用 delegate,于是同一个 ISBN 查了 8 次。生产环境可能是 800 次,数据库连接池瞬间被打满。

把加载逻辑改成 Caffeine 的 get(key, loader),并缓存 Optional<Book> 以同时容纳空结果,StampedeFixDemo 的输出是:

databaseReads=1
cached=Effective Java

代码的核心是把「检查」和「加载」合并:

Cache<String, Optional<Book>> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(5))
        .build();
Optional<Book> book = cache.get("isbn-1", key -> {
    Optional<Book> loaded = delegate.findByIsbn(key);
    return loaded.isPresent() ? loaded : Optional.empty();
});

Caffeine 保证同一个键在同一时刻只有一个 loader 在执行,其他线程阻塞等待结果。这个保证是「每 JVM」的,不是全局的:部署 4 个实例,每个实例仍然会各查一次数据库,总共 4 次。要跨实例合并,需要 Redis 分布式锁、Redis 单飞(SETNX + 双重检查)或逻辑过期方案。

单飞不是没有代价:

  • 所有等待线程共享同一个 loader 的异常。loader 抛异常时,等待线程会收到包装后的异常;如果 loader 阻塞很久,等待线程也跟着阻塞。loader 内部要有超时。
  • 单飞只对「同一个键」生效。不同键同时 miss 仍然会并发回源,容量规划要按不同键的数量算。
  • 如果 loader 返回 null,Caffeine 不缓存结果,下一次请求还会重新加载。用 Optional.empty() 而不是 null,才能把空结果也纳入单飞和缓存。
  • 分布式锁的实现要处理锁超时、持有者崩溃、锁误删。用 SET key token NX PX 3000,释放时用 Lua 比较 token,不要直接 DEL。锁的粒度是 ISBN,不是整个缓存。

逻辑过期是另一种思路:缓存值里带一个 expireAt,读到过期值时先返回旧值,只有一个线程去刷新。它避免了等待,但实现比 Caffeine 的 refreshAfterWrite 复杂,跨实例还要共享「谁在刷新」的状态。单机优先用 refreshAfterWrite,跨实例再考虑 Redis 锁或消息队列。

雪崩:大量 key 同时过期与 TTL 抖动

雪崩有两种来源。

第一种是「同时过期」。应用启动时批量预热 10,000 本书,TTL 统一 5 分钟;5 分钟后,这 10,000 条几乎在同一秒失效,所有请求一起回源。数据库看到的不是平稳的 QPS,而是一个尖峰。定时任务批量刷新、整点缓存重建也会造成同样的效果。

第二种是「缓存层整体不可用」。Redis 挂掉、网络分区、连接池耗尽,所有请求同时穿过 Redis 打到数据库。本地缓存还能挡住一部分热点,但冷键全部回源,数据库很可能扛不住。

TTL 抖动解决第一种。不要让所有条目用同一个固定 TTL,而是在基础 TTL 上加一个随机偏移:

Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfter(new Expiry<String, String>() {
            @Override
            public long expireAfterCreate(String key, String value, long currentTime) {
                long base = Duration.ofMinutes(5).toNanos();
                long jitter = ThreadLocalRandom.current()
                        .nextLong(0, Duration.ofMinutes(2).toNanos());
                return base + jitter;
            }

            @Override
            public long expireAfterUpdate(String key, String value,
                    long currentTime, long currentDuration) {
                return expireAfterCreate(key, value, currentTime);
            }

            @Override
            public long expireAfterRead(String key, String value,
                    long currentTime, long currentDuration) {
                return currentDuration;
            }
        })
        .build();

本机编译运行 TtlJitterDemo,输出 size=1,确认 Expiry 的三个方法签名正确。expireAfterCreate 返回纳秒,基础 5 分钟加 0 到 2 分钟的随机偏移;expireAfterUpdate 重新计算,避免更新后所有条目又对齐;expireAfterRead 返回 currentDuration,表示读不延长寿命,保持「写入过期」的语义。

Expiry 是逐条目的,expireAfterWrite 是整个缓存统一的。需要抖动时只能用 Expiry,不要试图通过多次 put 模拟。随机偏移的范围通常是基础 TTL 的 10% 到 30%,太大浪费缓存,太小起不到打散作用。

Redis 层的 TTL 也要抖动。RedisBookCache.put 当前写死 300 秒:

jedis.set(PREFIX + book.isbn(), mapper.writeValueAsString(book),
        redis.clients.jedis.params.SetParams.setParams().ex(TTL_SECONDS));

可以改成 TTL_SECONDS + ThreadLocalRandom.current().nextInt(60)。注意 Redis 的过期是惰性删除加定期采样,同一秒到期的键不会在同一毫秒全部消失,但大量键同时过期仍然会让回源集中在一个时间窗口,抖动依然有价值。

第二种雪崩靠架构而不是参数:本地缓存作为第一层,Redis 作为第二层,数据库前面再加限流和熔断。Redis 故障时,RedisBookCache 的 available 标志会把它降级掉,本地缓存继续服务热点;冷键回源时,数据库要有限流保护,避免被打死。缓存层的高可用(哨兵、集群、多副本)是另一层,不能只靠客户端重试。

把模式落进 CachingBookRepository:一次改写

把前面的片段拼起来,CachingBookRepository 可以改成三层:正缓存、负缓存、Redis,回源用单飞。下面是一个可编译的骨架,省略了与本节无关的 save/update/search 细节。

public final class CachingBookRepository implements BookRepository {

    private final BookRepository delegate;
    private final RedisBookCache redis;
    private final Cache<String, Book> local;
    private final Cache<String, Optional<Book>> negative;

    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();
        this.negative = Caffeine.newBuilder()
                .maximumSize(5_000)
                .expireAfterWrite(Duration.ofSeconds(30))
                .build();
    }

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

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

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

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

有几个点需要说明,避免把骨架当成无条件正确的代码。

第一,local.get(isbn, loader) 的 loader 返回 null 表示「不缓存」。数据库确认不存在时,代码把空结果写进 negative,然后返回 null;Caffeine 不把 null 写进正缓存,下一次请求先命中负缓存。这个配合是故意的,不是漏写。

第二,负缓存检查在 local.get 之前,仍然有一个极小的窗口:两个线程同时发现负缓存为空,同时进入 local.get;Caffeine 对同一个键会合并 loader,所以只有一个线程真正回源,另一个等待。这个组合比「两个 getIfPresent 相加」可靠,但不要把它推广成「任何多级缓存都自动原子」。

第三,deleteByIsbn 现在无论 deleted 是否为 true 都失效本地和负缓存,只有 Redis 失效仍然跟着 deleted 走。这是为了处理「数据库已不存在、本地还有旧值」的边界;代价是多一次本地失效,可以接受。

第四,写路径仍然是「先写数据库,再更新缓存」。并发更新时,两个线程可能以不同顺序写缓存,最后缓存里留下旧值。彻底解决需要版本号(Book 加 version 字段,写缓存时比较版本)或「删除缓存而不是更新」,再配合短 TTL 兜底。对图书元数据这种低冲突场景,5 分钟 TTL 通常够用;对库存、余额这类高冲突数据,不能只靠 Cache-Aside。

第五,Redis 仍然是单点语义:本地正缓存命中时不会读 Redis,所以其他实例更新后,本实例可能继续返回旧值最多 5 分钟。14.3 会讨论失效广播、版本号和「接受最终一致」三种取舍。

常见错误与取舍

错误一:负缓存不设上限。 随机 ISBN 可以无限填充负缓存,内存比穿透先出问题。maximumSize 和格式校验必须同时有。

错误二:把数据库异常写成空结果。 catch (Exception e) { return Optional.empty(); } 会让一次数据库故障在 TTL 内变成「所有书都不存在」。负缓存只缓存「确认不存在」,异常应该向上抛或走降级,不要混进空结果。

错误三:负缓存 TTL 和正缓存一样长。 新书入库后 5 分钟查不到,用户会认为系统丢了数据。负缓存 TTL 通常是正缓存的十分之一量级,并在写路径主动失效。

错误四:用 getIfPresent 做高并发回源。 检查与加载分离,热点 key 过期时 N 个线程查 N 次。用 get(key, loader) 获得 JVM 内单飞;跨实例再考虑分布式锁。

错误五:以为 Caffeine 单飞是全局的。 4 个实例就是 4 次回源。单飞只保护单 JVM,不能替代 Redis 锁或逻辑过期。容量规划要按实例数乘回源次数。

错误六:所有 key 用同一个 TTL。 批量加载后同时过期,数据库看到尖峰。用 Expiry 做逐条目抖动,Redis TTL 也加随机偏移。

错误七:先删缓存再写数据库。 并发读会把旧值回填,删除完成后缓存里仍有旧数据。顺序应该是先写数据库,成功后再失效缓存;接受一个短窗口,用 TTL 和版本号兜底。

错误八:缓存 key 不做规范化。 ISBN 大小写、前后空格、连字符不同,会生成多个 key,命中率下降,失效时也容易漏。入口统一 trim、大小写和格式,再进入缓存层。

边界:强一致和缓存天然冲突。 缓存的价值是用一致性换延迟和吞吐。能接受「最多 5 分钟旧值」,才适合 Cache-Aside;不能接受,就不该缓存,或者必须走带版本号的读写协议。不要把「加个缓存」当成无代价的优化。

边界:布隆过滤器不能删除。 标准布隆过滤器不支持删除,数据删除后位图仍然说「可能存在」,需要重建或使用计数布隆过滤器。它适合「只增不删」或「删除很少」的键集合;图书下架频繁时,负缓存更直接。

小结

  • 穿透是「数据不存在」,击穿是「热点键失效」,雪崩是「大量键同时失效或缓存层故障」;三者的对策不同。
  • Cache-Aside 的读路径是缓存 → 数据库 → 回填,写路径是先写数据库、再更新或失效缓存;顺序不能反。
  • 负缓存把 Optional.empty() 存进有界、短 TTL 的缓存,能显著降低穿透;只缓存确认不存在,不缓存异常。
  • get(key, loader) 提供 JVM 内单飞,解决热点键击穿;跨实例仍会各回源一次,需要 Redis 锁或逻辑过期。
  • TTL 抖动要用 Caffeine 的 Expiry 逐条目设置,Redis TTL 也要加随机偏移;统一 TTL 会把过期时间对齐成尖峰。
  • 改写后的 CachingBookRepository 组合正缓存、负缓存、Redis 和单飞,但写路径仍有并发窗口,强一致场景不能只靠它。
  • 缓存是「用一致性换延迟」的取舍;先明确能接受多旧的数据,再决定缓存什么、缓存多久。

本地缓存和模式都建立在「单实例」假设上。下一节把 Redis 接进来,讨论 key 设计、TTL、连接池超时、故障降级,以及两个实例同时读写时到底会看到什么。

阅读导航:上一节:14.1 本地缓存:Caffeine 与失效策略 · 下一节:14.3 分布式缓存:Redis 与一致性取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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