黑客用脚本每秒查询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后端技术干货!

更多推荐