布隆过滤器+缓存穿透实战,内存降90%
发布日期: 2026/07/30 阅读总量: 0

1. 一个真实场景:优惠券查询把MySQL打趴了

双十一大促前,公司优惠券业务上线了一个新功能:用户输入券码或券ID查询详情。上线后某天,监控告警MySQL CPU飙升到98%,慢查询堆积。我们排查发现,大量请求查询的是不存在的券ID(恶意刷接口),比如递增的ID从1到100万,而真实有效券只有50万。每次请求都穿透Redis缓存去查MySQL,最终MySQL扛不住。

这就是典型的缓存穿透。我们的Redis缓存策略是“查不到就查库,再写入缓存”,但对于不存在的key,每次都会查库,缓存形同虚设。

2. 方案对比:空值缓存 vs 布隆过滤器 vs 组合拳

2.1 空值缓存(Null Cache)

查不到的数据也写入Redis,设置一个较短的过期时间(比如5分钟)。思路简单,但问题明显:
- 恶意请求可以构造大量不存在的ID,导致Redis中塞满无用key,内存暴涨。
- 如果需要存储1亿个不存在的key(假设每个key+value约100字节),需要10GB内存。

2.2 布隆过滤器(Bloom Filter)

用位数组和多个哈希函数判断“不存在”一定不存在,“存在”可能误判。特点:
- 空间极小:1亿数据,1%误判率只需约119MB内存(相比空值缓存的10GB,内存降90%)。
- 判断速度快(O(k),k是哈希函数个数)。
- 无法删除元素(除非Counting Bloom Filter)。
- 需要提前加载所有有效ID到过滤器。

2.3 组合方案:布隆过滤器 + 空值缓存

布隆过滤器用于快速拦截绝对不存在的key(99%正确),剩下的误判(约1%)走缓存+空值缓存兜底。这样即便布隆过滤器判为“存在”但其实不存在,也会在Redis空值缓存中命中(短期),减少数据库压力。我们最终采用此方案。

三种方案核心对比
方案内存占用(1亿key)查询耗时(含网络)是否支持删除
空值缓存约10GB1.5ms是(过期即可)
布隆过滤器约119MB0.5ms(本地)0.1ms(Redis)否(需重建)
布隆+空值过滤库119MB + 少量误判空值~0.6ms有限支持(空值可过期)

3. 原理:布隆过滤器到底怎么算的?

一个长度为m bits的位数组,k个独立的哈希函数。插入元素时,将k个哈希位置置1。查询时,若所有位置都是1则返回“可能存在”;若有任意位为0则返回“一定不存在”。

误判率p的决定公式:

m = - n * ln(p) / (ln2)^2
k = (m / n) * ln2

其中n为预计元素数量,m为位数组长度(bits),p为容忍的误判率。实际工程中取整。

例如:n = 1亿,p=1%,则:
m = -1e8 * ln(0.01) / (0.6931)^2 ≈ -1e8 * (-4.605) / 0.4805 ≈ 9.58e8 bits ≈ 114.3MB。k = (9.58e8 / 1e8) * 0.6931 ≈ 6.64,取整数7。

所以我们需要一个119MB(9.58亿bits / 8 / 1024 / 1024 ≈ 114MB,加上一些结构开销约119MB)的位数组,每个元素用7个哈希函数。误判率约1%。

4. 代码实现:PHP8.3 + Laravel11 + RedisBloom

我们使用Redis 6.x内置的RedisBloom模块(bf.add, bf.exists),也可以自己实现BloomFilter类。这里给出两种:一是原生PHP类(适合不能装模块的场景),二是RedisBloom的用法。

4.1 原生PHP BloomFilter类

/**
 * BloomFilter for PHP8.3
 * 使用Redis作为位图存储(原生的bitset也可改用内存数组,但这里展示分布式版本)
 */
class BloomFilter
{
    const KEY = 'bloom:coupon';
    private $redis;
    private $m;        // 位数组长度
    private $k;        // 哈希函数个数
    private $seeds;    // 种子列表

    public function __construct(Redis $redis, int $expectedElements, float $falsePositiveRate = 0.01)
    {
        $this->redis = $redis;
        // 计算最优参数
        $ln2 = log(2);
        $this->m = (int) ceil(-$expectedElements * log($falsePositiveRate) / ($ln2 * $ln2));
        $this->k = (int) round(($this->m / $expectedElements) * $ln2);
        // 生成k个质数种子
        $this->seeds = $this->generatePrimes($this->k);
    }

    public function add(string $element): void
    {
        foreach ($this->getHashPositions($element) as $pos) {
            $this->redis->setBit(self::KEY, $pos, 1);
        }
    }

    public function exists(string $element): bool
    {
        foreach ($this->getHashPositions($element) as $pos) {
            if ($this->redis->getBit(self::KEY, $pos) === 0) {
                return false;
            }
        }
        return true;
    }

    private function getHashPositions(string $element): array
    {
        $positions = [];
        foreach ($this->seeds as $seed) {
            $hash = hash('murmur3f', $element, $seed); // 假设有murmur3f(需安装扩展或自实现)
            $positions[] = abs(crc32($hash)) % $this->m;
        }
        return $positions;
    }

    private function generatePrimes(int $count): array
    {
        $primes = [];
        $num = 2;
        while (count($primes) < $count) {
            $isPrime = true;
            for ($i = 2; $i * $i <= $num; $i++) {
                if ($num % $i === 0) { $isPrime = false; break; }
            }
            if ($isPrime) $primes[] = $num;
            $num++;
        }
        return $primes;
    }
}

4.2 使用RedisBloom(推荐)

# 安装RedisBloom模块
sudo apt install redis-server
redis-cli
> MODULE LOAD /path/to/redisbloom.so
# 或使用Docker:redislabs/rebloom

Laravel中通过Redis门面操作:

// Laravel Service Provider 中注册 BloomFilter 单例
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Redis;

class BloomFilterServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton('bloomfilter', function () {
            $redis = Redis::connection()->client();
            return new class($redis) {
                private $redis;
                private $key = 'bloom:coupon';

                public function __construct($redis) { $this->redis = $redis; }

                public function add(string $item): void
                {
                    $this->redis->rawCommand('BF.ADD', $this->key, $item);
                }

                public function exists(string $item): bool
                {
                    return $this->redis->rawCommand('BF.EXISTS', $this->key, $item) === 1;
                }

                public function create(int $expectedItems, float $errorRate = 0.01): void
                {
                    $this->redis->rawCommand('BF.RESERVE', $this->key, $errorRate, $expectedItems);
                }
            };
        });
    }
}

4.3 中间件:防止缓存穿透

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class CachePenetrationGuard
{
    public function handle(Request $request, Closure $next)
    {
        $couponId = $request->route('couponId');
        // 1. 布隆过滤器快速判断
        if (!app('bloomfilter')->exists($couponId)) {
            // 一定不存在,直接拦下
            return response()->json(['error' => 'coupon not found'], 404);
        }

        // 2. 查询Redis缓存(含空值缓存)
        $cacheKey = 'coupon:' . $couponId;
        $data = Cache::get($cacheKey);
        if ($data !== null) {
            if ($data === 'NULL_PLACEHOLDER') {
                return response()->json(['error' => 'coupon not found'], 404);
            }
            return response()->json(['data' => $data]);
        }

        // 3. 查数据库
        $result = DB::table('coupons')->where('id', $couponId)->first();
        if ($result) {
            Cache::put($cacheKey, $result, 3600);
            return response()->json(['data' => $result]);
        } else {
            // 空值写入Redis,过期时间5分钟
            Cache::put($cacheKey, 'NULL_PLACEHOLDER', 300);
            return response()->json(['error' => 'coupon not found'], 404);
        }
    }
}

4.4 重建布隆过滤器:从MySQL全量同步

// Artisan命令:bloomfilter:rebuild
class BloomFilterRebuild extends Command
{
    protected $signature = 'bloomfilter:rebuild';
    public function handle()
    {
        $redis = Redis::connection()->client();
        $key = 'bloom:coupon';
        // 删除旧过滤器(RedisBloom必须先DEL再BF.RESERVE)
        $redis->del($key);
        // 创建新过滤器,预计50万有效券,误判率1%
        $redis->rawCommand('BF.RESERVE', $key, 0.01, 500000);
        // 分批从数据库取出ID
        DB::table('coupons')->select('id')->chunk(1000, function ($ids) use ($redis, $key) {
            foreach ($ids as $row) {
                $redis->rawCommand('BF.ADD', $key, $row->id);
            }
        });
        $this->info('Bloom filter rebuilt successfully.');
    }
}

5. 效果数据:压测结果

测试环境:PHP8.3 + Laravel11 + MySQL8.0.35(单机) + Redis 6.2.7(带RedisBloom 2.6.3)。服务器:4核8G。压测工具:ab(ApacheBench)。

5.1 场景:查询100万次,其中80万是不存在的ID

方案QPS (requests/s)平均耗时 (ms)MySQL QPSRedis内存占用
无缓存(直查库)5201925200
空值缓存(过期5min)420023400 (误判后补缓存)10GB(假设1亿不存在的key写入)
布隆过滤器(1%误判)610016~61(1%误判)119MB
布隆 + 空值缓存590017~61(1%误判)119MB + 少量空值缓存(约2万条)

结论:布隆过滤器有效地将MySQL压力降至原来的1/8,同时内存占用仅为空值缓存的1%左右。组合方案进一步提升可用性。

6. 避坑指南(实战踩过的坑)

  1. 布隆过滤器无法删除元素:业务中券ID可能会过期下架,但我们没有删除操作。我们采用定期重建策略:每天凌晨从数据库全量同步一次。或者使用Counting Bloom Filter(但内存翻倍),权衡后选择重建。
  2. 哈希函数选择:必须使用一致性好的哈希,如MurmurHash3。不要用简单的模运算,否则冲突高。我们的PHP实现用了murmur3f扩展,但原生用crc32+seed也能凑合,但误判率会偏高。建议直接使用RedisBloom,内部已优化。
  3. 并发初始化:第一次重建时,如果多个进程同时执行BF.RESERVE会冲突。我们用Redis分布式锁(Laravel中Cache::lock)防止重复创建。
  4. 误判率不是越小越好:m越大,内存越大。1%误判率对于大多数业务可接受,因为走空值缓存后对数据库压力不大。若需要更低,可调整为0.1%,但内存约需160MB。
  5. RedisBloom的坑:BF.RESERVE必须在元素添加前执行,否则会自动创建(但参数不是最优)。另外,Redis所在机器内存要足够,我们最初分配给Redis 512MB,布隆过滤器就占了119MB,导致其他key被淘汰。需要单独评估。
  6. 空值缓存过期时间:不要设置太长,否则会导致数据不一致(比如优惠券已经上架,但空值缓存还在)。我们设置为5分钟,配合布隆过滤器更新(每天重建一次),可以接受。
  7. 性能细节:布隆过滤器判断每个元素需要k次getBit操作(Redis网络IO),如果并发高,可以将位数组放在本地内存(如共享内存),但分布式场景更适合用Redis。我们本地测试了一次getBit在0.1ms以内,总和0.7ms,可接受。
  8. 大数据量预热:1亿条数据添加到RedisBloom耗时约30秒(单线程),我们放在后台任务执行,且用chunk减少网络往返。

7. 总结

布隆过滤器在缓存穿透场景中表现优异,内存节省90%以上,QPS提升10倍。搭配空值缓存可解决剩余误判。代码已在生产环境运行半年,未出大问题。如果你也面临缓存穿透,优先选RedisBloom,省心又高效。