百億節點、毫秒級延遲,攜程金融基於 NebulaGraph 的大規模圖應用實踐 5. 總結與展望

作者:霖霧,攜程數據開發工程師,關注圖數據庫等領域。

0. 背景

2017 年 9 月攜程金融成立,在金融和風控業務中,有多種場景需要對圖關係網絡進行分析和實時查詢,傳統關係型數據庫難以保證此類場景下的關聯性能,且實現複雜性高,離線關聯耗時過長,因此對圖數據庫的需求日益增加。攜程金融從 2020 年開始引入大規模圖存儲和圖計算技術,基於 NebulaGraph 構建了千億級節點的圖存儲和分析平臺,並取得了一些實際應用成果。

本文主要分享 NebulaGraph 在攜程金融的實踐,希望能帶給大家一些實踐啓發。

本文主要從以下幾個部分進行分析:

  • 圖基礎介紹
  • 圖平臺建設
  • 內部應用案例分析
  • 痛點與優化
  • 總結規劃

1. 圖基礎

首先我們來簡單介紹下圖相關的概念:

1.1 什麼是圖

在計算機科學中,圖就是一些頂點的集合,這些頂點通過一系列邊結對(連接)。比如我們用一個圖表示社交網絡,每一個人就是一個頂點,互相認識的人之間通過邊聯繫。

在圖數據庫中,我們使用(起點,邊類型,rank,終點)表示一條邊。起點和終點比較好理解,表示一條邊兩個頂點的出入方向。邊類型則是用於區分異構圖的不同邊,如我關注了你,我向你轉賬,關注和轉賬就是兩種不同種類的邊。而 rank 是用來區分同起始點同終點的不同邊,如 A 對 B 的多次轉賬記錄,起點、終點、邊類型是完全相同的,因此就需要如時間戳作爲 rank 來區分不同的邊。

同時,點邊均可具有屬性,如:A 的手機號、銀行卡、身份證號、籍貫等信息均可作爲 A 的點屬性存在,A 對 B 轉賬這條邊,也可以具有屬性,如轉賬金額,轉賬地點等邊屬性。

1.2 什麼時候用圖

(信息收集於開源社區、公開技術博客、文章、視頻)

1.2.1 金融風控

  • 詐騙電話的特徵提取,如不在三步社交鄰居圈內,被大量拒接等特徵。實時識別攔截。(銀行 / 網警等)
  • 轉賬實時攔截(銀行 / 支付寶等)
  • 實時欺詐檢測,羊毛黨的識別(電商)
  • 黑產羣體識別,借貸記錄良好用戶關聯,爲用戶提供更高額貸款、增加營收

1.2.2 股權穿透

影子集團、集團客戶多層交叉持股、股權層層嵌套複雜關係的識別(天眼查 / 企查查)

1.2.3 數據血緣

在數據倉庫開發過程中, 會因爲數據跨表關聯產生大量的中間表,使用圖可直接根據關係模型表示出數據加工過程和數據流向,以及在依賴任務問題時快速定位上下游。

1.2.4 知識圖譜

構建行業知識圖譜。

1.2.5 泛安全

IP 關係等黑客攻擊場景,計算機進程與線程等安全管理。

1.2.6 社交推薦

  • 好友推薦,行爲相似性,諮詢傳播路徑,可能認識的人,大V 粉絲共同關注,共同閱讀文章等,商品相似性,實現好友商品或者諮詢的精準推薦;
  • 通過對用戶畫像、好友關係等,進行用戶分羣、實現用戶羣體精準管理;

1.2.7 代碼依賴分析

分析代碼依賴關係。

1.2.8 供應鏈上下游分析

如:汽車供應鏈上下游可涉及上萬零件及供應商,分析某些零件成本上漲 / 供應商單一 / 庫存少等多維度的影響。(捷豹)

1.3 誰在研發圖,誰在使用圖

(信息收集於開源社區、公開技術博客、文章、視頻)

目前國內幾家大公司都有各自研發的圖數據庫,主要滿足內部應用的需求,大多數都是閉源的,開源的僅有百度的 HugeGraph。其他比較優秀的開源產品有 Google Dgraph,vesoft 的 NebulaGraph 等,其中 NebulaGraph 在國內互聯網公司應用非常廣泛。結合我們的應用場景,以及外部公開的測試和內部壓測,我們最終選擇 NebulaGraph 構建金融圖平臺。

2. 圖平臺建設

2.1 圖平臺建設

我們的圖平臺早期只有 1 個 3 節點的 Nebula 集羣。隨着圖應用場景的不斷擴充,需要滿足實時檢索、離線分析、數據同步與校驗等功能,最終演化成上述架構圖。

2.1.1 離線圖

主要用於圖構建階段(建模、圖算法分析),通過 spark-connector 同集團的大數據平臺打通,此外我們還將 NebulaGraph 提供的數 10 種常用圖算法進行工具化包裝,方便圖分析人員在 Spark 集羣提交圖算法作業。

2.1.2 線上圖

經過離線圖分析確定最終建模後,會通過 spark-connector 將數據導入線上圖。通過對接 qmq 消息(集團內部的消息框架)實時更新,對外提供實時檢索服務。同時也會有 T+1 的 HIVE 增量數據通過 spark-connector 按天寫入。

2.1.3 全量校驗

雖然 NebulaGraph 通過 TOSS 保證了正反邊的插入一致性,但仍不支持事務,隨着數據持續更新,實時圖和離線(HIVE 數據)可能會存在不一致的情況,因此我們需要定期進行全量數據的校驗(把圖讀取到 Hive,和 Hive 表存儲的圖數據進行比對,找出差異、修復),保證數據的最終一致性。

2.1.4 集羣規模

爲了滿足千億節點的圖業務需求,實時集羣採用三臺獨立部署的高性能機器,每臺機器 64 core / 320 GB / 12 TB SSD ,版本爲 Nebula v2.5,跨機房部署。離線集羣 64 core / 320 GB / 3.6 TB SSD * 12 ,測試集羣 48 core / 188 GB / 5T HDD * 4.

2.2 遇到的問題

在 NebulaGraph 應用過程中,也發現一些問題,期待逐步完善:

  1. 資源隔離問題,目前 Nebula 沒有資源分組隔離功能,不同業務會相互影響;如業務圖 A 在導數據,業務圖 B 線上延遲就非常高。
  2. 版本升級問題:
    1. NebulaGraph 在版本升級過程中需要停止服務,無法實現熱更新;對於類似實時風控等對可靠性要求非常高的場景非常不友好。此種情況下如需保證在線升級,就需要配備主備集羣,每個集羣切量後挨個升級,增加服務複雜性和運維成本。
    2. 客戶端不兼容,客戶端需要跟着服務端一起升級版本。對於已有多個應用使用的 Nebula 集羣,想要協調各應用方同時升級客戶端是比較困難的。

3. 內部應用案例分析

3.1 數據血緣圖

數據治理是近年來比較熱的一個話題,他是解決數倉無序膨脹的有效手段,其中數據血緣是數據有效治理的重要依據,攜程金融藉助 NebulaGraph 構建了數據血緣圖,以支撐數據治理的系統建設。

數據血緣就是數據產生的鏈路,記錄數據加工的流向,經過了哪些過程和階段;主要解決 ETL 過程中可能產出幾十甚至幾百個中間表導致的複雜表關係,借用數據血緣可以清晰地記錄數據源頭到最終數據的生成過程。

圖 a 是數據血緣的關係圖,採用庫名 + 表名作爲圖的頂點來保證點的唯一性,點屬性則是分開的庫名和表名,以便通過庫名或者表名進行屬性查詢。在兩張表之間會建立一條邊,邊的屬性主要存放任務的產生運行情況,比如說:任務開始時間,結束時間、用戶 ID 等等同任務相關的信息。

圖 b 是實際查詢中的一張關係圖,箭頭的方向表示了表的加工方向,通過上游或者下游表我們可以快速地找到它的依賴,清晰明瞭地顯示從上游到下游的每一個鏈路。

如果要表達複雜的血緣依賴關係圖,通過傳統的關係型數據庫需要複雜的 SQL 實現(循環嵌套),性能也比較差,而通過圖數據庫實現,則可直接按數據依賴關係存儲,讀取也快於傳統 DB,非常簡潔。目前,數據血緣也是攜程金融在圖數據庫上的一個經典應用。

3.2 風控關係人圖

關係人圖常用於欺詐識別等場景,它是通過 ID、設備、手機標識以及其他介質信息關聯不同用戶的關係網絡。比如說,用戶 A 和用戶 B 共享一個 Wi-Fi,他們便是局域網下的關係人;用戶 C 和用戶 D 相互下過單,他們便是下單關係人。簡言之,系統通過多種維度的數據關聯不同的用戶,這便是關係人圖。

構建模型時,通常要查詢某個時點(比如欺詐事件發生前)的關係圖,對當時的圖進行模型抽取和特徵構建,我們稱這個過程爲圖回溯。隨着回溯時間點的不同,返回的圖數據也是動態變化的;比如某人上午,下午各自打了一通電話,需要回溯此人中午時間點時的圖關係,只會出現上午的電話記錄,具體到圖,則每類邊都具有此類時間特性,每一次查詢都需要對時間進行限制。

對於圖回溯場景,最初我們嘗試通過 HIVE SQL 實現,發現對於二階及以上的圖回溯,SQL 表達會非常複雜,而且性能不可接受(比如二階回溯 Hive 需要跑數小時,三階回溯 Hive 幾乎不能實現);因此嘗試藉助圖數據庫來實現,把時間作爲邊 rank 進行建模,再根據邊關係進行篩選來實現回溯。這種回溯方式更直觀、簡潔,使用簡單的 API 即可完成,在性能上相比 Hive 也有 1 個數量級以上的提升(二階回溯,圖節點:百億級,待回溯節點:10 萬級)。

下面用一個例子說明:如圖(a),點 A 分別在 t0、t1、t2 時刻建立了一條邊,t0、t1、t2爲邊 rank 值,需要返回 tx 時的的圖關係數據,只能返回 t0、t1 對應的點 B、C,因爲當回溯到 tx 時間點時候,t2 還沒有發生;最終返回的圖關係爲 t0 和 t1 時候,VertexA ->VertexBVertexA -> VertexC(見圖(c))。這個例子是用一種邊進行回溯,實際查詢中可能會涉及到 2~3 跳,且存在異構邊(打電話是一種邊,點外賣又是一種邊,下單酒店機票是一種邊,都是不同類型的邊),而這種異構圖的數據都具有回溯特徵,因此實際的關係人圖回溯查詢也會變得複雜。

3.3 實時反欺詐圖

用戶下單時,會進入一個快速風控的階段:通過基於關係型數據庫和圖數據庫的規則進行模型特徵計算,來判斷這個用戶是不是風險用戶,要不要對該用戶進行下單攔截(實時反欺詐)。

我們可以根據圖關係配合模型規則,用來挖掘欺詐團伙。比如說,已知某個 uid 是犯欺團伙的一員,根據圖關聯來判斷跟他關係緊密的用戶是不是存在欺詐行爲。爲了避免影響正常用戶的下單流程,風控階段需要快速響應,因此對圖查詢的性能要求非常高(P95 < 15 ms)。我們基於 NebulaGraph 構建了百億級的反欺詐圖,在查詢性能的優化方面進行了較多思考。

此圖 Schema 爲脫敏過後的部分圖模型,當中隱藏很多建模信息。這裏簡單講解下部分的查詢流程和關聯信息。

如上圖爲一次圖查詢流程,每一次圖查詢由多個起始點如用戶 uid、用戶 mobile 等用戶信息同時開始,每條線爲一次關聯查詢,因此一次圖查詢由幾十次點邊查詢組成,由起始點經過一跳查詢和 2 跳查詢,最終將結果集返回給風控引擎。

系統會將用戶的信息,轉化爲該用戶的標籤。在圖查詢的時候,根據這些標籤,如 uid、mobile 進行獨立查詢。舉個例子,根據某個 uid 進行一跳查詢,查詢出它關聯的 5 個手機號。再根據這 5 個手機號進行獨立的 2 跳查詢,可能會出來 25 個 uid,查詢會存在數據膨脹的情況。因此,系統會做一個查詢限制。去查看這 5 個手機號關聯的 uid 是不是超過了系統設定的熱點值。如果說通過 mobile 查詢出來關聯的手機號、uid 過多的話,系統就會判斷其爲熱點數據,不進行邊結果返回。(二階/三階回溯,圖點邊:百億級)。

4.1 痛點及優化

在上述應用場景中,對於風控關係人圖和反欺詐圖,由於圖規模比較大(百億點邊),查詢較多,且對時延要求較高,遇到了一些典型問題,接下來簡單介紹一下。

4.1.1 查詢性能問題

爲了滿足實時場景 2 跳查詢 P95 15 ms 需求,我們針對圖 Schema 和連接池以及查詢端做了一些優化:

4.1.2 犧牲寫性能換取讀性能

首先,我們來看看這樣的一個需求:查詢 ID 關聯的手機號,需要滿足對於這個手機號關聯邊不超過 3 個。這裏解釋下爲什麼要限制關聯邊數量,因爲我們正常個體關聯邊數量是有限的,會有一個對於大多數人的 P95 這樣的閾值邊數量,超過這個閾值就是髒數據。爲了這個閾值校驗, 就需要對每次查詢的結果再多查詢一跳。

如圖(a)所示,我們需要進行 2 次查詢,第一跳查詢是爲了查詢用戶 ID 關聯的手機號,第二跳查詢是爲了保證我們的結果值是合法的(閾值內),這樣每跳查詢最終需要進行 2 跳查詢來滿足。如圖給出了圖查詢的 nGQL 2 步僞碼,這種情況下無法滿足我們的高時效性。如何優化呢?看下圖(b) :

我們可以將熱點查詢固定在點屬性上,這樣一跳查詢時就可以知道該點有多少關聯邊,避免進行圖 a 中(2)語句驗證。還是以圖 (a)爲例,從一個用戶 ID 開始查詢,查詢他的手機號關聯,此時因爲手機號關聯的邊已經變成了點屬性(修改了 schema),圖(a) 2 條查詢語句實現的功能就可以變成一條查詢 go from $id over $edgeName where $手機號.用戶id邊數據 <5 | limit 5

這種設計的好處就是,在讀的時候可以加速驗證過程,節約了一跳查詢。帶來的成本是:每寫一條邊,同時需要更新 2 個點屬性來記錄點的關聯邊情況,而且需要保證冪等(保證重複提交不會疊加屬性 +1)。當插入一條邊的時,先去圖裏面查詢邊是否存在,不存在纔會進行寫邊以及點屬性 +1 的操作。也就是我們犧牲了寫性能,來換取讀性能,並通過定期 check 保證數據一致。

4.1.3 池化連接降低時延

第二個優化手段是通過池化連接降低時延。Nebula 官方連接池每次進行查詢均需要進行建立初始化連接-執行查詢任務-關閉連接。而在高頻(QPS 會達到幾千)的查詢場景中,頻繁的創建、關閉連接非常影響系統的性能和穩定性。且建立連接過程耗時平均需要 6 ms, 比實際查詢時長 1.5 ms 左右高出幾倍,這是不可接受的。因此我們對官方客戶端進行了二次封裝,實現連接的複用和共享。最後,將查詢 P95 從 20 ms 降低到了 4 ms。通過合理控制併發,我們最終將 2 跳查詢性能控制在 P95 15 ms 。

這裏貼下代碼供參考:

public class SessionPool {
 
    /**
     * 創建連接池
     *
     * @param maxCountSession 默認創建連接數
     * @param minCountSession 最大創建連接數
     * @param hostAndPort     機器端口列表
     * @param userName        用戶名
     * @param passWord        密碼
     * @throws UnknownHostException
     * @throws NotValidConnectionException
     * @throws IOErrorException
     * @throws AuthFailedException
     */
    public SessionPool(int maxCountSession, int minCountSession, String hostAndPort, String userName, String passWord) throws UnknownHostException, NotValidConnectionException, IOErrorException, AuthFailedException {
        this.minCountSession = minCountSession;
        this.maxCountSession = maxCountSession;
        this.userName = userName;
        this.passWord = passWord;
        this.queue = new LinkedBlockingQueue<>(minCountSession);
        this.pool = this.initGraphClient(hostAndPort, maxCountSession, minCountSession);
        initSession();
    }
 
    public Session borrow() {
        Session se = queue.poll();
        if (se != null) {
            return se;
        }
        try {
            return this.pool.getSession(userName, passWord, true);
        } catch (Exception e) {
            log.error("execute borrow session fail, detail: ", e);
            throw new RuntimeException(e);
        }
    }
 
    public void release(Session se) {
        if (se != null) {
            boolean success = queue.offer(se);
            if (!success) {
                se.release();
            }
        }
    }
 
    public void close() {
        this.pool.close();
    }
 
    private void initSession() throws NotValidConnectionException, IOErrorException, AuthFailedException {
        for (int i = 0; i < minCountSession; i++) {
            queue.offer(this.pool.getSession(userName, passWord, true));
        }
    }
 
    private NebulaPool initGraphClient(String hostAndPort, int maxConnSize, int minCount) throws UnknownHostException {
        List<HostAddress> hostAndPorts = getGraphHostPort(hostAndPort);
        NebulaPool pool = new NebulaPool();
        NebulaPoolConfig nebulaPoolConfig = new NebulaPoolConfig();
        nebulaPoolConfig = nebulaPoolConfig.setMaxConnSize(maxConnSize);
        nebulaPoolConfig = nebulaPoolConfig.setMinConnSize(minCount);
        nebulaPoolConfig = nebulaPoolConfig.setIdleTime(1000 * 600);
        pool.init(hostAndPorts, nebulaPoolConfig);
        return pool;
    }
 
    private List<HostAddress> getGraphHostPort(String hostAndPort) {
        String[] split = hostAndPort.split(",");
        return Arrays.stream(split).map(item -> {
            String[] splitList = item.split(":");
            return new HostAddress(splitList[0], Integer.parseInt(splitList[1]));
        }).collect(Collectors.toList());
    }
 
    private Queue<Session> queue;
 
    private String userName;
 
    private String passWord;
 
    private int minCountSession;
 
    private int maxCountSession;
 
    private NebulaPool pool;
 
}

4.1.4 查詢端優化

對於查詢端,像 3.3 中的例圖,每一次圖查詢由多個起始點開始,可拆解爲幾十次點邊查詢,需要讓每一層的查詢儘可能地併發進行,降低最終時延。我們可以先對 1 跳查詢併發(約十幾次查詢),再對結果進行分類合併,進行第二輪的迭代併發查詢(十幾到幾十次查詢),通過合理地控制併發,可將一次組合圖查詢的 P95 控制在 15 ms 以內。

4.2 邊熱點問題

在圖查詢過程中,存在部分用戶 ID 關聯過多信息,如黃牛用戶關聯過多信息,這部分異常用戶會在每一次查詢時被過濾掉,不會繼續參與下一次查詢,避免結果膨脹。而判斷是否爲異常用戶,則依賴於數據本身設定的閾值,異常數據不會流入下一階段對模型計算造成干擾。

4.3 一致性問題

NebulaGraph 本身是沒有事務的,對於上文寫邊以及點屬性 +1 的操作,如何保證這些操作的一致性,上文提到過,我們會定期對全量 HIVE 表數據和圖數據庫進行 check,以 HIVE 數據爲準對線上圖進行修正,來實現最終一致性。目前來說,圖數據庫和 HIVE 表不一致的情況還是比較少的。

5. 總結與展望

基於 NebulaGraph 的圖業務應用,完成了對數據血緣、對關係人網絡、反欺詐等場景的支持,並將持續應用在金融更多場景下,助力金融業務。我們將持續跟進社區,結合自身應用場景推進圖平臺建設;同時也期待社區版能提供熱升級、資源隔離、更豐富易用的算法包、更強大的 Studio 等功能。


謝謝你讀完本文 (///▽///)

如果你想嚐鮮圖數據庫 NebulaGraph,記得去 GitHub 下載、使用、(з)-☆ star 它 -> GitHub;和其他的 NebulaGraph 用戶一起交流圖數據庫技術和應用技能,留下「你的名片」一起玩耍呀~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章