隨着大數據時代的到來,數據信息在給咱們生活帶來便利的同時,一樣也給咱們帶來了一系列的考驗與挑戰。本文主要介紹了基於 Apache HBase 與 Google SimHash 等多種算法共同實現的一套支持百億級文本數據類似度計算與快速去重系統的設計與實現。該方案在公司業務層面完全解決了多主題海量文本數據所面臨的存儲與計算慢的問題。html
常見的有餘弦夾角算法、歐式距離、Jaccard 類似度、最長公共子串、編輯距離等。這些算法對於待比較的文本數據很少時還比較好用,但在海量數據背景下,若是天天產生的數據以千萬計算,咱們如何對於這些海量千萬級的數據進行高效的合併去重和類似度計算呢?java
若是咱們選好了類似度計算和去重的相關算法,那咱們怎麼去作呢?若是待比較的文本數據少,咱們簡單遍歷全部文本進行比較便可,那對於巨大的數據集咱們該怎麼辦呢?遍歷很明顯是不可取的。ios
基於問題一,咱們引入了 SimHash 算法來實現海量文本的類似度計算與快速去重。下面咱們簡單瞭解下該算法。算法
在介紹 SimHash 算法以前,咱們先簡單介紹下局部敏感哈希是什麼。局部敏感哈希的基本思想相似於一種空間域轉換思想,LSH 算法基於一個假設,若是兩個文本在原有的數據空間是類似的,那麼分別通過哈希函數轉換之後的它們也具備很高的類似度;相反,若是它們自己是不類似的,那麼通過轉換後它們應仍不具備類似性。數據庫
局部敏感哈希的最大特色就在於保持數據的類似性,舉一個小小的例子說明一下:對A文章微調後咱們稱其爲B文章(可能只是多了一個‘的’字),若是此時咱們計算兩篇文章的 MD5 值,那麼必將截然不同。而局部敏感哈希的好處是通過哈希函數轉換後的值也只是發生了微小的變化,即若是兩篇文章類似度很高,那麼在算法轉換後其類似度也會很高。apache
MinHash 與 SimHash 算法都屬於局部敏感哈希,通常狀況若每一個 Feature 無權重,則 MinHash 效果優於 SimHash 有權重時 SimHash 合適。長文本使用 Simhash 效果很好,短文本使用 Simhash 準備度不高。json
SimHash 是 Google 在2007年發表的論文《Detecting Near-Duplicates for Web Crawling 》中提到的一種指紋生成算法或者叫指紋提取算法,被 Google 普遍應用在億級的網頁去重的 Job 中,其主要思想是降維,通過simhash降維後,可能僅僅獲得一個長度爲32或64位的二進制由01組成的字符串。而一維查詢則是很是快速的。數組
SimHash的工做原理咱們這裏略過,你們能夠簡單理解爲:咱們能夠利用SimHash算法爲每個網頁/文章生成一個長度爲32或64位的二進制由01組成的字符串(向量指紋),形如:1000010010101101111111100000101011010001001111100001001011001011。微信
兩個碼字的對應比特取值不一樣的比特數稱爲這兩個碼字的海明距離。在一個有效編碼集中,任意兩個碼字的海明距離的最小值稱爲該編碼集的海明距離。舉例以下:10101和00110從第一位開始依次有第一位、第4、第五位不一樣,則海明距離爲3。併發
在 google 的論文給出的數據中,64位的簽名,在海明距離爲3的狀況下,可認爲兩篇文檔是類似的或者是重複的,固然這個值只是參考值。
這樣,基於 SimHash 算法,咱們就能夠將百億千億級的高維特徵文章轉變爲一維字符串後再經過計算其海明距離判斷網頁/文章的類似度,可想效率必將大大提升。
到這裏類似度問題基本解決,可是按這個思路,在海量數據幾百億的數量下,效率問題仍是沒有解決的,由於數據是不斷添加進來的,不可能每來一條數據,都要和全庫的數據作一次比較,按照這種思路,處理速度會愈來愈慢,線性增加。
這裏,咱們要引入一個新的概念:抽屜原理,也稱鴿巢原理。下面咱們簡單舉例說一下:
桌子上有四個蘋果,但只有三個抽屜,若是要將四個蘋果放入三個抽屜裏,那麼必然有一個抽屜中放入了兩個蘋果。若是每一個抽屜表明一個集合,每個蘋果就能夠表明一個元素,假若有n+1個元素放到n個集合中去,其中一定有一個集合裏至少有兩個元素。
抽屜原理就是這麼簡單,那若是用它來解決咱們海量數據的遍歷問題呢?
針對海量數據的去重效率,咱們能夠將64位指紋,切分爲4份16位的數據塊,根據抽屜原理在海明距離爲3的狀況,若是兩個文檔類似,那麼它必有一個塊的數據是相等的。
那也就是說,咱們能夠以某文本的 SimHash 的每一個16位截斷指紋爲 Key,Value 爲 Key 相等時文本的 SimHash 集合存入 K-V 數據庫便可,查詢時候,精確匹配這個指紋的4個16位截斷指紋所對應的4個 SimHash 集合便可。
如此,假設樣本庫,有2^37 條數據(1375億數據),假設數據均勻分佈,則每一個16位(16個01數字隨機組成的組合爲2^16 個)倒排返回的最大數量爲
(2^37) 4 / (2^16) =8388608個候選結果,4個16位截斷索引,總的結果爲:48388608=33554432,約爲3356萬,經過
這樣一來的降維處理,原來須要比較1375億次,如今只須要比較3356萬次便可獲得結果,這樣以來大大提高了計算效率。
根據網上測試數據顯示,普通 PC 比較1000萬次海明距離大約須要 300ms,也就是說3356萬次(1375億數據)只需花費3356/1000*0.3=1.0068s。那也就是說對於千億級文本數據(若是每一個文本1kb,約100TB數據)的類似度計算與去重工做咱們最多隻須要一秒的時間便可得出結果。
饒了這麼大一週,咱們終於將須要講明的理論知識給你們過了一遍。爲了闡述的儘可能清晰易懂,文中不少理論知識的理解借鑑了大量博主大牛的博客,原文連接已在文末附上,有不太明白的地方快快跪拜大牛們的博客吧,哈哈!
下面咱們着重介紹一下 HBase 存儲表的設計與實現。
基於上文咱們能夠大概知道,若是將64位指紋平分四份,海明距離取3,那麼必有一段16位截取指紋的數據是相等的。而每一段16位截取指紋對應一個64位指紋集合,且該集合中的每一個64位指紋必有一段16位截取指紋與該段16位截取指紋重合。咱們能夠簡單表示(以8位非01指紋舉例)爲:
key | value(set) |
---|---|
12 | [12345678,12345679] |
23 | [12345678,12345679,23456789] |
那若是基於 HBase 去實現的話,咱們大概對比三種可能的設計方案。
以 16 位指紋做爲 HBase 數據表的行鍵,將每個與之可能類似的64位指紋做爲 HBase 的列,列值存文章id值,即構建一張大寬表。以下表所示(以8位非01指紋舉例):
rowkey | column1 | column2 | column3 | ... |
---|
實際數據表多是這個樣子:
rowkey | 12345678 | 32234567 | 23456789 | 12456789 | ... |
---|---|---|---|---|---|
12 | 1102101 | 1102102 | ... | ||
23 | 1102104 | 1102105 | ... | ||
34 | 1102106 | ... |
那其實這樣設計表的話該 HBase 表 Rowkey 的個數就是一個肯定的數值:16個01數字隨機組成的組合爲2^16 個。也就是共2^16=65536行。 列的個數其實也是固定的,即2^64=184467440737億萬列。
此時,好比說咱們比較56431234與庫中全部文本的類似度,只需拉去rowkey in (56,43,12,34) 四行數據遍歷每行列,因爲 HBase 空值不進行存儲,全部只會遍歷存在值的列名。
由上文咱們計算出1350億數據若是平均分佈的話每行大約有839萬列,且不說咱們的數據量可能遠遠大於千億級別,也不說以64位字符串做爲列名所佔的存儲空間有多大,單單千億級數據量 HBase 每行就大約839萬列,雖然說HBase號稱支持千萬行百萬列數據存儲,但總歸仍是設計太不合理。數據不會理想化均勻分佈,總列數高達184467440737億萬列也使人堪憂。
以 16 位指紋與64位指紋拼接後做爲 HBase 數據表的行鍵,該表只有一列,列值存文章id值,即構建一張大長表。以下表所示(以8位非01指紋舉例):
rowkey | id |
---|
實際數據表多是這個樣子:
rowkey | id |
---|---|
12_12345678 | 1 |
34_12345678 | 1 |
56_12345678 | 1 |
78_12345678 | 1 |
34_22345678 | 2 |
23_12235678 | 3 |
如此設計感受要比第一種方法要好一些,每一篇文章會被存爲四行。但一樣有諸多缺點,一是 Rowkey 過長,二是即使咱們經過某種轉變設計解決了問題一,那獲取數據時咱們也只能將 Get 請求轉爲四個Scan併發掃描+StartEnKey 去掃描表獲取數據。固然,若是想實現順序掃描還可能存在熱點問題。在存儲上,也形成了數據大量冗餘。
在真實生產環境中,咱們採起該方案來避免上述兩個方案中出現的問題與不足。下面簡單介紹一下(若是您有更好更優的方案,歡迎留言,先表示感謝!)
簡言之呢,就是本身在 HBase 端維護了一個 Set 集合(協處理器),並以 Json 串進行存儲,格式以下:
{ "64SimHash1":"id1", "64SimHash2":"id2", ... ... }
基於公司存在多種主題類型的文本數據,且互相隔離,去重與類似度計算也是分主題進行,咱們的 Rowkey 設計大體以下:
Rowkey = HashNumber_ContentType_16SimHash (共24位)
計算公式以下:String.format("%02x", Math.abs(key.hashCode()) % 64)
表結構大體以下:
rowkey | si | s0 | s1 | s2 | s3 | ... | |
---|---|---|---|---|---|---|---|
01_news_010101010101010101 | value | 1 | Json 串 | ... | |||
02_news_010101010101010110 | value | 2 | Json 串 | Json 串 | ... | ||
03_news_100101010101010110 | value | 3 | Json 串 | Json 串 | Json 串 | ... | |
01_xbbs_010101010101010101 | value | 1 | Json 串 | ... |
si:客戶端傳遞過來的欲存儲的值,由64位 Simhash 與 Id 經過雙下劃線拼接而成,諸如 Simhash__Id 的形式。
s0:記錄該行數據共有多少個 Set 集合,每個 Set 集合存儲10000個K-V對兒(約1MB)。
s1:第一個 Set 集合,Json 串存儲,若是 Size > 10000 ,以後來的數據將存入s2。
s2:以此類推。
固然最核心的部分是s1/s2/s3 中 Json 串中要排重。最簡單的辦法無非是每次存入數據前先將全部 Set 集合中的數據讀到客戶端,將欲存的數據與集合中全部數據比對後再次插入。這將帶來大量往返IO開銷,影響寫性能。所以,咱們在此引入了 HBase 協處理器技術來規避這個問題,即在服務端完成全部排重操做。大體代碼以下:
package com.learn.share.scenarios.observers; import com.google.gson.Gson; import com.google.gson.JsonObject; import org.apache.commons.lang.StringUtils; import org.apache.hadoop.hbase.Cell; import org.apache.hadoop.hbase.CellUtil; import org.apache.hadoop.hbase.CoprocessorEnvironment; import org.apache.hadoop.hbase.client.Durability; import org.apache.hadoop.hbase.client.Get; import org.apache.hadoop.hbase.client.Put; import org.apache.hadoop.hbase.client.Result; import org.apache.hadoop.hbase.coprocessor.BaseRegionObserver; import org.apache.hadoop.hbase.coprocessor.ObserverContext; import org.apache.hadoop.hbase.coprocessor.RegionCoprocessorEnvironment; import org.apache.hadoop.hbase.regionserver.wal.WALEdit; import org.apache.hadoop.hbase.util.Bytes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.util.List; /** * 基於協處理器構建百億級文本去重系統 */ public class HBaseSimHashSetBuildSystem extends BaseRegionObserver { private Logger logger = LoggerFactory.getLogger(HBaseSimHashSetBuildSystem.class); @Override public void start(CoprocessorEnvironment e) throws IOException { logger.info("Coprocessor opration start..."); } /** * * @param e * @param put * @param edit * @param durability * @throws IOException */ @Override public void prePut(ObserverContext<RegionCoprocessorEnvironment> e, Put put, WALEdit edit, Durability durability) throws IOException { // test flag logger.info("do something before Put Opration..."); List<Cell> cells = put.get(Bytes.toBytes("f"), Bytes.toBytes("si")); if (cells == null || cells.size() == 0) { return; } String simhash__itemid = Bytes.toString(CellUtil.cloneValue(cells.get(0))); if (StringUtils.isEmpty(simhash__itemid)||simhash__itemid.split("__").length!=2){ return; } String simhash = simhash__itemid.trim().split("__")[0]; String itemid = simhash__itemid.trim().split("__")[1]; // 獲取Put Rowkey byte[] row = put.getRow(); // 經過Rowkey構造Get對象 Get get = new Get(row); get.setMaxVersions(1); get.addFamily(Bytes.toBytes("f")); Result result = e.getEnvironment().getRegion().get(get); Cell columnCell = result.getColumnLatestCell(Bytes.toBytes("f"), Bytes.toBytes("s0")); // set size if (columnCell == null) { // 第一次存儲數據,將size初始化爲1 logger.info("第一次存儲數據,將size初始化爲1"); JsonObject jsonObject = new JsonObject(); jsonObject.addProperty(simhash,itemid); Gson gson = new Gson(); String json = gson.toJson(jsonObject); put.addColumn(Bytes.toBytes("f"),Bytes.toBytes("s1"), Bytes.toBytes(json)); // json 數組 put.addColumn(Bytes.toBytes("f"),Bytes.toBytes("s0"), Bytes.toBytes("1")); // 初始化 }else { byte[] sizebyte = CellUtil.cloneValue(columnCell); int size = Integer.parseInt(Bytes.toString(sizebyte)); logger.info("非第一次存儲數據 ----> Rowkey `"+Bytes.toString(row)+"` simhash set size is : "+size +", the current value is : "+simhash__itemid); for (int i = 1; i <= size; i++) { Cell cell1 = result.getColumnLatestCell(Bytes.toBytes("f"), Bytes.toBytes("s"+i)); String jsonBefore = Bytes.toString(CellUtil.cloneValue(cell1)); Gson gson = new Gson(); JsonObject jsonObject = gson.fromJson(jsonBefore, JsonObject.class); int sizeBefore = jsonObject.entrySet().size(); if(i==size){ if(!jsonObject.has(simhash)){ if (sizeBefore==10000){ JsonObject jsonone = new JsonObject(); jsonone.addProperty(simhash,itemid); String jsonstrone = gson.toJson(jsonone); put.addColumn(Bytes.toBytes("f"),Bytes.toBytes("s"+(size+1)), Bytes.toBytes(jsonstrone)); // json 數組 put.addColumn(Bytes.toBytes("f"),Bytes.toBytes("s0"), Bytes.toBytes((size+1)+"")); // 初始化 }else { jsonObject.addProperty(simhash,itemid); String jsonAfter = gson.toJson(jsonObject); put.addColumn(Bytes.toBytes("f"),Bytes.toBytes("s"+size), Bytes.toBytes(jsonAfter)); // json 數組 } }else { return; } }else{ if(!jsonObject.has(simhash)){ continue; }else { return; } } } } } }
如此,當咱們須要對某一文本指紋與庫中數據進行比對時,只需一個Table.Get(List<Get>) 操做便可返回全部的數據,而後基於s0依次獲取各個 Set 集合中的數據便可。
下面咱們算一筆帳,假設咱們某主題類型數據依然有 2^37 條數據(1375億數據),假設數據均勻分佈,則每一個16位(16個01數字隨機組成的組合爲2^16 個)倒排返回的最大數量爲 (2^37) * 4 / (2^16) =8388608個候選結果,即每行約839個 Set 集合,每一個Set 集合大約1M 的話,數據存儲量也必然不會太大。
你若是有十種不一樣主題的數據,HBase 行數無非也才 (2^16)*10 = 655360 行而已。
若是再加上 Snappy 壓縮呢?
若是再加上 Fast-Diff 編碼呢?
若是再開啓 Mob 對象存儲呢? 每一個 Set 是否是能夠存10萬個鍵值對?每行只需90個 Set 集合。
也或許,若是數據量小的話,使用 Redis 是否是更好呢?
總之,優化完善和不完美的地方還不少,本文也就簡單敘述到此,若是您有好的建議或是不一樣見解,歡迎留言哦!感恩~ 晚安各位~~
1. https://blog.csdn.net/u010454030/article/details/49102565
2. http://www.lanceyan.com/tech/arch/simhash_hamming_distance_similarity2-html.html
3. https://cloud.tencent.com/developer/news/218062
4. https://blog.csdn.net/qq_36142114/article/details/80540303
5. https://blog.csdn.net/u011467621/article/details/49685107
轉載請註明出處!歡迎關注本人微信公衆號【HBase工做筆記】