我把一千万次 SQL 校验干成了一次 Redis 布隆过滤,速度快到怀疑人生

在系统规模还很小时,许多问题都像不存在一样。但当用户量突破数百万、上千万后,你会发现某些你从未关注过的“小细节”,会突然变成拖垮系统的幕后黑手。

我就踩过这样一个坑——一个看似微不足道的用户名重复校验,差点把数据库打到 CPU 100%。

这篇文章将带你从“数据库快烧掉了”,一路走到“靠 Redis Bloom Filter 稳住场面”的完整过程,并给出 Spring Boot + Redis 最小可运行 Demo。

数据库第一次给我“吼”:你别查了行不行?

故事的开始超级普通。

注册接口需要校验用户名是否已经存在:

SELECT COUNT(*) FROM users WHERE username = 'praveen';

没任何问题。 没任何难度。 也没任何风险——直到用户数量突破 10,000,000

然后,我的世界开始变得不太美好:

  • CPU 一直拉满
  • 请求响应变慢
  • 数据库连接池被瞬间占满
  • 监控图上红线一条接一条

让我震惊的是,整个注册流程最耗时的居然不是密码校验、不是业务逻辑,而是这个看似 harmless 的“是否存在”查询。

那一刻我终于清楚了一件事:

数据库天生不适合做 “我以前看过这个吗?” 的判断。

哪怕你给 username 建了索引,也一样会触发:

  • I/O
  • 网络往返
  • CPU 计算
  • 连接池争抢

规模一大,这种查询就是慢性毒药。

我需要一个 能在访问数据库之前做预筛选 的机制。

这时,我遇见了 Bloom Filter:数据库门口的保镖

如果你把数据库比喻成一家酒吧,那 Bloom Filter 就像门口的保镖。

它不需要认识所有人,但它很快就能告知你:

  • “这人肯定没来过(不存在)” —— 100% 准确
  • “嗯?可能来过,你进去问下(可能存在)” —— 有必定误报率

这样,大量根本不存在的用户名 就会被挡在数据库门外。

在实际测试中,它帮我过滤掉 90% ~ 99% 的数据库查询

数据库:终于可以喘口气了。

为什么我选择 Redis 来承载 Bloom Filter?

Bloom Filter 本质上是一个位数组 + 多个哈希函数。 Redis 天然适合作为它的容器,由于:

  • Redis 全内存存储,速度是纳秒级别
  • 支持位图操作(setbit / getbit)
  • 可以集群共享
  • 可以持久化

几乎是为 Bloom Filter 量身定制的。

特别适合这些场景:

  • 用户名 / 邮箱是否重复
  • 交易请求是否重复
  • 抓取系统去重
  • 爬虫 URL 判重
  • 防垃圾消息

手把手带你搭一个最小可用 Demo(Spring Boot + Redis)

接下来,打造一个本地可跑的版本。

Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>


    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>


    <dependency>
        <groupId>io.lettuce.core</groupId>
        <artifactId>lettuce-core</artifactId>
    </dependency>
</dependencies>

本地安装 Redis

Mac/Linux

brew install redis
redis-server

Windows

choco install redis
redis-server

配置文件 application.properties

spring.application.name=bloom-filter-demo
spring.redis.host=localhost
spring.redis.port=6379

核心逻辑:Bloom Filter 实现(已优化代码)

路径:
src/main/java/com/icoderoad/bloomfilter/service/BloomFilterService.java

package com.icoderoad.bloomfilter.service;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;


import java.nio.charset.StandardCharsets;


@Service
public class BloomFilterService {


    // 位数组长度
    private static final int SIZE = 1_000_000;


    // 多种哈希函数种子
    private static final int[] SEEDS = {7, 11, 13, 31, 37, 61};


    @Autowired
    private RedisTemplate<String, Object> redisTemplate;


    private static final String BLOOM_KEY = "username_bloom_filter";


    /** 添加元素 */
    public void add(String username) {
        for (int seed : SEEDS) {
            int hash = hash(username, seed);
            redisTemplate.opsForValue().setBit(BLOOM_KEY, hash, true);
        }
    }


    /** 判断可能存在 */
    public boolean mightContain(String username) {
        for (int seed : SEEDS) {
            int hash = hash(username, seed);
            Boolean bit = redisTemplate.opsForValue().getBit(BLOOM_KEY, hash);
            if (bit == null || !bit) {
                return false; // 只要有一个 bit 为 false,则必定不存在
            }
        }
        return true; // 所有 bit 都是 true,则可能存在
    }


    /** 哈希函数 */
    private int hash(String value, int seed) {
        int result = 0;
        byte[] data = value.getBytes(StandardCharsets.UTF_8);
        for (byte b : data) {
            result = result * seed + b;
        }
        return Math.abs(result % SIZE);
    }
}

REST 接口

路径:
src/main/java/com/icoderoad/bloomfilter/controller/UsernameController.java

package com.icoderoad.bloomfilter.controller;


import com.icoderoad.bloomfilter.service.BloomFilterService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;


@RestController
@RequestMapping("/usernames")
public class UsernameController {


    @Autowired
    private BloomFilterService bloomFilterService;


    @PostMapping("/add/{username}")
    public String addUsername(@PathVariable String username) {
        bloomFilterService.add(username);
        return "Username added: " + username;
    }


    @GetMapping("/check/{username}")
    public String checkUsername(@PathVariable String username) {
        boolean exists = bloomFilterService.mightContain(username);
        return exists ? "Username might exist!" : "Username definitely does not exist!";
    }
}

本地测试

添加用户名

curl -X POST http://localhost:8080/usernames/add/praveencodes

检查已添加用户

curl http://localhost:8080/usernames/check/praveencodes

检查不存在的用户

curl http://localhost:8080/usernames/check/randomuser

内部到底发生了什么?

过程超级简单但高效:

  1. 使用多个哈希函数对 username 计算多个 hash
  2. Redis 位数组对应位置全部置 1
  3. 查询时只检查这些位是否都是 1
  4. 若都为 1 → 可能存在
  5. 若任意为 0 → 肯定不存在
  6. 如果判断“不存在”,则不会访问数据库

结果就是:

数据库请求从 千万级下降到十万级QPS 提升数倍延迟从 3ms → 0.05ms

真实压测结果(1000 万条数据)

指标

传统数据库

Bloom Filter 预筛选

单次查询耗时

3–5 ms

0.05 ms

总 DB 查询数

10,000,000

~100,000

内存占用

仅几 MB

Bloom Filter 就像一个超级前置缓存,把无意义的数据请求挡在数据库之外。

Bloom Filter 的局限性

Bloom Filter 看似完美,但必须认识这几点:

有误报(False Positive)

  • 会误判“存在”
  • 不会误判“不存在” (即:不会漏掉实际存在的数据)

无法删除

  • 位数组只有 0/1
  • 删除会影响其他元素 (除非使用 Counting Bloom Filter)

参数需要调优

  • 位数组大小
  • 哈希函数数量
  • 容量规划 都会影响误报率与性能

结语:唯一比 Bloom Filter 更可怕的,是忽视它

我们常常会把注意力放在复杂的优化上,列如多线程、分库分表、连接池调优。 但真正让系统崩溃的,有时就是这样一个不起眼的存在校验。

Bloom Filter 的出现不是为了替代数据库,而是为了 保护数据库

它能把大量重复、无意义、可预判的请求挡掉, 让数据库把资源留给真正有价值的请求。

如果你的系统有以下情况:

  • 用户量大
  • 去重操作频繁
  • 需要高吞吐量
  • 在做 “是否存在” 判定

那 Bloom Filter 必定值得你立刻把它接入生产。

你不会后悔。 你的数据库更不会。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
水流花谢的头像 - 鹿快
评论 共11条

请登录后发表评论