Neil Zhu,簡書ID Not_GOD姜性,University AI 創(chuàng)始人 & Chief Scientist猪叙,致力于推進世界人工智能化進程。制定并實施 UAI 中長期增長戰(zhàn)略和目標夹囚,帶領團隊快速成長為人工智能領域最專業(yè)的力量纵刘。
作為行業(yè)領導者,他和UAI一起在2014年創(chuàng)建了TASA(中國最早的人工智能社團), DL Center(深度學習知識中心全球價值網(wǎng)絡)荸哟,AI growth(行業(yè)智庫培訓)等假哎,為中國的人工智能人才建設輸送了大量的血液和養(yǎng)分。此外鞍历,他還參與或者舉辦過各類國際性的人工智能峰會和活動舵抹,產(chǎn)生了巨大的影響力,書寫了60萬字的人工智能精品技術內(nèi)容劣砍,生產(chǎn)翻譯了全球第一本深度學習入門書《神經(jīng)網(wǎng)絡與深度學習》惧蛹,生產(chǎn)的內(nèi)容被大量的專業(yè)垂直公眾號和媒體轉(zhuǎn)載與連載。曾經(jīng)受邀為國內(nèi)頂尖大學制定人工智能學習規(guī)劃和教授人工智能前沿課程秆剪,均受學生和老師好評赊淑。
parent-child 關系
類似于 nested model:可以關聯(lián)兩個實體。不同在于仅讽,nested object 中所有的實體必須存在同一個文檔中陶缺,而在 parent-child 中,parent 和 children 可以是完全分開的文檔洁灵。
parent-child 功能讓我們可以將一種文檔類型以一對多的關系關聯(lián)到另一個上饱岸。相比 nested object 的好處在于:
- parent 文檔可以不需要重新索引 children 進行更新。
- child 文檔可以被添加徽千、修改或者刪除苫费,而不影響 parent 或者其他 children。這在 child 文檔很多和增改頻率很高的時候尤其有用双抽。
- child 文檔可以被作為搜索請求的結(jié)果返回百框。
Elasticsearch 維護了一個 parent 到 children 的映射。所以在查詢時刻的連接(join)會很快牍汹,但是這樣也給 parent-child 關系帶來了限制:parent 和所有 children 必須處在一個分片上铐维。
parent-child ID 映射作為字段數(shù)據(jù)存放在內(nèi)存中柬泽。后期會有計劃將這個默認設置改成使用 doc values。
parent-child 映射
為了建立 parent-child 關系的需求是指定哪種文檔類型應該是 child 類型的 parent嫁蛇。這個必須在?索引創(chuàng)建時刻?指定锨并,或者使用 update-mapping
API 在 child 類型被創(chuàng)建前指定。
假設睬棚,我們一家公司在很多城市都有自己的分部第煮。我們希望將員工和他們工作的地址關聯(lián)。我們需要搜索分部抑党、員工個人包警,和為特定分部工作的員工,所以 nested 模型就沒有作用了新荤。當然揽趾,我們是可以使用 application-side-join 或者 data denormalization台汇,但這里我們試試 parent-child苛骨。
現(xiàn)在我們必須要做的是告訴 Elasticsearch employee
類型將 branch
文檔類型作為其 _parent
,這個我們可以在創(chuàng)建索引的時候指定:
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 文檔時,你必須指定關聯(lián)的 parent 文檔的 ID:
PUT /company/employee/1?parent=london ...1
{
"name": "Alice Smith",
"dob": "1970-10-24",
"hobby": "hiking"
}
-
employee
文檔是london
分部的 child
parent
ID 有兩個作用:創(chuàng)建了 parent 和 child 之間的關聯(lián)牵素,確保 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 需要根據(jù)所有單個文檔請求指定逆屡。不像搜索請求圾旨,會被轉(zhuǎn)發(fā)給一個索引中的所有分片,這些單個文檔的請求只會轉(zhuǎn)發(fā)給那個包含對應文檔的分片——如果 parent
ID 沒有指定魏蔗,這些請求可能就會被轉(zhuǎn)發(fā)到錯誤的分片上砍的。
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
查詢和過濾器可以用來根據(jù) children 的內(nèi)容找到 parent 文檔诫惭。例如翁锡,我們可以找到所有包含出生在 1980 后的員工的分部:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"query": {
"range": {
"dob": {
"gte": "1980-01-01"
}
}
}
}
}
}
如同 nested query,has_child
查詢可以匹配多個 child 文檔夕土,每個都有相應的相關分數(shù)馆衔。這些分數(shù)如何化歸為針對 parent 文檔的單獨分數(shù)取決于 score_mode
參數(shù)。默認設置是 none
怨绣,這會忽視 child 分數(shù)并給 parents 分配了 1.0
的分值角溃,不過這里也可以使用 avg
,min
篮撑,max
和 sum
减细。
下面的查詢將會返回 london
和 liverpool
,但是 london
會有更高的分數(shù)赢笨,因為 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 文檔的分值茧妒。只有在你在乎分值的時候才需要根據(jù)需要設置模式萧吠。
min_children 和 max_children
has_child
查詢和過濾器都接受 min_children
和 max_children
參數(shù),僅當匹配 children 的數(shù)量在指定的范圍內(nèi)會返回 parent 文檔桐筏。
這個查詢將會匹配有至少兩位員工的分部:
GET /company/branch/_search
{
"query": {
"has_child": {
"type": "employee",
"min_children": 2, ...1
"query": {
"match_all": {}
}
}
}
}
- 分部必須有至少兩位員工才能匹配
有 min_children
和 max_children
參數(shù)的 has_child
查詢或者過濾器的性能和啟用計分的 has_child
查詢相同纸型。
has_child 過濾器
has_child
過濾器和has_child
查詢工作機制差不多,只是這里不會支持score_mode
參數(shù)梅忌。就和其他的過濾器類似:包含或者不包含狰腌,并不計分。
has_child 過濾器的結(jié)果并不緩存牧氮,通常的緩存規(guī)則應用在 has_child 過濾器內(nèi)部的 filter 上琼腔。
通過 parents 尋找 children
nested
查詢只會返回根文檔作為結(jié)果,parent-child 文檔本身是獨立的蹋笼,每個可以獨立地進行查詢展姐。has_child
查詢允許我們返回基于在其 children 的數(shù)據(jù)上 parents 文檔,has_parent
查詢則是基于 parents 的數(shù)據(jù)返回 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擂达,所以沒有必要去將多個分數(shù)化歸為單個的分數(shù)。選擇就是 score
和 none
這兩者胶滋。
has_parent 過濾器
has_parent
過濾器和has_parent
查詢工作機制相同板鬓,除了它不支持score_mode
參數(shù)悲敷。僅僅可以用在過濾器中。
has_parent
過濾器的結(jié)果并不緩存俭令,通常的緩存機制用在has_parent
過濾器的內(nèi)部 filter 上后德。
children 聚合
parent-child 支持 children 聚合作為 nested 聚合直接的類似。parent 聚合不支持抄腔。
下面的例子展示了我們?nèi)绾胃鶕?jù)國家來確定員工最愛的興趣愛好:
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
聚合聯(lián)結(jié)了 parent 文檔和相關聯(lián)的 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 已經(jīng)確保了每個 branch
文檔被路由到和 parent country
文檔同樣的分片上悟耘。然而落蝙,看看使用同樣的技術在 employee
grandchildren上:
PUT /company/employee/1?parent=london{ "name": "Alice Smith", "dob": "1970-10-24", "hobby": "hiking"}
這兒員工文檔的路由分片會被 parent ID London 確定,但是 london
文檔會根據(jù)其 parent ID ——uk 確定暂幼。很可能 grandchild 會得到和它 parent 和 grandparent 不同的分片筏勒,最終會導致 grandparent grandchild 關系失效。
于是我們重新設計粟誓,增加一個額外的 routing
參數(shù)奏寨,將這個設置為 grandparent ID 來保證所有三代都索引在同一個分片上。索引請求應該像這樣:
PUT /company/employee/1?parent=london&routing=uk
{
"name": "Alice Smith",
"dob": "1970-10-24",
"hobby": "hiking"
}
-
routing
值覆蓋了parent
值
parent
值仍然會用來連接員工文檔和其parent鹰服,但是 routing
值需要對所有單個文檔請求設置。
查詢和聚合揽咕,只要你一步一步通過每一代文檔悲酷。例如,為了找到有員工喜歡滑雪的國家亲善,我們需要將國家和分部设易、分部和員工進行聯(lián)結(jié):
GET /company/country/_search
{
"query": {
"has_child": {
"type": "branch",
"query": {
"has_child": {
"type": "employee",
"query": {
"match": {
"hobby": "hiking"
}
}
}
}
}
}
}
實戰(zhàn)建議
parent-child 連接是在管理關系時有用的技術,其前提是索引性能比搜索性能更加重要蛹头,也帶來了一個顯著的代價顿肺。parent-child 查詢時間可能是等價的 nested query 五到十倍。
內(nèi)存使用
parent-child ID 映射仍舊是存在內(nèi)存中的渣蜗。有計劃將這個映射使用 doc value 替代屠尊,這肯定是較大的內(nèi)存節(jié)約。在進行了這個更新前耕拷,你需要注意下面的事:每個 parent 文檔的字符串 _id
字段需要存放在內(nèi)存中讼昆,每個 child 文檔需要 8 字節(jié)(long value)的內(nèi)存。實際上骚烧,這個可以有壓縮技術的支持浸赫,但這是一個解決方向闰围。
你可以檢查使用 indices-stats
API 來追蹤 parent-child 緩存使用了多少內(nèi)存,或者 node-stats
API(在節(jié)點層的總結(jié)):
GET /_nodes/stats/indices/id_cache?human ...1
- 以比較友好的格式按節(jié)點返回內(nèi)存使用 ID 緩存的情況
global ordinals 和 延時
parent-child 使用 global ordinals 來加速聯(lián)結(jié)既峡。不管 parent-child 映射使用 in-memory 緩存或者磁盤 doc value羡榴,global ordinals 仍然需要在每次索引變動后進行重建。
在分片中的 parent 越多运敢,就需要更長的 global ordinals 來構(gòu)建炕矮。parent-child 是最適合對每個 parent 有很多 children的情況,而不是很多的 parent 少量的 children者冤。
global ordinals 默認是 lazily 構(gòu)建的:在每個刷新 refresh 后的第一個 parent-child 查詢或者聚合將會觸發(fā) global ordinal 的構(gòu)建肤视。這會引入一個明顯延遲增加。你可以使用 eager_global_ordinals
來從查詢時刻到刷新時刻的變動構(gòu)建 global ordinal 的代價涉枫,通過將 _parent
字段映射按照如下修改:
PUT /company
{
"mappings": {
"branch": {},
"employee": {
"_parent": {
"type": "branch",
"fielddata": {
"loading": "eager_global_ordinals" ...1
}
}
}
}
}
-
_parent
字段的 Global ordinals 將會在新的 segment 在搜索可見前構(gòu)建邢滑。
有很多 parent 時,global ordinals 需要幾秒鐘進行構(gòu)建愿汰。這樣的話困后,增加 refresh_interval
來讓刷新減少并讓 global ordinals 留存更長就比較合理了。這會大幅降低每秒鐘都重建 global ordinals CPU 代價衬廷。
多代關系總結(jié)性思考
連接多代的能力看起來很誘人摇予,但是你會發(fā)現(xiàn)它帶來的代價:
- 更多的連接,更差的性能
- 每代 parents 需要讓他們的字符串
_id
字段存儲在內(nèi)存中吗跋,這樣也會消耗大量內(nèi)存
所以在你考慮需要處理的關系侧戴,考量 parent-child 是不是合適的選擇是,可以看看下面的一些建議:
- 謹慎地使用 parent-child 關系跌宛,?僅僅在有更多的 children 時采用
- 避免在一個單獨的查詢中使用多個 parent-child 聯(lián)結(jié)
- 避免在使用
has_child
過濾器中使用計分酗宋,或者在has_child
查詢中將score_mode
設置為none
- 盡量讓 parent ID 短,以減少內(nèi)存使用量
綜上所述:在嘗試 parent-child 關系前考慮其他類型的關系技術