千萬級數據查詢:CK、ES、RediSearch 怎麼選?

目錄

前言

在開發中遇到一個業務訴求,需要在千萬量級的底池數據中篩選出不超過 10W 的數據,並根據配置的權重規則進行排序、打散(如同一個類目下的商品數據不能連續出現 3 次)。下面對該業務訴求的實現,設計思路和方案優化進行介紹。

對 “千萬量級數據中查詢 10W 量級的數據” 設計瞭如下方案:

初版設計方案

整體方案設計爲:

技術方案如下:

每天運行導數任務,把現有的千萬量級的底池數據(Hive 表)導入到 Clickhouse 中,後續使用 CK 表進行數據篩選。

將業務配置的篩選規則和排序規則,構建爲一個「篩選 + 排序」對象 SelectionQueryCondition。

從 CK 底池表取「目標數據」時,開啓多線程,進行分頁篩選,將獲取到的「目標數據」存放到 result 列表中。

//分頁大小  默認 5000
int pageSize = this.getPageSize();
//頁碼數
int pageCnt = totalNum / this.getPageSize() + 1;

List<Map<String, Object>> result = Lists.newArrayList();
List<Future<List<Map<String, Object>>>> futureList = new ArrayList<>(pageCnt);

//開啓多線程調用
for (int i = 1; i <= pageCnt; i++) {
    //將業務配置的篩選規則和排序規則 構建爲 SelectionQueryCondition 對象
    SelectionQueryCondition selectionQueryCondition = buildSelectionQueryCondition(selectionQueryRuleData);
    selectionQueryCondition.setPageSize(pageSize);
    selectionQueryCondition.setPage(i);
    futureList.add(selectionQueryEventPool.submit(new QuerySelectionDataThread(selectionQueryCondition)));
}


for (Future<List<Map<String, Object>>> future : futureList) {
    //RPC 調用
    List<Map<String, Object>> queryRes = future.get(20, TimeUnit.SECONDS);
    if (CollectionUtils.isNotEmpty(queryRes)) {
        // 將目標數據存放在 result 中
        result.addAll(queryRes);
    }
}

④對目標數據 result 進行排序,得到最終的「結果數據」。

CK 分頁查詢

在「初版設計方案」章節的第 3 步提到了「從 CK 底池表取目標數據時,開啓多線程,進行分頁篩選」。此處對 CK 分頁查詢進行介紹。

①封裝了 queryPoolSkuList 方法,負責從 CK 表中獲得目標數據。該方法內部調用了 sqlSession.selectList 方法。

public List<Map<String, Object>> queryPoolSkuList( Map<String, Object> params ) {
    List<Map<String, Object>> resultMaps = new ArrayList<>();

    QueryCondition queryCondition = parseQueryCondition(params);
    List<Map<String, Object>> mapList = lianNuDao.queryPoolSkuList(getCkDt(),queryCondition);
    if (CollectionUtils.isNotEmpty(mapList)) {
        for (Map<String,Object> data : mapList) {
            resultMaps.add(camelKey(data));
        }
    }
    return resultMaps;
}
// lianNuDao.queryPoolSkuList

@Autowired
@Qualifier("ckSqlNewSession")
private SqlSession sqlSession;

public List<Map<String, Object>> queryPoolSkuList( String dt, QueryCondition queryCondition ) {
    queryCondition.setDt(dt);
    queryCondition.checkMultiQueryItems();
    return sqlSession.selectList("LianNu.queryPoolSkuList",queryCondition);
}

②sqlSession.selectList 方法中調用了和 CK 交互的 queryPoolSkuList 查詢方法,部分代碼如下:

<select id="queryPoolSkuList" parameterType="com.jd.bigai.domain.liannu.QueryCondition" resultType="java.util.Map">
    select sku_pool_id,i
    tem_sku_id,
    skuPoolName,
    price,
    ...
    ...
    businessType
    from liannu_sku_pool_indicator_all
    where
    dt=#{dt}
    and
    <foreach collection="queryItems" separator=" and " item="queryItem" open=" " close=" " >
        <choose>
            <when test="queryItem.type == 'equal'">
                ${queryItem.field} = #{queryItem.value}
            </when>
            ...
            ...
        </choose>
    </foreach>
    <if test="orderBy == null">
        group by sku_pool_id,item_sku_id
    </if>
    <if test="orderBy != null">
        group by sku_pool_id,item_sku_id,${orderBy} order by ${orderBy} ${orderAd}
    </if>
    <if test="limitEnd != 0">
        limit #{limitStart},#{limitEnd}
    </if>
</select>

③可以看到,在 CK 分頁查詢時,是通過 limit #{limitStart},#{limitEnd} 實現的分頁。

limit 分頁方案,在「深翻頁」時會存在性能問題。初版方案上線後,在 1000W 量級的

底池數據中篩選 10W 的數據,最壞耗時會達到 10s~18s 左右。

使用 ES Scroll Scan 優化深翻頁

對於 CK 深翻頁時候的性能問題,進行了優化,使用 Elasticsearch 的 scroll scan 翻頁方案進行優化。

| ES 的翻頁方案

ES 翻頁,有下面幾種方案:

對上述幾種翻頁方案,查詢不同數目的數據,耗時數據如下表:

| 耗時數據

此處,分別使用 Elasticsearch 的 scroll scan 翻頁方案、初版中的 CK 翻頁方案進行數據查詢,對比其耗時數據。

如上測試數據,可以發現,以十萬,百萬,千萬量級的底池爲例:

ES+Hbase 組合查詢方案

在「使用 ES Scroll Scan 優化深翻頁」中,使用 Elasticsearch 的 scroll scan 翻頁方案對深翻頁問題進行了優化,但在實現時爲單線程調用,所以最終測試耗時數據並不是特別理想,和 CK 翻頁方案性能差不多。

在調研階段發現,從底池中取出 10W 的目標數據時,一個商品包含多個字段的信息(CK 表中一行記錄有 150 個字段信息),如價格、會員價、學生價、庫存、好評率等。

對於一行記錄,當減少獲取字段的個數時,查詢耗時會有明顯下降。如對 sku1 的商品,從之前獲取價格、會員價、學生價、親友價、庫存等 100 個字段信息,縮減到只獲取價格、庫存這兩個字段信息。

如下圖所示,使用 ES 查詢方案,對查詢同樣條數的場景(從千萬級底池中篩選出 7W+ 條數據),獲取的每條記錄的字段個數從 32 縮減到 17,再縮減到 1 個(其實是兩個字段,一個是商品唯一標識 sku_id,另一個是 ES 對每條文檔記錄的 doc_id)時,查詢的耗時會從 9.3s 下降到 4.2s,再下降到 2.4s。

從中可以得出如下結論:

下面對結論中涉及的 query 和 fetch 查詢階段進行補充說明。

| ES 查詢的兩個階段

在 ES 中,搜索一般包括兩個階段:

| 組合使用 Hbase

《ES 億級數據檢索優化,三秒返回突破性能瓶頸一文調研的基礎上,發現「減少不必要的查詢展示字段」可以明顯縮短查詢耗時。

沿着這個優化思路,設計了一種新的查詢方案:

使用 ES + Hbase 組合查詢方案,在線上進行了小規模的灰度測試。在 1000W 量級的底池數據中篩選 10W 的數據,對比 CK 翻頁方案,最壞耗時從 10~18s 優化到了 3~6s 左右。

也應該看到,使用 ES + Hbase 組合查詢方案,會增加系統複雜度,同時數據也需要同時存儲到 ES 和 Hbase。

RediSearch+RedisJSON 優化方案

RediSearch 是基於 Redis 構建的分佈式全文搜索和聚合引擎,能以極快的速度在 Redis 數據集上執行復雜的搜索查詢。

RedisJSON 是一個 Redis 模塊,在 Redis 中提供 JSON 支持。RedisJSON 可以和 RediSearch 無縫配合,實現索引和查詢 JSON 文檔。

根據一些參考資料,RediSearch + RedisJSON 可以實現極高的性能,可謂碾壓其他 NoSQL 方案。在後續版本迭代中,可考慮使用該方案來進一步優化。

下面給出 RediSearch + RedisJSON 的部分性能數據。

| RediSearch 性能數據

在同等服務器配置下索引了 560 萬個文檔 (5.3GB),RediSearch 構建索引的時間爲 221 秒,而 Elasticsearch 爲 349 秒。RediSearch 比 ES 快了 58%。

數據建立索引後,使用 32 個客戶端對兩個單詞進行檢索,RediSearch 的吞吐量達到 12.5K ops/sec,ES 的吞吐量爲 3.1K ops/sec,RediSearch 比 ES 要快 4 倍。

同時,RediSearch 的延遲爲 8ms,而 ES 爲 10ms,RediSearch 延遲稍微低些。

| RedisJSON 性能數據

根據官網的性能測試報告,RedisJson + RedisSearch 可謂碾壓其他 NoSQL:

在混合工作負載場景中,實時更新不會影響 RedisJSON 的搜索和讀取性能,而 ES 會受到影響:

此外,RedisJSON 的讀取、寫入和負載搜索延遲,在更高的百分位數中遠比 ES 和 MongoDB 穩定。

當增加寫入比率時,RedisJSON 還能處理越來越高的整體吞吐量。而當寫入比率增加時,ES 會降低它可以處理的整體吞吐量。

總結

本文從一個業務訴求觸發,對 “千萬量級數據中查詢 10W 量級的數據” 介紹了不同的設計方案。

對於在 1000W 量級的底池數據中篩選 10W 的數據的場景,不同方案的耗時如下:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sxwXFmjhCBdDGw_OamQqTw