【Java后端技术亮点】布隆过滤器防缓存穿透:千万级数据下几MB内存的极致防护
·
黑客用脚本每秒查询10万次不存在的用户ID,数据库直接被打崩——这就是缓存穿透。本文将揭秘如何用几MB内存的布隆过滤器,轻松挡住恶意攻击,守护你的数据库。

文章目录
一、场景引入:一场有预谋的攻击
1.1 真实案例
某电商平台上线新活动,突然遭受攻击:
攻击时间线:
T+0秒:攻击脚本启动,每秒10万次请求
T+1秒:请求参数:userId=999999999(数据库不存在)
T+2秒:Redis缓存未命中(Key不存在)
T+3秒:请求打到MySQL,数据库CPU飙升
T+10秒:数据库连接池耗尽,服务不可用
T+30秒:运维发现异常,紧急封IP
问题根源:攻击者故意查询不存在的数据,每次请求都穿透缓存打到数据库。
1.2 什么是缓存穿透?
缓存穿透是指查询一个不存在的数据,由于缓存中也没有,每次请求都会打到数据库。
正常请求流程:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 请求 │────→│ Redis │────→│ MySQL │
│ user=1 │ │ 命中 │ │ 不查询 │
└─────────┘ └─────────┘ └─────────┘
缓存穿透流程:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 请求 │────→│ Redis │────→│ MySQL │
│ user=999│ │ 未命中 │────→│ 查询 │
│(不存在) │ │ │ │ 无结果 │
└─────────┘ └─────────┘ └─────────┘
↑_____________________________|
每次请求都走这条路径
1.3 缓存穿透的危害场景
| 场景 | 攻击方式 | 危害程度 |
|---|---|---|
| 恶意攻击 | 批量查询不存在的ID | 🔴 极高 |
| 业务误用 | 前端传错参数 | 🟡 中 |
| 数据删除 | 查询已删除的数据 | 🟠 高 |
| 爬虫扫描 | 遍历ID范围 | 🟠 高 |
二、解决方案:布隆过滤器
2.1 什么是布隆过滤器?
布隆过滤器(Bloom Filter)是一种空间效率极高的概率型数据结构,用于判断一个元素可能存在或一定不存在。
布隆过滤器核心特性:
┌─────────────────────────────────────────────────────────────┐
│ │
│ 查询结果: │
│ ├── "可能存在" → 有一定误判率(可能不存在但被判断为存在) │
│ └── "一定不存在" → 100%准确(不存在就是不存在) │
│ │
│ 特点: │
│ - 零误杀:不会把存在的判断为不存在 │
│ - 空间极小:1000万数据只需约6MB │
│ - 无删除:标准布隆过滤器不支持删除(有改进版) │
│ │
└─────────────────────────────────────────────────────────────┘
2.2 布隆过滤器原理
布隆过滤器工作原理:
1. 初始化:创建一个全为0的bit数组
┌─────────────────────────────────────┐
│ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 │
└─────────────────────────────────────┘
2. 添加元素:使用多个Hash函数计算位置,将对应bit置为1
添加 "user:100":
Hash1("user:100") = 2 → 置为1
Hash2("user:100") = 5 → 置为1
Hash3("user:100") = 11 → 置为1
┌─────────────────────────────────────┐
│ 0 0 1 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0 │
└─────────────────────────────────────┘
↑ ↑ ↑
3. 查询元素:计算Hash位置,如果所有位置都是1,则"可能存在"
查询 "user:100":
Hash1 = 2 (是1) ✓
Hash2 = 5 (是1) ✓
Hash3 = 11 (是1) ✓
→ 结果:可能存在 ✓
查询 "user:999"(不存在):
Hash1 = 3 (是0) ✗
→ 结果:一定不存在 ✓
2.3 误判率计算
/**
* 布隆过滤器参数计算
*
* 假设:
* - 预期数据量 n = 100万
* - 期望误判率 p = 0.01 (1%)
*
* 计算公式:
* - 最优bit数组大小 m = -n * ln(p) / (ln(2)^2)
* - 最优Hash函数个数 k = m * ln(2) / n
*
* 计算结果:
* - m ≈ 958万bit ≈ 1.2MB
* - k ≈ 7个Hash函数
*/
三、实战代码:从零实现布隆过滤器防缓存穿透
3.1 Redisson布隆过滤器实现(推荐)
/**
* Redisson布隆过滤器配置
* 开箱即用,支持分布式场景
*/
@Configuration
@Slf4j
public class RedissonBloomFilterConfig {
@Autowired
private RedissonClient redissonClient;
/**
* 用户ID布隆过滤器
* 预期100万用户,误判率1%
*/
@Bean
public RBloomFilter<String> userBloomFilter() {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloom:user");
// 初始化:预期元素数量100万,误判率1%
bloomFilter.tryInit(1000000L, 0.01);
log.info("✅ 用户布隆过滤器初始化完成,预期数据量: 100万, 误判率: 1%");
return bloomFilter;
}
/**
* 商品ID布隆过滤器
* 预期10万商品,误判率0.1%
*/
@Bean
public RBloomFilter<String> productBloomFilter() {
RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloom:product");
// 初始化:预期元素数量10万,误判率0.1%
bloomFilter.tryInit(100000L, 0.001);
log.info("✅ 商品布隆过滤器初始化完成,预期数据量: 10万, 误判率: 0.1%");
return bloomFilter;
}
}
3.2 布隆过滤器预热服务
/**
* 布隆过滤器预热服务
* 系统启动时将已有数据加载到布隆过滤器
*/
@Component
@Slf4j
public class BloomFilterPreheatService implements CommandLineRunner {
@Autowired
private RBloomFilter<String> userBloomFilter;
@Autowired
private RBloomFilter<String> productBloomFilter;
@Autowired
private UserMapper userMapper;
@Autowired
private ProductMapper productMapper;
@Override
public void run(String... args) {
log.info("🚀 开始预热布隆过滤器...");
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 预热用户布隆过滤器
preheatUserBloomFilter();
// 预热商品布隆过滤器
preheatProductBloomFilter();
stopWatch.stop();
log.info("✅ 布隆过滤器预热完成,耗时: {}ms", stopWatch.getTotalTimeMillis());
}
/**
* 预热用户布隆过滤器
*/
private void preheatUserBloomFilter() {
log.info("📦 开始预热用户布隆过滤器...");
// 清空旧数据(支持重建)
userBloomFilter.delete();
userBloomFilter.tryInit(1000000L, 0.01);
// 分批查询用户ID
int batchSize = 1000;
long lastId = 0L;
int totalCount = 0;
while (true) {
List<Long> userIds = userMapper.selectIdList(lastId, batchSize);
if (CollUtil.isEmpty(userIds)) {
break;
}
// 批量添加到布隆过滤器
for (Long userId : userIds) {
userBloomFilter.add("user:" + userId);
}
totalCount += userIds.size();
lastId = userIds.get(userIds.size() - 1);
// 每10000条打印一次进度
if (totalCount % 10000 == 0) {
log.info("⏳ 用户布隆过滤器预热进度: {} 条", totalCount);
}
}
log.info("✅ 用户布隆过滤器预热完成,共 {} 条", totalCount);
}
/**
* 预热商品布隆过滤器
*/
private void preheatProductBloomFilter() {
log.info("📦 开始预热商品布隆过滤器...");
productBloomFilter.delete();
productBloomFilter.tryInit(100000L, 0.001);
List<Long> productIds = productMapper.selectAllIds();
if (CollUtil.isNotEmpty(productIds)) {
for (Long productId : productIds) {
productBloomFilter.add("product:" + productId);
}
}
log.info("✅ 商品布隆过滤器预热完成,共 {} 条", CollUtil.isEmpty(productIds) ? 0 : productIds.size());
}
}
3.3 业务层使用布隆过滤器
/**
* 用户服务(使用布隆过滤器防缓存穿透)
*/
@Service
@Slf4j
public class UserService {
@Autowired
private RBloomFilter<String> userBloomFilter;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
/**
* 获取用户信息(布隆过滤器防护版)
*/
public UserVO getUserById(Long userId) {
String bloomKey = "user:" + userId;
String cacheKey = "user:detail:" + userId;
// Step 1: 布隆过滤器判断
if (!userBloomFilter.contains(bloomKey)) {
log.debug("🛡️ 布隆过滤器拦截不存在用户: {}", userId);
return null; // 用户一定不存在,直接返回
}
// Step 2: 查询Redis缓存
String json = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(json)) {
return JSON.parseObject(json, UserVO.class);
}
// Step 3: 查询数据库
User user = userMapper.selectById(userId);
if (user == null) {
// 布隆过滤器误判(极小概率),缓存空值防止再次穿透
redisTemplate.opsForValue().set(cacheKey, "null", 5, TimeUnit.MINUTES);
return null;
}
// Step 4: 写入缓存
UserVO userVO = convertToVO(user);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(userVO),
30, TimeUnit.MINUTES);
return userVO;
}
/**
* 新增用户(同步更新布隆过滤器)
*/
@Transactional
public void addUser(UserDTO dto) {
// 1. 插入数据库
User user = convertToEntity(dto);
userMapper.insert(user);
// 2. 添加到布隆过滤器
userBloomFilter.add("user:" + user.getId());
// 3. 写入缓存
String cacheKey = "user:detail:" + user.getId();
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(convertToVO(user)),
30, TimeUnit.MINUTES);
log.info("✅ 新增用户并同步布隆过滤器: {}", user.getId());
}
/**
* 删除用户(布隆过滤器无法删除,需要其他策略)
*/
@Transactional
public void deleteUser(Long userId) {
// 1. 删除数据库
userMapper.deleteById(userId);
// 2. 删除缓存
String cacheKey = "user:detail:" + userId;
redisTemplate.delete(cacheKey);
// 3. 注意:标准布隆过滤器不支持删除!
// 方案1:使用布谷鸟过滤器(支持删除)
// 方案2:定时重建布隆过滤器
// 方案3:使用Counting Bloom Filter
log.info("🗑️ 删除用户(布隆过滤器保留): {}", userId);
}
}
3.4 自研Guava布隆过滤器(单机场景)
/**
* Guava布隆过滤器(单机场景)
* 适合不依赖Redis的轻量级应用
*/
@Component
@Slf4j
public class GuavaBloomFilterService {
// 用户布隆过滤器
private BloomFilter<String> userBloomFilter;
// 商品布隆过滤器
private BloomFilter<String> productBloomFilter;
@PostConstruct
public void init() {
// 创建布隆过滤器
userBloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000000, // 预期数据量
0.01 // 误判率
);
productBloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
100000,
0.001
);
log.info("✅ Guava布隆过滤器初始化完成");
}
/**
* 添加用户到布隆过滤器
*/
public void addUser(Long userId) {
userBloomFilter.put("user:" + userId);
}
/**
* 判断用户可能存在
*/
public boolean mightContainUser(Long userId) {
return userBloomFilter.mightContain("user:" + userId);
}
/**
* 获取布隆过滤器统计信息
*/
public void printStats() {
log.info("用户布隆过滤器预期插入数量: {}", userBloomFilter.approximateElementCount());
log.info("用户布隆过滤器预期误判率: {}", userBloomFilter.expectedFpp());
}
}
3.5 布谷鸟过滤器(支持删除)
/**
* 布谷鸟过滤器(Cuckoo Filter)
* 支持删除操作,是布隆过滤器的升级版
*/
@Component
@Slf4j
public class CuckooFilterService {
@Autowired
private RedissonClient redissonClient;
private RCuckooFilter<String> userCuckooFilter;
@PostConstruct
public void init() {
userCuckooFilter = redissonClient.getCuckooFilter("cuckoo:user");
// 初始化:预期100万数据
userCuckooFilter.tryInit(1000000L);
log.info("✅ 布谷鸟过滤器初始化完成");
}
/**
* 添加元素
*/
public void add(Long userId) {
userCuckooFilter.add("user:" + userId);
}
/**
* 删除元素(布谷鸟过滤器支持删除)
*/
public void delete(Long userId) {
userCuckooFilter.delete("user:" + userId);
}
/**
* 查询元素
*/
public boolean contains(Long userId) {
return userCuckooFilter.contains("user:" + userId);
}
}
四、高级进阶:多层防护架构
4.1 完整防护链路
/**
* 缓存穿透多层防护服务
* 布隆过滤器 + 缓存空值 + 接口限流
*/
@Service
@Slf4j
public class CachePenetrationProtectService {
@Autowired
private RBloomFilter<String> userBloomFilter;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
/**
* 多层防护获取用户
*
* 防护层级:
* L1: 接口限流(防止恶意攻击)
* L2: 布隆过滤器(快速判断不存在)
* L3: 缓存空值(防止重复查询不存在数据)
* L4: 互斥锁(防止缓存击穿)
*/
@SentinelResource(value = "getUser", blockHandler = "getUserBlockHandler")
public UserVO getUserWithProtect(Long userId) {
String bloomKey = "user:" + userId;
String cacheKey = "user:detail:" + userId;
// L1: 参数校验
if (userId == null || userId <= 0) {
throw new BizException("非法用户ID");
}
// L2: 布隆过滤器判断
if (!userBloomFilter.contains(bloomKey)) {
log.debug("🛡️ 布隆过滤器拦截: {}", userId);
return null;
}
// L3: 查询缓存
String json = redisTemplate.opsForValue().get(cacheKey);
// 缓存空值判断
if ("null".equals(json)) {
log.debug("🛡️ 缓存空值拦截: {}", userId);
return null;
}
if (StrUtil.isNotBlank(json)) {
return JSON.parseObject(json, UserVO.class);
}
// L4: 互斥锁防止缓存击穿
String lockKey = "lock:user:" + userId;
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查
json = redisTemplate.opsForValue().get(cacheKey);
if (StrUtil.isNotBlank(json)) {
return "null".equals(json) ? null : JSON.parseObject(json, UserVO.class);
}
// 查询数据库
User user = userMapper.selectById(userId);
if (user == null) {
// 缓存空值,5分钟过期
redisTemplate.opsForValue().set(cacheKey, "null", 5, TimeUnit.MINUTES);
return null;
}
// 写入缓存
UserVO vo = convertToVO(user);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(vo),
30, TimeUnit.MINUTES);
return vo;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 其他线程等待后重试
ThreadUtil.sleep(100);
return getUserWithProtect(userId);
}
}
/**
* 限流降级处理
*/
public UserVO getUserBlockHandler(Long userId, BlockException ex) {
log.warn("🚫 用户查询被限流: {}", userId);
throw new BizException("系统繁忙,请稍后再试");
}
}
4.2 布隆过滤器定时重建
/**
* 布隆过滤器定时重建
* 解决删除数据导致的误判问题
*/
@Component
@Slf4j
public class BloomFilterRebuildScheduler {
@Autowired
private RedissonClient redissonClient;
@Autowired
private UserMapper userMapper;
/**
* 每周日凌晨3点重建布隆过滤器
*/
@Scheduled(cron = "0 0 3 ? * SUN")
public void rebuildUserBloomFilter() {
log.info("🔄 开始重建用户布隆过滤器...");
String tempName = "bloom:user:temp";
String activeName = "bloom:user";
try {
// 1. 创建临时布隆过滤器
RBloomFilter<String> tempFilter = redissonClient.getBloomFilter(tempName);
tempFilter.tryInit(1000000L, 0.01);
// 2. 加载最新数据
int batchSize = 1000;
long lastId = 0L;
int totalCount = 0;
while (true) {
List<Long> userIds = userMapper.selectIdList(lastId, batchSize);
if (CollUtil.isEmpty(userIds)) {
break;
}
for (Long userId : userIds) {
tempFilter.add("user:" + userId);
}
totalCount += userIds.size();
lastId = userIds.get(userIds.size() - 1);
}
// 3. 原子替换
RBloomFilter<String> activeFilter = redissonClient.getBloomFilter(activeName);
activeFilter.delete();
tempFilter.rename(activeName);
log.info("✅ 用户布隆过滤器重建完成,共 {} 条", totalCount);
} catch (Exception e) {
log.error("❌ 布隆过滤器重建失败", e);
// 清理临时过滤器
redissonClient.getBloomFilter(tempName).delete();
}
}
}
五、预判问题与解答
Q1:布隆过滤器的误判率可以降低到0吗?
A:理论上不可能降到0,但可以通过调整参数降低:
// 误判率与空间占用的关系
// 100万数据:
// 误判率1% → 约1.2MB
// 误判率0.1% → 约1.8MB
// 误判率0.01% → 约2.4MB
// 实际应用中,1%的误判率已经足够低
// 即使误判,也只是多查一次Redis/DB,影响很小
Q2:布隆过滤器不支持删除,怎么办?
A:有三种解决方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 布谷鸟过滤器 | 支持删除 | 空间利用率略低 | 需要频繁删除的场景 |
| 定时重建 | 简单可靠 | 有重建窗口期 | 删除不频繁的场景 |
| Counting Bloom Filter | 支持删除 | 空间占用4倍 | 内存充足的场景 |
Q3:布隆过滤器和缓存空值有什么区别?
A:两者是互补关系,不是替代关系:
布隆过滤器:
- 作用:快速判断数据是否可能存在
- 优点:内存极小,判断极快
- 缺点:有一定误判率,不支持删除
缓存空值:
- 作用:缓存查询结果为空的情况
- 优点:100%准确
- 缺点:占用Redis内存,需要设置过期时间
最佳实践:两者结合使用
1. 先用布隆过滤器快速过滤
2. 再用缓存空值兜底
Q4:布隆过滤器预热数据量太大怎么办?
A:采用分批加载+异步加载策略:
// 分批加载
int batchSize = 1000;
while (true) {
List<Long> ids = mapper.selectIdList(lastId, batchSize);
if (ids.isEmpty()) break;
for (Long id : ids) {
bloomFilter.add("user:" + id);
}
// 每批休眠10ms,避免CPU飙高
Thread.sleep(10);
}
// 异步加载(不阻塞启动)
@Async("taskExecutor")
public void asyncPreheat() {
preheatBloomFilter();
}
Q5:布隆过滤器对性能有影响吗?
A:影响极小,几乎可以忽略:
| 操作 | 时间复杂度 | 实际耗时 |
|---|---|---|
| 添加元素 | O(k) | ~1μs |
| 查询元素 | O(k) | ~1μs |
| Hash计算 | O(1) | ~0.1μs |
k是Hash函数个数(通常6-8个),单次查询只需几微秒。
六、面试高频考点
考点1:布隆过滤器的优缺点?
参考答案:
优点:
1. 空间效率极高:1000万数据只需约6MB
2. 查询速度快:O(k)时间复杂度,微秒级
3. 无漏报:不存在的数据100%能判断出来
4. 保密性好:不存储原始数据
缺点:
1. 有误判率:可能把不存在的判断为存在
2. 不支持删除:标准布隆过滤器无法删除(可用布谷鸟过滤器解决)
3. 无法获取元素列表:只能判断存在性
4. 需要提前预估数据量
考点2:如何解决布隆过滤器的误判问题?
参考答案:
1. 调整参数:
- 增大bit数组(降低误判率,但占用更多内存)
- 增加Hash函数个数
2. 结合其他手段:
- 布隆过滤器判断"可能存在"后,再查Redis确认
- 即使误判也只是多查一次缓存,影响很小
3. 使用改进版:
- 布谷鸟过滤器:支持删除,误判率更低
- Counting Bloom Filter:支持删除
4. 定期重建:
- 清理已删除数据,降低误判率累积
考点3:Redis的布隆过滤器插件和Redisson有什么区别?
参考答案:
| 特性 | RedisBloom插件 | Redisson |
|---|---|---|
| 部署方式 | 需要安装插件 | 纯Java客户端 |
| 分布式 | 天然支持 | 基于Redis实现 |
| 性能 | 更高(C实现) | 稍低(Java实现) |
| 功能 | 基础功能 | 功能更丰富 |
| 易用性 | 需要额外部署 | 开箱即用 |
考点4:布隆过滤器除了防缓存穿透还有什么用?
参考答案:
1. 邮箱黑名单过滤:快速判断邮箱是否在黑名单
2. URL去重:爬虫已访问URL判断
3. 推荐去重:给用户推荐时过滤已看过的内容
4. 数据库查询优化:先判断数据是否存在,避免无效查询
5. 日志去重:判断日志是否已处理过
6. 比特币轻节点:SPV验证交易是否存在
七、总结与最佳实践
7.1 核心要点回顾
布隆过滤器防缓存穿透核心流程:
┌─────────────────────────────────────────────────────────────┐
│ 1. 系统启动预热 │
│ ├── 从数据库加载已有数据ID │
│ ├── 分批添加到布隆过滤器 │
│ └── 支持定时重建(解决删除问题) │
│ │
│ 2. 请求处理流程 │
│ ├── L1: 参数校验(拦截非法请求) │
│ ├── L2: 布隆过滤器(快速判断不存在) │
│ ├── L3: 查询Redis缓存 │
│ ├── L4: 缓存空值(防止重复穿透) │
│ └── L5: 互斥锁查DB(防止缓存击穿) │
│ │
│ 3. 数据变更同步 │
│ ├── 新增:同步添加到布隆过滤器 │
│ ├── 删除:定时重建或使用布谷鸟过滤器 │
│ └── 更新:不影响布隆过滤器 │
└─────────────────────────────────────────────────────────────┘
7.2 适用场景
| 场景 | 是否推荐 | 说明 |
|---|---|---|
| 用户系统 | ✅ 强烈推荐 | 防止恶意查询不存在用户 |
| 商品系统 | ✅ 强烈推荐 | 防止爬虫遍历商品ID |
| 订单系统 | ✅ 推荐 | 防止查询不存在订单 |
| 配置系统 | ⚠️ 可选 | 数据量小,可直接缓存 |
| 日志系统 | ✅ 推荐 | URL去重、日志去重 |
7.3 性能提升数据
某社交平台实测数据(遭受攻击时):
| 指标 | 无防护 | 布隆过滤器防护 | 提升 |
|---|---|---|---|
| 数据库QPS | 100,000 | 500 | 99.5%↓ |
| 接口RT | 2000ms | 20ms | 99%↓ |
| 内存占用 | - | 6MB | 极小 |
| 拦截率 | 0% | 99.9% | 大幅提升 |
八、参考与拓展
互动讨论:你在项目中遇到过缓存穿透问题吗?除了布隆过滤器还有什么解决方案?欢迎在评论区分享!
如果本文对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,持续获取更多Java后端技术干货!
更多推荐


所有评论(0)