1. 前言

在前些年的开发中就遇到几个类似的场景:

场景1

场景:我当前业务表结构冗余了人员的信息字段,当人员的基本信息发生变更或删除时,会推送MQ。我当前业务监听到有人员信息变更的MQ消息,会查数据库,看看该人员在当前表中是否存在。如果存在则更新,如果不存在则无需处理。

问题:每当监听到人员变更的的MQ消息,就需要查询表中是否存在,给数据库带来消耗。

尝试方案:将当前表中的所有人员ID,存入redis Set集合中。在监听到人员变更的MQ消息时,先去Set中检查一下是否存在。如果存在再去更新数据库。

尝试方案的问题:当人员数量几十万的时候,redis Set集合也会很大,redis key太大影响性能,检查人员是否存在也会变慢。

场景2

我们有些高频调用的查询接口,因为调用频率高,查库复杂,因此已经做了缓存。缓存的策略是:如果查询对应的缓存key存在,则从缓存获取;如果不存在,则在查库,如果查库能获得有效值,再将结果存入缓存。

带来的问题:接口对外暴露后,因为前台调用方不可控,可能总会查询不存在的key。造成缓存穿透,频繁的无效查库。

尝试方案:针对缓存穿透,当查询某个key,在库中不存在时。在缓存中依然创建一个key,value设为null。

尝试方案的问题:设为null的key,就只有等过期时间到了后,自行删除。有些时候上一秒查库不存在,给对应的key缓存了一个为null的value。但下一秒库中有了,走缓存拿到的依然是null。

更好的解决方案

对于上面可能遇到的问题,和尝试的解决方案,有一个更好的解决方案:布隆过滤器。

可以理解为场景1里面的那个Set,只不过容量更小,检索性能更高。那么针对场景2缓存穿透的问题,将所有的缓存key存入布隆过滤器中,如果在过滤器中的key,再通过缓存、数据库获取。

2. 布隆过滤器

布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。主要用于判断一个元素是否在一个集合中。

通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。同时检索速度也越来越慢。上述三种结构的检索时间复杂度分别为:

  • O(n):链表
  • O(logn):树
  • O(1):哈希表

这个时候,布隆过滤器(Bloom Filter)就应运而生。

2.1. 原理

当一个元素被加入集合时,通过N个Hash函数将这个元素进行Hash,算出一个整数索引值,然后对数组长度进行取模运算,从而得到一个位置,每个Hash函数都会得到一个不同的位置,然后再把位数组中的几个位置点都置为1。

检索时,也会把哈希的几个位置算出来,然后看看位数组中对应的几个位置是否都为1,只要有一个位为0,那么就说明布隆过滤器里不存在这个元素。

但是,这几个位置都为1,并不能完全说明这个元素就一定存在其中。因为散列函数是会有碰撞的,不同的输入,可能在哈希后为同一个位置。即有可能这些位置为1是因为其他元素的存在,这就是布隆过滤器会出现误判的原因。

因此查询某个变量的时候我们只要看看这些点是不是都是 1 就可以大概率知道集合中有没有它了

  • 如果这些点有任何一个 0,则被查询变量一定不在。
  • 如果都是 1,则被查询变量很可能存在。

2.2. 特性与优缺点

1. 不存在时一定不存在

一个元素如果判断结果为存在的时候元素不一定存在,但是判断结果为不存在的时候则一定不存在。

原因:布隆过滤器的误判是指多个输入经过哈希之后,在相同的bit位的值置1了,这样就无法判断究竟是哪个输入产生的,因此误判的根源在于相同的 bit 位被多次映射且置 1。

2. 只增不删

布隆过滤器可以添加元素,但是不能删除元素。因为删掉元素会导致误判率增加。

原因:上述原因中的情况,多个输入经过哈希之后,在相同的bit位的值置1了,也造成了布隆过滤器的删除问题。因为布隆过滤器的每一个 bit 并不是独占的,很有可能多个元素共享了某一位。如果我们直接删除这一位的话,会影响其他的元素。

如果需要删除一批元素,可以考虑重新初始化一个布隆过滤器,替换原来的。

3. 优点
  • 占用空间极小,插入和查询速度极快;
  • 布隆过滤器可以表示全集,其它任何数据结构都不能;
3. 缺点
  • 误算率随着元素的增加而增加;
  • 一般情况下无法删除元素;

2.3. 应用场景

基于上述的功能,我们大致可以把布隆过滤器用于以下的场景之中:

1. 大数据判断是否存在来实现去重

这就可以实现出上述的去重功能,如果你的服务器内存足够大的话,那么使用 HashMap 可能是一个不错的解决方案,理论上时间复杂度可以达到 O(1) 的级别,但是当数据量起来之后,还是只能考虑布隆过滤器。

2. 判断用户是否访问过

判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。

3. 爬虫/邮箱等系统的过滤

平时不知道你有没有注意到有一些正常的邮件也会被放进垃圾邮件目录中,这就是使用布隆过滤器误判导致的。

4. 天然适合缓存穿透场景

布隆过滤器天然就能应对缓存穿透的场景。

首先,布隆过滤器的应用策略正好和缓存是相反的:

  • 缓存策略:缓存中不存在的,再去查db。
  • 布隆过滤器策略:过滤器中存在的,再去查缓存(db)。

然后,由于它的特性:一个元素如果判断结果为存在的时候元素不一定存在,但是判断结果为不存在的时候则一定不存在。

这表明它的误判率并不影响它的策略:

  • 当判断结果为存在时:不一定存在。带来的不好的结果,顶多就是多查一次缓存。
  • 当判断结果为不存在时:一定不存在。策略中判断不存在时,当前请求就会被拦截,这方面是没有误判的。

所以说,布隆过滤器天然适合缓存穿透的场景,它的误判率对与该场景没有丝毫影响。

2.4. 实现

有很多布隆过滤器的实现,就如同之前将限流器的实现有 guava 和 redisson,布隆过滤器的实现也一样。

下面两种实现方式十分相似,都会初始化两个参数:

  • 初始容量:当实际元素的数量超过这个初始化容量时,误判率上升。设置的过大,会浪费存储空间,设置的过小,就会影响准确率,所以在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出设置值很多。
  • 期望错误率:期望错误率越低,需要的空间就越大。错误率越小,需要的存储空间就越大,对于不需要过于精确的场景,错误率设置稍大一点也可以。

2.4.1. guava

1. pom
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>23.0</version>
</dependency>
2. main
    private static String STR_1="str_1";
    private static String STR_2="str_2";
    private static String STR_101="str_101";

    public static void main(String[] args) {
        BloomFilter<String> bloomFilter=BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),10000,0.0001);
        for (int i = 0; i < 100; i++) {
            bloomFilter.put("str_" + i);
        }
        log.info("{}是否存在:{}",STR_1,bloomFilter.mightContain(STR_1));
        log.info("{}是否存在:{}",STR_2,bloomFilter.mightContain(STR_2));
        log.info("{}是否存在:{}",STR_101,bloomFilter.mightContain(STR_101));
    }

执行后返回的结果是:

23:30:45.960 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_1是否存在:true
23:30:45.965 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_2是否存在:true
23:30:45.966 [main] INFO pers.kerry.redislimitservice.controller.DemoController - str_101是否存在:false

Guava 提供的布隆过滤器的实现还是很不错的 ,但是它有一个重大的缺陷就是只能单机使用 (另外,容量扩展也不容易),而现在互联网一般都是分布式的场景。为了解决这个问题,我们就需要用到 Redis 中的布隆过滤器了。

2.4.2. redisson

1. pom
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.15.5</version>
        </dependency>
2. application
spring:
  redis:
    redisson:
      config:
        singleServerConfig:
          address: redis://127.0.0.1:6379
          database: 0
3. controller
    @GetMapping("bloom-filter")
    public boolean bloomFilter(String str) {
        RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("bloom:filter:test1");
        bloomFilter.tryInit(10000, 0.0001);
        for (int i = 0; i < 100; i++) {
            bloomFilter.add("str_" + i);
        }
        return bloomFilter.contains(str);
    }

3. Redis BitMap

上述讲到 Redisson 基于布隆过滤器的实现,本质上是redis支持了布隆过滤器,这里就要讲到 redis 的 BitMap 结构。

redis BitMap 并不作为redis基础数据类型,redis的基本数据类型只有5种:string、list、set、zset、hash。

而 BitMap 就是基于SDS(Simple Dynamic String,简单动态字符串)实现的,所以针对 BitMap Key 的名称执行 TYPE KEY_NAME 命令时,返回的是 string。

因此,我们在讲 BitMap 数据结构之前,先讲一下 SDS。

3.1. SDS字符串

1. 数据结构

SDS 的数据结构定义为:

struct sdshdr {

    unsigned int len;

    unsigned int free;

    char buf[];
};
  • len:记录buf数组中已使用字节的数量,即等于SDS所保存字符串的长度。
  • free:记录buf数组中未使用字节的数量
  • buf:char数组,用于保存实际字符串数据,注意数组末尾总会保留一个空字符串'\0'。
2. 着重介绍一下buf

它是 char 数组,char 是C语言中的字符类型,占1个字节(Byte),即8个位(Bit)。

buf 尾部自动追加一个'\0'字符并不会计算在 SDS 的len中,这是为了遵循 C 字符串以空字符串结尾的惯例,使得 SDS 可以直接使用一部分string.h库中的函数,如strlen。

3. SDS优点

SDS 具有以下优点,但这里就不展开了。这里就简单列一下,可后期专门看这方面的资料:

  • 常数复杂度获取字符串长度。
  • 杜绝缓冲区溢出。
  • 减少修改字符串长度时所需的内存重分配次数。
  • 二进制安全。
  • 兼容部分C字符串函数。

3.2. BitMap位图

在简单了解 SDS 的数据结构后,就比较方便理解 BitMap了。

如果我们需要记录某一用户在一年中每天是否有登录我们的系统这一需求该如何完成呢?如果使用KV存储,每个用户需要记录365个,当用户量上亿时,这所需要的存储空间是惊人的。

Redis 为我们提供了BitMap位图这一数据结构,每个用户每天的登录记录只占据一位,365天就是365位。8位(Bit)1个字节(Byte),因此仅仅需要46字节就可存储,极大地节约了存储空间。

BitMap 简称位图,是由多个二进制位组成的数组,数组中的每个二进制位都有与之对应的偏移量,可以通过这些偏移量对位图中指定的一个或多个二进制位进行操作。

1. 命令

Redis提供了SETBIT、GETBIT、BITCOUNT、BITOP四个常用命令用于处理二进制位数组。

  • SETBIT:为位数组指定偏移量上的二进制位设置值,偏移量从0开始计数,二进制位的值只能为0或1。返回原位置值。
  • GETBIT:获取指定偏移量上二进制位的值。
  • BITCOUNT:统计位数组中值为1的二进制位数量。
  • BITOP:对多个位数组进行按位与、或、异或运算。

最常用的两个命令 setbit、getbit 执行的复杂度都是 O(1),算是拿空间换时间的做法。

2. 数据结构

BitMap 是基于 SDS实现的,所以说数据结构上一样。还记得之前 SDS 的数据结构中,buf 是一个char字节数组吧,数组中每个元素char有8个位,每个位中就可以存储我们 BitMap 中的数据。

gitbit 命令的源码如下:

void getbitCommand(client *c) {
    robj *o;
    char llbuf[32];
    uint64_t bitoffset;
    size_t byte, bit;
    size_t bitval = 0;
    // 获取offset
    if (getBitOffsetFromArgument(c,c->argv[2],&bitoffset,0,0) != C_OK)
        return;
    // 查找对应的位图对象
    if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.czero)) == NULL ||
        checkType(c,o,OBJ_STRING)) return;
  // 计算offset位于位数组的哪一行
    byte = bitoffset >> 3;
    // 计算offset在一行中的第几位,等同于取模
    bit = 7 - (bitoffset & 0x7);
    // #define sdsEncodedObject(objptr) (objptr->encoding == OBJ_ENCODING_RAW || objptr->encoding == OBJ_ENCODING_EMBSTR)
    if (sdsEncodedObject(o)) {
        // SDS 是RAW 或者 EMBSTR类型
        if (byte < sdslen(o->ptr))
            // 获取指定位置的值
            // 注意它不是真正的一个二维数组不能用((uint8_t*)o->ptr)[byte][bit]去获取呀~
            bitval = ((uint8_t*)o->ptr)[byte] & (1 << bit);
    } else {
        //  SDS 是 REDIS_ENCODING_INT 类型的整数,先转为String
        if (byte < (size_t)ll2string(llbuf,sizeof(llbuf),(long)o->ptr))
            bitval = llbuf[byte] & (1 << bit);
    }

    addReply(c, bitval ? shared.cone : shared.czero);
}

getbit 命令的执行过程如下:

  1. 计算 byte=offset/8,byte 值表示指定的 offset 位于位数组的哪个字节(计算在第几行);
  2. 指定 buf[i] 中的i了,接下来就要计算在8个字节中的第几位呢?使用 bit=(offset mod 8)+1 计算可得;
  3. 根据 byte 和 bit 在位数组中定位到目标值返回即可。

以GETBIT array 3为例,array表示上图中三个字节的位数组。

  1. byte=[3/8] 得到值为0,说明在 buf[0] 上
  2. bit=(3 mod 8)+1 得到值为4
  3. 定位到 buf[0] 字节的从左至右第4个位置上

引用 :


KerryWu
641 声望159 粉丝

保持饥饿