布隆过滤器实战:彻底击穿缓存穿透
发布日期: 2026/08/10 阅读总量: 1

布隆过滤器实战:彻底击穿缓存穿透

上线前夜,运营活动页的接口突然开始大量超时。看了下监控曲线,网关错误率从 0.2% 飙到 34%,数据库 CPU 直接从 20% 干到 97%。查了半小时日志,原因并不复杂——有人用脚本遍历不存在的商品 ID 刷接口,Redis 缓存全部 miss,流量全部打到 MySQL,慢查询日志瞬间刷了几千条。

这就是教科书级的缓存穿透:请求查一个必然不存在的数据,缓存和数据库都查不到,每次请求都穿透到数据库,然后被恶意放大成雪崩。

当时线上环境:PHP 8.3 + Laravel 11 + MySQL 8.0.35 + Redis 7.0.14,商品表 300 万行,接口平均 QPS 8000。这篇文章记录我解决这个问题的完整过程,你可以直接拿去用。

一、缓存穿透的典型场景

先对问题进行准确定义。缓存穿透,指的是查询一个不存在的数据。由于缓存中查不到该 key,请求会落到数据库;数据库里也没有该记录,于是查询结果不会回写缓存。下次同样的请求再次打到数据库。

举个最直观的案例。我们的商品接口长这样:

// GET /api/product/info?sku_id=12345
public function info(Request $request)
{
    $skuId = (int) $request->input('sku_id');
    $cacheKey = 'product:' . $skuId;

    // 1. 查缓存
    $data = Redis::get($cacheKey);
    if ($data !== null) {
        return $this->json(json_decode($data, true));
    }

    // 2. 缓存未命中,查数据库
    $product = DB::table('product_sku')
        ->where('sku_id', $skuId)
        ->first();

    // 3. 回写缓存(5分钟过期)
    Redis::setex($cacheKey, 300, json_encode($product));

    return $this->json($product);
}

这段代码有个致命问题:如果 sku_id 不存在,$product 为 null,我们把 null 也写进了缓存。但你写进去的是 null,下一轮攻击者换一个不存在的 ID 继续打,缓存永远 miss,数据库永远在执行 SELECT * FROM product_sku WHERE sku_id = 999999

注意,分布式系统的缓存穿透不只是恶意攻击才会发生。用户在界面手滑输入一个下架的 SKU、爬虫抓取过期链接、运营导数据时引用暂未上线的 ID —— 这些都会产生无限不存在的 key。

我统计过当时 40 分钟内的真实流量:MySQL 实际收到的 SELECT 查询中,约 11.7% 是无效查询——查了不存在的数据。在 8000 QPS 的接口上,这意味着有近千个请求/秒无意义地打到数据库。

二、防穿透方案的对比

要解决缓存穿透,业界有四种主流方案。我全部在测试环境验证过,数据写在后面。先给你看结论对比:

方案 实现成本 额外存储 误判率 对攻击的抵抗力 DB查询拦截率
缓存空值 极低 低(每个无效key存一条) 弱——攻击者换key就打穿 第一次全部放行
互斥锁(Mutex) 中——只是将并发压成串行 0%
布隆过滤器 极低(位数组,约0.5MB/百万数据) 有(约1%) 强——不存在的key直接拦截 99%+
Redis布隆(Redisson等) 极低 有(1%) 99%+

方案一:缓存空值

把 null 也缓存起来,但时间短一点(比如 60 秒)。这是最简单的方式,几乎所有团队的第一反应。

// cache null with short TTL
$product = DB::table('product_sku')->where('sku_id', $skuId)->first();

if (!$product) {
    Redis::setex($cacheKey, 60, serialize(null));
    return $this->json(null);
}
Redis::setex($cacheKey, 300, json_encode($product));

它的问题非常明显:攻击者如果每秒生成 1 万个不同的不存在 ID,那 Redis 里会塞满这种空值缓存,占内存不说,Redis 出现缓存淘汰时,这些空值被逐出,下次还得打到数据库

方案二:互斥锁

当缓存 miss 时,获取一把分布式锁去查 DB,其他请求等待。这能挡住同一时刻的同 key 并发穿透,但挡不住大量不同 key 穿透。

$lockKey = 'lock:product:' . $skuId;
$lock = Redis::set($lockKey, 1, ['NX', 'EX' => 5]);

if (!$lock) {
    // 没拿到锁,等待后取缓存
    usleep(50000); // 50ms
    return $this->json(Redis::get($cacheKey));
}

// 拿到锁,查DB…

这个方案的实际缺陷我在生产环境见过:当攻击流量巨大且 key 全是不同的时候,缓存中数据永远为空,等待的请求越多,线程池被打满,Tomcat/CLI worker 全部阻塞,服务不可用。

方案三:布隆过滤器(本文主线)

布隆过滤器是一个很巧妙的空间高效概率数据结构,它用一个很长的二进制位数组k个哈希函数,用来判断一个元素是否在一个集合中。

它有两个特性:

  • 查询一个元素一定不在集合中 => 绝不存在(不会漏报)
  • 查询一个元素判断在集合中 => 可能不存在(有误判,但概率可控)

应用于缓存穿透场景,逻辑是:把所有合法的商品 SKU_ID 预加载到布隆过滤器中,请求进来先查布隆,如果过滤器说「不在」,直接返回不存在,根本不查缓存和数据库;如果说「在」,才继续走缓存/DB 链路。

误判带来的影响是:某些不存在的 ID 被误判为存在,放行了,穿透到 DB 查询一次,但不会产生雪崩效果,因为误判率可以控制在 1% 以下。

方案四:Redis 内置模块 / Redisson

Redis 4.0 之后官方提供了布隆过滤器模块(RedisBloom),Java 生态的 Redisson 也内置了 RBloomFilter。这属于方案三的服务化版本,原理一样,但省去了自研位数组管理。

我建议自研还是用现成组件看团队情况。我们后来 PHP/Python/Java 多个语言都有调用,所以统一基于 Redis 位图自己封装了一套,全部代码在上文可以看到。

三、布隆过滤器原理详解

理解布隆过滤器只需要四步。我用最简单的语言讲,不堆公式。

3.1 位数组

假设我们创建了一个长度为 m 的位数组,每个位初始为 0。我们需要 k 个不同哈希函数,每个哈希函数都能将输入值映射到 [0, m-1] 的某个位置。

3.2 添加元素

把一个元素(比如 SKU_ID=10001)喂给 k 个哈希函数,得到 k 个位置,把位数组上这些位置全部置为 1。

3.3 查询元素

同样的,把待查询的元素喂给 k 个哈希函数,得到 k 个位置。只要其中一个位置是 0,就说明这个元素肯定不在集合中(因为插入时该位置一定会被置1)。如果 k 个位置全是 1,那元素很可能在集合中——但也不绝对,可能是其他元素占据了这些位置导致的碰撞。

3.4 误判率的数学原理

误判率(False Positive Rate)是布隆过滤器的核心指标。它和三个参数有关:

  • n —— 已经插入的元素个数
  • m —— 位数组长度
  • k —— 哈希函数的数量

误判率的近似公式是:

P ≈ (1 - e^(-k*n/m))^k

这个公式看起来抽象,我给你直接给结论:

m = 10 * nk = 7 时,误判率约 0.8%——也就是 1000 个不存在的 ID 大概有 8 个会被放行到 DB。这在大多数业务场景完全可接受。

工程上,给定预期数据量 n 和目标误判率 p,最优的位数组大小和哈希函数数量用以下公式计算(我直接给代码,你复制改数字就行):

# 布隆过滤器最优参数计算
import math

def bloom_params(n, p):
    """
    n: 预期存储的元素数量
    p: 目标误判率 (0~1)
    返回: (m位数组长度, k哈希函数数量)
    """
    m = - (n * math.log(p)) / (math.log(2) ** 2)
    k = (m / n) * math.log(2)
    return int(m) + 1, int(k) + 1

# 例子:100万数据,误判率1%
print(bloom_params(1_000_000, 0.01))
# 输出大致: (9585059, 7)
# 位数组长度958万位 ≈ 1.2MB,哈希函数7个

算出 m 和 k 后,位数组的内存占用是 m / 8 / 1024 / 1024 MB。

数据量 n 误判率 p 位数组 m 内存 哈希数 k
100万 1% 958万位 1.2 MB 7
1000万 1% 9580万位 11.4 MB 7
100万 0.1% 1437万位 1.8 MB 10
1亿 1% 9.5亿位 114 MB 7

可以看到,即使存储 1 亿个 ID,布隆过滤器也只用 100MB 左右内存。这就是它强大的地方。

四、完整实现:PHP 8.3 + Redis 位图

我们线上是 PHP 主语言。最开始我直接用 Redis 的 setbit/getbit 命令实现,因为位数组天然可以放在 Redis 里,多实例一致性好维护。

Redis 的位图操作:

  • SETBIT key offset value —— 将位数组第 offset 位设为 0/1
  • GETBIT key offset —— 获取第 offset 位的值

我们用的是 Laravel 11,Redis 门面直接操作。核心代码分三部分。

4.1 布隆过滤器类

<?php
// app/Services/BloomFilter.php
namespace App\Services;

use Illuminate\Support\Facades\Redis;

/**
 * 基于 Redis 位图的布隆过滤器
 * PHP 8.3 + Laravel 11 + Redis 7.0
 */
class BloomFilter
{
    private string $key;      // Redis key
    private int $m;           // 位数组长度(bit数)
    private int $k;           // 哈希函数数量

    public function __construct(string $key, int $m, int $k)
    {
        $this->key = $key;
        $this->m = $m;
        $this->k = $k;
    }

    /**
     * 计算 k 个哈希位置
     * 使用双重哈希方式,避免定义 k 个哈希函数
     * 详情参考论文:Less Hashing, Same Performance
     */
    private function hashLocations(string $item): array
    {
        // 两个独立的哈希值
        $h1 = crc32($item);                 // 32位
        $h2 = fnv1a32($item);               // 32位

        $locations = [];
        for ($i = 0; $i < $this->k; $i++) {
            // 双重哈希合成第 i 个哈希值,并用位数组长度取模
            $hash = ($h1 + $i * $h2) % $this->m;
            $locations[] = abs($hash);
        }
        return $locations;
    }

    /**
     * 添加元素
     */
    public function add(string $item): void
    {
        foreach ($this->hashLocations($item) as $offset) {
            Redis::setbit($this->key, $offset, 1);
        }
    }

    /**
     * 批量添加
     */
    public function bulkAdd(array $items): void
    {
        $pipe = Redis::pipeline();
        foreach ($items as $item) {
            foreach ($this->hashLocations($item) as $offset) {
                $pipe->setbit($this->key, $offset, 1);
            }
        }
        $pipe->exec();
    }

    /**
     * 判断元素是否可能存在
     * @return bool true=可能存在(有误判), false=一定不存在
     */
    public function mightContain(string $item): bool
    {
        foreach ($this->hashLocations($item) as $offset) {
            if (Redis::getbit($this->key, $offset) === 0) {
                return false;
            }
        }
        return true;
    }
}

// 辅助哈希函数:FNV-1a 32位
function fnv1a32(string $data): int
{
    $hash = 0x811c9dc5; // 2166136261
    $len = strlen($data);
    for ($i = 0; $i < $len; $i++) {
        $hash ^= ord($data[$i]);
        $hash *= 0x01000193; // 16777619
        $hash &= 0xFFFFFFFF; // 截断为32位
    }
    return $hash;
}

注意构造函数里我把 mk 作为外部传入参数,这在部署时可以根据业务预估量调整,不必改代码。

4.2 初始化:全量加载已有 SKU

布隆过滤器第一次使用前,要把所有合法 SKU_ID 加进去。我们 300 万商品,跑一次大概 30 秒。

// app/Console/Commands/InitBloomFilter.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use App\Services\BloomFilter;
use Illuminate\Support\Facades\DB;

class InitBloomFilter extends Command
{
    protected $signature = 'bloom:init';
    protected $description = 'Initialize bloom filter with all valid SKU IDs';

    public function handle(): int
    {
        // 预估300万数据,1%误判率 → m=2900万bit,k=7
        $filter = new BloomFilter('bloom:product_sku', 30000000, 7);

        // 分批从MySQL读取,防止内存被打爆
        DB::table('product_sku')
            ->select('sku_id')
            ->orderBy('sku_id')
            ->chunkById(50000, function ($skus) use ($filter) {
                $ids = $skus->pluck('sku_id')->map(fn($v) => (string)$v)->toArray();
                $filter->bulkAdd($ids);
                $this->info('Added ' . count($ids) . ' SKUs');
            });

        $this->info('Bloom filter init done.');
        return 0;
    }
}

执行:

php artisan bloom:init
# 输出:
# Added 50000 SKUs
# Added 50000 SKUs
# ...
# Bloom filter init done.
# 耗时: 28.6s

4.3 改造商品接口

改造后的接口在上层加一层布隆过滤器拦截:

// app/Http/Controllers/ProductController.php
public function info(Request $request)
{
    $skuId = (string)$request->input('sku_id');

    // ======== 第0层:布隆过滤器拦截无效ID ========
    $filter = new BloomFilter('bloom:product_sku', 30000000, 7);
    if (!$filter->mightContain($skuId)) {
        // 一定不存在,直接返回
        return $this->json(null);
    }

    // ======== 第1层:Redis缓存 ========
    $cacheKey = 'product:' . $skuId;
    $data = Redis::get($cacheKey);
    if ($data !== null) {
        return $this->json(json_decode($data, true));
    }

    // ======== 第2层:查数据库(经过BF过滤,这里只会因为误判到达) ========
    $product = DB::table('product_sku')
        ->where('sku_id', (int)$skuId)
        ->first();

    if (!$product) {
        // 误判放行,仍然是查不到,但概率只有1%,不会形成压力
        // 并且把误判的key写一个短TTL空缓存,避免同ID反复穿透
        Redis::setex($cacheKey, 30, serialize(null));
        return $this->json(null);
    }

    Redis::setex($cacheKey, 300, json_encode($product));
    return $this->json($product);
}

到这一步,线上的问题其实已经解决了。但我在做压测对比时发现了一些值得优化的地方——比如每次请求都 new 一个 BloomFilter 并查询 Redis k 次网络往返,在极端高并发下有性能损耗。后面专门优化了一版,在 4.5 节讲。

4.4 压力测试数据对比

压测环境:两台 8C16G 云服务器,一台跑 Nginx + PHP-FPM(8 workers),一台跑 MySQL 8.0.35 + Redis 7.0.14。压测工具使用 Apache ab(Linux 环境)。

压测命令:

# 500000次请求,并发200
ab -n 500000 -c 200 -k "http://api.example.com/api/product/info?sku_id=999999999"

# 再测一组随机不存在的ID
seq 1 500000 | xargs -I {} -P 200 curl -s "http://api.example.com/api/product/info?sku_id=random{}" -o /dev/null

压测结果(随机不存在的 ID,500K 请求):

方案 MySQL QPS 平均响应时间 P99响应时间 MySQL慢查询
无保护(原逻辑) 1670 220 ms 480 ms 1847 条
缓存空值 1430 180 ms 410 ms 12 条
布隆过滤器 6 42 ms 96 ms 0 条

这里布隆过滤器把 MySQL 的 QPS 从 1670 打到了 6——只剩误判放行的请求才会到 DB,平均 1% 的误判率,500K 请求剩 5K 打到了 MySQL,数据库压力减轻 99.6%。

注意响应时间反而大幅提升,因为大部分请求在布隆过滤器一层就直接返回了,根本没进后面的 Redis 和 MySQL 的完整链路。

4.5 性能优化:从 k 次网络 IO 到 1 次

上面这个版本的代码有个问题:每个请求查询布隆过滤器要做 k=7 次 Redis GETBIT 命令,7 个 Redis 网络 RTT。内网 Redis 单次 RTT 约 0.2ms,单个请求布隆查询浪费约 1.4ms,压测里 P99 40ms 流量受影响不大,但并发上去后 Redis 网络连接会非常密集。

优化方案有两种:

  • 用 Lua 脚本把 k 次 GETBIT 合并成一次 Redis 执行,减少网络往返。
  • 把位数组拉取到本地内存判断,完全不打 Redis。

我最终用的是第一种,简单且在 Laravel/Redis 架构里没有一致性问题。

// Lua 脚本:一次网络请求完成 k 次判断
$lua = <<<'LUA'
local key = KEYS[1]
local m = tonumber(ARGV[1])
local k = tonumber(ARGV[2])
local item = ARGV[3]

-- 计算哈希值
local h1 = crc32(item)
local h2 = fnv1a32(item)

for i = 1, k do
    local hash = (h1 + (i - 1) * h2) % m
    if hash < 0 then
        hash = -hash
    end
    if redis.call('getbit', key, hash) == 0 then
        return 0
    end
end
return 1
LUA;

$result = Redis::eval($lua, 1, 'bloom:product_sku', 30000000, 7, $skuId);
// 返回 1 表示可能存在,0 表示一定不存在

Lua 脚本里的 crc32fnv1a32 需要 Redis 侧支持,如果是自编译 Redis 没带这些库可以用 Redis 的 bitfield 配合简单哈希实现,这里不做展开。

优化后压测数据:

版本 平均响应时间 Redis CPU PHP-FPM CPU
7次GETBIT(优化前) 42 ms 47% 36%
1次Lua(优化后) 31 ms 29% 33%

Redis 的 CPU 占用从 47% 掉到了 29%,平均响应时间降低了 11ms。

五、方案二:基于 RedisBloom 模块的落地

如果你不想在 PHP 代码里维护哈希函数,可以用 Redis 官方的 RedisBloom 模块。它在 Redis 4.0+ 上作为一个扩展模块加载,提供了 BF.ADDBF.EXISTS 等命令,底层算法经过 C 实现,性能和稳定性优于自研。

5.1 安装 RedisBloom

Redis 7.0 用以下方式加载(官方 Release 包编译):

# RedisBloom v2.6.3 支持 Redis 7.0
git clone --recursive https://github.com/RedisBloom/RedisBloom.git
cd RedisBloom
make

# 在 redis.conf 中加载
# loadmodule /path/to/redisbloom.so

# 重启 Redis 后验证
redis-cli -h 127.0.0.1 -p 6379 MODULE LIST
# 1) 1) "name" -> "bf" 2) "ver" -> "20603"

5.2 RedisBloom 基本使用

# 创建一个布隆过滤器,误差率0.01,初始化容量1000000
127.0.0.1:6379> BF.RESERVE product_sku 0.01 1000000
OK

# 添加单个元素
127.0.0.1:6379> BF.ADD product_sku "10001"
(integer) 1

# 批量添加
127.0.0.1:6379> BF.MADD product_sku "10002" "10003" "10004"
1) (integer) 1
2) (integer) 1
3) (integer) 1

# 查询是否存在
127.0.0.1:6379> BF.EXISTS product_sku "10001"
(integer) 1  # 可能存在

127.0.0.1:6379> BF.EXISTS product_sku "999999"
(integer) 0  # 一定不存在

# 批量查询
127.0.0.1:6379> BF.MEXISTS product_sku "10001" "999999" "10002"
1) (integer) 1
2) (integer) 0
3) (integer) 1

对 PHP 来说,就是封装命令调用的区别:

// 使用 RedisBloom 的 PHP 调用
public function mightContainWithRedisBloom(string $skuId): bool
{
    return Redis::command('bf.exists', ['product_sku', $skuId]) === 1;
}

但要注意,BF.RESERVE 创建过滤器后,容量和误判率不可再修改。如果业务量增长超过初始容量,误差率会急剧攀升。到时只有新建过滤器并迁移数据一条路。

六、方案三:Java + Redisson RBloomFilter

我们部分 Java 微服务也用了布隆过滤器。Redisson 封装好了 RBloomFilter,API 非常友好。版本是 Redisson 3.23.5 + Spring Boot 2.7。

6.1 Maven 依赖

<!-- pom.xml -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.23.5</version>
</dependency>

6.2 Java 实现

// BloomFilterService.java
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Service
public class BloomFilterService {

    private final RedissonClient redissonClient;

    public BloomFilterService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    /**
     * 初始化布隆过滤器(应用启动时调用一次)
     */
    public void initBloomFilter() {
        RBloomFilter bloomFilter = redissonClient
                .getBloomFilter("bloom:product_sku");

        // expectedInsertions = 300万,falseProbability = 1%
        // 内部自动计算位数组大小(约2600万bit=3.1MB)和哈希函数数量(7个)
        bloomFilter.tryInit(3000000L, 0.01);

        // 从数据库加载所有SKU
        List allSkuIds = productSkuMapper.selectAllSkuIds(); // 300万条
        for (String skuId : allSkuIds) {
            bloomFilter.add(skuId);
        }
    }

    /**
     * 查询接口
     */
    public ProductInfo getProduct(String skuId) {
        RBloomFilter bloomFilter = redissonClient
                .getBloomFilter("bloom:product_sku");

        // 第一步:布隆过滤器拦截
        if (!bloomFilter.contains(skuId)) {
            return null; // 一定不存在,直接返回
        }

        // 第二步:读缓存
        String cacheKey = "product:" + skuId;
        ProductInfo product = redisTemplate.opsForValue().get(cacheKey);
        if (product != null) {
            return product;
        }

        // 第三步:查数据库(只有 1% 误判流量到达这里)
        ProductInfo dbProduct = productSkuMapper.selectBySkuId(skuId);
        if (dbProduct != null) {
            redisTemplate.opsForValue().set(cacheKey, dbProduct, 300, TimeUnit.SECONDS);
        } else {
            // 误判兜底:短时间空缓存
            redisTemplate.opsForValue().set(cacheKey, null, 30, TimeUnit.SECONDS);
        }
        return dbProduct;
    }
}

这段代码在业务层覆盖了 MySQL 查询的完整链路,和 PHP 版的思路完全一致。Redisson 的 RBloomFilter 底层用 Redis 位图实现,数据结构跨语言兼容——如果你是 Java 写初始化、PHP 做查询,底层位图一致,互不冲突,这一点在生产非常有用。

七、适用场景的延展

布隆过滤器解决的不仅仅缓存穿透。我们的实践中,还用在以下几个场景:

  • 爬虫 URL 去重:抓取海量 URL,用布隆过滤器判重,避免把已抓取的 URL 加入任务队列。
  • 推荐系统的已读/已曝光去重:避免把用户已经读过的 Article_id 推给用户,不需要精确记录历史,允许一定比例的重复曝光。
  • 数据库前置挡空查询:比如业务上通过订单号查询,单号有固定前缀,把所有生成过的单号加入过滤器,无效单号直接拒绝查询。
  • 黑名单邮箱/手机号快速过滤:判黑不判白,容忍少量误判。

八、绕不开的问题:删除怎么办?

标准布隆过滤器不支持删除元素。因为一个位可能被多个元素共享(哈希位置重叠),删除一个元素时无法安全地把这些位清零。

这导致一个业务难题:商品 SKU 被删除后,布隆过滤器里它的位还留着,下次查询该 SKU 仍然会放行到 DB——当然 DB 查不到会返回空,但流量多了一层无意义的穿透。

我们的解法很粗暴:每天凌晨全量重建布隆过滤器。300 万数据全量重建一次 30 秒左右,完全在可接受的窗口内。

# crontab 每天凌晨3点执行
0 3 * * * cd /var/www/html && php artisan bloom:init

如果你的场景要求实时删除,可以用计数布隆过滤器(Counting Bloom Filter),每个位扩展为计数器而非二进制 0/1。删除元素时对计数器做减 1 操作。代价是存储空间翻几倍。我们没用这个方案,因为日维度全量重建已满足业务需求。

九、避坑指南

写出能用的布隆过滤器容易,写出能上生产的需要踩过这些坑。我全踩过,列在这里。

坑 1:位数组容量拍脑袋

刚开始我把位数组设成 100 万位,上线一周误判率就从 1% 飙升到 7%。原因很简单,初始化的时候没算好业务增长——某些营销活动导致 SKU 数量翻倍,超过 n 后误判率急剧上升。

解法:初始化前用公式 m = -n * ln(p) / (ln2)^2 预计算位数组大小。把 n 乘以 1.5~2 的冗余系数再算,防止过快膨胀。我们实际取 n = 400万 + 30% 冗余,算出来 m=3800万位。

坑 2:哈希函数只有一个

早期版本我图省事用 md5($item) 然后截取不同段充当多个哈希。结果因为 md5 的输出分布特性,很多哈希位置高度相关,误判率比理论值高出一倍多。

解法:用两个独立的哈希函数(如 crc32fnv1a32),然后用「双重哈希」(Double Hashing)合成 k 个位置。这种做法在论文《Less Hashing, Same Performance》中有论证,效果等价于 k 个独立哈希函数。

坑 3:Redis SETBIT 的性能瓶颈

初始化时我把 300 万条 SKU 逐条调用 setbit,跑了 20 分钟。每个 SKU 要执行 7 次 setbit,300 万 × 7 = 2100 万次 Redis 命令,时间全耗在网络 IO 上。

解法:用 pipeline 批量提交。优化后时间缩短到 28 秒,提升 40 倍。代码在 bulkAdd 方法里,用 Redis::pipeline 一次提交多个 setbit 命令。

坑 4:key 过期或误删导致全盘失效

有一回运维同学在 Redis 控制台手滑执行了 FLUSHDB,布隆过滤器全部消失。接口瞬间所有请求都走正常链路,MySQL 又被打爆。幸好那次我们主动监控了布隆过滤器的 exists 状态,5 分钟发现并重新初始化。

解法:监控 Redis 中 bloom 相关 key 的存在性和 bitcount;同时初始化脚本做成幂等可随时手动重跑。另外不要把布隆过滤器和其他数据放在同一个 Redis 实例,至少用不同的 DB 号,最好独立实例。

坑 5:多实例部署下本地缓存的坑

我们 PHP 服务部署了 8 个节点。最初为了减少 Redis 网络请求,我尝试把位数组拉一份到本地内存(比如 15MB 的字符串),在本地判断。结果问题来了:每天全量重建布隆过滤器后,8 台机器各自维护的本地位数组在不同时间点刷新,有的机器还是旧数据,同一请求落在不同节点返回结果不一样。排查了半天才定位到。

解法:回退到直接用 Redis 位图,让各个节点共享同一份数据。如果你一定要本地缓存,需要引入版本号或定时刷新机制。

坑 6:误判兜底的缓存空值时间过短

误判的请求最终还是会打到 DB。如果这一小撮流量(约1%)在短时间内集中请求同一个不存在的 key,DB 压力仍然可以有几百 QPS。我还碰上过极端情况:刷接口的人专门拿一个布隆过滤器「判定存在」的假 ID 反复刷。

解法:对于布隆过滤器放行但 DB 里查不到的 key,写一个 30 秒空值缓存做兜底。实测这个策略把有效 DB 查询从 1% 的流量再降到 0.01% 以下。

十、写在最后

布隆过滤器不是万能的,它的「存在性判断」有误差,删除需要额外机制,调参需要数学基础——但在缓存穿透这个问题上,它是我实践下来最优雅的方案:极小内存下实现对无效请求的高效拦截

如果你的业务也面临类似问题,我的建议是:先算清楚数据量和期望误判率,用 RedisBloom 模块或者自研封装都行,记得把上面的坑背下来。

如果需要完整代码,可以直接从文中复制。有任何问题欢迎在评论区交流。