go-zero基础组件-分布式布隆过滤器(Bloom Filter)

为什么需要布隆过滤器

想象一下遇到下面的场景你会如何处理:

  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。


image

布隆过滤器优缺点

优点:

  1. 空间占用极小,因为本身不存储数据而是用比特位表示数据是否存在,某种程度有保密的效果。
  2. 插入与查询时间复杂度均为O(k),常数级别,k表示散列函数执行次数。
  3. 散列函数之间可以相互独立,可以在硬件指令层加速计算。

缺点:

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

误差(假阳性率)

布隆过滤器可以100%判断元素不在集合中,但是当元素在集合中时可能存在误判,因为当元素非常多时散列函数产生的k位点可能会重复。
维基百科有关于假阳性率的数学推导(见文末链接)这里我们直接给结论(实际上是我没看懂...),假设:

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

在创建布隆过滤器时我们为了找到合适的mk,可以根据预期元素数量n_ε_来推导出最合适的mk

image
image

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,最大支持232-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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342

推荐阅读更多精彩内容