Elasticsearch 中 Parent-Child 關系

Neil Zhu,簡書ID Not_GOD,University AI 創始人 & Chief Scientist,致力于推進世界人工智能化進程。制定并實施 UAI 中長期增長戰略和目標,帶領團隊快速成長為人工智能領域最專業的力量。
作為行業領導者,他和UAI一起在2014年創建了TASA(中國最早的人工智能社團), DL Center(深度學習知識中心全球價值網絡),AI growth(行業智庫培訓)等,為中國的人工智能人才建設輸送了大量的血液和養分。此外,他還參與或者舉辦過各類國際性的人工智能峰會和活動,產生了巨大的影響力,書寫了60萬字的人工智能精品技術內容,生產翻譯了全球第一本深度學習入門書《神經網絡與深度學習》,生產的內容被大量的專業垂直公眾號和媒體轉載與連載。曾經受邀為國內頂尖大學制定人工智能學習規劃和教授人工智能前沿課程,均受學生和老師好評。

parent-child 關系

類似于 nested model:可以關聯兩個實體。不同在于,nested object 中所有的實體必須存在同一個文檔中,而在 parent-child 中,parent 和 children 可以是完全分開的文檔。

parent-child 功能讓我們可以將一種文檔類型以一對多的關系關聯到另一個上。相比 nested object 的好處在于:

  1. parent 文檔可以不需要重新索引 children 進行更新。
  2. child 文檔可以被添加、修改或者刪除,而不影響 parent 或者其他 children。這在 child 文檔很多和增改頻率很高的時候尤其有用。
  3. child 文檔可以被作為搜索請求的結果返回。

Elasticsearch 維護了一個 parent 到 children 的映射。所以在查詢時刻的連接(join)會很快,但是這樣也給 parent-child 關系帶來了限制:parent 和所有 children 必須處在一個分片上。

parent-child ID 映射作為字段數據存放在內存中。后期會有計劃將這個默認設置改成使用 doc values。

parent-child 映射

為了建立 parent-child 關系的需求是指定哪種文檔類型應該是 child 類型的 parent。這個必須在?索引創建時刻?指定,或者使用 update-mapping API 在 child 類型被創建前指定。

假設,我們一家公司在很多城市都有自己的分部。我們希望將員工和他們工作的地址關聯。我們需要搜索分部、員工個人,和為特定分部工作的員工,所以 nested 模型就沒有作用了。當然,我們是可以使用 application-side-join 或者 data denormalization,但這里我們試試 parent-child。

現在我們必須要做的是告訴 Elasticsearch employee 類型將 branch 文檔類型作為其 _parent,這個我們可以在創建索引的時候指定:

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch"  ...1
      }
    }
  }
}
  1. 類型為 employee 的文檔是 類型 branch 的 children。

索引 parents 和 children

索引 parent 文檔跟以前一樣。parents 不需要知道任何關于其 children 的信息:

POST /company/branch/_bulk
{ "index": { "_id": "london" }}
{ "name": "London Westminster", "city": "London", "country": "UK" }
{ "index": { "_id": "liverpool" }}
{ "name": "Liverpool Central", "city": "Liverpool", "country": "UK" }
{ "index": { "_id": "paris" }}
{ "name": "Champs élysées", "city": "Paris", "country": "France" }

在索引 children 文檔時,你必須指定關聯的 parent 文檔的 ID:

PUT /company/employee/1?parent=london  ...1
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}
  1. employee 文檔是 london 分部的 child

parent ID 有兩個作用:創建了 parent 和 child 之間的關聯,確保 child 文檔存在同一個分片上。在 Routing a Document to a Shard 中,我們解釋了 Elasticsearch 如何使用一個路由值,默認是文檔的 _id 來確定文檔應該屬于哪個分片。路由值插入到下面的公式中:

shard = hash(routing) % number_of_primary_shards

然而,如果 parent ID 指定了,路由值就是 parent ID 而不再是 _id 了。換言之,parent 和 child 使用了同樣的路由值——parent 的_id —— 所以他們會同樣存在一個分片上。

在使用 GET 請求檢索 child 文檔,或者索引、更新或者刪除 child 文檔時parent ID 需要根據所有單個文檔請求指定。不像搜索請求,會被轉發給一個索引中的所有分片,這些單個文檔的請求只會轉發給那個包含對應文檔的分片——如果 parent ID 沒有指定,這些請求可能就會被轉發到錯誤的分片上。

parent ID 在使用 bulk API 時也應該指定:

POST /company/employee/_bulk
{ "index": { "_id": 2, "parent": "london" }}
{ "name": "Mark Thomas", "dob": "1982-05-16", "hobby": "diving" }
{ "index": { "_id": 3, "parent": "liverpool" }}
{ "name": "Barry Smith", "dob": "1979-04-01", "hobby": "hiking" }
{ "index": { "_id": 4, "parent": "paris" }}
{ "name": "Adrien Grand", "dob": "1987-05-11", "hobby": "horses" }

如果你想改變 child 文檔的 parent 值,僅僅重新索引或者更新 child 文檔是不夠的——新的 parent 文檔可能會在不同的分片上。所以,你必須刪除舊的 child 文檔,然后索引新的 child。

通過 children 找到 parents

has_child 查詢和過濾器可以用來根據 children 的內容找到 parent 文檔。例如,我們可以找到所有包含出生在 1980 后的員工的分部:

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type": "employee",
      "query": {
        "range": {
          "dob": {
            "gte": "1980-01-01"
          }
        }
      }
    }
  }
}

如同 nested query,has_child 查詢可以匹配多個 child 文檔,每個都有相應的相關分數。這些分數如何化歸為針對 parent 文檔的單獨分數取決于 score_mode 參數。默認設置是 none,這會忽視 child 分數并給 parents 分配了 1.0 的分值,不過這里也可以使用 avgminmaxsum

下面的查詢將會返回 londonliverpool,但是 london 會有更高的分數,因為 Alice SmithBarry Smith 更好地匹配:

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type":       "employee",
      "score_mode": "max",
      "query": {
        "match": {
          "name": "Alice Smith"
        }
      }
    }
  }
}

默認 score_modenone,該設置明顯快于其他模式,因為 Elasticsearch 不需要計算每個 child 文檔的分值。只有在你在乎分值的時候才需要根據需要設置模式。

min_children 和 max_children

has_child 查詢和過濾器都接受 min_childrenmax_children 參數,僅當匹配 children 的數量在指定的范圍內會返回 parent 文檔。

這個查詢將會匹配有至少兩位員工的分部:

GET /company/branch/_search
{
  "query": {
    "has_child": {
      "type":         "employee",
      "min_children": 2,  ...1
      "query": {
        "match_all": {}
      }
    }
  }
}
  1. 分部必須有至少兩位員工才能匹配

min_childrenmax_children 參數的 has_child 查詢或者過濾器的性能和啟用計分的 has_child 查詢相同。

has_child 過濾器


has_child 過濾器和 has_child 查詢工作機制差不多,只是這里不會支持 score_mode 參數。就和其他的過濾器類似:包含或者不包含,并不計分。
has_child 過濾器的結果并不緩存,通常的緩存規則應用在 has_child 過濾器內部的 filter 上。

通過 parents 尋找 children

nested 查詢只會返回根文檔作為結果,parent-child 文檔本身是獨立的,每個可以獨立地進行查詢。has_child 查詢允許我們返回基于在其 children 的數據上 parents 文檔,has_parent 查詢則是基于 parents 的數據返回 children。

看起來和 has_child 很像。這個例子返回了在 UK 工作的員工:

GET /company/employee/_search
{
  "query": {
    "has_parent": {
      "type": "branch", 
      "query": {
        "match": {
          "country": "UK"
        }
      }
    }
  }
}
  1. 返回有類型為 branch 的 children

has_children 查詢也支持 score_mode,但是僅僅會接受兩個設置:none(默認)和 score。每個 child 僅僅有 1 個 parent,所以沒有必要去將多個分數化歸為單個的分數。選擇就是 scorenone 這兩者。

has_parent 過濾器


has_parent 過濾器和 has_parent 查詢工作機制相同,除了它不支持 score_mode 參數。僅僅可以用在過濾器中。
has_parent 過濾器的結果并不緩存,通常的緩存機制用在 has_parent 過濾器的內部 filter 上。

children 聚合

parent-child 支持 children 聚合作為 nested 聚合直接的類似。parent 聚合不支持。

下面的例子展示了我們如何根據國家來確定員工最愛的興趣愛好:

GET /company/branch/_search?search_type=count
{
  "aggs": {
    "country": {
      "terms": { ...1
        "field": "country"
      },
      "aggs": {
        "employees": {
          "children": {  ...2
            "type": "employee"
          },
          "aggs": {
            "hobby": {
              "terms": {  ...3
                "field": "employee.hobby"
              }
            }
          }
        }
      }
    }
  }
}
  1. branch 文檔中的country 字段。
  2. children 聚合聯結了 parent 文檔和相關聯的 children 類型 employee
  3. 來自 employee child 文檔的 hobby 字段。

Grandparents 和 Grandchildren

parent-child 關系可以擴展超過一代——grandchildren 可以有 grandparents——但是需要額外步驟來確保來自所有代的文檔索引在同一個分片上。
讓我們改變前面的例子來讓 country 類型是 branch 類型的 parent:

PUT /company
{
  "mappings": {
    "country": {},
    "branch": {
      "_parent": {
        "type": "country"  ...1
      }
    },
    "employee": {
      "_parent": {
        "type": "branch"  ...2
      }
    }
  }
}
  1. branchcountry 的 child
  2. employeebranch 的 child

國家和分部有一個簡單的 parent-child 關系,所以我們使用和之前同樣的過程:

POST /company/country/_bulk
{ "index": { "_id": "uk" }}
{ "name": "UK" }
{ "index": { "_id": "france" }}
{ "name": "France" }

POST /company/branch/_bulk
{ "index": { "_id": "london", "parent": "uk" }}
{ "name": "London Westmintster" }
{ "index": { "_id": "liverpool", "parent": "uk" }}
{ "name": "Liverpool Central" }
{ "index": { "_id": "paris", "parent": "france" }}
{ "name": "Champs élysées" }

parent ID 已經確保了每個 branch 文檔被路由到和 parent country 文檔同樣的分片上。然而,看看使用同樣的技術在 employee grandchildren上:

PUT /company/employee/1?parent=london{ "name": "Alice Smith", "dob": "1970-10-24", "hobby": "hiking"}

這兒員工文檔的路由分片會被 parent ID London 確定,但是 london 文檔會根據其 parent ID ——uk 確定。很可能 grandchild 會得到和它 parent 和 grandparent 不同的分片,最終會導致 grandparent grandchild 關系失效。

于是我們重新設計,增加一個額外的 routing 參數,將這個設置為 grandparent ID 來保證所有三代都索引在同一個分片上。索引請求應該像這樣:

PUT /company/employee/1?parent=london&routing=uk 
{
  "name":  "Alice Smith",
  "dob":   "1970-10-24",
  "hobby": "hiking"
}
  1. routing 值覆蓋了 parent

parent 值仍然會用來連接員工文檔和其parent,但是 routing 值需要對所有單個文檔請求設置。

查詢和聚合,只要你一步一步通過每一代文檔。例如,為了找到有員工喜歡滑雪的國家,我們需要將國家和分部、分部和員工進行聯結:

GET /company/country/_search
{
  "query": {
    "has_child": {
      "type": "branch",
      "query": {
        "has_child": {
          "type": "employee",
          "query": {
            "match": {
              "hobby": "hiking"
            }
          }
        }
      }
    }
  }
}

實戰建議

parent-child 連接是在管理關系時有用的技術,其前提是索引性能比搜索性能更加重要,也帶來了一個顯著的代價。parent-child 查詢時間可能是等價的 nested query 五到十倍。

內存使用

parent-child ID 映射仍舊是存在內存中的。有計劃將這個映射使用 doc value 替代,這肯定是較大的內存節約。在進行了這個更新前,你需要注意下面的事:每個 parent 文檔的字符串 _id 字段需要存放在內存中,每個 child 文檔需要 8 字節(long value)的內存。實際上,這個可以有壓縮技術的支持,但這是一個解決方向。

你可以檢查使用 indices-stats API 來追蹤 parent-child 緩存使用了多少內存,或者 node-stats API(在節點層的總結):

GET /_nodes/stats/indices/id_cache?human ...1
  1. 以比較友好的格式按節點返回內存使用 ID 緩存的情況

global ordinals 和 延時

parent-child 使用 global ordinals 來加速聯結。不管 parent-child 映射使用 in-memory 緩存或者磁盤 doc value,global ordinals 仍然需要在每次索引變動后進行重建。

在分片中的 parent 越多,就需要更長的 global ordinals 來構建。parent-child 是最適合對每個 parent 有很多 children的情況,而不是很多的 parent 少量的 children。

global ordinals 默認是 lazily 構建的:在每個刷新 refresh 后的第一個 parent-child 查詢或者聚合將會觸發 global ordinal 的構建。這會引入一個明顯延遲增加。你可以使用 eager_global_ordinals 來從查詢時刻到刷新時刻的變動構建 global ordinal 的代價,通過將 _parent 字段映射按照如下修改:

PUT /company
{
  "mappings": {
    "branch": {},
    "employee": {
      "_parent": {
        "type": "branch",
        "fielddata": {
          "loading": "eager_global_ordinals"  ...1
        }
      }
    }
  }
}
  1. _parent 字段的 Global ordinals 將會在新的 segment 在搜索可見前構建。

有很多 parent 時,global ordinals 需要幾秒鐘進行構建。這樣的話,增加 refresh_interval 來讓刷新減少并讓 global ordinals 留存更長就比較合理了。這會大幅降低每秒鐘都重建 global ordinals CPU 代價。

多代關系總結性思考

連接多代的能力看起來很誘人,但是你會發現它帶來的代價:

  • 更多的連接,更差的性能
  • 每代 parents 需要讓他們的字符串 _id 字段存儲在內存中,這樣也會消耗大量內存

所以在你考慮需要處理的關系,考量 parent-child 是不是合適的選擇是,可以看看下面的一些建議:

  • 謹慎地使用 parent-child 關系,?僅僅在有更多的 children 時采用
  • 避免在一個單獨的查詢中使用多個 parent-child 聯結
  • 避免在使用 has_child 過濾器中使用計分,或者在 has_child 查詢中將 score_mode 設置為 none
  • 盡量讓 parent ID 短,以減少內存使用量

綜上所述:在嘗試 parent-child 關系前考慮其他類型的關系技術

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,321評論 6 543
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,559評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,442評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,835評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,581評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,922評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,931評論 3 447
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,096評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,639評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,374評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,591評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,104評論 5 364
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,789評論 3 349
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,196評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,524評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,322評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,554評論 2 379

推薦閱讀更多精彩內容