关于golang:详解布隆过滤器的原理和实现

8次阅读

共计 6234 个字符,预计需要花费 16 分钟才能阅读完成。

为什么须要布隆过滤器

设想一下遇到上面的场景你会如何解决:

  1. 手机号是否反复注册
  2. 用户是否参加过某秒杀流动
  3. 伪造申请大量 id 查问不存在的记录,此时缓存未命中, 如何防止缓存穿透

针对以上问题惯例做法是:查询数据库,数据库硬扛,如果压力并不大能够应用此办法,放弃简略即可。

改良做法:用 list/set/tree 保护一个元素汇合,判断元素是否在汇合内,工夫复杂度或空间复杂度会比拟高。如果是微服务的话能够用 redis 中的 list/set 数据结构, 数据规模十分大此计划的内存容量要求可能会十分高。

这些场景有个共同点,能够将问题形象为:如何高效判断一个元素不在汇合中?
那么有没有一种更好计划能达到工夫复杂度和空间简单双优呢?

有!布隆过滤器

什么是布隆过滤器

布隆过滤器(英语:Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器能够用于检索一个元素是否在一个汇合中,它的长处是空间效率和查问工夫都远远超过个别的算法。

工作原理

布隆过滤器的原理是,当一个元素被退出汇合时,通过 K 个散列函数将这个元素映射成一个位数组中的 K 个点(offset),把它们置为 1。检索时,咱们只有看看这些点是不是都是 1 就(大概)晓得汇合中有没有它了:如果这些点有任何一个 0,则被检元素肯定不在;如果都是 1,则被检元素很可能在。这就是布隆过滤器的根本思维。

简略来说就是筹备一个长度为 m 的位数组并初始化所有元素为 0,用 k 个散列函数对元素进行 k 次散列运算跟 len(m)取余失去 k 个地位并将 m 中对应地位设置为 1。

布隆过滤器优缺点

长处:

  1. 空间占用极小,因为自身不存储数据而是用比特位示意数据是否存在,某种程度有窃密的成果。
  2. 插入与查问工夫复杂度均为 O(k),常数级别,k 示意散列函数执行次数。
  3. 散列函数之间能够互相独立,能够在硬件指令层减速计算。

毛病:

  1. 误差(假阳性率)。
  2. 无奈删除。

误差(假阳性率)

布隆过滤器能够 100% 判断元素不在汇合中,然而当元素在汇合中时可能存在误判,因为当元素十分多时散列函数产生的 k 位点可能会反复。
维基百科有对于假阳性率的数学推导(见文末链接)这里咱们间接给论断(实际上是我没看懂 …),假如:

  • 位数组长度 m
  • 散列函数个数 k
  • 预期元素数量 n
  • 冀望误差_ε_

在创立布隆过滤器时咱们为了找到适合的 m 和 k,能够依据预期元素数量 n 与 ε 来推导出最合适的 m 与 k。

java 中 Guava, Redisson 实现布隆过滤器估算最优 m 和 k 采纳的就是此算法:

// 计算哈希次数
@VisibleForTesting
static int optimalNumOfHashFunctions(long n, long m) {// (m / n) * log(2), but avoid truncation due to division!
    return Math.max(1, (int) Math.round((double) m / n * Math.log(2)));
}

// 计算位数组长度
@VisibleForTesting
static long optimalNumOfBits(long n, double p) {if (p == 0) {p = Double.MIN_VALUE;}
    return (long) (-n * Math.log(p) / (Math.log(2) * Math.log(2)));
}

无奈删除

位数组中的某些 k 点是多个元素重复使用的,如果咱们将其中一个元素的 k 点全副置为 0 则间接就会影响其余元素。
这导致咱们在应用布隆过滤器时无奈解决元素被删除的场景。

能够通过 定时重建 的形式革除脏数据。如果是通过 redis 来实现的话重建时不要间接删除原有的 key,而是学生成好新的再通过 rename 命令即可,再删除旧数据即可。

go-zero 中的 bloom filter 源码剖析

core/bloom/bloom.go

一个布隆过滤器具备两个外围属性:

  1. 位数组:
  2. 散列函数

go-zero 实现的 bloom filter 中位数组采纳的是Redis.bitmap,既然采纳的是 redis 天然就反对分布式场景,散列函数采纳的是MurmurHash3

Redis.bitmap 为什么能够作为位数组呢?

Redis 中的并没有独自的 bitmap 数据结构,底层应用的是动静字符串(SDS)实现,而 Redis 中的字符串理论都是以二进制存储的。
aASCII 码是 97,转换为二进制是:01100001,如果咱们要将其转换为 b 只须要进一位即可:01100010。上面通过 Redis.setbit 实现这个操作:

set foo a \
OK \
get foo \
“a” \
setbit foo 6 1 \
0 \
setbit foo 7 0 \
1 \
get foo \
“b”

bitmap 底层应用的动静字符串能够实现动静扩容,当 offset 到高位时其余地位 bitmap 将会主动补 0,最大反对 2^32-1 长度的位数组(占用内存 512M),须要留神的是调配大内存会阻塞 Redis 过程。
依据下面的算法原理能够晓得实现布隆过滤器次要做三件事件:

  1. k 次散列函数计算出 k 个位点。
  2. 插入时将位数组中 k 个位点的值设置为 1。
  3. 查问时依据 1 的计算结果判断 k 位点是否全副为 1,否则示意该元素肯定不存在。

上面来看看 go-zero 是如何实现的:

对象定义

// 示意通过多少散列函数计算
// 固定 14 次
maps = 14

type (
    // 定义布隆过滤器构造体
    Filter struct {
        bits   uint
        bitSet bitSetProvider
    }
    // 位数组操作接口定义
    bitSetProvider interface {check([]uint) (bool, error)
        set([]uint) error
    }
)

位数组操作接口实现

首先须要了解两段 lua 脚本:

// ARGV: 偏移量 offset 数组
// KYES[1]: setbit 操作的 key
// 全副设置为 1
setScript = `
    for _, offset in ipairs(ARGV) do
        redis.call("setbit", KEYS[1], offset, 1)
    end
    `
// ARGV: 偏移量 offset 数组
// KYES[1]: setbit 操作的 key
// 查看是否全副为 1
testScript = `
    for _, offset in ipairs(ARGV) do
        if tonumber(redis.call("getbit", KEYS[1], offset)) == 0 then
            return false
        end
    end
    return true
    `

为什么肯定要用 lua 脚本呢?
因为须要保障整个操作是原子性执行的。

// redis 位数组
type redisBitSet struct {
    store *redis.Client
    key   string
    bits  uint
}
// 查看偏移量 offset 数组是否全副为 1
// 是: 元素可能存在
// 否: 元素肯定不存在
func (r *redisBitSet) check(offsets []uint) (bool, error) {args, err := r.buildOffsetArgs(offsets)
    if err != nil {return false, err}
    // 执行脚本
    resp, err := r.store.Eval(testScript, []string{r.key}, args)
    // 这里须要留神一下, 底层应用的 go-redis
    // redis.Nil 示意 key 不存在的状况需非凡判断
    if err == redis.Nil {return false, nil} else if err != nil {return false, err}

    exists, ok := resp.(int64)
    if !ok {return false, nil}

    return exists == 1, nil
}

// 将 k 位点全副设置为 1
func (r *redisBitSet) set(offsets []uint) error {args, err := r.buildOffsetArgs(offsets)
    if err != nil {return err}
    _, err = r.store.Eval(setScript, []string{r.key}, args)
    // 底层应用的是 go-redis,redis.Nil 示意操作的 key 不存在
    // 须要针对 key 不存在的状况非凡判断
    if err == redis.Nil {return nil} else if err != nil {return err}
    return nil
}

// 构建偏移量 offset 字符串数组, 因为 go-redis 执行 lua 脚本时参数定义为[]stringy
// 因而须要转换一下
func (r *redisBitSet) buildOffsetArgs(offsets []uint) ([]string, error) {var args []string
    for _, offset := range offsets {
        if offset >= r.bits {return nil, ErrTooLargeOffset}
        args = append(args, strconv.FormatUint(uint64(offset), 10))
    }
    return args, nil
}

// 删除
func (r *redisBitSet) del() error {_, err := r.store.Del(r.key)
    return err
}

// 主动过期
func (r *redisBitSet) expire(seconds int) error {return r.store.Expire(r.key, seconds)
}

func newRedisBitSet(store *redis.Client, key string, bits uint) *redisBitSet {
    return &redisBitSet{
        store: store,
        key:   key,
        bits:  bits,
    }
}

到这里位数组操作就全副实现了,接下来看下如何通过 k 个散列函数计算出 k 个位点

k 次散列计算出 k 个位点

// k 次散列计算出 k 个 offset
func (f *Filter) getLocations(data []byte) []uint {
    // 创立指定容量的切片
    locations := make([]uint, maps)
    // maps 示意 k 值, 作者定义为了常量:14
    for i := uint(0); i < maps; i++ {
        // 哈希计算, 应用的是 "MurmurHash3" 算法, 并每次追加一个固定的 i 字节进行计算
        hashValue := hash.Hash(append(data, byte(i)))
        // 取下标 offset
        locations[i] = uint(hashValue % uint64(f.bits))
    }
  
    return locations
}

插入与查问

增加与查问实现就非常简单了,组合一下下面的函数就行。

// 增加元素
func (f *Filter) Add(data []byte) error {locations := f.getLocations(data)
    return f.bitSet.set(locations)
}

// 查看是否存在
func (f *Filter) Exists(data []byte) (bool, error) {locations := f.getLocations(data)
    isSet, err := f.bitSet.check(locations)
    if err != nil {return false, err}
    if !isSet {return false, nil}

    return true, nil
}

改良倡议

整体实现十分简洁高效,那么有没有改良的空间呢?

集体认为还是有的,下面提到过主动计算最优 m 与 k 的数学公式,如果创立参数改为:

预期总数量 expectedInsertions

冀望误差 falseProbability

就更好了,尽管作者正文里特地提到了误差阐明,然而实际上作为很多开发者对位数组长度并不敏感,无奈直观晓得 bits 传多少预期误差会是多少。

// New create a Filter, store is the backed redis, key is the key for the bloom filter,
// bits is how many bits will be used, maps is how many hashes for each addition.
// best practices:
// elements - means how many actual elements
// when maps = 14, formula: 0.7*(bits/maps), bits = 20*elements, the error rate is 0.000067 < 1e-4
// for detailed error rate table, see http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html
func New(store *redis.Redis, key string, bits uint) *Filter {
    return &Filter{
        bits:   bits,
        bitSet: newRedisBitSet(store, key, bits),
    }
}

// expectedInsertions - 预期总数量
// falseProbability - 预期误差
// 这里也能够改为 option 模式不会毁坏原有的兼容性
func NewFilter(store *redis.Redis, key string, expectedInsertions uint, falseProbability float64) *Filter {bits := optimalNumOfBits(expectedInsertions, falseProbability)
    k := optimalNumOfHashFunctions(bits, expectedInsertions)
    return &Filter{
        bits:   bits,
        bitSet: newRedisBitSet(store, key, bits),
        k:      k,
    }
}

// 计算最优哈希次数
func optimalNumOfHashFunctions(m, n uint) uint {return uint(math.Round(float64(m) / float64(n) * math.Log(2)))
}

// 计算最优数组长度
func optimalNumOfBits(n uint, p float64) uint {return uint(float64(-n) * math.Log(p) / (math.Log(2) * math.Log(2)))
}

回到问题

如何预防非法 id 导致缓存穿透?

因为 id 不存在导致申请无奈命中缓存流量间接打到数据库,同时数据库也不存在该记录导致无奈写入缓存,高并发场景这无疑会极大减少数据库压力。
解决方案有两种:

  1. 采纳布隆过滤器

数据写入数据库时需同步写入布隆过滤器,同时如果存在脏数据场景(比方:删除)则须要定时重建布隆过滤器,应用 redis 作为存储时不能够间接删除 bloom.key,能够采纳 rename key 的形式更新 bloom

  1. 缓存与数据库同时无奈命中时向缓存写入一个过期工夫较短的空值。

材料

布隆过滤器(Bloom Filter)原理及 Guava 中的具体实现

布隆过滤器 - 维基百科

Redis.setbit

我的项目地址

https://github.com/zeromicro/go-zero

欢送应用 go-zerostar 反对咱们!

微信交换群

关注『微服务实际 』公众号并点击 交换群 获取社区群二维码。

正文完
 0