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 的好處在于:
- parent 文檔可以不需要重新索引 children 進行更新。
- child 文檔可以被添加、修改或者刪除,而不影響 parent 或者其他 children。這在 child 文檔很多和增改頻率很高的時候尤其有用。
- 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
}
}
}
}
- 類型為
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"
}
-
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
的分值,不過這里也可以使用 avg
,min
,max
和 sum
。
下面的查詢將會返回 london
和 liverpool
,但是 london
會有更高的分數,因為 Alice Smith
比 Barry Smith
更好地匹配:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"score_mode": "max",
"query": {
"match": {
"name": "Alice Smith"
}
}
}
}
}
默認
score_mode
為none
,該設置明顯快于其他模式,因為 Elasticsearch 不需要計算每個 child 文檔的分值。只有在你在乎分值的時候才需要根據需要設置模式。
min_children 和 max_children
has_child
查詢和過濾器都接受 min_children
和 max_children
參數,僅當匹配 children 的數量在指定的范圍內會返回 parent 文檔。
這個查詢將會匹配有至少兩位員工的分部:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"min_children": 2, ...1
"query": {
"match_all": {}
}
}
}
}
- 分部必須有至少兩位員工才能匹配
有 min_children
和 max_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"
}
}
}
}
}
- 返回有類型為
branch
的 children
has_children
查詢也支持 score_mode
,但是僅僅會接受兩個設置:none
(默認)和 score
。每個 child 僅僅有 1 個 parent,所以沒有必要去將多個分數化歸為單個的分數。選擇就是 score
和 none
這兩者。
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"
}
}
}
}
}
}
}
}
- 在
branch
文檔中的country
字段。 -
children
聚合聯結了 parent 文檔和相關聯的 children 類型employee
。 - 來自
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
}
}
}
}
-
branch
是country
的 child -
employee
是branch
的 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"
}
-
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
- 以比較友好的格式按節點返回內存使用 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
}
}
}
}
}
-
_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 關系前考慮其他類型的關系技術