以下是针对缓存穿透 的布隆过滤器(Bloom Filter)治理方案,涵盖**本地(Guava)与分布式(RedisBloom)**双模式,包含数学原理、生产级代码与容错架构。
一、缓存穿透的杀伤逻辑
1.1 攻击模型
恶意请求 → 查询不存在Key → 绕过Cache直击DB → DB连接耗尽 → 级联雪崩
高危场景:
- 恶意遍历 :
user?id=-1,order?oid=uuid(随机UUID撞库) - 历史数据清理:批量删除Key后,上游未感知继续请求
- 参数构造 :SQL注入式试探
user?id=1' or '1'='1
传统方案缺陷:
- 缓存空值(Cache-Aside null) :需设置较短TTL(1-5分钟),且无法防御随机Key攻击(每个Key只访问一次,空值无意义)
二、布隆过滤器数学原理
2.1 空间奇迹
存储一亿条数据,仅需 ~100MB (1%误判率),而HashMap需 ~6GB。
核心公式(m=位数组大小,n=元素数量,k=哈希函数数,p=误判率):
m=−nlnp(ln2)2≈1.44⋅n⋅log2(1p)m = -\frac{n \ln p}{(\ln 2)^2} \approx 1.44 \cdot n \cdot \log_2(\frac{1}{p})m=−(ln2)2nlnp≈1.44⋅n⋅log2(p1)
k=mnln2≈0.69⋅mnk = \frac{m}{n} \ln 2 \approx 0.69 \cdot \frac{m}{n}k=nmln2≈0.69⋅nm
最优参数速查表(n=1亿):
| 误判率 § | 位数组大小 (m) | 哈希函数数 (k) | 理论空间 |
|---|---|---|---|
| 0.1% (1‰) | 1.44 GB | 10 | 180 MB |
| 1% (1%) | 958 MB | 7 | 120 MB |
| 0.01% (万分之一) | 2.88 GB | 14 | 360 MB |
注:1 Byte = 8 bits,上表已换算
2.2 假阳性与假阴性
- 假阳性(False Positive) :BF判定存在 → 实际不存在(可接受,仅损失一次Cache查询)
- 假阴性(False Negative) :BF判定不存在 → 实际存在(致命,导致数据丢失)
绝对约束 :BF禁止删除(Counting Bloom Filter除外),否则产生假阴性。
三、Guava BloomFilter(本地模式)
3.1 适用场景
- 单节点部署 或本机缓存预热
- 数据量<千万级(受限于JVM Heap)
- 允许JVM重启重建(数据不持久)
3.2 生产级代码
java
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
public class LocalBloomFilter {
// 预计存储100万用户ID,误判率0.01%
private static final BloomFilter<String> USER_ID_BF =
BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1_000_000,
0.0001
);
// 双过滤策略:Guava本地 + Redis远端
public User getUser(String userId) {
// 1. 本地BF挡截(零RTT)
if (!USER_ID_BF.mightContain(userId)) {
log.warn("BF拦截非法用户ID: {}", userId);
return null; // 直接返回,不打穿透
}
// 2. Redis查询
User user = redisTemplate.opsForValue().get("user:" + userId);
if (user != null) return user;
// 3. 查询DB(极少发生)
user = userDao.findById(userId);
if (user == null) {
// 缓存空值短期防穿透(双重保险)
redisTemplate.opsForValue().set(
"user:" + userId,
"NULL",
60,
TimeUnit.SECONDS
);
} else {
redisTemplate.opsForValue().set("user:" + userId, user);
}
return user;
}
// 数据初始化(启动时全量加载)
@PostConstruct
public void init() {
List<String> allUserIds = userDao.findAllIds();
allUserIds.forEach(USER_ID_BF::put);
log.info("BF初始化完成, 录入{}条数据", allUserIds.size());
}
// 新增用户时同步写入BF(避免假阴性)
public void addUser(User user) {
userDao.save(user);
USER_ID_BF.put(user.getId());
redisTemplate.delete("user:" + user.getId()); // 清缓存
}
}
3.3 进阶:Counting Bloom Filter(支持删除)
Guava未原生支持,但可通过过时标记+定时重建模拟:
java
// 使用BitMap + Int计数器(Redis方案更优,见下文)
四、RedisBloom(分布式模式)
4.1 RedisBloom模块 vs 原生BitMap
- 原生BitMap:需自行实现哈希函数与多Key管理,易出错
- RedisBloom模块 (官方提供):
- 命令式操作:
BF.ADD,BF.EXISTS,BF.RESERVE - 支持SCANDUMP/LOAD(持久化与迁移)
- 支持Count-Min Sketch(频率统计)
- 命令式操作:
安装:
bash
redis-server --loadmodule /path/to/redisbloom.so
4.2 分布式架构设计
分层过滤架构:
Client → 本地Caffeine (L1) → RedisBloom (L2过滤) → Redis Data (L3) → DB
Java实现(Lettuce + RedisBloom):
java
@Configuration
public class RedisBloomConfig {
@Bean
public RedisBloomClient bloomClient() {
// 连接RedisBloom模块
RedisURI uri = RedisURI.builder()
.withHost("redis-cluster")
.withPort(6379)
.build();
return new RedisBloomClient(uri);
}
// 初始化布隆过滤器(误判率0.1%,容量1亿)
@PostConstruct
public void initBloom() {
try {
redisBloomClient.execute("BF.RESERVE",
"bf:user_id", // Key
"0.001", // 误判率
"100000000" // 初始容量(自动扩容)
);
} catch (Exception e) {
// BF.EXISTS 表示已存在,忽略
}
// 全量导入(使用管道批量写入)
batchLoadData();
}
public boolean mightContain(String userId) {
// BF.EXISTS 返回0/1
Long exists = (Long) redisBloomClient.execute("BF.EXISTS",
"bf:user_id", userId);
return exists == 1;
}
public void addElement(String userId) {
redisBloomClient.execute("BF.ADD", "bf:user_id", userId);
}
}
4.3 Hot Key与大Key治理
问题:单BF Key存储亿级数据,导致:
- Redis单线程瓶颈:BF.EXISTS成为热点
- 大Key风险:网络传输与持久化阻塞
分片(Sharding)策略:
java
public class ShardedBloomFilter {
private final int shardCount = 8; // 分8片
private final String prefix = "bf:user:";
public boolean mightContain(String userId) {
int shard = hash(userId) % shardCount;
String key = prefix + shard;
return redisBloomClient.exists(key, userId);
}
// 使用CRC16均匀分布
private int hash(String key) {
return Math.abs(key.hashCode()) % shardCount;
}
}
五、双写一致性与重建策略
5.1 数据同步机制
写路径(避免假阴性):
DB事务提交成功后 → 立即BF.ADD → 异步刷入Redis
- 异常处理 :若BF写入失败,记录本地日志队列,定时重试,避免同步阻塞主流程
异步重建(全量同步):
java
// 使用Canal监听MySQL Binlog
@CanalListener
public void onUserChange(CanalEntry entry) {
if (entry.getType() == INSERT) {
String userId = extractId(entry);
// 双写BF(本地+Redis)
localBF.put(userId);
redisBloomClient.add("bf:user_id", userId);
}
}
5.2 定期重建(解决BF无法删除问题)
时间轮盘重建:
- 每24小时 新建一个BF(
bf:user:v2),写入新数据 - 查询时双Check :
BF.EXISTS bf:user:v1 && BF.EXISTS bf:user:v2 - 48小时后删除
v1,将v2改为v1(交替滚动)
布谷鸟过滤器(Cuckoo Filter)替代 :
若需支持删除,可替换为Cuckoo Filter(Redis 4.0+模组),支持动态删除且空间效率更高。
六、容错与降级方案
6.1 多层降级开关
java
public class DegradableBloomFilter {
private volatile boolean bloomEnabled = true;
public User getUser(String id) {
// 开关1:BF总开关(Redis故障时关闭)
if (bloomEnabled) {
try {
if (!bloomClient.exists(id)) return null;
} catch (Exception e) {
// Redis故障,降级为直接查Cache+DB,告警
bloomEnabled = false;
alert("BF降级");
}
}
// 开关2:本地BF兜底(分布式BF失效时启用)
if (!bloomEnabled && !localBf.mightContain(id)) {
return null;
}
return queryCache(id);
}
}
6.2 缓存空值Fallback
当BF误判(极小概率)打到DB时,使用空值缓存保DB:
java
if (user == null) {
// 布隆过滤器误判防护(防缓存击穿)
redisTemplate.opsForValue().set(
"null:user:" + id,
"",
5, // 极短TTL
TimeUnit.SECONDS
);
return null;
}
七、方案对比与选型矩阵
| 维度 | Guava本地BF | RedisBloom分布式 | 缓存空值 |
|---|---|---|---|
| 存储位置 | JVM Heap | Redis Server | Redis Server |
| 容量上限 | 单机内存(建议<1亿) | 数十亿(可水平扩展) | 取决于Key数量 |
| 一致性 | 单点,重启丢失 | 集群持久化 | 易过期,需续期 |
| RTT成本 | 0(本地内存) | 0.5-1ms(网络IO) | 0.5-1ms |
| 删除支持 | 不支持(需重建) | 不支持(需重建) | 支持(TTL删除) |
| 适用数据 | 静态/准静态字典 | 海量动态数据 | 少量固定非法Key |
| 复杂度 | 低 | 高(需运维Redis模块) | 极低 |
决策树:
- QPS>10万 且数据量<1000万 → Guava本地 + Redis备份
- 数据量>1亿 或多服务共享 → RedisBloom分片
- 预算受限 且容忍短时空窗 → 缓存空值(TTL 1分钟)
八、线上实战 checklist
markdown
□ BF初始化是否使用**批量管道**(非逐条ADD,减少RTT)
□ 是否设置BF自动扩容(RedisBloom的`NON_SCALING`设为no)
□ 误率是否压测验证(抽样1万Key人工校验)
□ 是否监控BF内存使用(`BF.INFO bf:user`查看字节数)
□ 写DB后是否**同步写BF**(假阴性会击穿缓存)
□ 是否配置**BF降级开关**(Redis故障时切空值缓存)
□ 定期重建任务是否低峰期执行(避免SCAN阻塞)
□ 分片策略是否均匀(避免Redis节点热点倾斜)
核心认知 :布隆过滤器是概率型防御 ,必须配合缓存空值 作为二道防线,且永远保留DB降级熔断(Sentinel)作为最后保障。