Redis 系列 12. 布隆过滤器原理与 Redis 落地


1. 布隆过滤器解决什么问题

缓存穿透的典型场景是:请求的 key 在 Redis 没有,在 MySQL 也没有。攻击者如果不断请求不存在的 ID,请求就会绕过缓存打到数据库。

布隆过滤器可以在请求进入缓存和数据库之前先做一次判断:

请求 userId
  -> 布隆过滤器判断:一定不存在
  -> 直接返回空结果,避免访问 Redis 和 MySQL

布隆过滤器的核心特性是:

布隆过滤器判断不存在,那就一定不存在;判断存在,只能说明可能存在。

项目 说明
目的 用较低内存成本拦截明显不存在的数据
方式 不保存完整数据,只用 bit 标记和多次 hash 判断元素是否可能存在

布隆过滤器初始状态

2. 原理拆开看

布隆过滤器由一个 bit 数组和多个 hash 函数组成。bit array 本质上是一段连续 bit 空间,用来记录某些 hash 位置是否被置为 1。

2.1 写入过程

写入一个元素时:

  1. 对元素做多次 hash。
  2. 得到多个 bit 下标。
  3. 把这些位置都设置为 1。

2.2 判断过程

判断一个元素是否存在时:

  1. 同样做多次 hash。
  2. 检查这些 bit 是否都是 1。
  3. 只要有一个 bit 是 0,就说明这个元素一定没加入过。
  4. 如果全是 1,只能说明可能加入过,因为多个元素可能 hash 到同一批位置。

误判的核心来源就是 hash 冲突。多个不同元素可能把同一批 bit 置为 1,导致一个从未写入的元素在查询时也命中所有 bit。

实际分析误判率时,通常假设多个 hash 函数近似独立。工程实现里可以用多个 seed、双重 hash 或成熟库来逼近这个假设。

布隆过滤器原理

3. 优缺点

项目 说明
优点 空间效率高,判断速度快
缺点 有误判率,普通布隆过滤器不支持删除
适合 缓存穿透防护、黑名单预判、已读判重
不适合 需要 100% 精确存在判断、需要频繁删除的场景

误判是指:一个实际不存在的元素,被判断为可能存在。误判会导致请求继续查 Redis 或 MySQL,但不会把真实存在的数据误判成不存在。

这个误判也叫 false positive。布隆过滤器不会产生 false negative:只要它判断不存在,就可以认为一定不存在,前提是写入链路没有漏写。

普通布隆过滤器不能删除,是因为多个元素可能共享同一个 bit。把某个 bit 改回 0,可能会影响其他元素。需要删除能力时,可以考虑 Counting Bloom Filter 或换数据结构。

4. Redis 怎么实现布隆过滤器

有两种方式:

方式 说明
RedisBloom 模块 官方生态模块,提供 BF.ADD、BF.EXISTS 等命令
手写 Bitmap 用 Redis String 的 SETBIT、GETBIT 自己实现

如果生产环境允许加载模块,RedisBloom 更完整,底层仍然是 bitset 加概率数据结构,只是它把容量、误判率、扩容等细节封装成更可靠的命令。这里用 Bitmap 手写一个简化版,便于理解底层原理。

5. 用 SETBIT 写入

写入元素:

setbit bloom:user 12345 1
setbit bloom:user 67890 1
setbit bloom:user 13579 1

判断元素:

getbit bloom:user 12345
getbit bloom:user 67890
getbit bloom:user 13579

真实代码里不能只有一个 hash,否则误判率会很高。应该用多个 hash 函数算出多个 offset。单 hash 更接近简单 Bitmap 标记,冲突一多就会快速失去过滤价值。

6. Java 简化实现

以下是一个便于理解的简化版。生产环境要使用更稳定的 hash 算法,并根据预估数据量和误判率计算 bit 数组大小。

public class RedisBloomFilter {

    private static final String KEY = "bf:user";
    private static final long SIZE = 1L << 28;
    private static final int[] SEEDS = {3, 7, 11, 13, 31, 37};

    private final StringRedisTemplate redisTemplate;

    public RedisBloomFilter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void add(String value) {
        for (int seed : SEEDS) {
            long offset = hash(value, seed);
            redisTemplate.opsForValue().setBit(KEY, offset, true);
        }
    }

    public boolean mightContain(String value) {
        for (int seed : SEEDS) {
            long offset = hash(value, seed);
            Boolean bit = redisTemplate.opsForValue().getBit(KEY, offset);
            if (!Boolean.TRUE.equals(bit)) {
                return false;
            }
        }
        return true;
    }

    private long hash(String value, int seed) {
        long result = 0;
        for (int i = 0; i < value.length(); i++) {
            result = seed * result + value.charAt(i);
        }
        return (SIZE - 1) & result;
    }
}

这里的 hash 只是演示用的非 cryptographic hash,不适合安全场景,也不适合直接用于对抗恶意输入。生产环境应使用成熟实现,例如 RedisBloom、Guava BloomFilter,或经过验证的 hash 方案。

这里的 SIZE 必须是业务评估后的值。数据量越大、bit 数组越小、hash 函数越少,误判率越高。

误判率可以用下面的近似公式理解:

p ≈ (1 - e^(-kn/m))^k

其中:

参数 含义
m bit array 长度
n 预计写入元素数量
k hash 函数数量
p 期望误判率

在给定 n 和 p 后,可以反推需要的 bit 数和 hash 函数数量。常见近似公式是:

m = -n * ln(p) / (ln2)^2
k = (m / n) * ln2

所以 SIZE = 1L << 28 和 SEEDS 数量都不应该拍脑袋设置。SEEDS 越多不一定越好,hash 次数太多会增加 Redis 往返和 CPU 成本;太少又会提高误判率。

7. 启动时初始化

可以在应用启动时把数据库已有 ID 加载进布隆过滤器:

@Component
public class UserBloomInitializer {

    private final UserMapper userMapper;
    private final RedisBloomFilter bloomFilter;

    public UserBloomInitializer(UserMapper userMapper, RedisBloomFilter bloomFilter) {
        this.userMapper = userMapper;
        this.bloomFilter = bloomFilter;
    }

    @PostConstruct
    public void init() {
        List<Long> ids = userMapper.selectAllIds();
        for (Long id : ids) {
            bloomFilter.add(String.valueOf(id));
        }
    }
}

如果数据量很大,不建议每个应用实例启动都全量加载。全量加载会带来 cold start delay,也可能在发布时集中打爆 MySQL 和 Redis。可以由单独任务构建布隆过滤器,业务实例只使用。

如果通过 MQ 或定时任务重建布隆过滤器,本质上属于 eventual consistency rebuild。重建期间要考虑新老过滤器切换、增量数据补偿和失败回滚。

8. 查询链路怎么接

查询用户详情:

public UserDTO getUser(Long userId) {
    String id = String.valueOf(userId);
    if (!bloomFilter.mightContain(id)) {
        return null;
    }

    String key = "user:" + userId;
    UserDTO cache = redisCache.get(key);
    if (cache != null) {
        return cache;
    }

    UserDO user = userMapper.selectById(userId);
    if (user == null) {
        return null;
    }

    UserDTO dto = convert(user);
    redisCache.set(key, dto, 10, TimeUnit.MINUTES);
    return dto;
}

如果布隆过滤器判断不存在,直接返回空结果。判断可能存在,再走 Redis 和 MySQL。

这条链路解决的是 cache penetration pattern,也就是大量不存在 key 穿透缓存打到数据库。mightContain 不能作为强一致判断依据,它只适合做访问前置过滤。

9. 新增数据时怎么办

新增用户成功后,要同步加入布隆过滤器:

@Transactional(rollbackFor = Exception.class)
public Long createUser(CreateUserCommand command) {
    UserDO user = command.toEntity();
    userMapper.insert(user);

    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            bloomFilter.add(String.valueOf(user.getId()));
        }
    });

    return user.getId();
}

不要在事务提交前写布隆过滤器。否则数据库事务回滚后,布隆过滤器里可能已经留下这个 ID,后续查询会多打一次 Redis 或 MySQL。

这里仍然存在一个小窗口:数据库提交成功,但布隆过滤器写入失败。严格一点可以把新增 ID 投递到 MQ,由消费者重试写入布隆过滤器。

MQ 消费者要保证幂等。同一个 ID 重复加入布隆过滤器没有副作用,因此可以安全重试。真正要避免的是 DB 已提交但消息丢失、消费者长时间失败,导致新数据被过滤器误判为不存在。

10. 和缓存空对象的区别

缓存空对象也是解决缓存穿透的常用方法:

set user:404 "__NULL__" ex 60

对比:

方案 优点 缺点
缓存空对象 简单,能挡住重复请求 大量随机 key 会污染 Redis
布隆过滤器 空间效率高,适合海量 ID 判断 有误判率,普通版本不支持删除

缓存空对象的风险是 key explosion:如果攻击者持续构造随机 ID,即使每个空对象 TTL 很短,也可能在短时间制造大量空 key。

布隆过滤器更适合 large-scale key filtering。实际项目可以组合使用:布隆过滤器挡住明显不存在的 ID,少量漏进来的不存在请求再短 TTL 缓存空对象。

11. 扩容和删除能力

普通布隆过滤器不容易动态扩容。容量设计过小后,bit array 会越来越满,误判率持续升高。要解决这个问题,可以使用 Scalable Bloom Filter,或者重新构建一个更大的过滤器并做平滑切换。

普通布隆过滤器也不支持安全删除。如果业务必须删除元素,可以考虑 Counting Bloom Filter,用计数器替代单个 bit。但它会显著增加内存成本,也要处理计数溢出和并发更新问题。

12. 本文小结

布隆过滤器可以用三层模型理解:

层次 作用
Bloom Filter probabilistic membership structure,判断元素是否可能存在
Redis storage backend,保存 bitset 或 RedisBloom 结构
Hash mapping layer,把元素映射到多个 bit offset

它适合做缓存穿透防护和海量 ID 前置过滤,但不适合强一致存在判断、审计、计费、频繁删除和需要动态扩容的场景。工程落地时,核心不是只会写 SETBIT,而是先明确容量、误判率、初始化、增量补偿和重建方案。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录