轉自 http://www.10tiao.com/html/674/201712/2656596293/3.html
背景
最近有一個項目是點擊日誌(10億/天)實時計算,架構上簡單來說就是利用flunted去從前端機收集原始日誌,然後發給Kafka,Spark消費日誌並計算保存結果到Redis。
Kafka的Producer和Consumer端的配置是異步且保證不丟消息,因此當超時發生時,就可能會導致消息的重發或者重複消費,需要在消費環節保證冪等。Spark消費邏輯主要是根據多個維度進行計數計算,因此,我們需要在計算之前去重來保證不重複計數。
考慮到去重數據規模很大,爲10億量級,且我們的業務場景允許FP(False-Positive,假陽性,即實際爲非重複數據,被誤判爲重複數據),因此自然而然考慮到用Bloom-Filter(布隆過濾器)這個極其節約空間,且時間複雜度也極低的,存在一定的誤判(可控)的算法。
Bloom-Filter
介紹
布隆過濾器(Bloom filter)是由巴頓.布隆於1970年提出的。它實際上是一個很長的二進制向量和一系列隨機映射函數。
Bloom filter的思想很簡單優雅。我們假設有k個hash function和m位bit的向量filter: 處理輸入的過程如下:
-
使用k個hash函數計算hash值;
-
將每個hash值對m取餘,得到k個在filter中的位置;
-
將這k個位置的bit置爲1
判定一個輸入是否在filter中的操作如下:
-
使用k個hash函數計算hash值;
-
將每個hash值對m取餘,得到k個在filter中的位置;
-
看所有的位置是不是都是1,如果是返回true,否則返回false
如下圖示意:
誤判率計算
這裏不詳細展開False positive的數學分析,只給出結論:
P \approx ( 1 - e ^ {- \frac {mk}n}) ^ k P≈(1−e−nmk)k
當m/n固定時,選擇
k = \frac nm ln2 k=mnln2
附近的一個整數,將使P(False positive possibility)最小。[1]
應用場景
給定一個集合S(注意,這裏的集合是傳統意義上的集合:元素彼此不同。本文不考慮multiset),給定一個元素e,需要判斷e∈Se∈S 是否成立。(學術界一般稱爲membership問題)
-
爬蟲:URL是否被爬過(海量url,允許False Positive —— 少一次抓取又何妨)
-
垃圾郵件:全世界至少有幾十億個垃圾郵件地址,大家也都有過誤判爲垃圾郵件的經歷[2]
實際應用中我們需要針對業務的數據量級和對誤判量的要求來選取參數m和k。
下面,我們來看一下不同的m/n,k的條件下的誤判率表現。
False-Positive-Ratio表(含內存空間佔用)
設n爲10億,設m分別爲30、50,k分別爲8、16,結果下表:
m | m/n | k | FPR | FPN | Mem |
---|---|---|---|---|---|
300億 | 30 | 8 | 9.01e-6 | 9011 | 3.49GB |
300億 | 30 | 16 | 7.26e-7 | 726 | 3.49GB |
500億 | 50 | 8 | 2.28e-7 | 228 | 5.82GB |
500億 | 50 | 16 | 1e-9 | 1 | 5.82GB |
10億爲1天的數據量,假設數據24小時均勻分佈,那10分鐘的數據約爲700萬,設m分別爲30、50,k分別爲8、16,結果下表:
m | m/n | k | FPR | FPN | Mem |
---|---|---|---|---|---|
2100萬 | 30 | 8 | 9.01e-6 | 63 | 25.03MB |
2100萬 | 30 | 16 | 7.26e-7 | 5 | 25.03MB |
3500萬 | 50 | 8 | 2.28e-7 | 1.6 | 41.72MB |
3500萬 | 50 | 16 | 1e-9 | 0 | 41.72MB |
由上述表格,可取m/n爲50, k爲16,能滿足業務要求(誤判率:1e-9)。
以上,理論上的準備已經足夠充分,後面講一種基於Redis的通用實現方案。首先我們需要先了解一下Redis的SETBIT方法。
Redis數據結構String的SETBIT方法
SETBIT key offset value
對 key 所儲存的字符串值,設置或清除指定偏移量上的位(bit)。
位的設置或清除取決於 value 參數,可以是 0 也可以是 1 。
當 key 不存在時,自動生成一個新的字符串值。
字符串會進行伸展(grown)以確保它可以將 value 保存在指定的偏移量上。當字符串值進行伸展時,空白位置以 0 填充。
offset 參數必須大於或等於 0 ,小於 2^32 (bit 映射被限制在 512 MB 之內)。
可用版本:>= 2.2.0
時間複雜度: O(1)
返回值:指定偏移量原來儲存的位。
redis> SETBIT bit 10086 1(integer) 0redis> GETBIT bit 10086(integer) 1redis> GETBIT bit 100 # bit 默認被初始化爲 0(integer) 0
上面摘自Redis手冊,可見,SETBIT方法可以針對string類型的value做bit級別的操作,而Bloom filter也是針對bit進行操作,因此我們可以利用SETBIT來實現Bloom filter。
下面我們就來基於PHP,一步一步來實現一個通用的Bloom filter。
基於phpredis的Demo
BKDRHash
BKDRHash是一個即好記憶效果又很突出的哈希函數[3],C語言描述如下:
// BKDR Hash Functionunsigned int BKDRHash(char *str){ unsigned int seed = 131; // 31 131 1313 13131 131313 etc..
unsigned int hash = 0; while (*str)
{
hash = hash * seed + (*str++);
} return (hash & 0x7FFFFFFF);
}
Bloom filter算法需要多個Hash函數,我們可以給BKRDHash設置不同的seed來完成多Hash計算,如下文PHP代碼所示。
php的BRDKHash實現
function getBKDRHashSeed($n) { if ($n === 0) return 31;
$j = $n + 2;
$r = 0; for ($i = 0; $i < $j; $i ++) { if ($i % 2) {// 奇數
$r = $r * 10 + 3;
} else {
$r = $r * 10 + 1;
}
}
return $r;
}
function BKDRHash($str, $seed) {
$hash = 0;
$len = strlen($str);
$i = 0; while ($i < $len) {
$hash = ((floatval($hash * $seed) & 0x7FFFFFFF) + ord($str[$i])) & 0x7FFFFFFF;
$i ++;
}
return ($hash & 0x7FFFFFFF);
}
getBKDRHashSeed函數用來獲取不同的seed,n依次從0取到k-1,從而得到k個seed,傳入BKDRHash,計算出k個hashCode。
實現代碼
class Bf{ public $redis; public $key; public $m; public $k; public function __construct($key, $m, $k) { if ($m > 4294967296) {
error_log('ERROR: m over 4294967296'); return false;
} $this->key = $key; $this->m = $m; $this->k = $k; $this->redis = new Redis(); $this->redis->connect('127.0.0.1', 6379);
} public function add($e) {
$e = (string)$e;
$c = 0; for ($i = 0; $i < $this->k; $i ++) {
$seed = self::getBKDRHashSeed($i);
$hash = self::BKDRHash($e, $seed);
$offset = $hash % $this->m;
$t1 = microtime(true);
$c += $this->redis->setbit($this->key, $offset, 1);
$t2 = microtime(true);
$cost = round(($t2-$t1)*1000, 3).'ms';
error_log('[' . date('Y-m-d H:i:s', time()) . '] DEBUG: redis-time-spent=' . $cost . ' entry=' . $e . ' c=' . $c);
} return $c === $this->k;
} public function flushall() { return $this->redis->delete($this->key);
} static public function getBKDRHashSeed($n) { if ($n === 0) return 31;
$j = $n + 2;
$r = 0; for ($i = 0; $i < $j; $i ++) { if ($i % 2) {// 奇數
$r = $r * 10 + 3;
} else {
$r = $r * 10 + 1;
}
} return $r;
} static public function BKDRHash($str, $seed) {
$hash = 0;
$len = strlen($str);
$i = 0; while ($i < $len) {
$hash = ((floatval($hash * $seed) & 0x7FFFFFFF) + ord($str[$i])) & 0x7FFFFFFF;
$i ++;
} return ($hash & 0x7FFFFFFF);
}
}
上面的代碼就是Bloom filter的類實現。
#!/usr/bin/env php <?php$n = empty($_SERVER['argv'][1]) ? pow(2, 30) : intval($_SERVER['argv'][2]);for ($i = 0; $i < $n; $i ++) {
$word = genRandWord(); echo $word . "\n";
}function genRandWord() {
$max = rand(4, 12);
$chars = [];
for ($i = 0; $i < $max; $i ++) {
$chars[] = chr(rand(97, 122));
}
$word = join('', $chars); return $word;
}
爲了測試,我們通過上述代碼生成了1000w條隨即字符串(長度[4,12],全小寫字母),寫入到sample.txt文件中。
看看有多少重複的:
[tf@jp002 bf4redis]$ cat sample.txt |wc -l 10000000 [tf@jp002 bf4redis]$ cat sample.txt |sort |uniq |wc -l 9254122
重複量爲:10000000 - 9254122 = 745878
測試腳本如下:
$fp = fopen('./sample.txt', 'r');while ($word = fgets($fp)) {
$word = trim($word); if (empty($word)) { continue;
}
$rt = $bf->add($word); if ($rt) {
error_log('WARNING: ' . $word . ' EXIST!');
}
}fclose($fp);
測試參數:
-
m=2^32=4294967296(m/n = 4294967296/10000000 ≈ 429.50)
-
k=8
測試結果
-
總耗時:1h4m45s
-
Bloom-Filter Add QPS: 2574/s
-
Redis QPS:20592/s(一次add操作需要請求k(8)次redis)
-
正確性:
[tf@jp002 bf4redis]$ cat v1.log |grep 'EXIST!' |wc -l745878
-
誤判數:0
優化
上面代碼中,每次往Bloom filter中add一條數據,需要請求k次redis,性能都損耗在網絡IO上了,我們先將這個環節給優化掉。
redis的pipelining介紹
Redis Pipelining可以一次發送多個命令,並按順序執行、返回結果,節省RTT(Round Trip Time)。
每個SETBIT都是獨立的,之間沒有任何聯繫,沒有必要保證其原子性,因此無需採用multi方式,距相關資料查證,採用pipelining的效率提升10倍左右,而multi反而會降低效率。
優化後的類
class Bf{ public $redis; public $key; public $m; public $k; public function __construct($key, $m, $k) { if ($m > 4294967296) {
error_log('ERROR: m over 4294967296'); return false;
} $this->key = $key; $this->m = $m; $this->k = $k; $this->redis = new Redis(); $this->redis->connect('127.0.0.1', 6379);
} public function add($e) {
$e = (string)$e; $this->redis->multi(Redis::PIPELINE); for ($i = 0; $i < $this->k; $i ++) {
$seed = self::getBKDRHashSeed($i);
$hash = self::BKDRHash($e, $seed);
$offset = $hash % $this->m; $this->redis->setbit($this->key, $offset, 1);
}
$t1 = microtime(true);
$rt = $this->redis->exec();
$t2 = microtime(true);
$cost = round(($t2-$t1)*1000, 3).'ms';
$c = array_sum($rt);
error_log('[' . date('Y-m-d H:i:s', time()) . '] DEBUG: redis-time-spent=' . $cost . ' entry=' . $e . ' c=' . $c); return $c === $this->k;
} public function flushall() { return $this->redis->delete($this->key);
} static public function getBKDRHashSeed($n) { if ($n === 0) return 31;
$j = $n + 2;
$r = 0; for ($i = 0; $i < $j; $i ++) { if ($i % 2) {// 濂囨暟
$r = $r * 10 + 3;
} else {
$r = $r * 10 + 1;
}
} return $r;
} static public function BKDRHash($str, $seed) {
$hash = 0;
$len = strlen($str);
$i = 0; while ($i < $len) {
$hash = ((floatval($hash * $seed) & 0x7FFFFFFF) + ord($str[$i])) & 0x7FFFFFFF;
$i ++;
} return ($hash & 0x7FFFFFFF);
}
}
優化後的測試結果
-
總耗時:13m21s
-
Bloom-Filter Add QPS: 12000/s
-
Redis QPS:12000/s
-
正確性:
[tf@jp002 bf4redis]$ cat v2.log |grep 'EXIST!' |wc -l745878
-
誤判數:0
速度提升了5倍!
再優化
剛剛Redis官方文檔裏面對SETBIT的介紹中有這樣一句:
bit 映射被限制在 512 MB 之內
往回翻看上文中
False-Positive-Ratio表(含內存空間佔用)
可以看到如果m爲500億,Bloom filter的內存空間會佔用大約5.82GB,大大查過Redis的bit映射範圍限制。
因此我們需要對該Bloom filter實現做分佈式改造,根據m的規模, 構建多個bit表,不同的輸入會sharding到對應的bit表。
分佈式Bloom-Filter
考慮到單個redis實例的內存是有上限的,我們可以設計兩級sharding:
-
第一級將不同的輸入sharding到對應的redis實例
-
第二級將輸入sharding到對應的key上(不同的key代表不同的Bloom filter)
優化後的demo(完整代碼)
class Bf{ public $key; public $m; public $k; public $nPartitions; public $redisCfg; public $nRedis; public $maxOffs = []; const MAX_PARTITION_SIZE = 4294967296; //redis string's max len is pow(2, 32) bits = 512MB
//const MAX_PARTITION_SIZE = 65536;
public function __construct($redisCfg, $key, $m, $k) { $this->nRedis = count($redisCfg); if ($m > self::MAX_PARTITION_SIZE) { $this->nPartitions = ceil(ceil($m / $this->nRedis) / self::MAX_PARTITION_SIZE);
} else { $this->nPartitions = 1;
} $this->key = $key; $this->m = $m; $this->k = $k; $this->redisCfg = $redisCfg;
} private function getPosition($e) {
$nRedis = count($this->redisCfg);
$hash = crc32($e);
$i = $hash % $nRedis;
$redis = SRedis::getSingeton($this->redisCfg[$i]);
$key = $this->key . '.' . $hash % $this->nPartitions; return [$i, $redis, $key];
} public function add($e) {
$e = (string)$e; list($n, $redis, $key) = $this->getPosition($e); //var_dump($this->key, $this->m, $this->k, $this->nRedis, $this->nPartitions, $redis, $key);
$redis->multi(Redis::PIPELINE); for ($i = 0; $i < $this->k; $i ++) {
$seed = self::getBKDRHashSeed($i);
$hash = self::BKDRHash($e, $seed);
$offset = $hash % $this->m; if ($offset > @$this->maxOffs[$n.'|'.$key]) $this->maxOffs[$n.'|'.$key] = $offset; //only 4 log
$redis->setbit($key, $offset, 1);
}
$t1 = microtime(true);
$rt = $redis->exec();
$t2 = microtime(true);
$cost = round(($t2-$t1)*1000, 3).'ms';
$c = array_sum($rt);
error_log('[' . date('Y-m-d H:i:s', time()) . '] DEBUG: redis[' . $n . ']-time-spent=' . $cost . ' maxOffset-of-' . $n.'|'.$key . '=' . $this->maxOffs[$n.'|'.$key] . ' entry=' . $e . ' c=' . $c); return $c === $this->k;
} public function flushall() { foreach ($this->redisCfg as $cfg) {
$redis = SRedis::getSingeton($cfg); for ($i = 0; $i < $this->nPartitions; $i ++) {
$redis->delete($this->key . '.' . $i);
}
}
} static public function getBKDRHashSeed($n) { if ($n === 0) return 31;
$j = $n + 2;
$r = 0; for ($i = 0; $i < $j; $i ++) { if ($i % 2) {// 濂囨暟
$r = $r * 10 + 3;
} else {
$r = $r * 10 + 1;
}
} return $r;
} static public function BKDRHash($str, $seed) {
$hash = 0;
$len = strlen($str);
$i = 0; while ($i < $len) {
$hash = ((floatval($hash * $seed) & 0x7FFFFFFF) + ord($str[$i])) & 0x7FFFFFFF;
$i ++;
} return ($hash & 0x7FFFFFFF);
}
}class SRedis{ public function getSingeton($cfg) { static $pool; if (empty($cfg) || !is_array($cfg)) { return false;
}
$k = serialize($cfg); if (empty($pool[$k])) {
$redis = new Redis();
call_user_func_array([$redis, 'connect'], array_values($cfg));
$pool[$k] = $redis;
} return $pool[$k];
}
}if ($_SERVER['argc'] < 4) { die("Usage: ./" . $_SERVER['argv'][0] . " <bloom-filter's name> <m> <k>\n");
}
$key = trim($_SERVER['argv'][3]);
$m = intval($_SERVER['argv'][4]);
$k = intval($_SERVER['argv'][5]);
$sampleFile = __DIR__ . '/sample.txt';
$redisCfg = [
[ 'host' => '127.0.0.1', 'port' => 6379, /* 'timeout' => 5, 'reserved' => null, 'retry_interval' => 1000, 'read_timeout' => 1, */
],
];
$bf = new Bf($redisCfg, $key, $m, $k);
$bf->flushall();
$fp = fopen($sampleFile, 'r');while ($word = fgets($fp)) {
$word = trim($word); if (empty($word)) { continue;
}
$rt = $bf->add($word); if ($rt) {
error_log('WARNING: ' . $word . ' EXIST!');
}
}
fclose($fp);
綜上,我們實現了一個基於Redis的通用Bloom filter。