PHP如何实现高性能布隆过滤器避免缓存穿透问题发生

各位,大家下午好!搬好小板凳,把手机调静音,咱们今天不聊虚的,聊点能保命的。

我是你们的老朋友,一个在代码堆里摸爬滚打多年的PHP老兵。今天要讲的题目,听起来挺高大上,叫“高性能布隆过滤器解决缓存穿透”。别听到“布隆”两个字就腿软,也别一听到“高性能”就想跑,咱们今天就把它拆解了,揉碎了,用最通俗、最幽默的方式讲明白。

这事儿得从“缓存穿透”这个大坑说起。


第一章:数据库的噩梦——缓存穿透

想象一下,你开了一家便利店,门口有个保安,负责看门。这是数据库

为了不让大家老往仓库跑,你在门口放了个缓存,也就是那个自动门。如果有人想拿东西,保安先看缓存,如果有,直接给他开门,不用进仓库。如果缓存里没有,保安就得去仓库翻找。如果仓库里真的有,就把东西拿出来,顺便更新一下缓存,以后就不用再翻仓库了。这就是缓存击穿或者缓存雪崩,虽然也疼,但好歹仓库是有的。

缓存穿透是什么情况呢?

就是有人专跟你作对。他手里拿着一把钥匙,就是那种大家都有可能拿的钥匙(比如用户ID),疯狂地敲你门。

“请问,我有这把钥匙吗?”
保安(缓存)看了一眼,说:“没有。”
于是保安去仓库(数据库)翻了一整天,一无所获。保安心想:“嘿,这钥匙能不能配啊?要是能配就好了。”结果这哥们儿第二天又来了,拿同样的钥匙,又敲了一天。

这就是缓存穿透

更惨的是,如果这哥们儿不是一个人,而是一群人呢?
一群恶霸,每人手里拿着一把假钥匙,疯狂地敲你的门。保安疯狂地去仓库翻找,虽然仓库里没有东西,但你的数据库CPU、IO直接被打爆了!最后数据库崩了,你的网站挂了,老板在办公室拍桌子,问你:“这数据库是不是坏了?为什么这么多请求都进不来?”

传统的解决方法是什么?

  1. 空值缓存:保安去仓库翻了一圈没找到,回来就在门口贴个条子:“这家伙没东西”。结果第二天恶霸又来了,保安一看条子说“没东西”,直接把人打发走。这招虽然能防,但条子(缓存)贴多了,内存不就爆了吗?而且万一哪天仓库真的有东西了,保安还没来得及擦条子呢,用户就被骗了。
  2. 参数校验:在代码里加个判断,如果是乱码、太长的ID,直接返回错误。这就像保安给恶霸设了个路障,但恶霸多了,路障也挡不住。

所以,我们需要更高级的保安,或者说,更高级的门禁系统。


第二章:布隆过滤器——概率论的浪漫

这时候,布隆过滤器登场了。

布隆过滤器是什么?简单说,它就是一个极度抠门的存储空间。它不存数据的内容,只存数据的“指纹”。

它的工作原理是这样的:

  1. 它有一个巨大的、全是0的数组。就像一排排空桌子。
  2. 当你往里面放一个用户ID(比如 123456)时,它用三个哈希函数(你可以理解为三个不同的角度)去算这个ID的位置。
    • 哈希1说:“这哥们儿坐第10号桌。”
    • 哈希2说:“不,坐第100号桌。”
    • 哈希3说:“我看坐第1000号桌最合适。”
  3. 它把这三个位置全部标记为 1
  4. 下次有人问:“这哥们儿有吗?”
    • 它再去查这三个位置。如果全是 1,那好,极大概率有!
    • 如果有一个位置是 0,那绝对没有

重点来了!注意听!
布隆过滤器有个特点:它可能会“冤枉好人”。
它说“有”,但他可能真的不在。这是假阳性。
但布隆过滤器绝不会漏掉“坏人”!它说“没有”,那他肯定不在。

这太适合我们的场景了!
我们不需要知道这个不存在的用户ID到底在不在数据库,我们只需要在它进数据库之前,先用布隆过滤器拦住它。如果布隆过滤器说“没有”,那咱们就赶紧回家,别去查数据库了!这就能把那群拿着假钥匙的恶霸直接挡在门外,保护我们的数据库。


第三章:PHP如何实现布隆过滤器(从零开始)

PHP本身处理位运算和底层内存操作有点弱,但如果我们要自己实现一个,也能行。不过为了高性能,在生产环境中,我们通常利用 Redis 的 Bitmap 功能,或者使用扩展 phpbloom

但作为专家,咱们得懂原理,所以先教大家写一个手写版的布隆过滤器。这玩意儿代码不长,但细节满满。

1. 核心参数

  • BitMap大小($size):决定了你能存多少数据。如果你存100万个ID,布隆过滤器的大小得比这个大,不然大家都挤在一个小桌子上,冲突就多了。通常设置为存储元素数量的10倍左右。
  • Hash函数数量($hashNum):哈希函数越多,冲突概率越低,但计算量越大。一般3到5个就够了。

2. 代码实现

class BloomFilter
{
    // 假设我们存大约100万个key,数组大小设为1000万位(约1.25MB),足够用了
    private $size = 10000000; 
    private $bitMap = []; // 这里可以用数组模拟位图,但在PHP里为了性能,实际应用推荐Redis

    // 哈希函数数组
    private $hashFunctions = [];

    public function __construct($size = 10000000)
    {
        $this->size = $size;
        // 初始化三个哈希函数
        // 这里为了演示,用了简单的自定义函数,实际生产建议用MurmurHash等高散列函数
        $this->hashFunctions = [
            fn($str) => crc32($str) % $this->size,
            fn($str) => abs(crc32($str) ^ crc32($str . '_salt')) % $this->size,
            fn($str) => abs(crc32($str . '_salt2') ^ crc32($str . '_salt')) % $this->size,
        ];
    }

    /**
     * 添加元素
     */
    public function add($value)
    {
        foreach ($this->hashFunctions as $hashFunc) {
            $index = $hashFunc($value);
            // 设置该位为1
            // PHP中数组的key可以是整数,所以我们直接赋值
            $this->bitMap[$index] = 1;
        }
    }

    /**
     * 判断元素是否存在
     * @return bool
     */
    public function exists($value)
    {
        foreach ($this->hashFunctions as $hashFunc) {
            $index = $hashFunc($value);
            // 如果有一个位置是0,那绝对不存在
            if (!isset($this->bitMap[$index])) {
                return false;
            }
        }
        // 如果所有位置都是1,那可能存在(或者被假阳性了)
        return true;
    }
}

代码解析(敲黑板):
看这个 add 方法,我把同一个字符串通过不同的盐值(_salt)处理,保证了不同的哈希函数生成的索引不一样。这就像在问三个不同性格的管家查账,每个人都有不同的账本,三个都说你有,那你就有;有一个说没,那你就真没。

上面的代码用了PHP的普通数组。如果在高并发下,数组的内存分配和赋值开销也不小。真正的性能怪兽是 Redis


第四章:Redis布隆过滤器——实战中的王者

在PHP里用Redis实现布隆过滤器,那是相当优雅。Redis本身就支持位操作(BITMAP)。

1. 为什么选Redis?

  • 内存管理:Redis的位图非常紧凑,且由C语言编写,操作内存极快。
  • 共享:如果你的PHP服务是多台机器的,Redis是单机版,大家共享一个布隆过滤器,数据库就不容易因为某一台机器的恶意攻击而崩溃。
  • 原子性:Redis的命令都是原子的,不会出现并发问题。

2. 基本操作

Redis的 SETBITGETBIT 命令是核心。

  • SETBIT key offset value: 将 offset 位的值设置为 value (0 or 1)。
  • GETBIT key offset: 获取 offset 位的值。

3. PHP封装一个Redis布隆过滤器

我们需要一个类,把 SETBIT 和 GETBIT 封装起来,再加上我们的哈希逻辑。

class RedisBloomFilter
{
    private $redis;
    private $key;
    // Redis布隆过滤器需要指定预期插入的数量,以便计算合适的位数
    // 比如我们预期存1亿个用户ID,每个key用160位(5个哈希函数 * 32位int),
    // 那么总长度就是 1亿 * 160 = 160亿位 = 20GB。
    // 这太大了!所以实际开发中,我们需要控制预期数量,或者使用更高级的库。
    private $expectedInserts; 

    public function __construct($redisHost, $redisPort, $key, $expectedInserts = 1000000)
    {
        $this->redis = new Redis();
        $this->redis->connect($redisHost, $redisPort);
        $this->key = $key;
        $this->expectedInserts = $expectedInserts;

        // 初始化位图大小:预期插入数 * 每个元素占用的位数(通常5-8位)
        // 这里为了简单演示,假设每个元素占5位
        $this->size = $expectedInserts * 5;
    }

    /**
     * 添加到布隆过滤器
     */
    public function add($value)
    {
        // 我们需要多个哈希函数,为了简单,这里用hash函数加上不同的种子
        $hashes = $this->getHashes($value);

        foreach ($hashes as $hash) {
            $this->redis->setbit($this->key, $hash, 1);
        }
    }

    /**
     * 检查是否存在
     */
    public function exists($value)
    {
        $hashes = $this->getHashes($value);

        foreach ($hashes as $hash) {
            // 只要有一个位是0,肯定不存在
            if ($this->redis->getbit($this->key, $hash) == 0) {
                return false;
            }
        }

        return true;
    }

    /**
     * 辅助:生成哈希值
     */
    private function getHashes($str)
    {
        $results = [];
        // 模拟3个哈希函数
        // 使用 crc32 和不同的 salt
        $results[] = abs(crc32($str . 'salt1') % $this->size);
        $results[] = abs(crc32($str . 'salt2') % $this->size);
        $results[] = abs(crc32($str . 'salt3') % $this->size);

        return $results;
    }
}

关于位数的数学题:
你可能注意到了,代码里有个 $this->size
如果我们有100万个用户,而且想保持极低的误判率(比如1%),我们不能只算1百万个位置。
根据公式:$n$ 是元素个数,$p$ 是误判率,$m$ 是位数组长度,$k$ 是哈希函数个数。
$m = – frac{n ln p}{(ln 2)^2}$
如果 $n=100w, p=0.01$ (1%),算出来 $m$ 大概在 $9.6w$ 左右。
所以代码里的 $expectedInserts * 5 其实是一个比较保守的估算。

  • 坑点:如果你设置的 $expectedInserts 太小(比如只有1000,但实际插入了100万),那布隆过滤器里的位置很快就会不够用,冲突率会飙升到100%,变成垃圾过滤器。
  • 策略:实际开发中,如果你非常确定数据量,就设大点。如果不确定,Redis的布隆过滤器其实有个专门的库 phpbloom,它会根据实际插入的数据动态扩容(但这比较复杂,这里暂不展开)。

第五章:终极组合拳——缓存 + 布隆过滤器

现在,我们已经有了两样武器:

  1. Redis缓存:存热点数据。
  2. 布隆过滤器:存“不存在”的标记。

怎么把它们捏在一起,防止数据库哭泣?

流程图(请脑补):

  1. 用户请求GET /api/user/123456

  2. 第一步:布隆过滤器拦截

    • PHP代码去查布隆过滤器:“有没有 123456 这个ID?”
    • 情况A:布隆过滤器说“没有”。
      • 动作:直接返回 404 Not Found 或者空对象。
      • 结果:请求结束了,没进数据库。数据库保住了!
    • 情况B:布隆过滤器说“可能有”。
      • 动作:继续往下走。
  3. 第二步:Redis缓存拦截

    • PHP代码去查Redis:“缓存里有 123456 吗?”
    • 情况B1:Redis里有。
      • 动作:把数据拿出来,返回给用户。
    • 情况B2:Redis里没有(缓存穿透了)。
      • 动作:去查数据库。
  4. 第三步:数据库查询

    • DB查 123456
    • 情况1:数据库里有。
      • 动作:把数据查出来,存入Redis缓存(设置过期时间,比如5分钟),存入布隆过滤器(如果还没存的话),然后返回。
    • 情况2:数据库里没有(真的穿透了)。
      • 动作:查不到数据。这时候,我们把一个空值存入Redis(防止短时间内再来人还要查DB),存入布隆过滤器

关键代码示例:

假设这是我们的业务代码(PHP):

function getUser($userId) {
    $redis = getRedis();
    $bloomFilter = getBloomFilter();

    // 1. 布隆过滤器预检
    // 如果布隆过滤器说没有,那就不用查DB了,直接返回
    if (!$bloomFilter->exists($userId)) {
        error_log("布隆过滤器拦截了不存在的用户: " . $userId);
        return null; // 或者 throw new NotFoundException();
    }

    // 2. 查询缓存
    $cacheKey = "user_info:" . $userId;
    $data = $redis->get($cacheKey);

    if ($data !== false) {
        // 命中缓存
        return json_decode($data, true);
    }

    // 3. 缓存未命中,查数据库
    $userInfo = Db::query("SELECT * FROM users WHERE id = ?", [$userId]);

    if ($userInfo) {
        // 数据库有数据:回写缓存
        $redis->setex($cacheKey, 300, json_encode($userInfo)); // 5分钟过期

        // 关键点:把数据加入布隆过滤器
        // 这一步非常重要,否则过滤器里永远是空的
        $bloomFilter->add($userId);

        return $userInfo;
    } else {
        // 数据库没数据:缓存空值,防止缓存穿透
        // 这里有个争议点:空值要不要加布隆过滤器?
        // 有人说不加,因为布隆过滤器会误报。
        // 但为了防止“缓存穿透”,通常的做法是:
        // 1. 把空值放入Redis。
        // 2. 把这个不存在的ID加入布隆过滤器。

        $redis->setex($cacheKey, 60, json_encode(null)); // 1分钟过期

        // 把这个“假”的ID加进去,下次再有人查这个ID,布隆过滤器直接告诉它“没有”
        // 注意:虽然这会导致布隆过滤器产生假阳性(误判),但这是为了保住数据库值得的代价
        $bloomFilter->add($userId);

        return null;
    }
}

这段代码讲了个什么道理?
你看,当那个恶霸(攻击者)拿着不存在的ID 999999999 来敲门时:

  1. 第一步,布隆过滤器直接说“滚蛋”。恶霸还没等到DB响应,就被打发了。
  2. 只有当布隆过滤器说“有点意思”时,才会去查缓存。
  3. 如果查了缓存没查到,去查DB,DB没查到,咱们就把 999999999 这个ID记在布隆过滤器里。下次他再来,布隆过滤器直接把他拉黑。

性能分析:

  • Redis操作SETBITGETBIT 都是O(1)操作。
  • 哈希计算:O(k),k通常很小(3-5)。
  • 总耗时:通常在几毫秒以内。相比于数据库的一次IO(几十到几百毫秒),这就是降维打击。

第六章:进阶与陷阱——别让布隆过滤器坑了你

虽然布隆过滤器很香,但老司机都知道,这玩意儿有几个坑,掉进去你就得修服务器。

1. 假阳性

还记得吗?布隆过滤器可能会说“有”,其实“没有”。
这在什么情况下会出问题?
假设你用的是布隆过滤器来做鉴权(比如判断用户有没有权限访问某个API)。
如果布隆过滤器误判了,说“他有权限”,结果数据库里其实没有权限,那用户就操作了不该操作的数据。
对策:布隆过滤器通常不用于强一致性要求的鉴权。它更适合用在“兜底”逻辑中,即:布隆说没有,那就一定没有;布隆说有,我再查数据库确认一下(这就是上面的代码逻辑)。

2. 容量问题

布隆过滤器一旦建好,只能往里加,不能删。
如果你建了一个存1亿个元素的布隆过滤器,你只能往里加1亿个。加满了之后,新来的数据全被当成“有”,误判率飙升。
对策:在生产环境中,布隆过滤器通常配合 缓存预热 使用。在系统启动时,把数据库里所有的ID都加载到布隆过滤器里。同时,为了防止内存溢出,你需要预估一个合理的 $expectedInserts$,或者使用支持动态扩容的库。

3. PHP内存限制

如果你不使用Redis,而是像第二章那样用PHP数组写布隆过滤器。
PHP数组的内存开销很大。一个空数组占用的内存可能就有几十KB。
如果你的 $size 设置了 10000000(1000万位),看起来很小(1.25MB),但PHP数组如果用稀疏矩阵存储,内存可能会膨胀到几十MB甚至上百MB。
对策永远不要在PHP进程里用原生数组写生产级的布隆过滤器! 除非你的数组非常紧凑(比如用位图扩展),否则内存会OOM(Out of Memory)。

4. 初始化时机

布隆过滤器不是现成的。
如果你的数据库里有1亿条数据,你不能等到请求来了再一个个加。
流程应该是:

  1. 服务器启动。
  2. 读取数据库配置或脚本,遍历所有ID。
  3. 往布隆过滤器里 add 一遍。
  4. 开始对外服务。

5. 多实例一致性

如果你的PHP是集群部署的(比如3个节点),你把布隆过滤器放在本地内存里,那没用。节点A查不到,节点B查得到,数据库还是会崩。
必须把布隆过滤器放在Redis里! 所有的PHP节点共享同一个Redis里的布隆过滤器。


第七章:性能对比与总结

咱们来做个“赛跑”:

场景:100万个请求,其中99万个是查“不存在的ID”。

  • 方案一:无布隆过滤器

    • 100万次请求 -> 全部打穿缓存 -> 100万次数据库查询 -> CPU 100%,数据库挂掉。
  • 方案二:缓存空值

    • 100万次请求 -> 缓存未命中 -> 100万次数据库查询(查不到)-> 写入100万条空缓存 -> 数据库崩溃。
    • 后续:第二次请求,查缓存,有数据了,返回。看起来好了,但如果有人清空了缓存,又崩了。
  • 方案三:布隆过滤器

    • 100万次请求 -> 100万次布隆过滤器查询(Redis O(1))-> 全部拦截 -> 0次数据库查询 -> 数据库毫发无损,系统稳如老狗。

结论
布隆过滤器不仅仅是“提高性能”,它是“生存保障”。在高并发、大数据量的场景下,它是防御缓存穿透、缓存击穿的神器。

PHP怎么写?
别搞花里胡哨的。买个高性能的Redis,用Redis的Bitmap命令,或者用 phpbloom 这个优秀的扩展,把它集成到你的代码逻辑里:先查布隆,再查缓存,最后查DB

写代码嘛,不仅要写得快,还要写得稳。布隆过滤器就是那个让你写代码时能睡个安稳觉的“黑科技”。

好了,今天的讲座就到这里。回去记得把那个布隆过滤器加到你的项目里去。别等到老板拍桌子的时候才想起我。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注