Redis 布隆過濾器實戰「快取擊穿、雪崩效應」

老錢發表於2019-03-22

本文作者:盧瑋,掌閱資深後端工程師

為什麼引入

我們的業務中經常會遇到穿庫的問題,通常可以通過快取解決。 如果資料維度比較多,結果資料集合比較大時,快取的效果就不明顯了。 因此為了解決穿庫的問題,我們引入Bloom Filter。

開源專案地址:github.com/luw2007/blo…

我們先看看一般業務快取流程

快取流程圖

先查詢快取,快取不命中再查詢資料庫。 然後將查詢結果放在快取中即使資料不存在,也需要建立一個快取,用來防止穿庫。這裡需要區分一下資料是否存在。 如果資料不存在,快取時間可以設定相對較短,防止因為主從同步等問題,導致問題被放大。

這個流程中存在薄弱的問題是,當使用者量太大時,我們會快取大量資料空資料,並且一旦來一波冷使用者,會造成雪崩效應。 對於這種情況,我們產生第二個版本流程:redis過濾冷使用者快取流程

redis過濾冷使用者的快取流程圖

我們將資料庫裡面中命中的使用者放在redis的set型別中,設定不過期。 這樣相當把redis當作資料庫的索引,只要查詢redis,就可以知道是否資料存在。 redis中不存在就可以直接返回結果。 如果存在就按照上面提到一般業務快取流程處理。

聰明的你肯定會想到更多的問題:

  1. redis本身可以做快取,為什麼不直接返回資料呢?
  2. 如果資料量比較大,單個set,會有效能問題?
  3. 業務不重要,將全量資料放在redis中,佔用伺服器大量記憶體。投入產出不成比例?

問題1需要區分業務場景,結果資料少,我們是可以直接使用redis作為快取,直接返回資料。 結果比較大就不太適合用redis存放了。比如ugc內容,一個評論裡面可能存在上萬字,業務欄位多。

redis使用有很多技巧。bigkey 危害比較大,無論是擴容或縮容帶來的記憶體申請釋放, 還是查詢命令使用不當導致大量資料返回,都會影響redis的穩定。這裡就不細談原因及危害了。 解決bigkey 方法很簡單。我們可以使用hash函式來分桶,將資料分散到多個key中。 減少單個key的大小,同時不影響查詢效率。

問題3是redis儲存佔用記憶體太大。因此我們需要減少記憶體使用。 重新思考一下引入redis的目的。 redis像一個集合,整個業務就是驗證請求的引數是否在集合中。

過濾器
這個結構就像洗澡的時候用的雙向閥門:左邊熱水,右邊冷水。

大部分的程式語言都內建了filter。 拿python舉例,filter函式用於過濾序列, 過濾掉不符合條件的元素,返回由符合條件元素組成的列表。

我們看個例子:

$ python2
Python 2.7.10 (default, Oct  6 2017, 22:29:07)
[GCC 4.2.1 Compatible Apple LLVM 9.0.0 (clang-900.0.31)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> s = {2, 4}
>>> filter(lambda x:x in s, [0, 1, 2])
[2]
複製程式碼

集合s中存在 2,4兩個數字,我們需要查詢 0,1,2 那些在集合s中。 lambda x:x in s構造一個匿名函式,判斷入參x是否在集合s中。 過濾器filter依次對列表中的數字執行匿名函式。最終返回列表[2]

redis中實現set用了兩種結構:intset和hash table。 非數字或者大量數字時都會退化成hash table。 那麼是否好的演算法可以節省hash table的大小呢?

其實早在1970年由Burton Howard Bloom提出的布隆過濾器(英語:Bloom Filter)。 它實際上是一個很長的二進位制向量和一系列隨機對映函式。 布隆過濾器可以用於檢索一個元素是否在一個集合中。 它的優點是空間效率和查詢時間都遠遠超過一般的演算法, 缺點是有一定的誤識別率和刪除困難。

BloomFilter原理

我們常見的將業務欄位拼接之後md5,放在一個集合中。 md5生成一個固定長度的128bit的串。 如果我們用bitmap來表示,則需要

2**128 = 340282366920938463463374607431768211456 bit
複製程式碼

判斷一個值在不在,就變成在這個bitmap中判斷所在位是否為1。 但是我們全世界的機器儲存空間也無法儲存下載。 因此我們只能分配有限的空間來儲存。 比如:

import crc32

def BloomFilter(sample, size, hash_size=1):
    # 構造一個hash函式,將輸入資料雜湊到size一個位置上
    hash = lambda x:crc32(str(x).encode())%size
    collision, s = 0, set()
    for i in range(sample):
        k = set()
        for j in range(hash_size):
            k.add(hash(i+j*size/hash_size))
        # 只有所有雜湊結果k都在s中,才認為i重複
        if not k - s:
            collision += 1
            continue
        # 將雜湊結果k更新到集合s中
        s |= k
    return collision
複製程式碼

當只有一個hash函式時:很容易發生衝突。

one_hash

可以看到上面1和2的hash結果都是7,發生衝突。 如果增加hash函式,會發生什麼情況?

more_hash

我們使用更多的hash函式和更大的資料集合來測試。得到下面這張表

碰撞表

由此可以看到當增加hash方法能夠有效的降低碰撞機率。 比較好的資料如下:

碰撞表100

但是增加了hash方法之後,會降低空間的使用效率。當集合佔用總體空間達到25%的時候, 增加hash 的效果已經不明顯

碰撞表250

上面的使用多個hash方法來降低碰撞就是BloomFilter的核心思想。

適合的場景

  • 資料庫防止穿庫 Google Bigtable,Apache HBase和Apache Cassandra以及Postgresql 使用BloomFilter來減少不存在的行或列的磁碟查詢。避免代價高昂的磁碟查詢會大大提高資料庫查詢操作的效能。 如同一開始的業務場景。如果資料量較大,不方便放在快取中。需要對請求做攔截防止穿庫。

  • 快取當機 快取當機的場景,使用布隆過濾器會造成一定程度的誤判。原因是除了Bloom Filter 本身有誤判率,當機之前的快取不一定能覆蓋到所有DB中的資料,當當機後使用者請求了一個以前從未請求的資料,這個時候就會產生誤判。當然,快取當機時使用布隆過濾器作為應急的方式,這種情況應該也是可以忍受的。

  • WEB攔截器 相同請求攔截防止被攻擊。使用者第一次請求,將請求引數放入BloomFilter中,當第二次請求時,先判斷請求引數是否被BloomFilter命中。可以提高快取命中率

  • 惡意地址檢測 chrome 瀏覽器檢查是否是惡意地址。 首先針對本地BloomFilter檢查任何URL,並且僅當BloomFilter返回肯定結果時才對所執行的URL進行全面檢查(並且使用者警告,如果它也返回肯定結果)。

  • 比特幣加速 bitcoin 使用BloomFilter來加速錢包同步。

演算法優點:

  • 資料空間小,不用儲存資料本身。

演算法本身缺點:

  • 元素可以新增到集合中,但不能被刪除。
  • 匹配結果只能是“絕對不在集合中”,並不能保證匹配成功的值已經在集合中。
  • 當集合快滿時,即接近預估最大容量時,誤報的概率會變大。
  • 資料佔用空間放大。一般來說,對於1%的誤報概率,每個元素少於10位元,與集合中的元素的大小或數量無關。 - 查詢過程變慢,hash函式增多,導致每次匹配過程,需要查詢多個位(hash個數)來確認是否存在。

對於BloomFilter的優點來說,缺點都可以忽略。畢竟只需要kN的儲存空間就能儲存N個元素。空間效率十分優秀。

如何使用BloomFilter

BloomFilter 需要一個大的bitmap來儲存。鑑於目前公司現狀,最好的儲存容器是redis。 從github topics: bloom-filter中經過簡單的調研。

redis整合BloomFilter方案:

原生python 方法太慢,lua指令碼和module 部署比較麻煩。於是我們推薦使用pyreBloom,底層使用。

pyreBloom:master λ ls
Makefile      bloom.h       bloom.pxd     murmur.c      pyreBloom.pyx
bloom.c       bloom.o       main.c        pyreBloom.c
複製程式碼

從檔案命名上可以看到bloom 使用c編寫。pyreBloom 使用cython編寫。

bloom.h 裡面實現BloomFilter的核心邏輯,完成與redis server的互動;hash函式;新增,檢查和刪除方法的實現。

int init_pyrebloom(pyrebloomctxt * ctxt, char * key, uint32_t capacity, double error, char* host, uint32_t port, char* password, uint32_t db);
int free_pyrebloom(pyrebloomctxt * ctxt);

int add(pyrebloomctxt * ctxt, const char * data, uint32_t len);
int add_complete(pyrebloomctxt * ctxt, uint32_t count);

int check(pyrebloomctxt * ctxt, const char * data, uint32_t len);
int check_next(pyrebloomctxt * ctxt);

int delete(pyrebloomctxt * ctxt);
複製程式碼

pyreBloom.pyx

import math
import random

cimport bloom


class pyreBloomException(Exception):
	'''Some sort of exception has happened internally'''
	pass


cdef class pyreBloom(object):
	cdef bloom.pyrebloomctxt context
	cdef bytes               key

	property bits:
		def __get__(self):
			return self.context.bits

	property hashes:
		def __get__(self):
			return self.context.hashes

	def __cinit__(self, key, capacity, error, host='127.0.0.1', port=6379,
		password='', db=0):
		self.key = key
		if bloom.init_pyrebloom(&self.context, self.key, capacity,
			error, host, port, password, db):
			raise pyreBloomException(self.context.ctxt.errstr)

	def __dealloc__(self):
		bloom.free_pyrebloom(&self.context)

	def delete(self):
		bloom.delete(&self.context)

	def put(self, value):
		if getattr(value, '__iter__', False):
			r = [bloom.add(&self.context, v, len(v)) for v in value]
			r = bloom.add_complete(&self.context, len(value))
		else:
			bloom.add(&self.context, value, len(value))
			r = bloom.add_complete(&self.context, 1)
		if r < 0:
			raise pyreBloomException(self.context.ctxt.errstr)
		return r

	def add(self, value):
		return self.put(value)

	def extend(self, values):
		return self.put(values)

	def contains(self, value):
		# If the object is 'iterable'...
		if getattr(value, '__iter__', False):
			r = [bloom.check(&self.context, v, len(v)) for v in value]
			r = [bloom.check_next(&self.context) for i in range(len(value))]
			if (min(r) < 0):
				raise pyreBloomException(self.context.ctxt.errstr)
			return [v for v, included in zip(value, r) if included]
		else:
			bloom.check(&self.context, value, len(value))
			r = bloom.check_next(&self.context)
			if (r < 0):
				raise pyreBloomException(self.context.ctxt.errstr)
			return bool(r)

	def __contains__(self, value):
		return self.contains(value)

	def keys(self):
		'''Return a list of the keys used in this bloom filter'''
		return [self.context.keys[i] for i in range(self.context.num_keys)]
複製程式碼
原生pyreBloom方法:

cdef class pyreBloom(object):

    cdef bloom.pyrebloomctxt context
    cdef bytes

    property bits:

    property hashes:
    // 使用的hash方法數

    def delete(self):
    // 刪除,會在redis中刪除

    def put(self, value):
    // 新增 底層方法, 不建議直接呼叫

    def add(self, value):
    // 新增單個元素,呼叫put方法

    def extend(self, values):
    // 新增一組元素,呼叫put方法

    def contains(self, value):
    // 檢查是否存在,當`value`可以迭代時,返回`[value]`, 否則返回`bool`

    def keys(self):
    // 在redis中儲存的key列表
複製程式碼

由於pyreBloom使用hiredis庫,本身沒有重連等邏輯,於是錯了簡單的封裝。


    # coding=utf-8
    '''
    bloom filter 基礎模組

    可用方法:
    extend, keys, contains, add, put, hashes, bits, delete

    使用方法:
    >>> class TestModel(BaseModel):
    ...    PREFIX = "bf:test"
    >>> t = TestModel()
    >>> t.add('hello')
    1
    >>> t.extend(['hi', 'world'])
    2
    >>> t.contains('hi')
    True
    >>> t.delete()
    '''
    import logging
    from six import PY3 as IS_PY3
    from pyreBloom import pyreBloom, pyreBloomException

    from BloomFilter.utils import force_utf8


    class BaseModel(object):
        '''
        bloom filter 基礎模組
        引數:
            SLOT: 可用方法型別
            PREFIX: redis字首
            BF_SIZE: 儲存最大值
            BF_ERROR: 允許的出錯率
            RETRIES: 連線重試次數
            host: redis 伺服器IP
            port: redis 伺服器埠
            db: redis 伺服器DB
            _bf_conn: 內部儲存`pyreBloom`例項
        '''
        SLOT = {'add', 'contains', 'extend', 'keys', 'put', 'delete',
                'bits', 'hashes'}
        PREFIX = ""
        BF_SIZE = 100000
        BF_ERROR = 0.01
        RETRIES = 2

        def __init__(self, redis=None):
            '''
            初始化redis配置
            :param redis: redis 配置
            '''
            # 這裡初始化防止類靜態變數多個繼承類複用,導致資料被汙染
            self._bf_conn = None

            self._conf = {
                'host': '127.0.0.1', 'password': '',
                'port': 6379, 'db': 0
            }

            if redis:
                for k, v in redis.items():
                    if k in self._conf:
                        self._conf[k] = redis[k]
            self._conf = force_utf8(self._conf)

        @property
        def bf_conn(self):
            '''
            初始化pyreBloom
            '''
            if not self._bf_conn:
                prefix = force_utf8(self.PREFIX)
                logging.debug(
                    'pyreBloom connect: redis://%s:%s/%s, (%s %s %s)',
                    self._conf['host'], self._conf['port'], self._conf['db'],
                    prefix, self.BF_SIZE, self.BF_ERROR,
                )
                self._bf_conn = pyreBloom(
                    prefix, self.BF_SIZE, self.BF_ERROR, **self._conf)
            return self._bf_conn

        def __getattr__(self, method):
            '''呼叫pyrebloom方法
            沒有列舉的方法將從`pyreBloom`中獲取
            :param method:
            :return: pyreBloom.{method}
            '''
            # 只提供內部方法
            if method not in self.SLOT:
                raise NotImplementedError()

            # 捕獲`pyreBloom`的異常, 列印必要的日誌
            def catch_error(*a, **kwargs):
                '''多次重試服務'''
                args = force_utf8(a)
                kwargs = force_utf8(kwargs)
                for _ in range(self.RETRIES):
                    try:
                        func = getattr(self.bf_conn, method)
                        res = func(*args, **kwargs)
                        # python3 返回值和python2返回值不相同,
                        # 手工處理返回型別
                        if method == 'contains' and IS_PY3:
                            if isinstance(res, list):
                                return [i.decode('utf8') for i in res]
                        return res
                    except pyreBloomException as error:
                        logging.warn(
                            'pyreBloom Error:  %s %s', method, str(error))
                        self.reconnect()
                        if _ == self.RETRIES:
                            logging.error('pyreBloom Error')
                            raise error

            return catch_error

        def __contains__(self, item):
            '''跳轉__contains__方法
            :param item: 查詢元素列表/單個元素
            :type item: list/basestring
            :return: [bool...]/bool
            '''
            return self.contains(item)

        def reconnect(self):
            '''
            重新連線bloom
            `pyreBloom` 連線使用c driver,沒有提供timeout引數,使用了內建的timeout
            同時為了保證服務的可靠性,增加了多次重試機制。
            struct timeval timeout = { 1, 5000 };
            ctxt->ctxt = redisConnectWithTimeout(host, port, timeout);
            del self._bf_conn 會呼叫`pyreBloom`內建的C的del方法,會關閉redis連線
            '''
            if self._bf_conn:
                logging.debug('pyreBloom reconnect')
                del self._bf_conn
                self._bf_conn = None
                _ = self.bf_conn
複製程式碼

進階:計數過濾器(Counting Filter)

提供了一種在BloomFilter上實現刪除操作的方法,而無需重新重新建立過濾器。在計數濾波器中,陣列位置(桶)從單個位擴充套件為n位計數器。實際上,常規布隆過濾器可以被視為計數過濾器,其桶大小為一位。

插入操作被擴充套件為遞增桶的值,並且查詢操作檢查每個所需的桶是否為非零。然後,刪除操作包括遞減每個桶的值。

儲存桶的算術溢位是一個問題,並且儲存桶應該足夠大以使這種情況很少見。如果確實發生,則增量和減量操作必須將儲存區設定為最大可能值,以便保留BloomFilter的屬性。

計數器的大小通常為3或4位。因此,計算布隆過濾器的空間比靜態布隆過濾器多3到4倍。相比之下, Pagh,Pagh和Rao(2005)以及Fan等人的資料結構。(2014)也允許刪除但使用比靜態BloomFilter更少的空間。

計數過濾器的另一個問題是可擴充套件性有限。由於無法擴充套件計數布隆過濾器表,因此必須事先知道要同時儲存在過濾器中的最大鍵數。一旦超過表的設計容量,隨著插入更多金鑰,誤報率將迅速增長。

Bonomi等人。(2006)引入了一種基於d-left雜湊的資料結構,它在功能上是等效的,但使用的空間大約是計算BloomFilter的一半。此資料結構中不會出現可伸縮性問題。一旦超出設計容量,就可以將金鑰重新插入到雙倍大小的新雜湊表中。

Putze,Sanders和Singler(2007)的節省空間的變體也可用於通過支援插入和刪除來實現計數過濾器。

Rottenstreich,Kanizo和Keslassy(2012)引入了一種基於變數增量的新通用方法,該方法顯著提高了計算布隆過濾器及其變體的誤報概率,同時仍支援刪除。與計數布隆過濾器不同,在每個元素插入時,雜湊計數器以雜湊變數增量而不是單位增量遞增。要查詢元素,需要考慮計數器的確切值,而不僅僅是它們的正面性。如果由計數器值表示的總和不能由查詢元素的相應變數增量組成,則可以將否定答案返回給查詢。

開源專案地址:github.com/luw2007/blo…

相關文章