缓存穿透、击穿与雪崩:按流量形态保护回源
一万个请求查询不存在的商品,与一万个请求同时查询刚过期的热门商品,都会增加数据库负载,但处理方法不同。前一种需要减少无效查找,后一种需要让同一个结果只加载有限次数。
流量防护先看请求集中在哪些键、缓存是否可达以及回源正在做什么,再选择过滤、合并或限流。
三类未命中怎样放大负载
穿透、击穿与雪崩的区别
| 名称 | 请求与数据的形态 | 主要增加的工作 |
|---|---|---|
| 缓存穿透 | 不存在或不适合缓存的对象持续被查询 | 每次确认不存在,反复访问来源 |
| 缓存击穿 | 少数热点键缺失或到期,大量同键请求相遇 | 重复加载相同结果 |
| 缓存雪崩 | 大量键同时失效,或缓存服务整体不可用 | 很多不同查询同时转向来源 |
这些名称描述常见形态,不是互斥的故障类别。Redis 重启后,一批热门键缺失;其中一个热点引起加载竞争,其余不同键又共同占满数据库连接池。一个请求可能同时参与两个问题。
诊断时至少记录总请求数、miss 数、实际加载次数、每键热度分布、加载耗时和拒绝数。命中率下降只给出比例,无法说明数据库新增了多少查询;请求合并生效后,多个 miss 还可能共用一次加载。
缓存命中也可能形成热点
一台 Redis 上的某个键每次都命中,仍然需要执行命令、输出字节和处理客户端连接。大值反复读取还会占用网络和解码资源。
将一个热键复制成多个键,如果它们仍落在同一节点,并没有自动分散节点负载。L1 本地缓存可以减少远端读取,读副本可以分散某些读取,但同时增加副本新旧程度和失效处理。热点应该按节点 CPU、网络、命令耗时和应用端解码观察,不只按数据库是否空闲判断。容量与热点的关系见 BigKey 与多级缓存。
在昂贵查询之前筛掉不成立的输入
明显不合法的 ID、过大的分页、超长搜索词,应在应用入口按契约拒绝。认证和访问控制决定调用者能否查询,不能因为缓存里有数据就跳过授权。
合法格式的 ID 仍可能不存在。攻击者轮换大量随机 ID 时,给每个不存在的 ID 永久建一个条目,会把压力转移到缓存内存。需要限制负缓存大小、寿命和入口流量,避免保护机制自身成为无限增长点。
不存在的结果与 Bloom 过滤
空结果和源错误分开保存
负缓存保存的是“在某个查询条件下,来源确认没有结果”。例如 Optional.empty() 可以代表商品不存在;数据库连接异常、权限不足和查询超时应继续作为失败传播或按明确策略处理。
LoadingCache<Integer, Optional<String>> cache = Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(Duration.ofSeconds(5))
.build(id -> {
var rows = jdbc.queryForList(
"select title from product where id=?", String.class, id);
return rows.stream().findFirst();
});这里没有 catch 数据库异常并返回 empty。确认不存在的结果可以复用五秒,SQL 异常则不会被伪装成不存在。Caffeine 的加载方式和异常处理见 Population。
负缓存的键仍须包含租户和其他影响结果的条件。新增商品后,相关空结果需要被失效,或等待约定的短 TTL。对刚创建成功的调用者,可以直接返回创建结果,而不立即让它进入可能仍存有负缓存的普通读取路径。
不同业务可以为存在和不存在设置不同期限。Caffeine 的变长过期接口能够按条目返回过期时间,具体计时与容量策略见 Eviction。选择较短期限只缩短某次空结果的保存时间,不能解决无穷多新 ID 的流量。
Bloom 用少量位表达集合成员关系
Bloom 过滤器把元素通过若干散列位置映射到位数组。查询时,只要某个必要位置尚未设置,就能判断元素没有被加入当前集合;所有位置都已设置,则可能来自这个元素,也可能由其他元素共同占据。
查询商品 ID
→ 过滤器已就绪且包含所需增量?
否:按受限回源或明确拒绝策略处理
是:查询 Bloom
→ 否定:该元素不在已装入集合
→ 肯定:继续查缓存 / 数据库确认Bloom 的误判率描述非成员被判断为“可能存在”的概率,不是所有肯定结果中固定有相同比例出错。真实数据库命中比例不同,肯定结果中的错误占比也会不同。原理与用途见 Redis Bloom filter。
过滤器需要预计容量和目标误判率。经典固定 Bloom 的近似关系为 m ≈ -n ln(p)/(ln 2)²、k ≈ (m/n) ln 2,其中 n 是预计元素数,m 是位数,k 是散列次数,p 是目标误判概率。容量增加或更低误判率都可能增加内存与计算成本;可扩展实现还会增加子过滤器,实际费用需要按产品实现测量。
“不在过滤器”与“不在数据库”之间还隔着同步
数据库已提交新商品,Bloom 增量稍后才写入。此时过滤器正确回答“尚未加入”,应用却可能错误地回答“商品不存在”。
可以在创建流程中先加入过滤器、再完成业务创建,使失败创建产生一些可回源消除的肯定结果;也可以维护增量补丁集合或已同步位置,让新记录在完整进入过滤器前走受限确认。具体方案要与失败恢复一起设计:只调整两条命令的顺序,不能让跨系统更新自动变成事务。
批量重建也要覆盖构建期间的新写入。先取得快照位置,构建新一代过滤器,补上该位置之后的增量,再切换读取。旧一代在切换窗口中仍需保留到使用它的请求结束。
普通 Bloom 不支持随意清除一个元素对应的位,因为这些位可能也被其他元素共享。删除记录后留下肯定结果,会多一次数据库确认;若业务需要频繁删除和更精确成员操作,可以评估计数型结构、Cuckoo Filter 或准确集合的容量成本。
缺失或类型错误不能当成过滤器就绪
Redis BF.EXISTS 的否定结果还可能来自 key 不存在或 key 类型不正确。应用应把过滤器的创建、类型、版本和加载完成状态纳入就绪判断,不能在 Redis 重启后把空过滤器的所有否定结果直接返回给用户。返回规则见 BF.EXISTS。
协议解码也要匹配。RESP2 常以整数 0/1 返回,RESP3 可以返回 Boolean;Java 客户端自定义命令输出时,应使用支持实际类型的解码器。把每个“像 0/1 的结果”都交给整数解码,会在连接已成功后仍得到类型错误。
热点加载合并与多键回源预算
同一个键共享一次正在进行的加载
手工执行 getIfPresent → 查询 → put 时,多个线程可以先后看到 miss,再分别查询。把操作交给 Caffeine get(key, mappingFunction) 或 LoadingCache,可以在同一个缓存实例内协调同键加载。
八个请求,只有键 A
→ 可共享一次 A 的加载
→ 成功后各自取得同一结果
八个请求,分别查询键 A / B
→ 一次 A 的加载
→ 一次 B 的加载
→ 两个不同键仍可同时访问来源等待同一加载的请求仍占用自身的等待资源。加载迟迟不结束时,需要回源超时、等待上限和请求取消;反复失败后立即重新加载,还可能产生一连串失败波峰。失败退避应与全局预算配合。
进程内合并只影响当前 JVM。扩成十个应用实例后,同一轮热点加载仍可能在各实例分别执行,合计十次;加载失败或再次失效还会触发后续加载。使用跨进程协调时,则要额外处理租约到期、持有者暂停和结果传递,详见 锁与业务状态。不要只靠 SET NX 后固定等待来假设另一实例已经成功写好缓存。
刷新可以移动等待位置
在内容允许短期陈旧时,可以设置软刷新时间和硬失效时间。软到期后由一个任务刷新,其他请求暂用旧值;到硬期限仍未取得新值,再按契约回源或拒绝。
后台刷新需要容量、执行器和失败策略。若每个请求都发现软到期并提交刷新任务,只是把同步重复查询搬到了队列。Caffeine refreshAfterWrite 的加载条件、刷新失败和硬过期关系见 本地缓存。
对余额确认、授权撤销等要求即时决定的操作,应先明确能否使用陈旧结果,再讨论刷新优化。
分散过期时间,避免整批同一时刻 miss
基础 TTL:30 秒
随机偏移:0–10 秒
实际分配:30–40 秒给每个条目分配一个范围内的随机 TTL,可以把一批同时写入的数据分散到一段时间到期。它不会保证没有碰撞,也不会处理整台 Redis 不可达。多个实例使用相同固定随机种子还可能生成相同序列;测试可固定种子获得可重复结果,生产应采用与并发方式适配的随机源。
基础 TTL 加上抖动后的最大值,都要落在业务允许的内容期限内。若允许最多 30 秒的某种缓存保存时长,就不能为了分散流量而在其上再加十秒。
PTTL 可读取键当前剩余毫秒数,返回 -1 表示没有过期时间,-2 表示不存在。检查某次写入时应允许实际执行已经消耗了一部分期限,详见 PTTL。
不同键需要共用受限的加载资源
同键合并只能减少重复查询;不同键大量失效时,仍需要数据库连接、线程和远端请求的总预算。Semaphore 可以限制同一时刻进入加载逻辑的任务数,tryAcquire 失败后选择短暂等待、返回允许的旧值或快速拒绝。
并发上限与每秒请求上限不是同一个量。20 个并发任务,每个 5 毫秒和每个 500 毫秒完成时,吞吐量会不同;每秒配额还要使用相应的速率控制。信号量的获取、超时和释放约定见 Java Semaphore。
如果每个实例各允许 20 个回源任务,扩为十个实例后数据库可能同时收到 200 个。预算需要从下游总体承受能力分配,而不是只按应用实例 CPU 增加线程。
缓存整体故障与恢复时的真实受限 JDBC 实验见 缓存降级与恢复。
验证过滤、负缓存和并发加载
准备环境并执行四项测试
下载 缓存实验工程。环境为普通 Linux 用户、Bash、Docker 和 unzip;按 工程准备完成依赖下载,取得 PROJECT_DIR、LAB_DIR;按 Redis 网络准备启动 Redis 8.10.1 的 ca11-redis。
这是无宿主端口发布的 internal 网络实验;配置不包含生产认证与持久化,测试不能指向其他 Redis。工程使用真实 Caffeine、H2 JDBC 和 Redis Bloom 命令。
test "$(id -u)" -ne 0 || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-traffic-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=CacheTrafficTest test预期 4 个测试通过,BUILD SUCCESS。Java 17 使用对应 Maven 镜像重复执行。测试失败时查看具体断言;UNKNOWN COMMAND 说明目标发行版没有相应 Bloom 能力,需要核对镜像、服务端版本和已启用的命令。
确认不存在与真实数据库错误
missingRowTwoReadsLoads=1 databaseFailureTwoReadsLoads=2 failureNotCached=true测试先查询真实 H2 表中不存在的 ID,两次读取只有一次 SQL 加载。随后移除该负缓存并删除这个测试数据库中的 product 表,两次读取都产生真实 SQL 异常,加载次数各增加一次。
测试最后关闭自己创建的内存数据库。删表仅用于隔离测试,不能在业务库中照做。运行完整工程即可执行该负例,不需要读者手工修改任何已有数据库。
直接观察 Bloom 命令
在同一实验 Redis 中,用随机键避免覆盖其他结果:
FILTER_KEY="ca11:traffic:bloom:$(cat /proc/sys/kernel/random/uuid)"
docker exec ca11-redis redis-cli -e BF.RESERVE "$FILTER_KEY" 0.001 100
docker exec ca11-redis redis-cli -e BF.ADD "$FILTER_KEY" 42
docker exec ca11-redis redis-cli -e BF.EXISTS "$FILTER_KEY" 42
docker exec ca11-redis redis-cli -e BF.EXISTS "$FILTER_KEY" 43BF.RESERVE 成功返回 OK;第一次加入 42 返回 1,查询 42 返回 1。查询尚未加入的 43 通常返回 0,也允许出现误判的 1,不能把这个分支固定为绝不会出现。容量、扩展设置见 BF.RESERVE,加入行为见 BF.ADD。
测试为了稳定复现“数据库新记录尚未加入过滤器”,先找到一个过滤器明确回答否定的候选 ID,再将它插入真实 H2 表。数据库能查询到记录,而过滤器仍回答否定;补入该 ID 后,过滤器才回答肯定:
databaseNewRowExists=true staleBloomSaysAbsent=true afterDeltaAdded=true结束手工命令后,只删除刚创建的过滤器:
docker exec ca11-redis redis-cli -e DEL "$FILTER_KEY"
unset FILTER_KEY返回 1 表示本次确实删除了该键;若已不存在则为 0。测试工程自身的随机键由测试清理,不执行 FLUSHALL。
检查 TTL 抖动和不同键同时加载
jitterKeys=20 assignedMillisWithin=30000..40000 actualPttlPositive=true
eightCallsTwoKeysActualLoads=2 actualPeakLoads=2TTL 实验写入 20 个真实 Redis 键,逐项检查分配的 TTL 范围、实际 PTTL 和原值。它验证分配与保存,不把少量样本当作生产流量一定均匀的证明。
并发实验让八个线程通过屏障同时请求 A、B 两个键,加载器在可控位置等待。实际加载计数为 2,同时活跃加载峰值也为 2;同键请求共享结果,不同键仍同时工作。对照单键手工加载与 LoadingCache 的八线程实验在 Caffeine 篇中运行。
处理过程中看哪些变化
| 处理后现象 | 应继续检查 | 调整方向 |
|---|---|---|
| 空结果命中增加,但键数暴涨 | 随机 ID 数量、负缓存容量 | 限制输入和入口流量,设置容量与短 TTL |
| Bloom 否定比例突然接近全部 | 过滤器是否存在、是否就绪 | 恢复正确代次和增量,避免误拒绝 |
| miss 很多但加载次数少 | 等待时间与共享加载失败 | 补超时和有限退避 |
| 单键已合并,数据库仍过载 | 同时加载的不同键与实例数 | 下游总预算、并发限制和降级 |
| 过期已分散,Redis 故障仍压垮数据库 | 是否无限旁路 | 故障期间保持回源上限 |
| 命中率高但接口慢 | 热键字节、Redis 节点、客户端解码 | 缩小值、限制读取或引入受控 L1 |
负缓存短期保存来源确认不存在的结果,加载合并减少重复查询,回源限制控制剩余工作量。三种措施可以组合使用。
