缓存被打穿?1000万数据只用12MB内存:布隆过滤器实战指南

前言

最近在做系统性能优化,发现一个可以优化的点:大量请求在查询不存在的用户ID,每次都要穿透Redis缓存去查MySQL。虽然有缓存空值的机制,但内存占用还是很大。

团队讨论时,有人提到用布隆过滤器来解决。我研究了一下,发现这个方案确实合适:1000万用户数据,HashSet要400MB内存,布隆过滤器只需要12MB,还能挡住99%的无效请求。

这篇文章记录了我从原理、参数调优到分布式实战的完整过程。无论你是第一次听说布隆过滤器,还是准备在生产环境用它,都能找到有价值的内容。

阅读建议:

  • 想快速解决问题?直接跳到「六、实际工作中的应用场景」
  • 想理解原理?精读「一、布隆过滤器的本质」
  • 准备上生产?重点看「七、使用注意事项」
  • 技术选型或面试?关注「八、总结」

一、布隆过滤器的本质

1.1 解决什么问题

我们先看一个实际场景。假设系统有1000万注册用户,现在需要快速判断一个用户ID是否存在。

常规方案对比:

方案1:HashSet

java 复制代码
Set<Long> userIds = new HashSet<>();
// 加载1000万用户ID
userIds.contains(userId);  // 判断是否存在

内存占用:

  • Long类型:8字节
  • HashSet额外开销:约32字节/元素
  • 总计:1000万 × 40字节 = 400MB

方案2:布隆过滤器

java 复制代码
BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(),
    10_000_000,  // 1000万
    0.01         // 1%误判率
);
filter.mightContain(userId);  // 判断是否存在

内存占用:

  • 约120MB

节省了70%的内存!

但布隆过滤器有个特点:它会有误判。具体来说:

  • 如果它说"不存在",那一定不存在
  • 如果它说"可能存在",那可能是误判

这个特性决定了它的使用场景。

1.2 工作原理

布隆过滤器的核心数据结构是一个很长的bit数组,加上若干个Hash函数。

添加元素的过程:

graph LR A[元素 hello] --> B[Hash1] A --> C[Hash2] A --> D[Hash3] B --> E[位置3 = 1] C --> F[位置7 = 1] D --> G[位置12 = 1] E --> H[bit数组] F --> H G --> H

查询元素的过程:

graph LR A[查询 hello] --> B[Hash1] A --> C[Hash2] A --> D[Hash3] B --> E{位置3是1?} C --> F{位置7是1?} D --> G{位置12是1?} E --> H{都是1?} F --> H G --> H H -->|是| I[可能存在] H -->|否| J[一定不存在]

用代码表示就是:

java 复制代码
// bit数组
boolean[] bits = new boolean[1000000];

// 添加元素
void add(String key) {
    bits[hash1(key) % bits.length] = true;
    bits[hash2(key) % bits.length] = true;
    bits[hash3(key) % bits.length] = true;
}

// 查询元素
boolean contains(String key) {
    return bits[hash1(key) % bits.length]
        && bits[hash2(key) % bits.length]
        && bits[hash3(key) % bits.length];
}

核心就这么简单:用多个Hash函数映射到bit数组的不同位置,打上标记。

1.3 为什么会误判

看一个例子:

css 复制代码
添加 "hello":
  hash1("hello") → 位置3 ✓
  hash2("hello") → 位置7 ✓
  hash3("hello") → 位置12 ✓

添加 "world":
  hash1("world") → 位置5 ✓
  hash2("world") → 位置3 ✓  (和hello冲突)
  hash3("world") → 位置9 ✓

此时bit数组: [0,0,0,1,0,1,0,1,0,1,0,0,1,0,0,0,...]
               位置:  3   5   7   9      12

查询 "java":
  hash1("java") → 位置3  (被hello占了)
  hash2("java") → 位置7  (被hello占了)
  hash3("java") → 位置9  (被world占了)
  
三个位置都是1,误判为存在!

随着插入的元素越来越多,bit数组中1的密度越来越高,误判的概率也会增加。这就是为什么需要合理设置参数。

但别担心这1%的误判:它只是让你多查一次数据库,而不是返回错误结果。对于防缓存穿透这种场景,已经挡住了99%的无效请求,完全够用。

二、参数的数学原理

2.1 关键参数

布隆过滤器有三个核心参数:

  • n: 预期插入的元素个数
  • p: 期望的误判率
  • m: bit数组的长度
  • k: Hash函数的个数

前两个是我们设置的,后两个是自动计算出来的。

2.2 参数怎么算出来的

你只需要告诉布隆过滤器两个数字,它就能自动算出需要多大的空间和多少个Hash函数。

你设置:

  • 预计存多少个元素(比如1000万)
  • 能接受多少误判率(比如1%)

它自动计算:

  • 需要多大的bit数组
  • 需要几个Hash函数

举个例子:

diff 复制代码
我说:要存1000万个用户ID,误判率控制在1%

布隆过滤器算出来:
- bit数组:需要12MB内存
- Hash函数:需要7个

具体的数学公式很复杂,但好在Guava已经帮我们封装好了,直接用就行:

java 复制代码
BloomFilter.create(
    Funnels.longFunnel(),
    10_000_000,  // 告诉它:1000万元素
    0.01         // 告诉它:1%误判率
);
// 剩下的它自己算

2.3 不同参数的对比

元素数量 误判率 内存占用 Hash函数数
100万 1% 1.2 MB 7
100万 0.1% 1.8 MB 10
1000万 1% 12 MB 7
1000万 0.1% 18 MB 10
1亿 1% 120 MB 7
1亿 0.1% 180 MB 10

经验法则: 1%误判率下,每个元素约需1.2字节,远低于HashSet的40+字节。省下的几百MB内存,可能就是你的服务能否塞进容器的关键。

从表格可以看出:

  • 误判率降低10倍,内存增加约50%
  • Hash函数个数主要受误判率影响
  • 元素数量增加10倍,内存也增加约10倍

2.4 参数选择建议

误判率的选择:

makefile 复制代码
高精度场景(金融、安全): 0.001 (0.1%)
常规业务场景: 0.01 (1%)
宽松场景(日志去重): 0.03 (3%)

元素数量的预估:

这个非常关键。如果预估不准确,会导致实际误判率远高于预期。

java 复制代码
// 错误示例:预估10万,实际插入100万
BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(),
    100_000,   // 预估太小
    0.01
);

for (int i = 0; i < 1_000_000; i++) {
    filter.put(i);  // 实际插入100万
}

// 此时误判率可能高达30%以上

正确的做法是留出一定余量:

java 复制代码
int currentCount = userMapper.count();
int expectedCount = (int) (currentCount * 1.5);  // 留50%余量

BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(),
    expectedCount,
    0.01
);

三、单机场景实战

3.1 Guava实现

Guava是最常用的布隆过滤器实现。

基本使用:

java 复制代码
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

public class UserBloomFilter {
    
    private BloomFilter<Long> filter;
    
    @PostConstruct
    public void init() {
        try {
            // 创建布隆过滤器
            filter = BloomFilter.create(
                Funnels.longFunnel(),  // 数据类型
                10_000_000,            // 预期元素数
                0.01                   // 误判率
            );
            
            // 从数据库加载所有用户ID
            List<Long> userIds = userMapper.getAllUserIds();
            userIds.forEach(filter::put);
            
            log.info("布隆过滤器初始化完成,加载{}个用户ID", userIds.size());
            
        } catch (Exception e) {
            log.error("布隆过滤器初始化失败,服务无法启动", e);
            throw new IllegalStateException("BloomFilter init failed", e);
        }
    }
    
    public boolean mightContain(Long userId) {
        return filter.mightContain(userId);
    }
    
    // 关键:注册新用户时,必须同步更新过滤器
    public void addUser(Long userId) {
        filter.put(userId);  // 否则会漏判,导致有效请求被拦截
    }
}

防止缓存穿透:

java 复制代码
@Service
public class UserService {
    
    @Autowired
    private UserBloomFilter bloomFilter;
    
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    public User getUser(Long userId) {
        // 第一层:布隆过滤器
        if (!bloomFilter.mightContain(userId)) {
            log.info("布隆过滤器判断用户不存在: {}", userId);
            return null;  // 一定不存在,直接返回
        }
        
        // 第二层:Redis缓存
        String cacheKey = "user:" + userId;
        User user = redisTemplate.opsForValue().get(cacheKey);
        if (user != null) {
            return user;
        }
        
        // 第三层:数据库
        user = userMapper.selectById(userId);
        if (user != null) {
            redisTemplate.opsForValue().set(cacheKey, user, 1, TimeUnit.HOURS);
        }
        
        return user;
    }
}

查询流程:

graph TD A[用户请求 userId] --> B{布隆过滤器} B -->|不存在| C[直接返回null] B -->|可能存在| D{Redis缓存} D -->|命中| E[返回用户信息] D -->|未命中| F[查询MySQL] F --> G[写入Redis] G --> E

3.2 实战案例:短链接去重

在生成随机短码时,需要检查是否已被占用。如果每次都查数据库,性能会很差。

java 复制代码
@Service
public class ShortUrlService {
    
    // 布隆过滤器
    private BloomFilter<String> keywordFilter;
    
    @PostConstruct
    public void init() {
        keywordFilter = BloomFilter.create(
            Funnels.stringFunnel(Charsets.UTF_8),
            100_000_000,  // 预期1亿短链
            0.01
        );
        
        // 加载已有的短码
        List<String> keywords = shortUrlMapper.getAllKeywords();
        keywords.forEach(keywordFilter::put);
    }
    
    public String generateUniqueKeyword() {
        String keyword;
        int retryCount = 0;
        
        do {
            // 生成6位随机码
            keyword = RandomStringUtils.randomAlphanumeric(6);
            
            // 布隆过滤器快速判断
            if (!keywordFilter.mightContain(keyword)) {
                // 一定不存在,可以直接使用
                keywordFilter.put(keyword);
                return keyword;
            }
            
            // 可能存在,查数据库确认
            if (!shortUrlMapper.exists(keyword)) {
                keywordFilter.put(keyword);
                return keyword;
            }
            
            retryCount++;
        } while (retryCount < 10);
        
        throw new RuntimeException("生成短码失败");
    }
}

这个方案的优势:

  • 对于不存在的短码,直接返回,不查数据库
  • 对于可能存在的短码,才去数据库确认
  • 大约能减少90%的数据库查询

3.3 定期重建

随着数据量的增长,布隆过滤器的误判率会上升。需要定期重建。

java 复制代码
@Component
public class BloomFilterRebuilder {
    
    @Autowired
    private UserService userService;
    
    private BloomFilter<Long> userIdFilter;
    
    @PostConstruct
    public void init() {
        rebuildFilter();
    }
    
    // 每天凌晨2点重建
    @Scheduled(cron = "0 0 2 * * ?")
    public void scheduleRebuild() {
        log.info("开始重建布隆过滤器");
        rebuildFilter();
        log.info("布隆过滤器重建完成");
    }
    
    private void rebuildFilter() {
        // 查询当前用户数
        long currentCount = userMapper.count();
        long expectedCount = (long) (currentCount * 1.5);
        
        // 创建新的过滤器
        BloomFilter<Long> newFilter = BloomFilter.create(
            Funnels.longFunnel(),
            expectedCount,
            0.01
        );
        
        // 分批加载用户ID
        int pageSize = 10000;
        int pageNum = 0;
        
        while (true) {
            List<Long> userIds = userMapper.selectUserIds(pageNum, pageSize);
            if (userIds.isEmpty()) {
                break;
            }
            
            userIds.forEach(newFilter::put);
            pageNum++;
        }
        
        // 原子替换
        this.userIdFilter = newFilter;
    }
    
    public boolean contains(Long userId) {
        return userIdFilter.mightContain(userId);
    }
}

四、分布式场景实战

4.1 单机布隆过滤器的问题

在分布式环境下,单机内存中的布隆过滤器有明显的问题:

java 复制代码
应用实例1: BloomFilter A (内存)
应用实例2: BloomFilter B (内存)
应用实例3: BloomFilter C (内存)

问题:

  1. 每个实例都要加载全量数据,内存浪费
  2. 数据不同步,A添加的元素,B和C不知道
  3. 扩容时,新实例需要重新加载

4.2 Redis实现

使用Redisson提供的分布式布隆过滤器。

添加依赖:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.0</version>
</dependency>

配置:

java 复制代码
@Configuration
public class RedissonConfig {
    
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
            .setAddress("redis://localhost:6379")
            .setDatabase(0);
        return Redisson.create(config);
    }
}

使用:

java 复制代码
@Service
public class DistributedUserService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    private RBloomFilter<Long> userIdFilter;
    
    @PostConstruct
    public void init() {
        // 获取布隆过滤器
        userIdFilter = redissonClient.getBloomFilter("user:id:filter");
        
        // 初始化(只需要执行一次)
        if (!userIdFilter.isExists()) {
            userIdFilter.tryInit(10_000_000, 0.01);
            
            // 加载用户ID
            List<Long> userIds = userMapper.getAllUserIds();
            userIds.forEach(userIdFilter::add);
            
            log.info("Redis布隆过滤器初始化完成");
        }
    }
    
    public User getUser(Long userId) {
        // 布隆过滤器判断
        if (!userIdFilter.contains(userId)) {
            return null;
        }
        
        // 查Redis缓存
        String cacheKey = "user:" + userId;
        User user = redisTemplate.opsForValue().get(cacheKey);
        if (user != null) {
            return user;
        }
        
        // 查数据库
        user = userMapper.selectById(userId);
        if (user != null) {
            redisTemplate.opsForValue().set(cacheKey, user, 1, TimeUnit.HOURS);
        }
        
        return user;
    }
    
    public void addUser(User user) {
        // 保存到数据库
        userMapper.insert(user);
        
        // 添加到布隆过滤器
        userIdFilter.add(user.getId());
    }
}

4.3 架构对比

单机版:

graph TD A[应用实例1] --> B[本地BloomFilter] C[应用实例2] --> D[本地BloomFilter] E[应用实例3] --> F[本地BloomFilter] B --> G[MySQL] D --> G F --> G

分布式版:

graph TD A[应用实例1] --> D[Redis BloomFilter] B[应用实例2] --> D C[应用实例3] --> D D --> E[MySQL]

分布式版的优势:

  • 所有实例共享一个过滤器
  • 数据实时同步
  • 节省内存(只有一份)

五、开源项目中的使用

看看大厂和知名开源项目都在哪些地方用布隆过滤器,可以给我们一些启发。

5.1 爬虫框架

Scrapy:Python爬虫框架用布隆过滤器做URL去重。

复制代码
已爬URL → 放入布隆过滤器
新URL → 先查布隆过滤器 → 判断是否爬过

这样就不用把几亿个URL都存在内存里了。

5.2 数据库存储引擎

RocksDB:在读SSTable文件前,先用布隆过滤器判断key是否存在。

markdown 复制代码
查询key → 布隆过滤器判断 
         → 不存在:直接返回,省一次磁盘IO
         → 可能存在:读文件确认

HBase:在表配置里可以开启布隆过滤器。

sql 复制代码
CREATE TABLE 'user' (
    'info' {BLOOMFILTER => 'ROW'}
)

避免查询不存在的行时还要扫描磁盘。

5.3 浏览器安全

Chrome浏览器:用布隆过滤器判断URL是否为恶意网站。

markdown 复制代码
本地存2MB的布隆过滤器(包含已知恶意网站)
访问网址 → 先查本地过滤器
         → 安全:直接访问
         → 可能危险:请求服务器确认

这样大部分URL都不用请求服务器,快很多。

5.4 消息队列

Kafka:某些场景下用布隆过滤器做消息去重。

RabbitMQ:插件支持布隆过滤器防止重复消费。


小结: 布隆过滤器在工业界主要用于:

  • 去重(爬虫、消息队列)
  • 加速查询(数据库、存储引擎)
  • 减少网络请求(浏览器安全)

这些场景的共同点是:数据量大、允许一定误判、对性能要求高。

六、实际工作中的应用场景

6.1 防止缓存穿透

这是最常见的场景。

场景描述: 系统有1000万注册用户,恶意攻击者不断请求不存在的用户ID(如:99999999),每次请求都会穿透Redis缓存,直接查询MySQL。

解决方案:

java 复制代码
@Service
public class UserService {
    
    private BloomFilter<Long> userIdFilter;
    
    @PostConstruct
    public void init() {
        // 初始化布隆过滤器
        userIdFilter = BloomFilter.create(
            Funnels.longFunnel(),
            10_000_000,
            0.01
        );
        
        // 加载所有用户ID
        userMapper.getAllUserIds().forEach(userIdFilter::put);
    }
    
    public User getUser(Long userId) {
        // 布隆过滤器挡在最前面
        if (!userIdFilter.mightContain(userId)) {
            log.info("拦截无效请求: {}", userId);
            return null;
        }
        
        // 查缓存和数据库
        return getUserFromCacheOrDb(userId);
    }
    
    // 新用户注册时,加入过滤器
    public void register(User user) {
        userMapper.insert(user);
        userIdFilter.put(user.getId());
    }
}

效果:

  • 无效请求直接被拦截,不会打到数据库
  • QPS从500提升到5000
  • MySQL负载下降80%

6.2 爬虫URL去重

场景描述: 开发一个网页爬虫,需要记录已经爬取过的URL,避免重复爬取。如果用HashSet存储1亿个URL,需要占用约4GB内存。

解决方案:

java 复制代码
@Component
public class UrlDuplicateChecker {
    
    private BloomFilter<String> urlFilter;
    
    @PostConstruct
    public void init() {
        urlFilter = BloomFilter.create(
            Funnels.stringFunnel(Charsets.UTF_8),
            100_000_000,  // 1亿URL
            0.03          // 3%误判率(爬虫场景可接受)
        );
    }
    
    public boolean isDuplicate(String url) {
        if (urlFilter.mightContain(url)) {
            // 可能重复,查数据库确认
            return urlRepository.exists(url);
        }
        
        // 一定没爬过
        return false;
    }
    
    public void markAsCrawled(String url) {
        urlFilter.put(url);
        urlRepository.save(url);
    }
}

效果:

  • 内存占用从4GB降低到360MB
  • 90%的重复URL在布隆过滤器阶段就被识别

6.3 实时推荐去重

场景描述: 给用户推荐内容时,需要过滤掉已经推荐过的内容。用户的历史记录可能有上万条。

解决方案:

java 复制代码
@Service
public class RecommendService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    public List<Content> recommend(Long userId) {
        // 获取用户的布隆过滤器
        String filterKey = "recommend:history:" + userId;
        RBloomFilter<Long> historyFilter = 
            redissonClient.getBloomFilter(filterKey);
        
        if (!historyFilter.isExists()) {
            historyFilter.tryInit(50_000, 0.01);
            
            // 加载历史记录
            List<Long> history = recommendMapper.getHistory(userId);
            history.forEach(historyFilter::add);
        }
        
        // 获取候选内容
        List<Content> candidates = getTopCandidates(userId, 100);
        
        // 过滤已推荐的
        List<Content> filtered = candidates.stream()
            .filter(c -> !historyFilter.contains(c.getId()))
            .limit(20)
            .collect(Collectors.toList());
        
        // 记录本次推荐
        filtered.forEach(c -> historyFilter.add(c.getId()));
        
        return filtered;
    }
}

6.4 分布式日志去重

场景描述: 微服务架构下,同一个错误可能在多个实例上大量出现。需要对日志进行去重,避免告警轰炸。

解决方案:

java 复制代码
@Component
public class LogDeduplicator {
    
    @Autowired
    private RedissonClient redissonClient;
    
    private RBloomFilter<String> logFilter;
    
    @PostConstruct
    public void init() {
        logFilter = redissonClient.getBloomFilter("log:dedup");
        if (!logFilter.isExists()) {
            logFilter.tryInit(1_000_000, 0.01);
        }
        
        // 每小时重置一次
        Executors.newSingleThreadScheduledExecutor()
            .scheduleAtFixedRate(this::resetFilter, 1, 1, TimeUnit.HOURS);
    }
    
    public void logError(String message, Throwable t) {
        String logKey = message + ":" + t.getStackTrace()[0].toString();
        
        if (logFilter.contains(logKey)) {
            // 已经记录过,不重复告警
            return;
        }
        
        logFilter.add(logKey);
        
        // 发送告警
        alertService.sendAlert(message, t);
        
        // 记录日志
        log.error(message, t);
    }
    
    private void resetFilter() {
        logFilter.delete();
        logFilter.tryInit(1_000_000, 0.01);
    }
}

6.5 黑名单快速判断

场景描述: API网关需要拦截黑名单IP,黑名单有100万个IP地址。

解决方案:

java 复制代码
@Component
public class IpBlacklistFilter implements Filter {
    
    @Autowired
    private RedissonClient redissonClient;
    
    private RBloomFilter<String> blacklistFilter;
    
    @PostConstruct
    public void init() {
        blacklistFilter = redissonClient.getBloomFilter("ip:blacklist");
        
        if (!blacklistFilter.isExists()) {
            blacklistFilter.tryInit(1_000_000, 0.001);  // 更严格
            
            // 加载黑名单
            List<String> blacklist = blacklistMapper.getAll();
            blacklist.forEach(blacklistFilter::add);
        }
    }
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) {
        String ip = getClientIp(request);
        
        // 布隆过滤器判断
        if (blacklistFilter.contains(ip)) {
            // 可能在黑名单,数据库确认
            if (blacklistMapper.exists(ip)) {
                response.sendError(403, "IP被封禁");
                return;
            }
        }
        
        chain.doFilter(request, response);
    }
}

七、使用注意事项

7.1 不能删除元素

这是布隆过滤器最大的限制。

问题场景:

java 复制代码
// 用户注销账号
public void deleteUser(Long userId) {
    userMapper.delete(userId);
    
    // 想从布隆过滤器中删除
    userIdFilter.remove(userId);  // 没有这个方法!
}

为什么不能删除?

arduino 复制代码
假设 "user1" 和 "user2" 都映射到位置3

bit[3] = 1  (由user1和user2共同标记)

删除 user1 时,把 bit[3] 设为0
那么 user2 也查不到了!

解决方案:

方案1:定期重建

java 复制代码
@Scheduled(cron = "0 0 2 * * ?")
public void rebuild() {
    // 每天凌晨重建过滤器
    rebuildBloomFilter();
}

方案2:使用Counting Bloom Filter

java 复制代码
// 每个位置不是0/1,而是计数器
int[] counters = new int[size];

void add(key) {
    counters[hash1(key)]++;
    counters[hash2(key)]++;
}

void remove(key) {
    counters[hash1(key)]--;
    counters[hash2(key)]--;
}

缺点是内存占用增加4倍(int vs bit)。

方案3:设置过期时间

java 复制代码
// Redis版本,设置整体过期
RBloomFilter<String> filter = redisson.getBloomFilter("key");
filter.expire(1, TimeUnit.DAYS);

7.2 不能更新元素

java 复制代码
// 错误示例
public void updateUser(User user) {
    userMapper.update(user);
    
    // 想更新过滤器
    userIdFilter.remove(oldUserId);  // 不支持
    userIdFilter.add(newUserId);
}

布隆过滤器只支持两个操作:

  • add(): 添加元素
  • contains(): 查询元素

没有update()和remove()方法。

7.3 只能判断"一定不存在"

java 复制代码
// 错误理解
if (bloomFilter.contains(key)) {
    // 以为key一定存在 ← 错误!
    return db.get(key);
}

// 正确理解
if (!bloomFilter.contains(key)) {
    // key一定不存在 ← 正确
    return null;
}

if (bloomFilter.contains(key)) {
    // key可能存在,需要再确认
    User user = db.get(key);
    return user;
}

7.4 预估必须准确

java 复制代码
// 场景:预估100万,实际插入1000万
BloomFilter<Long> filter = BloomFilter.create(
    Funnels.longFunnel(),
    1_000_000,  // 预估
    0.01
);

for (int i = 0; i < 10_000_000; i++) {
    filter.put(i);  // 实际
}

// 测试误判率
int falsePositive = 0;
for (int i = 10_000_000; i < 10_010_000; i++) {
    if (filter.mightContain(i)) {
        falsePositive++;
    }
}

System.out.println("误判率: " + falsePositive / 10000.0);
// 输出:误判率: 0.35 (35%!)

实际误判率远超预期的1%。

解决方案:

java 复制代码
// 方案1:预估时留余量
int expected = currentCount * 2;

// 方案2:监控并重建
if (actualCount > expectedCount * 0.8) {
    rebuildWithLargerCapacity();
}

// 方案3:分片
BloomFilter[] filters = new BloomFilter[10];
int shardIndex = userId % 10;
filters[shardIndex].add(userId);

7.5 分布式场景的同步问题

单机版布隆过滤器在分布式环境下存在数据不一致。

css 复制代码
实例A添加了user1
实例B不知道
实例B查询user1,返回"不存在"

必须使用Redis等分布式实现。

7.6 序列化和持久化

Guava的布隆过滤器支持序列化:

java 复制代码
// 序列化到文件
BloomFilter<Long> filter = createFilter();
try (FileOutputStream fos = new FileOutputStream("bloom.dat")) {
    filter.writeTo(fos);
}

// 从文件读取
try (FileInputStream fis = new FileInputStream("bloom.dat")) {
    BloomFilter<Long> filter = BloomFilter.readFrom(fis, Funnels.longFunnel());
}

这在以下场景有用:

  • 应用重启后快速恢复
  • 定期备份
  • 跨应用共享

7.7 性能考虑

Hash函数的个数影响性能:

java 复制代码
// 误判率0.01,需要7个Hash
// 每次查询要计算7次Hash

// 误判率0.001,需要10个Hash
// 每次查询要计算10次Hash

在高并发场景下,需要权衡:

  • 更低的误判率 vs 更高的CPU消耗

建议:

java 复制代码
// 常规场景
误判率 = 0.01, Hash数 = 7

// 高并发场景
误判率 = 0.03, Hash数 = 5

// 高精度场景
误判率 = 0.001, Hash数 = 10

7.8 常见反模式

这些错误在生产环境中经常出现,需要特别注意:

反模式1:把布隆过滤器当精确集合用

java 复制代码
// 错误:判断用户是否在线
if (onlineUsersFilter.contains(userId)) {
    return "在线";  // 可能误判!
} else {
    return "离线";  // 这个是准的
}

// 布隆过滤器只能确定"一定不在",不能确定"一定在"

反模式2:在频繁增删的场景使用

java 复制代码
// 错误:购物车去重
cartFilter.add(productId);      // 加入购物车
cartFilter.remove(productId);   // 不支持删除!

// 购物车这种频繁增删的场景,应该用Redis Set

反模式3:忽略冷启动问题

java 复制代码
// 错误:服务重启后过滤器为空
@PostConstruct
public void init() {
    filter = BloomFilter.create(...);
    // 忘记加载历史数据
}

// 结果:服务重启后,所有请求都判断为"不存在",瞬间穿透DB

反模式4:分布式环境各自维护

java 复制代码
// 错误:每个实例维护本地过滤器
// 实例A添加了user1
bloomFilter.add(1L);

// 实例B不知道,还是判断不存在
// 导致数据不一致

正确做法:使用Redis分布式版本。

八、总结

8.1 核心要点

  1. 本质:用bit数组 + 多个Hash函数,实现空间高效的集合判断
  2. 特点:允许一定的误判,但节省大量内存
  3. 适用场景:海量数据去重、缓存穿透防护、黑名单判断
  4. 关键参数:预期元素数量、误判率
  5. 限制:不能删除、不能更新、有误判

8.2 决策框架

是否该用布隆过滤器?按这个流程判断:

markdown 复制代码
1. 数据量是否 ≥ 百万级?
   └─ 否 → 直接用 HashSet
   └─ 是 → 继续

2. 能否接受少量误判(1-3%)?
   └─ 否 → 考虑 BitMap 或 RoaringBitmap
   └─ 是 → 继续

3. 主要用途是判断"不存在"?
   └─ 是 → 布隆过滤器很合适
   └─ 否 → 继续

4. 是否需要删除或更新元素?
   └─ 是 → 考虑 Cuckoo Filter 或定期重建
   └─ 否 → 布隆过滤器完美匹配

8.3 使用建议

什么时候用布隆过滤器:

  • 数据量大(百万级以上)
  • 允许一定的误判
  • 主要用于判断"不存在"
  • 对内存敏感

什么时候不用:

  • 数据量小(直接用HashSet)
  • 需要100%准确
  • 需要删除或更新元素
  • 需要持久化全部数据

8.4 最佳实践

  1. 预估准确:预期数量 × 1.5倍
  2. 定期重建:避免误判率上升
  3. 监控指标:实际数量、误判率、内存占用
  4. 分布式用Redis:避免数据不一致
  5. 降级方案:过滤器失效时的兜底策略

8.5 进阶方向

如果这些基础的布隆过滤器不能满足需求,可以研究:

  • Counting Bloom Filter(支持删除)
  • Scalable Bloom Filter(动态扩容)
  • Cuckoo Filter(更低误判率,支持删除)

但对于大部分业务场景,Guava的基础实现已经足够了。


最后说一句: 布隆过滤器不是银弹,但它是在"空间、速度、准确性"三角中,一个极其优雅的折中。真正的工程智慧,不在于知道多少算法,而在于知道何时用哪个工具。

希望这篇文章能帮你避开90%工程师踩过的坑。

相关推荐
To Be Clean Coder12 小时前
【Spring源码】getBean源码实战(二)
java·后端·spring
程序员爱钓鱼13 小时前
Node.js 编程实战:RESTful API 设计
前端·后端·node.js
程序员爱钓鱼13 小时前
Node.js 编程实战:GraphQL 简介与实战
前端·后端·node.js
山上春13 小时前
Odoo 分布式单体与微服务模式深度对比研究报告
分布式·微服务·架构
千寻girling13 小时前
面试官 : “ 说一下 Map 和 WeakMap 的区别 ? ”
前端·javascript·面试
降临-max13 小时前
JavaWeb企业级开发---MySQL
java·开发语言·数据库·笔记·后端·mysql
C雨后彩虹13 小时前
二维伞的雨滴效应
java·数据结构·算法·华为·面试
思成Codes13 小时前
Golang并发编程——CSP模型
开发语言·后端·golang
郑泰科技13 小时前
SpringBoot项目实践:之前war部署到服务器好用,重新打包部署到服务器报404
服务器·spring boot·后端
IT_陈寒14 小时前
Vite 5 实战:7个鲜为人知的配置技巧让构建速度提升200%
前端·人工智能·后端