缓存穿透、击穿、雪崩这三个词经常一起出现,但它们的触发条件并不一样。穿透是请求的数据本身不存在,缓存层永远不可能命中;击穿是某个热点数据刚好过期,瞬时高并发把数据库打出一个缺口;雪崩则是大量缓存同时失效,后端压力像滚雪球一样扩大。如果把三者当成同一种问题处理,很容易出现布隆过滤器配了、随机过期加了,但热点 key 仍被打穿的尴尬局面。下面先分别拆解它们,再把布隆过滤器与随机过期放到正确的场景里。

一、穿透、击穿、雪崩的边界在哪里
先说穿透。它的典型场景是查询一个数据库里根本不存在的 key。比如商品编号从 10000 开始自增,攻击者故意请求 -1、-2 或者超大的随机编号,Redis 里一定没有,MySQL 里也一定没有。每次请求都会穿过缓存到达数据库,缓存命中率持续下降。常见的修复手段有参数校验、缓存空值和布隆过滤器。参数校验适合 ID 规则明确的情况,比如必须为正整数;缓存空值是把 null 也写进缓存并设置很短的过期时间,但它治标不治本,攻击者换一批随机 ID 就能继续打穿,而且大量空值会占用内存。布隆过滤器则是在缓存前面再放一个过滤器,用较小的内存判断一个 key 是否可能存在,从根源上拦截大部分无效 key。
击穿与穿透最大的区别是数据本身存在,只是热点 key 的缓存刚好过期。比如秒杀商品的库存缓存设置为 60 秒,到期瞬间如果 1000 个请求同时进来,它们都会发现缓存未命中,然后全部跑到数据库查询并回写缓存,数据库连接可能被打满。击穿的典型解法是互斥锁,也就是只允许一个线程或一个服务实例去数据库重建缓存,其他请求等待后重试读取缓存。逻辑过期方案也能缓解,它不删除 key,而是用一个单独字段记录逻辑过期时间,发现过期后先返回旧值,同时异步刷新,用户不会阻塞。
雪崩更像是系统性问题。通常是大量 key 的 TTL 设置相同,或者缓存服务整体重启,导致某一时刻大量请求同时落到数据库,甚至拖垮数据库。随机过期是解决雪崩最简单有效的手段之一,它把同一批 key 的过期时间打散,让压力分散到不同时间点。除此之外,还可以配合本地缓存、集群熔断、请求限流。穿透、击穿、雪崩三者的边界清楚了,后面才能选对工具:布隆过滤器主要解决穿透,随机过期主要缓解雪崩,击穿要靠锁和逻辑过期来兜底。
二、布隆过滤器的判断逻辑:一定不存在和可能存在
布隆过滤器的核心是一个位数组和多个哈希函数。插入一个元素时,用多个哈希函数分别计算位置,把位数组中对应位置置为 1;查询时再次计算这些位置,只要有一个位置是 0,就说明这个元素一定不存在。反过来,所有位置都是 1 时,只能说元素可能存在,因为不同元素可能共享某些位置,这就是误判。误判率跟位数组长度 m、预计元素数量 n、哈希函数个数 k 有关,近似为 p ≈ (1 - e^(-kn/m))^k。实际使用中最重要的是先估算 n,再根据业务接受度选择误判率,最后推算出需要的位数组大小。
Guava 提供了本地内存版的 BloomFilter,适合单体应用或本地缓存前置过滤。如果服务是分布式部署,更推荐 Redisson 的 RBloomFilter,它基于 Redis 位图实现,可以多个服务共享同一个过滤器。下面这段 Java 代码演示了本地布隆过滤器的初始化、数据预加载和拦截判断。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
public class BloomFilterDemo {
public static void main(String[] args) {
// 预计插入 100 万条数据,误判率 1%
BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000000,
0.01
);
bloomFilter.put("user:10001");
bloomFilter.put("user:10002");
// 请求到达时先判断
if (!bloomFilter.mightContain("user:99999")) {
System.out.println("key 一定不存在,直接返回空结果");
}
}
}
上面的过滤器在启动时需要从数据库或者初始化文件加载已有的 key。要注意布隆过滤器不支持删除,如果业务 key 会失效或下线,过滤器会出现少量误判,也就是说某些已经不存在的 key 仍会被判断为可能存在。对于这种场景,可以设置一个较小的误判率,并定期重建过滤器。容量规划上,假设要过滤 1000 万个 ID,误判率设为 0.01,位数组大约需要 12 MB;误判率降到 0.001 时,位数组大约需要 18 MB。用十几 MB 内存换取大量无效请求的拦截,性价比通常很高。但它只能回答 key 可能不存在,不能判断缓存是否过期,所以不能替代缓存过期策略。
三、随机过期如何平摊失效压力
随机过期的做法是在原本固定的 TTL 上增加一个随机偏移量。比如用户信息缓存统一设置 30 分钟,如果批量写入时都设置 1800 秒,那么 30 分钟后所有 key 会同时过期。改成 25 分钟基础时间加上 0 到 10 分钟的随机值,key 的过期时间就会分布在 25 到 35 分钟之间,数据库压力被抹平。这个随机区间并不是越大越好,区间太大会导致部分 key 过早过期,降低缓存命中率;区间太小又起不到分散作用。一般取基础过期时间的 20% 到 30% 比较稳妥。
下面这段 Go 代码演示了在写入 Redis 时给 key 设置随机 TTL。它先固定一个基础过期时间,再用随机数生成一个附加偏移量。实际项目中要确保随机数生成器在并发环境下安全,或者使用 Go 1.20 之后的全局随机源。
package main
import (
"context"
"math/rand"
"time"
"github.com/go-redis/redis/v8"
)
func setWithRandomTTL(rdb *redis.Client, key string, value string) error {
ctx := context.Background()
baseTTL := 30 * time.Minute
randomPart := time.Duration(rand.Intn(10*60)) * time.Second
return rdb.Set(ctx, key, value, baseTTL+randomPart).Err()
}
随机过期能够显著降低雪崩概率,但无法解决击穿。因为热点 key 即使加了随机偏移,它仍然会在某一个时间点过期,过期这一刻的高并发请求还是会同时去查数据库。所以热点数据还需要配合互斥锁。Java 里可以使用 Redis 的 setIfAbsent 实现简单分布式锁:只有一个请求能拿到锁并重建缓存,其他请求等待后重新读缓存。锁本身要设置过期时间,避免服务异常导致死锁。下面这段代码展示的是重建热点缓存的加锁逻辑。
public void rebuildCache(String key) throws InterruptedException {
String lockKey = "lock:" + key;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
// 从数据库加载数据,并写入缓存
} finally {
redisTemplate.delete(lockKey);
}
} else {
Thread.sleep(50);
// 重新尝试读取缓存
}
}
四、组合方案与调优清单
把前面提到的方案组合起来,请求进入服务后的正常顺序是:先经过布隆过滤器,如果被判定为不存在,直接返回空结果,不查缓存也不查数据库;如果可能存在,再查 Redis;命中缓存直接返回;未命中则尝试获取分布式锁,拿不到锁就短暂等待后重试;拿到锁后查询数据库并回写缓存,回写时 TTL 加上随机偏移量。这个链路同时覆盖了穿透、击穿和雪崩,适合大多数读多写少的业务。
调优时重点关注几个参数。布隆过滤器的预计容量要按当前数据量加上增长空间来设置,误判率通常选 0.01 到 0.001 之间;空值缓存只用于不可能被穿透攻击的合法 ID,例如 ID 存在但状态未发布,并且要设置很短过期时间;随机过期区间可以按基础 TTL 的 20% 到 30% 配置,监控缓存命中率和数据库慢查询来判断是否需要调整。分布式锁的持有时间不能太长,一般只覆盖数据库查询和回写缓存的过程,如果逻辑复杂可以改为逻辑过期加异步刷新。
布隆过滤器的一个常见误区是把它当作准确判断,看到 mightContain 返回 true 就认为 key 一定存在。实际上误判是必然存在的,业务上必须容忍少量不存在的数据继续打到缓存和数据库,只不过数量已经被大幅削减。另外,布隆过滤器扩容比较麻烦,数据量增长后原来的位数组会变得拥挤,误判率升高。可以在新建一个容量更大的过滤器后,把旧过滤器和数据库里的 key 同时灌入新过滤器,再用配置中心切换查询目标。整体看,穿透、击穿、雪崩没有银弹,但布隆过滤器加随机过期再配合互斥锁,已经能覆盖大多数生产场景,剩下的就是根据监控数据持续修正参数。