從點陣圖到布隆過濾器,C#實現

黑洞視界發表於2022-06-26

前言

本文將以 C# 語言來實現一個簡單的布隆過濾器,為簡化說明,設計得很簡單,僅供學習使用。

感謝@時總百忙之中的指導。

布隆過濾器簡介

布隆過濾器(Bloom filter)是一種特殊的 Hash Table,能夠以較小的儲存空間較快地判斷出資料是否存在。常用於允許一定誤判率的資料過濾及防止快取擊穿及等場景。

相較於 .NET 中的 HashSet 這樣傳統的 Hash Table,存在以下的優劣勢。

優勢:

  1. 佔用的儲存空間較小。不需要像 HashSet 一樣儲存 Key 的原始資料。

劣勢:

  1. 存在誤判率,過濾器認為不存在的資料一定不存在,但是認為存在的資料不一定真的存在。這個和布隆過濾器的實現方式有關。
  2. 不支援資料的刪除,下文會講為什麼不支援刪除。

資料的儲存

布隆過濾器的資料儲存在 點陣圖(Bitmap)上。Bitmap 簡而言之是二進位制位(bit)的陣列。Hash Table 儲存每個元素的位置,我們稱之為 桶(bucket), Bitmap 上的每一位就是布隆過濾器的 bucket。

布隆過濾器的每一個 bucket 只能儲存 0 或 1。資料插入時,布隆過濾器會通過 Hash 函式計算出插入的 key 對應的 bucket,並將該 bucket 設定為 1。

查詢時,再次根據 Hash 函式計算出 key 對應的 bucket,如果 bucket 的值是 1,則認為 key 存在。

Hash 衝突的解決方案

布隆過濾器使用了 Hash 函式,自然也逃不過 Hash 衝突的問題。對布隆過濾器而言,發生 Hash 衝突也就意味著會發生誤判。

傳統 Hash 演算法解決 Hash 衝突的方式有 開放定址法、連結串列法等。而布隆過濾器解決 Hash 衝突的方式比較特殊,它使用了多個 Hash 函式來解決衝突問題。

下圖中插入布隆過濾器的 Bar 和 Baz 經過 Hash1 計算出的位置是同一個,但 Hash2 計算出的位置是不一樣的,Bar 和 Baz 得以區分。

即使布隆過濾器使用了這種方式來解決 Hash衝突,衝突的可能性依舊存在,如下圖所示:

由於布隆過濾器不保留插入的 Key 的原始值,Hash 衝突是無法避免的。我們只能通過增加 Hash 函式的數量來減少衝突的概率,也就是減少誤判率。

假設布隆過濾器有 m 個 bucket,包含 k 個雜湊函式,已經插入了 n 個 key。經數學推導可得誤判率 ε 的公式如下:

具體推斷過程可參考 https://en.wikipedia.org/wiki/Bloom_filter。

布隆過濾器的誤判概率大致和 已經插入的 key 的數量 n 成正比,和 hash函式數量 k、bucket 數 m 成反比。為了減少誤判率,我們可以增加 m 或 增加 k,增加 m 意味著過濾器佔用儲存空間會增加,增加 k 則意味著插入和查詢時的效率會降低。

為什麼布隆過濾器不支援刪除

布隆過濾器通過多個 Hash 函式來解決衝突的設計,也意味著多著插入元素可能會共享同樣的 bucket,刪掉一個元素的同時,也會被其他元素的一部分 bucket 給刪掉。因此基於 Bitmap 實現的布隆過濾器是不支援刪除的。

用 C# 實現 Bitmap

在實現布隆過濾器之前,我們首先要實現一個 Bitmap。

在 C# 中,我們並不能直接用 bit 作為最小的資料儲存單元,但藉助位運算的話,我們就可以基於其他資料型別來表示,比如 byte。下文用 byte 作為例子來描述 Bitmap 的實現,但不僅限於 byte,int、long 等等也是可以的。

位運算

下面是 C# 中位運算的簡單介紹:

符號 描述 運算規則
& 兩個位都為1時,結果才為1
| 兩個位都為0時,結果才為0
^ 異或 兩個位相同為0,相異為1
~ 取反 0變1,1變0
<< 左移 各二進位全部左移若干位,低位補0
>> 右移 各二進位全部右移若干位,高位補0

一般來說,我們要進行位運算計算的資料通常都是由多個二進位組成的。對兩個數字使用 &|^ 這三個運算子時,需要對齊兩個數字的右邊,一位位地進行計算。

// 0b 代表值用二進位制表示數字
short a =                     0b0111111111111001;
byte  b =                            0b011111111;
short c = (short)(a & b);  // 0b0111111111111001
short d = (short)(a | b);  // 0b0111111111111111
short e = (short)(a ^ b);  // 0b0000000000000110
byte  f = (byte)~b;                  0b011111111;
short g = (short)(b << 1); // 0b0000000111111111;
short h = (short)(b >> 1); // 0b0000000001111111;

利用位運算建立 Bitmap

藉助 byte 實現 Bitmap,也就是要能夠修改和檢視 byte 上的每一個 bit 的值,同時,修改要能夠實現冪等。

  1. 指定位設定成 1
    按前面說的位運算的規則,是不能夠單獨修改 bit 序列中某一位的。位運算需要從右到左一對對計算。
    使用 | 可以實現這個功能。假設我們要改變從右開始下標為 3(初始位置0) 的 bit 的值,則需要準備一個該位置為 1,其他位置都是 0 的 bit 序列,與要改變的 bit 序列進行 | 運算。
// 為了將 a 的右邊數起第 3 位改成 1,需要準備一個 b
byte a =            0b010100010;
byte b = 1 << 3; // 0b000001000
a |= b;          // 0b010101010
  1. 指定位設定成 0
    和設定成 1 正好相反,需要準備一個指定位置為 0,其他位置都是 1 的 bit 序列,與要改變的 bit 序列進行 & 運算。
byte a =            0b010101010;
byte b = 1 << 3; // 0b000001000
b = ~b;          // 0b111110111
a &= b;          // 0b010100010
  1. 檢視指定位的值
    利用 & 運算子,只要計算結果不為 0,就代表指定位置的值為 1。
byte a =            0b010101010;
byte b = 1 << 3; // 0b000001000;
a &= b;          // 0b000001000;

瞭解了基本的操作之後,我們把資料儲存到 byte 陣列上。

class Bitmap
{
    private readonly byte[] _bytes;
    private readonly long _capacity;

    public Bitmap(long capacity)
    {
        _capacity = capacity;
        _bytes = new byte[_capacity / 8 + 1];
    }

    public long Capacity => _capacity;

    public void Set(long index)
    {
        if (index >= _capacity)
        {
            throw new IndexOutOfRangeException();
        }

        // 計算出資料存在第幾個 byte 上
        long byteIndex = index / 8;
        // 計算出資料存在第幾個 bit 上
        int bitIndex = (int)(index % 8);
        _bytes[byteIndex] |= (byte)(1 << bitIndex);
    }

    public void Remove(long index)
    {
        if (index >= _capacity)
        {
            throw new IndexOutOfRangeException();
        }

        long byteIndex = index / 8;
        int bitIndex = (int)(index % 8);
        _bytes[byteIndex] &= (byte)~(1 << bitIndex);
    }

    public bool Get(long index)
    {
        if (index >= _capacity)
        {
            throw new IndexOutOfRangeException();
        }

        long byteIndex = index / 8;
        int bitIndex = (int)(index % 8);

        return (_bytes[byteIndex] & (byte)(1 << bitIndex)) != 0;
    }
}

用 C# 實現 布隆過濾器

有了 Bitmap,我們再把 Hash 函式的實現準備好,一個簡單的布隆過濾器就可以完成了。這裡,我們參考 guava 這個 java 庫的實現。

https://github.com/google/guava/blob/master/guava/src/com/google/common/hash/BloomFilter.java

MurmurHash3 的使用

我們使用和 guava 一樣的 MurmurHash3 作為 Hash 函式的實現。

下面是筆者在 github 上找到的一個可用實現。

https://github.com/darrenkopp/murmurhash-net

使用這個庫,我們可以將任意長的 byte 陣列轉換成 128 位的二進位制位,也就是 16 byte。

byte[] data = Guid.NewGuid().ToByteArray(); 
// returns a 128-bit algorithm using "unsafe" code with default seed
HashAlgorithm murmur128 = MurmurHash.Create128(managed: false);
byte[] hash = murmur128.ComputeHash(data);

將任意型別的 key 轉換為 byte 陣列

Funnel 與 Sink 的定義

我們需要將各種型別 key 轉換成 MurmurHash 能夠直接處理的 byte 陣列。為此我們參考 guava 引入下面兩個概念:

  1. Funnel:將各類資料轉換成 byte 陣列,包括 int、bool、string 等built-in 型別及自定義的複雜型別。

  2. Sink:Funnel 的核心元件,作為資料的緩衝區。Funnel 在將自定義的複雜型別例項轉換成 byte 陣列時,就需要將資料拆解分批寫入 sink。

Funnel 可以定義成如下的委託,接受原始值,並將其寫入 sink 中。

delegate void Funnel<in T>(T from, ISink sink);

Sink 將不同型別的資料轉換成 byte 陣列並彙總到一起。

interface ISink
{
    ISink PutByte(byte b);
    
    ISink PutBytes(byte[] bytes);

    ISink PutBool(bool b);
    
    ISink PutShort(short s);

    ISink PutInt(int i);

    ISink PutString(string s, Encoding encoding);

    ISink PutObject<T>(T obj, Funnel<T> funnel);

    /// ... 其他 built-in 型別,讀者可自行補充
}

簡單的 Funnel 實現如下所示:

public class Funnels
{
    public static Funnel<string> StringFunnel = (from, sink) =>
        sink.PutString(from, Encoding.UTF8);
    
    public static Funnel<int> IntFunnel = (from, sink) =>
        sink.PutInt(from);
}

自定義複雜型別的 Funnel 實現則可以資料拆解分批寫入 sink。複雜型別的例項成員依舊可能是複雜型別,因此我們要在 Sink 上實現一個 PutObject 來提供套娃式拆解。

Funnel<Foo> funnelFoo = (foo, sink) =>
{
    sink.PutString(foo.A, Encoding.UTF8);
    sink.PutInt(foo.B);
    
    Funnel<Bar> funnelBar = (bar, barSink) => barSink.PutBool(bar.C);
    sink.PutObject(foo.Bar, funnelBar);
};

class Foo
{
    public string A { get; set; }

    public int B { get; set; }

    public Bar Bar { get; set; }
}

class Bar
{
    public bool C { get; set; }
}

Sink 的實現

Sink 的核心是 byte 陣列緩衝區的實現,利用 ArrayPool 我們可以很方便的實現一個 ByteBuffer。

class ByteBuffer : IDisposable
{
    private readonly int _capacity;
    private readonly byte[] _buffer;
    private int _offset;
    private bool _disposed;

    public ByteBuffer(int capacity)
    {
        _capacity = capacity;
        _buffer = ArrayPool<byte>.Shared.Rent(capacity);
    }

    public void Put(byte b)
    {
        CheckInsertable();
        _buffer[_offset] = b;
        _offset++;
    }

    public void Put(byte[] bytes)
    {
        CheckInsertable();
        bytes.CopyTo(_buffer.AsSpan(_offset, bytes.Length));
        _offset += bytes.Length;
    }

    public void PutInt(int i)
    {
        CheckInsertable();
        BinaryPrimitives.WriteInt32BigEndian(GetRemainingAsSpan(), i);
        _offset += sizeof(int);
    }
    
    public void PutShort(short s)
    {
        CheckInsertable();
        BinaryPrimitives.WriteInt32BigEndian(GetRemainingAsSpan(), s);
        _offset += sizeof(short);
    }

    // ... 其他的 primitive type 的實現

    public Span<byte> GetBuffer() =>
        _buffer.AsSpan(.._offset);

    public bool HasRemaining() => _offset < _capacity;

    public void Dispose()
    {
        _disposed = true;
        ArrayPool<byte>.Shared.Return(_buffer);
    }

    private void CheckInsertable()
    {
        if (_disposed)
        {
            throw new ObjectDisposedException(typeof(ByteBuffer).FullName);
        }

        if (_offset >= _capacity)
        {
            throw new OverflowException("Byte buffer overflow");
        }
    }

    private Span<byte> GetRemainingAsSpan() => _buffer.AsSpan(_offset..);
}

Sink 則是對 ByteBuffer 的進一步封裝,來適配當前使用場景。

class Sink : ISink, IDisposable
{
    private readonly ByteBuffer _byteBuffer;

    /// <summary>
    /// 建立一個新的 <see cref="Sink"/> 例項
    /// </summary>
    /// <param name="expectedInputSize">預計輸入的單個元素的最大大小</param>
    public Sink(int expectedInputSize)
    {
        _byteBuffer = new ByteBuffer(expectedInputSize);
    }

    public ISink PutByte(byte b)
    {
        _byteBuffer.Put(b);
        return this;
    }

    public ISink PutBytes(byte[] bytes)
    {
        _byteBuffer.Put(bytes);
        return this;
    }

    public ISink PutBool(bool b)
    {
        _byteBuffer.Put((byte)(b ? 1 : 0));
        return this;
    }

    public ISink PutShort(short s)
    {
        _byteBuffer.PutShort(s);
        return this;
    }

    public ISink PutInt(int i)
    {
        _byteBuffer.PutInt(i);
        return this;
    }

    public ISink PutString(string s, Encoding encoding)
    {
        _byteBuffer.Put(encoding.GetBytes(s));
        return this;
    }

    public ISink PutObject<T>(T obj, Funnel<T> funnel)
    {
        funnel(obj, this);
        return this;
    }

    public byte[] GetBytes() => _byteBuffer.GetBuffer().ToArray();

    public void Dispose()
    {
        _byteBuffer.Dispose();
    }
}

k 個 Hash 函式與 布隆過濾器 實現

上文提到了 布隆過濾器 通過 k 個 hash 函式來解決 hash 衝突問題。實踐中,我們可以把一次 murmur hash 的計算結果(16 byte)拆分為兩部分並轉換為 long 型別(一個 long 是 8 byte)。

這兩部分結果分別儲存到 hash1 和 hash2,第 k 個 hash 函式是對 hash1 和 hash2 的重新組合。

hash(k) = hash1 + (k-1) * hash2

public class BloomFilter<T>
{
    private readonly int _hashFunctions;
    private readonly Funnel<T> _funnel;
    private readonly int _expectedInputSize;
    private readonly Bitmap _bitmap;
    private readonly HashAlgorithm _murmur128;

    /// <summary>
    /// 建立一個新的 <see cref="BloomFilter"/> 例項
    /// </summary>
    /// <param name="funnel">與插入元素型別相關的<see cref="Funnel"/>的實現</param>
    /// <param name="buckets">BloomFilter 內部 Bitmap 的 bucket 數量,越大,誤判率越低</param>
    /// <param name="hashFunctions">hash 函式的數量,越多,誤判率越低</param>
    /// <param name="expectedInputSize">預計插入的單個元素的最大大小</param>
    public BloomFilter(Funnel<T> funnel, int buckets, int hashFunctions = 2, int expectedInputSize = 128)
    {
        _hashFunctions = hashFunctions;
        _funnel = funnel;
        _expectedInputSize = expectedInputSize;

        _bitmap = new Bitmap(buckets);
        _murmur128 = MurmurHash.Create128(managed: false);
    }

    public void Add(T item)
    {
        long bitSize = _bitmap.Capacity;

        var (hash1, hash2) = Hash(item);

        long combinedHash = hash1;
        for (int i = 0; i < _hashFunctions; i++)
        {
            _bitmap.Set((combinedHash & long.MaxValue) % bitSize);
            combinedHash += hash2;
        }
    }


    public bool MightContains(T item)
    {
        long bitSize = _bitmap.Capacity;

        var (hash1, hash2) = Hash(item);

        long combinedHash = hash1;
        for (int i = 0; i < _hashFunctions; i++)
        {
            if (!_bitmap.Get((combinedHash & long.MaxValue) % bitSize))
            {
                return false;
            }

            combinedHash += hash2;
        }

        return true;
    }


    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private (long Hash1, long Hash2) Hash(T item)
    {
        byte[] inputBytes;
        using (var sink = new Sink(_expectedInputSize))
        {
            sink.PutObject(item, _funnel);
            inputBytes = sink.GetBytes();
        }

        var hashSpan = _murmur128.ComputeHash(inputBytes).AsSpan();

        long lowerEight = BinaryPrimitives.ReadInt64LittleEndian(hashSpan.Slice(0,8));
        long upperEight = BinaryPrimitives.ReadInt64LittleEndian(hashSpan.Slice(8,8));
        return (lowerEight, upperEight);
    }
}

擴充套件

帶計數器的布隆過濾器

上文講到基於 Bitmap 實現的布隆過濾器不支援刪除,但如果把 Bitmap 這個 bit 陣列換成 n 個 bit 作為一個bucket的陣列,那單個 bucket 就具備了計數能力。這樣刪掉一個元素的時候,就是在這個計數器上減一,藉此能夠在有限的範圍內實現帶刪除功能的布隆過濾器,代價是,儲存空間會變成原來的 n 倍。

分散式布隆過濾器實現方案

如果你有布隆過濾器的實際使用需求,並且是在分散式環境,筆者推薦下面這個庫,它是作為 redis 的外掛提供的,詳情點選下方連結。
https://github.com/RedisBloom/RedisBloom

程式碼地址

為方便學習,本文所有的程式碼均已整理在 github:https://github.com/eventhorizon-cli/EventHorizon.BloomFilter

相關文章