前言
最近在做系统性能优化,发现一个可以优化的点:大量请求在查询不存在的用户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函数。
添加元素的过程:
查询元素的过程:
用代码表示就是:
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;
}
}
查询流程:
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 (内存)
问题:
- 每个实例都要加载全量数据,内存浪费
- 数据不同步,A添加的元素,B和C不知道
- 扩容时,新实例需要重新加载
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 架构对比
单机版:
分布式版:
分布式版的优势:
- 所有实例共享一个过滤器
- 数据实时同步
- 节省内存(只有一份)
五、开源项目中的使用
看看大厂和知名开源项目都在哪些地方用布隆过滤器,可以给我们一些启发。
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 核心要点
- 本质:用bit数组 + 多个Hash函数,实现空间高效的集合判断
- 特点:允许一定的误判,但节省大量内存
- 适用场景:海量数据去重、缓存穿透防护、黑名单判断
- 关键参数:预期元素数量、误判率
- 限制:不能删除、不能更新、有误判
8.2 决策框架
是否该用布隆过滤器?按这个流程判断:
markdown
1. 数据量是否 ≥ 百万级?
└─ 否 → 直接用 HashSet
└─ 是 → 继续
2. 能否接受少量误判(1-3%)?
└─ 否 → 考虑 BitMap 或 RoaringBitmap
└─ 是 → 继续
3. 主要用途是判断"不存在"?
└─ 是 → 布隆过滤器很合适
└─ 否 → 继续
4. 是否需要删除或更新元素?
└─ 是 → 考虑 Cuckoo Filter 或定期重建
└─ 否 → 布隆过滤器完美匹配
8.3 使用建议
什么时候用布隆过滤器:
- 数据量大(百万级以上)
- 允许一定的误判
- 主要用于判断"不存在"
- 对内存敏感
什么时候不用:
- 数据量小(直接用HashSet)
- 需要100%准确
- 需要删除或更新元素
- 需要持久化全部数据
8.4 最佳实践
- 预估准确:预期数量 × 1.5倍
- 定期重建:避免误判率上升
- 监控指标:实际数量、误判率、内存占用
- 分布式用Redis:避免数据不一致
- 降级方案:过滤器失效时的兜底策略
8.5 进阶方向
如果这些基础的布隆过滤器不能满足需求,可以研究:
- Counting Bloom Filter(支持删除)
- Scalable Bloom Filter(动态扩容)
- Cuckoo Filter(更低误判率,支持删除)
但对于大部分业务场景,Guava的基础实现已经足够了。
最后说一句: 布隆过滤器不是银弹,但它是在"空间、速度、准确性"三角中,一个极其优雅的折中。真正的工程智慧,不在于知道多少算法,而在于知道何时用哪个工具。
希望这篇文章能帮你避开90%工程师踩过的坑。