🎯 Redis布隆过滤器:用"概率"换"性能"的艺术

50 阅读13分钟

考察点: 位图、hash函数、不存在判定、容量规划

🎬 开场:一个关于"黑名单"的故事

想象你是机场安检员 ✈️:

场景1:完美方案(传统集合)

恐怖分子名单:1000万人
每次检查:在1000万人中查找
- 内存占用:1000万 × 32字节 = 320MB
- 查询速度:O(log n) 或 O(1)
- 准确性:100%

场景2:现实方案(布隆过滤器)

恐怖分子名单:1000万人
使用布隆过滤器:
- 内存占用:12MB(省了96%!)
- 查询速度:O(k) k为hash次数,极快
- 准确性:99.99%(可能误判)

什么是误判?

正常人A通过安检 → 布隆过滤器说:"他在黑名单!"
真实情况:他不在黑名单(误判!)

恐怖分子B来了 → 布隆过滤器说:"他在黑名单!"
真实情况:他确实在黑名单(正确!)

重点:
✅ 如果说"不在",那一定不在(不会漏掉真正的坏人)
❌ 如果说"在",可能误判(可能冤枉好人)

Redis布隆过滤器就是这样的"高效筛选器"! 🎯


第一部分:布隆过滤器原理 📚

1.1 什么是布隆过滤器?

布隆过滤器(Bloom Filter) = 位图 + 多个哈希函数

核心思想:
用极少的空间,快速判断一个元素"可能存在""一定不存在"

特点:
✅ 空间效率极高(位图)
✅ 查询速度极快(O(k))
✅ 不会漏判(说不存在就一定不存在)
❌ 可能误判(说存在可能是误判)
❌ 不能删除(删了会影响其他元素)

1.2 数据结构

布隆过滤器 = 一个很长的位数组 + k个哈希函数

位数组(bit array):
索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
位值: 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
      ↑ 每一位只占1bit(01)

k个哈希函数:
hash1("apple") = 3
hash2("apple") = 7
hash3("apple") = 12

1.3 添加元素(Add)

def add(element):
    """添加元素到布隆过滤器"""
    for hash_func in hash_functions:
        # 计算哈希值
        index = hash_func(element) % bit_array_size
        # 设置对应位为1
        bit_array[index] = 1

示例:添加 "apple"

初始状态:
索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
位值: 0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0

计算哈希:
hash1("apple") % 16 = 3
hash2("apple") % 16 = 7
hash3("apple") % 16 = 12

添加后:
索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
位值: 0  0  0  1  0  0  0  1  0  0  0  0  1  0  0  0
               ↑           ↑              ↑
            hash1       hash2          hash3

1.4 查询元素(Contains)

def contains(element):
    """判断元素是否可能存在"""
    for hash_func in hash_functions:
        index = hash_func(element) % bit_array_size
        # 如果任何一位是0,元素一定不存在
        if bit_array[index] == 0:
            return False
    # 所有位都是1,元素可能存在
    return True

示例:查询 "apple" 和 "banana"

当前状态:
索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
位值: 0  0  0  1  0  0  0  1  0  0  0  0  1  0  0  0

查询 "apple"hash1("apple") % 16 = 3  → bit[3] = 1hash2("apple") % 16 = 7  → bit[7] = 1hash3("apple") % 16 = 12 → bit[12] = 1 ✓
结果:可能存在 ✅

查询 "banana"hash1("banana") % 16 = 2  → bit[2] = 0 ✗
结果:一定不存在 ❌

1.5 误判原因

添加了 "apple""orange""grape"

位数组状态:
索引: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
位值: 0  1  0  1  0  1  0  1  0  0  1  0  1  0  0  1

查询 "banana"(没添加过):
hash1("banana") % 16 = 3  → bit[3] = 1 ✓ (apple设置的)
hash2("banana") % 16 = 7  → bit[7] = 1 ✓ (orange设置的)
hash3("banana") % 16 = 12 → bit[12] = 1 ✓ (grape设置的)

结果:误判为存在!❌

原因:
这些位被其他元素设置成1了
发生了"碰撞"

第二部分:Redis实现布隆过滤器 🛠️

2.1 方式1:使用Redis位图(Bitmap)

import redis
import mmh3  # MurmurHash3

class RedisBloomFilter:
    """基于Redis Bitmap实现的布隆过滤器"""
    
    def __init__(self, redis_client, key, size=10000000, hash_num=7):
        """
        :param redis_client: Redis连接
        :param key: Redis键名
        :param size: 位数组大小
        :param hash_num: 哈希函数数量
        """
        self.redis = redis_client
        self.key = key
        self.size = size
        self.hash_num = hash_num
    
    def _get_hash_positions(self, element):
        """计算元素的哈希位置"""
        positions = []
        for seed in range(self.hash_num):
            # 使用MurmurHash3计算哈希值
            hash_value = mmh3.hash(str(element), seed)
            position = hash_value % self.size
            positions.append(position)
        return positions
    
    def add(self, element):
        """添加元素"""
        positions = self._get_hash_positions(element)
        
        # 使用pipeline批量操作
        pipe = self.redis.pipeline()
        for pos in positions:
            pipe.setbit(self.key, pos, 1)
        pipe.execute()
        
        print(f"✅ 添加元素: {element}")
    
    def contains(self, element):
        """判断元素是否存在"""
        positions = self._get_hash_positions(element)
        
        # 使用pipeline批量查询
        pipe = self.redis.pipeline()
        for pos in positions:
            pipe.getbit(self.key, pos)
        results = pipe.execute()
        
        # 所有位都是1才返回True
        return all(results)
    
    def add_batch(self, elements):
        """批量添加元素"""
        pipe = self.redis.pipeline()
        
        for element in elements:
            positions = self._get_hash_positions(element)
            for pos in positions:
                pipe.setbit(self.key, pos, 1)
        
        pipe.execute()
        print(f"✅ 批量添加 {len(elements)} 个元素")


# 使用示例
if __name__ == "__main__":
    r = redis.Redis(host='localhost', port=6379)
    
    # 创建布隆过滤器
    bf = RedisBloomFilter(r, key="user:blacklist", size=10000000, hash_num=7)
    
    # 添加元素
    bf.add("user:12345")
    bf.add("user:67890")
    
    # 查询
    print(bf.contains("user:12345"))  # True(确实存在)
    print(bf.contains("user:99999"))  # False(不存在)
    print(bf.contains("user:11111"))  # 可能True(误判)

2.2 方式2:使用RedisBloom模块(推荐⭐⭐⭐⭐⭐)

Redis 4.0+ 可以安装 RedisBloom 模块,提供原生支持。

安装RedisBloom

# Docker安装
docker run -p 6379:6379 --name redis-bloom redis/redis-stack-server:latest

# 或编译安装
git clone --recursive https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make
redis-server --loadmodule ./redisbloom.so

使用RedisBloom

import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 创建布隆过滤器
# BF.RESERVE key error_rate capacity
r.execute_command('BF.RESERVE', 'user:blacklist', '0.01', '1000000')
# 误判率0.01,预期容量100万

# 添加元素
r.execute_command('BF.ADD', 'user:blacklist', 'user:12345')
# 返回: 1(成功添加)

# 批量添加
r.execute_command('BF.MADD', 'user:blacklist', 'user:111', 'user:222', 'user:333')
# 返回: [1, 1, 1]

# 判断是否存在
exists = r.execute_command('BF.EXISTS', 'user:blacklist', 'user:12345')
print(exists)  # 1(存在)

# 批量判断
results = r.execute_command('BF.MEXISTS', 'user:blacklist', 'user:111', 'user:999')
print(results)  # [1, 0]

# 查看过滤器信息
info = r.execute_command('BF.INFO', 'user:blacklist')
print(info)
# 输出:
# ['Capacity', 1000000, 'Size', 1437759, 'Number of filters', 1, ...]

第三部分:误判率计算 📊

3.1 误判率公式

布隆过滤器的误判率由三个参数决定:

m: 位数组大小(bit数)
n: 已添加元素数量
k: 哈希函数数量

误判率公式:
ε ≈ (1 - e^(-kn/m))^k

推导:
1. 单个位在一次哈希后仍为0的概率:1 - 1/m
2. k次哈希后仍为0的概率:(1 - 1/m)^k
3. n个元素后仍为0的概率:(1 - 1/m)^(kn)
4. 近似:(1 - 1/m)^(kn) ≈ e^(-kn/m)
5. 误判率:(1 - e^(-kn/m))^k

3.2 最优哈希函数数量

给定m和n,最优的k值:

k_optimal = (m/n) × ln(2) ≈ 0.693 × m/n

此时误判率最低:
ε_min ≈ 0.6185^(m/n)

3.3 参数计算器

import math

class BloomFilterCalculator:
    """布隆过滤器参数计算器"""
    
    @staticmethod
    def optimal_params(n, error_rate):
        """
        根据预期元素数量和误判率,计算最优参数
        :param n: 预期元素数量
        :param error_rate: 期望误判率(如0.01表示1%)
        :return: (m, k) 位数组大小和哈希函数数量
        """
        # 计算所需位数
        m = -n * math.log(error_rate) / (math.log(2) ** 2)
        m = int(math.ceil(m))
        
        # 计算最优哈希函数数量
        k = (m / n) * math.log(2)
        k = int(math.ceil(k))
        
        return m, k
    
    @staticmethod
    def calculate_error_rate(m, n, k):
        """
        计算误判率
        :param m: 位数组大小
        :param n: 元素数量
        :param k: 哈希函数数量
        :return: 误判率
        """
        error_rate = (1 - math.exp(-k * n / m)) ** k
        return error_rate
    
    @staticmethod
    def size_in_mb(m):
        """
        计算占用内存(MB)
        """
        return m / 8 / 1024 / 1024


# 使用示例
calc = BloomFilterCalculator()

# 场景1:100万用户,误判率1%
print("=" * 50)
print("场景1:100万用户,误判率1%")
n = 1000000
error_rate = 0.01
m, k = calc.optimal_params(n, error_rate)
print(f"位数组大小: {m:,} bits")
print(f"占用内存: {calc.size_in_mb(m):.2f} MB")
print(f"哈希函数数量: {k}")
print(f"实际误判率: {calc.calculate_error_rate(m, n, k):.4%}")

# 场景2:1000万用户,误判率0.1%
print("=" * 50)
print("场景2:1000万用户,误判率0.1%")
n = 10000000
error_rate = 0.001
m, k = calc.optimal_params(n, error_rate)
print(f"位数组大小: {m:,} bits")
print(f"占用内存: {calc.size_in_mb(m):.2f} MB")
print(f"哈希函数数量: {k}")
print(f"实际误判率: {calc.calculate_error_rate(m, n, k):.4%}")

# 输出:
# ==================================================
# 场景1:100万用户,误判率1%
# 位数组大小: 9,585,059 bits
# 占用内存: 1.14 MB
# 哈希函数数量: 7
# 实际误判率: 0.9998%
# ==================================================
# 场景2:1000万用户,误判率0.1%
# 位数组大小: 143,775,891 bits
# 占用内存: 17.11 MB
# 哈希函数数量: 10
# 实际误判率: 0.1000%

3.4 参数对照表

预期元素数误判率位数组大小内存占用哈希函数数
10万1%958,506 bits117 KB7
10万0.1%1,437,759 bits176 KB10
100万1%9,585,059 bits1.14 MB7
100万0.1%14,377,589 bits1.71 MB10
1000万1%95,850,587 bits11.4 MB7
1000万0.1%143,775,891 bits17.1 MB10
1亿1%958,505,870 bits114 MB7
1亿0.1%1,437,758,910 bits171 MB10

结论:

  • 误判率越低,内存越大
  • 哈希函数越多,计算越慢但误判率越低
  • 通常选择1%-0.1%的误判率

第四部分:实战应用场景 💼

4.1 场景1:防止缓存穿透

问题:

大量请求不存在的key
→ 缓存miss
→ 查询数据库
→ 数据库也没有
→ 返回空
→ 持续攻击,数据库崩溃!

解决方案:

class CacheWithBloomFilter:
    """带布隆过滤器的缓存"""
    
    def __init__(self, redis_client, mysql_conn):
        self.redis = redis_client
        self.mysql = mysql_conn
        self.bf = RedisBloomFilter(redis_client, "cache:bloom", size=10000000, hash_num=7)
        
        # 初始化:将所有存在的key加入布隆过滤器
        self._init_bloom_filter()
    
    def _init_bloom_filter(self):
        """初始化布隆过滤器"""
        print("🔄 初始化布隆过滤器...")
        
        # 从数据库读取所有有效的key
        cursor = self.mysql.cursor()
        cursor.execute("SELECT id FROM users WHERE deleted = 0")
        
        keys = [f"user:{row[0]}" for row in cursor.fetchall()]
        
        # 批量添加到布隆过滤器
        for i in range(0, len(keys), 1000):
            batch = keys[i:i+1000]
            for key in batch:
                self.bf.add(key)
        
        print(f"✅ 初始化完成,加载了 {len(keys)} 个key")
    
    def get(self, key):
        """获取缓存"""
        # 1. 先查布隆过滤器
        if not self.bf.contains(key):
            # 一定不存在,直接返回
            print(f"❌ 布隆过滤器:{key} 不存在")
            return None
        
        # 2. 可能存在,查Redis缓存
        value = self.redis.get(key)
        if value:
            print(f"✅ 缓存命中:{key}")
            return value
        
        # 3. 缓存miss,查数据库
        print(f"🔍 查询数据库:{key}")
        value = self._query_db(key)
        
        if value:
            # 存在,写入缓存
            self.redis.setex(key, 3600, value)
            return value
        else:
            # 真的不存在(误判)
            print(f"⚠️ 误判:{key} 实际不存在")
            return None
    
    def set(self, key, value):
        """设置缓存"""
        # 写入缓存
        self.redis.setex(key, 3600, value)
        
        # 添加到布隆过滤器
        self.bf.add(key)
    
    def delete(self, key):
        """删除缓存"""
        self.redis.delete(key)
        # 注意:布隆过滤器无法删除!
        # 只能等待过期或重建

4.2 场景2:防止重复推送

class PushService:
    """推送服务(防止重复推送)"""
    
    def __init__(self, redis_client):
        self.redis = redis_client
        # 每天一个布隆过滤器
        self.bf_key_prefix = "push:dedup:"
    
    def _get_bf_key(self):
        """获取今天的布隆过滤器key"""
        from datetime import datetime
        today = datetime.now().strftime("%Y%m%d")
        return f"{self.bf_key_prefix}{today}"
    
    def push(self, user_id, message):
        """推送消息"""
        bf_key = self._get_bf_key()
        
        # 生成唯一标识
        push_id = f"{user_id}:{message['type']}:{message['content_id']}"
        
        # 检查是否已推送
        exists = self.redis.execute_command('BF.EXISTS', bf_key, push_id)
        
        if exists:
            print(f"⚠️ 重复推送,已过滤:{push_id}")
            return False
        
        # 添加到布隆过滤器
        self.redis.execute_command('BF.ADD', bf_key, push_id)
        
        # 执行推送
        print(f"✅ 推送成功:{push_id}")
        self._do_push(user_id, message)
        
        return True
    
    def _do_push(self, user_id, message):
        """实际推送逻辑"""
        pass


# 使用示例
push_service = PushService(redis_client)

# 推送消息
push_service.push(12345, {"type": "article", "content_id": 999})
# ✅ 推送成功

push_service.push(12345, {"type": "article", "content_id": 999})
# ⚠️ 重复推送,已过滤

4.3 场景3:爬虫URL去重

class WebCrawler:
    """爬虫URL去重"""
    
    def __init__(self, redis_client):
        self.redis = redis_client
        self.bf = RedisBloomFilter(redis_client, "crawler:urls", size=100000000, hash_num=7)
        # 支持1亿个URL
    
    def add_url(self, url):
        """添加URL到待爬队列"""
        # 检查是否已爬取
        if self.bf.contains(url):
            print(f"⚠️ URL已爬取,跳过:{url}")
            return False
        
        # 添加到布隆过滤器
        self.bf.add(url)
        
        # 添加到爬取队列
        self.redis.lpush("crawler:queue", url)
        print(f"✅ 新URL入队:{url}")
        
        return True
    
    def crawl(self):
        """爬取网页"""
        while True:
            # 从队列取URL
            url = self.redis.rpop("crawler:queue")
            
            if not url:
                break
            
            print(f"🕷️ 正在爬取:{url}")
            # 爬取页面...
            # 提取新链接...
            # 添加新链接到队列


# 使用示例
crawler = WebCrawler(redis_client)

crawler.add_url("https://example.com/page1")  # ✅ 新URL入队
crawler.add_url("https://example.com/page2")  # ✅ 新URL入队
crawler.add_url("https://example.com/page1")  # ⚠️ URL已爬取,跳过

第五部分:优化与注意事项 ⚠️

5.1 布隆过滤器无法删除

问题:

添加了user:12345
后来user:12345被删除了
但布隆过滤器中无法删除!

解决方案1:定期重建

def rebuild_bloom_filter():
    """定期重建布隆过滤器"""
    # 创建新的布隆过滤器
    new_bf = RedisBloomFilter(redis_client, "cache:bloom:new")
    
    # 从数据库加载当前有效的key
    valid_keys = get_valid_keys_from_db()
    
    for key in valid_keys:
        new_bf.add(key)
    
    # 原子性切换
    redis_client.rename("cache:bloom:new", "cache:bloom")
    
    print("✅ 布隆过滤器重建完成")

# 定时任务:每天凌晨3点重建

解决方案2:使用计数布隆过滤器(Counting Bloom Filter)

# 使用多个位代替一个位,支持删除
# 但会占用更多空间(4-16倍)

解决方案3:时间分片

def get_bf_key(ttl_days=7):
    """每天一个布隆过滤器,保留7天"""
    from datetime import datetime, timedelta
    
    today = datetime.now()
    bf_keys = []
    
    for i in range(ttl_days):
        date = (today - timedelta(days=i)).strftime("%Y%m%d")
        bf_keys.append(f"cache:bloom:{date}")
    
    return bf_keys

def contains(element):
    """查询所有有效的布隆过滤器"""
    for bf_key in get_bf_key():
        if bf_exists(bf_key, element):
            return True
    return False

5.2 容量规划

# 预估规划
预期元素数量: 1000万
误判率: 0.1%
→ 内存需求: 17.1 MB

实际规划:
- 预留50%缓冲 → 1500万容量
- 内存需求: 25.7 MB
- 使用RedisBloom: BF.RESERVE key 0.001 15000000

5.3 性能优化

# 1. 批量操作
BF.MADD key element1 element2 element3  # 比多次ADD快

# 2. pipeline
for element in elements:
    pipe.execute_command('BF.ADD', key, element)
pipe.execute()

# 3. 合理设置过期时间
EXPIRE bloom:filter:20240101 86400  # 24小时后过期

🎓 总结:布隆过滤器选型

         [需要去重/过滤?]
               |
        ┌──────┴──────┐
        ↓             ↓
  [数据量多大?]  [能容忍误判?]
        |             |
    < 100万        能容忍
        |             |
      Set          布隆过滤器
        
    > 1000万      不能容忍
        |             |
   布隆过滤器      Set/数据库

记忆口诀 🎵

布隆过滤很神奇,
位图加上哈希函数。
空间节省九成半,
查询速度快如飞。

说不存在就不在,
说存在可能误判。
只能添加不能删,
定期重建来更新。

缓存穿透它能防,
URL去重它在行。
黑名单防刷它管,
推送去重也能用!

面试要点 ⭐

  1. 原理:位图+k个哈希函数
  2. 特点:不会漏判(说不存在就不存在),可能误判(说存在可能不存在)
  3. 误判率:ε ≈ (1 - e^(-kn/m))^k
  4. 最优k值:k = 0.693 × m/n
  5. 无法删除:只能重建或用计数布隆过滤器
  6. 应用场景:缓存穿透、去重、黑名单过滤
  7. RedisBloom:BF.RESERVE、BF.ADD、BF.EXISTS

最后总结:

布隆过滤器就像机场安检的"快速通道" ✈️:

  • 说你不在黑名单:一定正确,直接通过 ✅
  • 说你在黑名单:可能误判,需要二次确认 🔍
  • 好处:速度极快,节省资源
  • 代价:可能冤枉好人(但不会放过坏人)

记住:用空间换时间,用概率换确定性! 🎯

加油,高性能系统架构师!💪