go-zero bloom

84 阅读4分钟

bloom原理

  • 分配 m 位的向量 v,最初全部设置为 0
  • 选择 k 个独立的哈希函数
  • 对于每个函数:
    • 当我们设置值 a 的时候
      • v 中的位置 h1(a), h2(a), ..., hk(a) 处的位置设为 1
  • 对于值 b 的查询:
    • 我们检查 h1(b), h2(b), ..., hk(b) 处的值
      • 如果其中任何一个位的值为 0,则 b 肯定不存在于集合中,否则,我们推断 b 在集合中

布隆过滤器有一定的误判,这个大家自己查资料

使用方法:

package main

import (
   "context"
   "fmt"

   "github.com/zeromicro/go-zero/core/bloom"
   "github.com/zeromicro/go-zero/core/stores/redis"
)

func main() {
   store := redis.MustNewRedis(redis.RedisConf{
      Host: "192.168.0.112:6379",
      Pass: "nil",
      Type: "node",
   })

   ctx := context.Background()

   filter := bloom.New(store, "testbloom", 64)
   filter.AddCtx(ctx, []byte("kevin"))
   filter.AddCtx(ctx, []byte("wan"))
   fmt.Println(filter.ExistsCtx(ctx, []byte("kevin")))
   fmt.Println(filter.ExistsCtx(ctx, []byte("wan")))
   fmt.Println(filter.ExistsCtx(ctx, []byte("nothing")))
}

上面这些不解释了

源码分析

整个 bloom 的代码结构如下 image.png

可以看到,bloom包对外仅提供两个方法:

  • Add
    • 向集合中添加一个元素
  • Exists
    • 判断某个元素是否存在

具体实现

type (
   // A Filter is a bloom filter.
   Filter struct {
      bits   uint
      bitSet bitSetProvider
   }

   bitSetProvider interface {
      check(ctx context.Context, offsets []uint) (bool, error)
      set(ctx context.Context, offsets []uint) error
   }
)

这段代码定义了接口 bitSetProvider, 然后Filter中的 bitSetbitSetProvider 类型,在go-zero中,使用了redis 作为布隆过滤器

接下来我们看 New 方法:


// for detailed error rate table, see http://pages.cs.wisc.edu/~cao/papers/summary-cache/node8.html
// maps as k in the error rate table
const maps = 14

// 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),
   }
}

go-zero中使用了14个哈希函数,这里建议的最佳实践 20 * 预估的元素数量,可以让错误率为 0.000067 < 1e-4。这里的 bitSet 初始化了一个 redisSet

可以具体看看:

redisSet

image.png

redisSet 实现了 bitSetProvider 接口。

我们看看具体的实现

func (r *redisBitSet) set(ctx context.Context, offsets []uint) error {
   args, err := r.buildOffsetArgs(offsets)
   if err != nil {
      return err
   }

   _, err = r.store.ScriptRunCtx(ctx, setScript, []string{r.key}, args)
   if err == redis.Nil {
      return nil
   }

   return err
}

这里会调用 buildOffsetArgs 方法,将传进来的 offsets 转换为 字符串类型的 offset 然后执行 redis.ScriptRunCtx

    setScript = redis.NewScript(`
for _, offset in ipairs(ARGV) do
   redis.call("setbit", KEYS[1], offset, 1)
end
`

这里使用了 lua 脚本来设置 bitmap 对应的位数的值为 1

这里再附带一点lua脚本的好处:

  1. 减少网络开销: 在Redis操作需求需要向Redis发送5次请求,而使用脚本功能完成同样的操作只需要发送一个请求即可,减少了网络往返时延。
  2. 原子操作: Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说在编写脚本的过程中无需担心会出现竞态条件,也就无需使用事务。事务可以完成的所有功能都可以用脚本来实现。
  3. 复用: 客户端发送的脚本会永久存储在Redis中,这就意味着其他客户端(可以是其他语言开发的项目)可以复用这一脚本而不需要使用代码完成同样的逻辑。
  4. 速度快:见 与其它语言的性能比较, 还有一个 JIT编译器可以显著地提高多数任务的性能; 对于那些仍然对性能不满意的人, 可以把关键部分使用C实现, 然后与其集成, 这样还可以享受其它方面的好处。

命令格式

EVAL script numkeys key [key ...] arg [arg ...]

demo:

# demo1
redis> eval '
for _, offset in ipairs(ARGV) do
   if tonumber(redis.call("getbit", KEYS[1], offset)) == 0 then
      return false
   end
end
return true
' 2 testbloom 11 0

输出
1

# demo2

redis> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

输出
1) "key1"
2) "key2"
3) "first"
4) "second"

好的,现在我们继续回到源码阅读

现在看看 exists方法:

func (r *redisBitSet) check(ctx context.Context, offsets []uint) (bool, error) {
   args, err := r.buildOffsetArgs(offsets)
   if err != nil {
      return false, err
   }

   resp, err := r.store.ScriptRunCtx(ctx, testScript, []string{r.key}, args)
   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
}

代码逻辑也很简单,首先把offset转化为 string 类型的 args

然后执行script:

    testScript = redis.NewScript(`
for _, offset in ipairs(ARGV) do
   if tonumber(redis.call("getbit", KEYS[1], offset)) == 0 then
      return false
   end
end
return true
`)

// 建议改名为 checkScript

这里会检查每一个 offset 的值是否为 0,如果为 0 的话,则返回 false,在 redis 中的表现则是 nullgo 中表现则是 redis.nil

源码中写的是

resp, err := r.store.ScriptRunCtx(ctx, testScript, []string{r.key}, args)
   if err == redis.Nil {
      return false, nil
   }

如果是 nil ,则返回 fase,该值不存在