详解布隆过滤器及其实战案例

简介: 布隆过滤器是一种基于位数组和哈希函数的高效数据结构,主要用于判断元素是否存在。其核心原理是通过多个哈希函数将元素映射到位数组的不同位置,查询时若所有对应位均为1则可能存在(可能存在误判),否则一定不存在。布隆过滤器具有空间效率高、查询速度快的特点,但无法删除元素且误判率随元素增加而上升。典型应用场景包括缓存穿透防护、黑名单过滤、URL去重等。文章还介绍了Guava和Redis两种实现方式,并给出了一个点赞系统的优化案例,展示了布隆过滤器在实际业务中的应用价值。

 1.算法解析

1.1 算法概念

布隆过滤器可以看作是位数组和多个哈希函数共同组成的数据结构,相较于我们平时使用的List,Map,Set等数据结构,它的占用空间更小,查找效率更高。但是因为哈希函数的使用,其映射过程可能存在哈希冲突,导致查询结果并不准确,理论上,集合中的元素越多,误报的可能性就越大。并且布隆过滤器的元素不可被删除。

布隆过滤器会用一个比较大的bit数组来存所有的数据,数组中的每个元素大小为1bit,每个元素只能代表0或1,因此,布隆过滤器能极大地节省内存。这样算来,存100万个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。

总结:一个名叫 Bloom 的人提出了一种来检索元素是否在给定大集合中的数据结构,这种数据结构是高效且性能很好的,但缺点是具有一定的错误识别率和删除难度。并且,理论情况下,添加到集合中的元素越多,误报的可能性就越大。

1.2 算法原理

当一个元素加入布隆过滤器中的时候,会进行如下操作:

  1. 使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
  2. 根据得到的哈希值,在位数组中把对应下标的值置为 1。

当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:

  1. 对给定元素再次进行相同的哈希计算;
  2. 得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

Bloom Filter 的简单原理图如下:

image.gif 编辑

如图所示,当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。

如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。

综上,我们可以得出:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

1.3 算法应用

  1. 判断给定数据是否存在:比如判断一个数字是否存在于包含大量数字的数字集中(数字集很大,上亿)、 防止缓存穿透(判断请求的数据是否有效避免直接绕过缓存请求数据库)等等、邮箱的垃圾邮件过滤(判断一个邮件地址是否在垃圾邮件列表中)、黑名单功能(判断一个 IP 地址或手机号码是否在黑名单中)等等。
  2. 去重:比如爬给定网址的时候对已经爬取过的 URL 去重、对巨量的 QQ 号/订单号去重。

去重场景也需要用到判断给定数据是否存在,因此布隆过滤器主要是为了解决海量数据的存在性问题。

2.GuavaCache中的BloomFilter

自己实现的目的主要是为了让自己搞懂布隆过滤器的原理,Guava 中布隆过滤器的实现算是比较权威的,所以实际项目中我们不需要手动实现一个布隆过滤器。

首先我们需要在项目中引入 Guava 的依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.0-jre</version>
</dependency>

image.gif

实际使用如下:

我们创建了一个最多存放 最多 1500 个整数的布隆过滤器,并且我们可以容忍误判的概率为百分之(0.01)

// 创建布隆过滤器对象
BloomFilter<Integer> filter = BloomFilter.create(
    Funnels.integerFunnel(),
    1500,
    0.01);
// 判断指定元素是否存在
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));
// 将元素添加进布隆过滤器
filter.put(1);
filter.put(2);
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));

image.gif

在我们的示例中,当 mightContain() 方法返回 true 时,我们可以 99%确定该元素在过滤器中,当过滤器返回 false 时,我们可以 100%确定该元素不存在于过滤器中。

Guava 提供的布隆过滤器的实现还是很不错的(想要详细了解的可以看一下它的源码实现),但是它有一个重大的缺陷就是只能单机使用(另外,容量扩展也不容易),而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 Redis 中的布隆过滤器了。

3.基于Redis的BitMap整合BloomFilter

package com.lgh.web.manager.bloomFilter;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.List;
/**
 * 布隆过滤器帮助类,用于生成哈希值和判断元素是否存在于布隆过滤器中
 * @Author GuihaoLv
 */
@Component
public class BloomFilterHelper<T> {
    // 默认误判率:1%,是平衡空间和误判率的常用值
    private static final double DEFAULT_FPP = 0.01;
    // 默认预计数据量:100万条,适配初期业务规模
    private static final long DEFAULT_EXPECTED_NUM = 1_000_000L;
    // 哈希函数的种子集合:多个质数,保证哈希值分布均匀,减少冲突
    private static final List<Integer> SEEDS = Arrays.asList(3, 5, 7, 11, 13, 31, 37, 61);
    private final int numHashFunctions;//哈希函数个数
    private final long bitSize;//位图大小
    //无参构造 :默认预计数据量100万条,误判率1%
    public BloomFilterHelper() {
        this(DEFAULT_EXPECTED_NUM, DEFAULT_FPP);
    }
    // 有参构造:自定义预计数据量和误判率,计算核心参数
    public BloomFilterHelper(long expectedNum, double fpp) {
        // 参数校验:避免非法输入导致公式计算出错
        Assert.isTrue(expectedNum > 0, "预计数据量必须大于0");
        Assert.isTrue(fpp > 0 && fpp < 1, "误判率必须在(0,1)之间");
        // 计算位图大小
        this.bitSize = optimalNumOfBits(expectedNum, fpp);
        // 计算哈希函数个数
        this.numHashFunctions = optimalNumOfHashFunctions(expectedNum, this.bitSize);
    }
    //位图大小计算
    private long optimalNumOfBits(long n, double p) {
        if (p == 0) {
            p = Double.MIN_VALUE; // 避免除以0
        }
        // 核心公式:m = -n * ln(p) / (ln(2))²
        return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
    }
    //哈希函数个数计算
    private int optimalNumOfHashFunctions(long n, long m) {
        // 核心公式:k = (m/n) * ln(2),取最大值至少为1
        return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
    }
    //添加元素
    public void add(StringRedisTemplate redisTemplate, String key, T value) {
        // 参数校验:避免空值导致哈希计算出错
        Assert.notNull(value, "元素不能为null");
        // 1. 计算元素的所有哈希偏移量
        long[] offsets = getOffsets(value.toString());
        // 2. 遍历所有偏移量,将Redis Bitmap对应位置置为1
        for (long offset : offsets) {
            redisTemplate.opsForValue().setBit(key, offset, true);
        }
    }
    // 判断元素是否存在
    public boolean contains(StringRedisTemplate redisTemplate, String key, T value) {
        if (value == null) {
            return false;
        }
        // 1. 计算元素的所有哈希偏移量
        long[] offsets = getOffsets(value.toString());
        // 2. 遍历所有偏移量:只要有一个位置为0,元素一定不存在
        for (long offset : offsets) {
            if (!redisTemplate.opsForValue().getBit(key, offset)) {
                return false;
            }
        }
        // 3. 所有位置都为1,元素可能存在
        return true;
    }
    // 计算所有哈希偏移量
    private long[] getOffsets(String value) {
        // 初始化偏移量数组,长度=最优哈希函数个数
        long[] offsets = new long[numHashFunctions];
        for (int i = 0; i < numHashFunctions; i++) {
            // 循环取种子(避免种子数量不足)
            int seed = SEEDS.get(i % SEEDS.size());
            // 计算哈希值,并对bitmap大小取模,得到偏移量
            offsets[i] = hash(value, seed) % bitSize;
        }
        return offsets;
    }
    //单个哈希函数实现
    private long hash(String value, int seed) {
        try {
            // 使用MD5哈希算法(稳定性高、分布均匀)
            MessageDigest md = MessageDigest.getInstance("MD5");
            // 种子+元素拼接后计算哈希:保证不同种子生成不同哈希值
            md.update((seed + value).getBytes(StandardCharsets.UTF_8));
            byte[] digest = md.digest();
            // 将MD5的16字节结果转为长整型(取前8字节)
            long hash = 0;
            for (int i = 0; i < 8; i++) {
                hash |= ((long) (digest[i] & 0xFF)) << (i * 8);
            }
            // 取绝对值:避免负偏移量
            return Math.abs(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("MD5哈希算法不可用", e);
        }
    }
}

image.gif

4.BloomFilter的一个实战优化案例

4.1 问题背景

设计一个点赞操作的架构,关键在于判断是否赞过,执行点赞/取消点赞操作以及统计点赞数量。

原始方案:

/**
     * 判断是否点赞
     * @param blogId
     * @return
     */
    public Boolean isLiked(Long blogId) {
        Long userId=UserUtil.getUserId();
        Boolean flag=blogMapper.isLiked(userId,blogId);
        return flag;
    }
     /**
     * 点赞或取消点赞
     * @param blogId
     * @return
     */
    @Transactional
    public Boolean like(Long blogId) {
        Long userId=UserUtil.getUserId();
        Boolean flag=blogMapper.isLiked(userId,blogId);
        Boolean result1=false;
        Boolean result2=false;
        if(flag){
            //取消点赞
           result1=blogMapper.unlike(blogId);
           result2=blogMapper.deleteLike(blogId,userId);
        } else {
            //点赞
           result1=blogMapper.like(blogId);
           result2=blogMapper.addLike(blogId,userId);
        }
        return result1&&result2;
    }

image.gif

4.2 优化思路

image.gif 编辑

4.3 代码实现

/**
     * 判断是否点赞(核心:Redis优先)
     * @param blogId
     * @return
     */
    public Boolean isLiked(Long blogId) {
        Long userId = UserUtil.getUserId();
        String likeBloomKey = userId + ":" + blogId;
        // 1. 布隆过滤器第一层过滤:一定不存在直接返回false
        if (!bloomFilterHelper.contains(stringRedisTemplate, RedisKeyConstant.BLOOM_FILTER_LIKE_KEY, likeBloomKey)) {
            return false;
        }
        // 2. Redis Set第二层过滤:存在则返回true
        String likeSetKey = String.format(RedisKeyConstant.BLOG_LIKE_SET_KEY, blogId);
        Boolean isMember = stringRedisTemplate.opsForSet().isMember(likeSetKey, userId.toString());
        if (Boolean.TRUE.equals(isMember)) {
            return true;
        }
        // 3. MySQL兜底查询(极少走到这一步)
        Boolean flag = blogMapper.isLiked(userId, blogId);
        // 补偿写入Redis:避免下次再查MySQL
        if (Boolean.TRUE.equals(flag)) {
            stringRedisTemplate.opsForSet().add(likeSetKey, userId.toString());
            bloomFilterHelper.add(stringRedisTemplate, RedisKeyConstant.BLOOM_FILTER_LIKE_KEY, likeBloomKey);
        }
        return flag;
    }
    // ====================== 点赞/取消点赞(核心:先更库,再更缓存) ======================
    @Transactional(rollbackFor = Exception.class)
    public Boolean like(Long blogId) {
        Long userId = UserUtil.getUserId();
        String likeBloomKey = userId + ":" + blogId;
        String likeSetKey = String.format(RedisKeyConstant.BLOG_LIKE_SET_KEY, blogId);
        String likeCountKey = String.format(RedisKeyConstant.BLOG_LIKE_COUNT_KEY, blogId);
        // 1. 查询当前点赞状态
        Boolean isLiked = isLiked(blogId);
        Boolean result1 = false;
        Boolean result2 = false;
        if (isLiked) {
            // 2. 取消点赞:先更库
            result1 = blogMapper.unlike(blogId);
            result2 = blogMapper.deleteLike(blogId, userId);
            if (result1 && result2) {
                // 3. 再更缓存:删除Set中的用户ID + 递减点赞数
                stringRedisTemplate.opsForSet().remove(likeSetKey, userId.toString());
                stringRedisTemplate.opsForValue().decrement(likeCountKey);
            }
        } else {
            // 2. 点赞:先更库
            result1 = blogMapper.like(blogId);
            result2 = blogMapper.addLike(blogId, userId);
            if (result1 && result2) {
                // 3. 再更缓存:添加Set + 递增点赞数 + 更新布隆
                stringRedisTemplate.opsForSet().add(likeSetKey, userId.toString());
                stringRedisTemplate.opsForValue().increment(likeCountKey);
                bloomFilterHelper.add(stringRedisTemplate, RedisKeyConstant.BLOOM_FILTER_LIKE_KEY, likeBloomKey);
            }
        }
        return result1 && result2;
    }
    // ====================== 查询帖子点赞数(核心:Redis优先) ======================
    public Integer getLikedCount(Long blogId) {
        String likeCountKey = String.format(RedisKeyConstant.BLOG_LIKE_COUNT_KEY, blogId);
        // 1. 先查Redis
        String countStr = stringRedisTemplate.opsForValue().get(likeCountKey);
        if (countStr != null) {
            return Integer.parseInt(countStr);
        }
        // 2. Redis未命中,查MySQL兜底
        Integer count = blogMapper.getLikedCount(blogId);
        if (count == null) {
            count = 0;
        }
        // 3. 补偿写入Redis
        stringRedisTemplate.opsForValue().set(likeCountKey, count.toString());
        return count;
    }

image.gif


相关文章
|
4天前
|
人工智能 JSON 机器人
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
本文带你零成本玩转OpenClaw:学生认证白嫖6个月阿里云服务器,手把手配置飞书机器人、接入免费/高性价比AI模型(NVIDIA/通义),并打造微信公众号“全自动分身”——实时抓热榜、AI选题拆解、一键发布草稿,5分钟完成热点→文章全流程!
10597 53
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
|
10天前
|
人工智能 JavaScript API
解放双手!OpenClaw Agent Browser全攻略(阿里云+本地部署+免费API+网页自动化场景落地)
“让AI聊聊天、写代码不难,难的是让它自己打开网页、填表单、查数据”——2026年,无数OpenClaw用户被这个痛点困扰。参考文章直击核心:当AI只能“纸上谈兵”,无法实际操控浏览器,就永远成不了真正的“数字员工”。而Agent Browser技能的出现,彻底打破了这一壁垒——它给OpenClaw装上“上网的手和眼睛”,让AI能像真人一样打开网页、点击按钮、填写表单、提取数据,24小时不间断完成网页自动化任务。
2422 5
|
24天前
|
人工智能 JavaScript Ubuntu
5分钟上手龙虾AI!OpenClaw部署(阿里云+本地)+ 免费多模型配置保姆级教程(MiniMax、Claude、阿里云百炼)
OpenClaw(昵称“龙虾AI”)作为2026年热门的开源个人AI助手,由PSPDFKit创始人Peter Steinberger开发,核心优势在于“真正执行任务”——不仅能聊天互动,还能自动处理邮件、管理日程、订机票、写代码等,且所有数据本地处理,隐私完全可控。它支持接入MiniMax、Claude、GPT等多类大模型,兼容微信、Telegram、飞书等主流聊天工具,搭配100+可扩展技能,成为兼顾实用性与隐私性的AI工具首选。
24075 122
|
4天前
|
人工智能 IDE API
2026年国内 Codex 安装教程和使用教程:GPT-5.4 完整指南
Codex已进化为AI编程智能体,不仅能补全代码,更能理解项目、自动重构、执行任务。本文详解国内安装、GPT-5.4接入、cc-switch中转配置及实战开发流程,助你从零掌握“描述需求→AI实现”的新一代工程范式。(239字)
2369 126

热门文章

最新文章