本节目标:能区分缓存穿透、击穿和雪崩,知道
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 字符都能进入查询路径。
穿透的防线应该从外到内:
- 格式校验:ISBN-10 或 ISBN-13 有固定格式和校验位,格式不对直接在 Web 层返回 400,不进入缓存和数据库。这一层成本最低,能挡掉大部分随机字符串。
- 负缓存:对「格式合法但数据库确实没有」的键,缓存一个空结果,给较短的 TTL。下一节展开。
- 布隆过滤器:在缓存前放一个位图,判断「这个键是否可能存在」。不存在直接返回,存在才查缓存/数据库。它对「不存在」有 100% 的拦截率,对「存在」有假阳性;位图需要随数据更新重建,适合键集合相对稳定的场景。
- 限流与鉴权:对高频随机查询做速率限制,避免攻击者用负缓存本身撑爆内存。
四层不是必须全上。先做格式校验和负缓存,通常足够;布隆过滤器在键空间很大、攻击明显时再引入。
负缓存:把「没有」也缓存起来
负缓存(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 与一致性取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。