基於HBase構建千億級文字資料相似度計算與快速去重系統

bluetooth發表於2021-09-09

前言

隨著大資料時代的到來,資料資訊在給我們生活帶來便利的同時,同樣也給我們帶來了一系列的考驗與挑戰。本文主要介紹了基於 Apache HBase 與 Google SimHash 等多種演算法共同實現的一套支援百億級文字資料相似度計算與快速去重系統的設計與實現。該方案在公司業務層面徹底解決了多主題海量文字資料所面臨的儲存與計算慢的問題。

一. 面臨的問題

1. 如何選擇文字的相似度計算或去重演算法?

常見的有餘弦夾角演算法、歐式距離、Jaccard 相似度、最長公共子串、編輯距離等。這些演算法對於待比較的文字資料不多時還比較好用,但在海量資料背景下,如果每天產生的資料以千萬計算,我們如何對於這些海量千萬級的資料進行高效的合併去重和相似度計算呢?

2. 如何實現快速計算文字相似度或去重呢?

如果我們選好了相似度計算和去重的相關演算法,那我們怎麼去做呢?如果待比較的文字資料少,我們簡單遍歷所有文字進行比較即可,那對於巨大的資料集我們該怎麼辦呢?遍歷很明顯是不可取的。

3. 海量資料的儲存與快速讀寫

二. SimHash 演算法引入

基於問題一,我們引入了 SimHash 演算法來實現海量文字的相似度計算與快速去重。下面我們簡單瞭解下該演算法。

1. 區域性敏感雜湊

在介紹 SimHash 演算法之前,我們先簡單介紹下區域性敏感雜湊是什麼。區域性敏感雜湊的基本思想類似於一種空間域轉換思想,LSH 演算法基於一個假設,如果兩個文字在原有的資料空間是相似的,那麼分別經過雜湊函式轉換以後的它們也具有很高的相似度;相反,如果它們本身是不相似的,那麼經過轉換後它們應仍不具有相似性。

區域性敏感雜湊的最大特點就在於保持資料的相似性,舉一個小小的例子說明一下:對A文章微調後我們稱其為B文章(可能只是多了一個‘的’字),如果此時我們計算兩篇文章的 MD5 值,那麼必將大相徑庭。而區域性敏感雜湊的好處是經過雜湊函式轉換後的值也只是發生了微小的變化,即如果兩篇文章相似度很高,那麼在演算法轉換後其相似度也會很高。

MinHash 與 SimHash 演算法都屬於區域性敏感雜湊,一般情況若每個 Feature 無權重,則 MinHash 效果優於 SimHash 有權重時 SimHash 合適。長文字使用 Simhash 效果很好,短文字使用 Simhash 準備度不高。

2. SimHash 演算法

SimHash 是 Google 在2007年發表的論文《Detecting Near-Duplicates for Web Crawling 》中提到的一種指紋生成演算法或者叫指紋提取演算法,被 Google 廣泛應用在億級的網頁去重的 Job 中,其主要思想是降維,經過simhash降維後,可能僅僅得到一個長度為32或64位的二進位制由01組成的字串。而一維查詢則是非常快速的。

SimHash的工作原理我們這裡略過,大家可以簡單理解為:我們可以利用SimHash演算法為每一個網頁/文章生成一個長度為32或64位的二進位制由01組成的字串(向量指紋),形如:1000010010101101111111100000101011010001001111100001001011001011。

3. 海明距離

兩個碼字的對應位元取值不同的位元數稱為這兩個碼字的海明距離。在一個有效編碼集中,任意兩個碼字的海明距離的最小值稱為該編碼集的海明距離。舉例如下:10101和00110從第一位開始依次有第一位、第四、第五位不同,則海明距離為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位截斷索引,總的結果為:4*8388608=33554432,約為3356萬,透過
這樣一來的降維處理,原來需要比較1375億次,現在只需要比較3356萬次即可得到結果,這樣以來大大提升了計算效率。

根據網上測試資料顯示,普通 PC 比較1000萬次海明距離大約需要 300ms,也就是說3356萬次(1375億資料)只需花費3356/1000*0.3=1.0068s。那也就是說對於千億級文字資料(如果每個文字1kb,約100TB資料)的相似度計算與去重工作我們最多隻需要一秒的時間即可得出結果。

四. HBase 儲存設計

饒了這麼大一週,我們終於將需要講明的理論知識給大家過了一遍。為了闡述的儘量清晰易懂,文中很多理論知識的理解借鑑了大量博主大牛的部落格,原文連結已在文末附上,有不太明白的地方快快跪拜大牛們的部落格吧,哈哈!

下面我們著重介紹一下 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 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
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位)

  • HashNumber: 為防熱點,對錶進行Hash預分割槽(64個預分割槽),佔2個字元
    計算公式如下:String.format("%02x", Math.abs(key.hashCode()) % 64)
  • ContentType :內容主題型別,佔4個字元
  • 16SimHash: 16位 SimHash 擷取指紋,由01組成

表結構大致如下:

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) 操作即可返回所有的資料,然後基於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 是不是更好呢?

總之,最佳化完善和不完美的地方還很多,本文也就簡單敘述到此,如果您有好的建議或是不同看法,歡迎留言哦!感恩~ 晚安各位~~

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/3402/viewspace-2825257/,如需轉載,請註明出處,否則將追究法律責任。

相關文章