【問題排查篇】一次業務問題對 ES 的 cardinality 原理探究 | 京東雲技術團隊

京東雲開發者發表於2023-05-12

作者:京東科技 王長春

業務問題

小編工作中負責業務的一個服務端系統,使用了 Elasticsearch 服務做資料儲存,業務運營人員反饋,使用者在使用該產品時發現,使用者後臺統計的訂單筆數和匯出的訂單筆數不一致

交易訂單筆數不對,出現差錯訂單了?這一聽極為震撼!出現這樣的問題,在金融科技公司裡面是絕對不允許發生的,得馬上定位問題並解決!

線上反饋業務資料查詢和匯出資料不一致

小編馬上聯絡業務和相關人員,透過梳理上游系統的呼叫關係,發現業務系統使用到的是我這邊的 ES 的儲存服務,然後對線上情況進行復現,基本瞭解問題的現象:

  1. 使用者操作後臺裡的訂單總筆數:商戶頁面的"訂單總筆數","訂單總筆數"使用的是小編 ES 儲存服務中 ES 的統計聚合功能,其中訂單總筆數是使用了 cardinality 操作,並且使用的是 orderId(訂單編號)進行統計去重。
  2. 匯出功能裡的訂單總筆數:匯出功能使用的是 ES 儲存服務中的 ES 條件查詢功能,匯出功能是進行分頁查詢的。

問題定位

這兩個查詢數量不一致,首先看查詢條件是否一致呢?

經過一番排查,業務系統在呼叫查詢訂單總數和匯出訂單總數的這兩個查詢條件是一致的,也就是請求到我這邊 ES 服務時,統計聚合的查詢和分頁匯出的查詢條件是一致的,但是為什麼會在 ES 裡面查詢的結果是不一致的呢?難道 ES 裡面的資料不全?統計聚合或分頁匯出的其中有一個不準了?

為了具體排查哪個操作可能存在問題,於是透過相同條件下查詢資料庫的總數和 ES 裡面的資料進行對比。發現相同條件下,資料庫裡面的資料和 ES 條件查詢的總數是一致的, 同時業務的 orerId 欄位是沒有重複,所以可以確定的是:透過 orderId 進行統計聚合去重的操作是有問題的。

資料庫查詢數量

運營後臺查詢數量

資料庫查詢:資料庫是做分庫分表,此處資料庫查詢使用的是公司內的資料部銀河大表——公司資料部會 T+1日從業務從庫資料庫中抽取 T 日的增量資料放在建立的"大表"中, 方便各業務進行資料使用。

運營後臺查詢:運營後臺查詢是直接查詢 ES 儲存服務。

資料部大表數量 = MySQL 資料庫分庫分表表裡數量 = 運營控制檯查詢數量 = ES 儲存文件數量

問題定位:
ES 儲存服務對外給業務提供的: 透過 orderId 進行統計聚合去重(cardinality)的功能應該是有問題的。

ES 的 cardinality 原理探究

上面說過,小編負責的 ES 儲存服務對外給業務提供了透過指定業務欄位進行統計聚合去重的功能,統計聚合去重使用的是 ES 的 cardinality 功能。透過業務的查詢的條件,使用 ES 的聚合功能 cardinality 操作,對映到 ES 層的操作命令如下程式碼所示,

執行業務的查詢條件操作,從 ES 的管理端後臺裡面查詢竟然復現了和線上生產一樣的結果,聚合統計的是 21514,條件查詢的是 21427!!!

可以確定的就是這個 cardinality 操作,導致了兩個查詢的資料不一致,如下圖所示:

GET datastore_big_es_1_index/datastore_big_es_1_type/_search
{
  "size": 3,
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "v021.raw": "selfhelp"
          }
        },
        {
          "match": {
            "v012.raw": "1001"
          }
        },
        {
          "match": {
            "typeId": "00029"
          }
        },
        {
          "range": {
            "createdDate": {
              "gte": "2021-02-01",
              "lt": "2021-03-01"
            }
          }
        },
        {
          "bool": {
            "should": [
              {
                "match": {
                  "v031.raw": "113692300"
                }
              }
            ]
          }
        }
      ]
    }
  },
  "aggs": {
    "distinct_orderId": {
      "cardinality": {
        "field": "v033.raw"
      }
    }
  }
}

ES叢集控制檯cardinality操作

為什麼 cardinality 操作會出現這樣的結果呢?

小編開始陷入了想當然的陷阱—— 以為這就是一個簡簡單單的統計去重的功能,ES 做的多好,幫你去重並統計數量了。然後事實並不是,透過 Elasticsearch 對 cardinality 官方文件解釋,終於找到了原因。

可以參考Elasticsearch 2.x 版本官方文件對 cardinality的解釋:cardinality

其中對 cardinality 演算法核心解釋是:

ES文件中對cardinality演算法介紹

可以總結如下:

  1. cardinality 並不是像關係型資料庫 MySQL 一樣精確去重的,cardinality做的是一個近似值,是 ES 幫你"估算"出的,這個估算使用的HyperLogLog++(HLL)演算法,在速度上非常快,遍歷一次即可統計去重,具體可看文件中推薦的論文。
  2. ES 做cardinality估算,是可以設定估算精確度,即設定引數  precision_threshold 引數,但是這個引數在 0-40000, 這個值越大意味著精度越高,同時意味著損失更多的記憶體,是以記憶體空間換精度。
  3. 在小資料量下,ES 的這個"估算"精度是非常高的,幾乎可以說是等於實際數量。

ES 中 cardinality 引數驗證

下面對 ES 的 cardinality 的precision_threshold引數進行驗證:

1、大資料量下,設定最高精度及其以上,仍然會存在誤差:

大資料量下,設定percision_threshold高精度值驗證

2、小資料量下,設定最高精度,可以和實際數量保持一致:

小資料量下,設定percision_threshold高精度驗證

那麼線上的為什麼聚合統計的是 21514,條件查詢的是 21427?

線上程式碼執行和ES叢集設定都沒有主動設定過 precision_threshold 引數,那麼可以知道,這個應該是 ES 叢集設定的預設值。線上 ES 叢集版本為 5.4x  因此找到 5.4 版本的官方文件,發現 5.4 版本中設定的是預設值 precision_threshold=3000, 在此條件下查詢的統計聚合出來的值是 21514。

另外 ES 官方對 cardinality 操作中的precision_threshold引數也做了研究,研究了官方文件中precision_threshold設定cardinality查詢失敗率查詢資料量級的關係,可作為我們在業務開發中進行參考,如下圖所示:
官方文件中precision_threshold設定和cardinality查詢失敗率的關係研究

Elasticsearch 5.4版本官方文件對cardinality中precision_threshold引數的研究文件:precision_threshold

總結與方案

透過對 cardinality 的原理探究, 需要明白的是 : 我們使用 cardinality 是需要區分使用場景的。

  1. 對於精確統計的業務場景,是不建議使用的。例如:訂單數的統計(統計結果會引起歧義)的場景下,不建議使用。
  2. 對於非精確統計的業務場景,那麼可以說是很有用了,尤其是在大資料量的場景下,在保持一定的準確性下,同時能提供高效能。例如:監控指標資料,大盤比例計算等場景,在非精確統計下,是有很大用處。

基於小編的這個業務場景,對商戶訂單進行統計,是屬於精確統計場景,那 cardinality 操作就不適合了。又因為業務的 orderId 是不會重複的,理論上在我們 ES 叢集中每個記錄的 orderId 都是唯一的,因此可以不用進行去重,而可以直接使用 ES 的 count 操作,將訂單數統計彙總出,對應 Elasticsearch 開發包中 COUNT API 如下:

org.springframework.data.elasticsearch.core.ElasticsearchTemplate
#count(org.springframework.data.elasticsearch.core.query.SearchQuery, java.lang.Class<T>)

public <T> long count(SearchQuery searchQuery, Class<T> clazz) {
    QueryBuilder elasticsearchQuery = searchQuery.getQuery();
    QueryBuilder elasticsearchFilter = searchQuery.getFilter();
    return elasticsearchFilter == null ? this.doCount(this.prepareCount(searchQuery, clazz), elasticsearchQuery) : this.doCount(this.prepareSearch(searchQuery, clazz), elasticsearchQuery, elasticsearchFilter);
}

最後歡迎大家點贊、收藏、評論,轉發!❤️❤️❤️

相關文章