前言
這篇文章是出自於線上課程 Complete Guide to Elasticsearch 的所記錄的筆記。
這篇文章使用的 ES 版本為 7.16.2
這一篇文章要來介紹如何透過 Elastic 的 join queries 來達成與 RDBMS 一樣 join 兩張 table 的效果。
正文
在 RDBMS 中,透過正規化的方式將大表分成多個小表,並再透過 join 的方式將它們結合在一起。
然而在 Elastic 中,反而是建議 **反正規化 (Denomornize)**,因為這樣帶來的效益對 ES 而言會更好,雖然反正規化會讓空間使用上的效益不佳,但通常 ES 都不會被拿來當作主要的資料庫,而是會為了追求效能兒犧牲空間。
雖然 ES 並不能做到像 RDBMS 的 join,但還是能達到一些簡易的 document join。
查詢 nested objects
當我們新增一個包含 nested 欄位的 index department
1 2 3 4 5 6 7 8 9 10 11 12 13
   | PUT /department {   "mappings": {       "properties": {         "name": {           "type": "text"         },         "employees": {           "type": "nested"       }     }   } }
   | 
 
新增一筆資料
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
   | POST /department/_doc {   "name": "HR",   "employees": [     {       "name": "Joy",       "age": "42",       "position": "Senior Marketing Manager",       "gender": "F"     },     {       "name": "Toe",       "age": "18",       "position": "Interm",       "gender": "M"     }   ] }
   | 
 
今天當要搜尋 employees 的資料時,透過下面這個方法是行不通的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
   | GET /department/_search {   "query": {     "bool": {       "must": [         {           "match": {             "employees.position": "nterm"           }         },         {           "term": {             "employees.gender.keyword": "M"           }         }       ]     }   } }
   | 
 
因為 nested 欄位的資料,雖然原本是 object,但實際儲存在 ES 會變得不一樣,資料與資料之間會變得沒有關係。
Inner hits
透過 inner hits 的查詢,可以進一步查詢 nested 的欄位中,真正符合條件的資料。
加入 _source: false 讓資料不要那麼龐大
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
   | GET /department/_search {   "_source": false,    "query": {     "nested": {       "path": "employees",       "inner_hits": {},          "query": {           "bool": {             "must": [               {                 "match": {                   "employees.position": "interm"                 }               },               {                 "term": {                   "employees.gender.keyword": "M"                 }               }           ]         }       }     }   } }
   | 
 
透過 inner hits 可以看到符合條件的搜尋更詳盡的資料 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
   | {   "took" : 1,   "timed_out" : false,   "_shards" : {     "total" : 1,     "successful" : 1,     "skipped" : 0,     "failed" : 0   },   "hits" : {     "total" : {       "value" : 1,       "relation" : "eq"     },     "max_score" : 1.5645323,     "hits" : [       {         "_index" : "department",         "_type" : "_doc",         "_id" : "GQkfI34BeiHXdjTu0YQ5",         "_score" : 1.5645323,         "inner_hits" : {           "employees" : {             "hits" : {               "total" : {                 "value" : 1,                 "relation" : "eq"               },               "max_score" : 1.5645323,               "hits" : [                 {                   "_index" : "department",                   "_type" : "_doc",                   "_id" : "GQkfI34BeiHXdjTu0YQ5",                   "_nested" : {                     "field" : "employees",                     "offset" : 1                   },                   "_score" : 1.5645323,                   "_source" : {                     "gender" : "M",                     "name" : "Toe",                     "position" : "Interm",                     "age" : "18"                   }                 }               ]             }           }         }       }     ]   } }
 
  | 
 
Document relationships 的 mappings
新增一筆 deparment 與 employee 的 mapping,join_field 是自定義的名字
1 2 3 4 5 6 7 8 9 10 11 12 13
   | PUT /department {   "mappings": {     "properties": {       "join_field": {         "type": "join",         "relations": {          "department": "employee"          }       }     }   } }
   | 
 
新增一筆 department
1 2 3 4 5
   | PUT /department/_doc/1 {   "name": "Development",   "join_field": "department" }
   | 
 
新增一筆 employee
1 2 3 4 5 6 7 8 9 10 11
   | # 指向 parent PUT /department/_doc/3?routing=1 {   "name": "percy",   "age": 27,   "gender": "M",   "join_field": {     "name": "employee",     "parent": 1   } }
   | 
 
routing 指的是資料要儲存在哪一個 shard,這個值是透過 document ID 而來,如果不指定的話會發生錯誤 - [routing] is missing for join field [join_field]” 
Query by parent ID
透過 parent ID 的方式來查詢資料,type 需填入與 parent 相關的 child。
1 2 3 4 5 6 7 8 9
   | GET /department/_search {   "query": {     "parent_id": {       "type": "employee",       "id": 1     }   } }
   | 
 
Query child doc by parent
有時候我們並不曉得 child 的 parent ID 為何,因此可以透過是否有 parent 來判別。
1 2 3 4 5 6 7 8 9 10 11 12 13
   | GET /department/_search {   "query": {     "has_parent": {       "parent_type": "department",       "query": {         "term": {           "name.keyword": "Development"         }       }     }   } }
   | 
 
 parent matching 預設是不計算分數的,若要啟用,可以補上 score: true
1 2 3 4 5 6 7 8 9 10 11 12 13 14
   | GET /department/_search {   "query": {     "has_parent": {       "score": true,       "parent_type": "department",       "query": {         "term": {           "name.keyword": "Development"         }       }     }   } }
   | 
 
Query parents by child
我們也可以透過 child 來查詢 parent 
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
   | GET /department/_search {   "query": {     "has_child": {       "type": "employee",       "query": {         "bool": {           "must": [               {                 "range": {                   "age": {                     "gte": 50                   }                 }               }             ],           "should": [             {               "term": {                 "gender.keyword": "M"               }             }           ]         }       }     }   } }
   | 
 
child 與 parent 查詢,預設都是不計算分數的,但 child 可以使用的計分方式更多

child 計分的欄位名稱為 score_mode
Multi-level relation
今天資料之間的關係可能不會這麼單純,只有一對 parent & child,而應該是多個 parent & child 所組合成。
假設關係圖如下

將關係圖轉化成 query 的結果如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
   | PUT /department/_doc/1 {   "name": "Company",   "join_field": "company" }
  PUT /department/_doc/2?routing=1 {   "name": "Development",   "join_field": {     "name": "department",     "parent": 1   } }
  PUT /department/_doc/3?routing=1 {   "name": "Percy",   "join_field": {     "name": "employee",     "parent": 2   } }
 
   | 
 

> 唯一要注意的是 employee 的 routing=1,因為資料與最高階層的資料要放在同一個 shard。
儘管階層的關係變多了,但搜尋的方法依然不變
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
   | GET /department/_search {   "query": {     "has_child": {       "type": "department",       "query": {         "has_child": {           "type": "employee",           "query": {             "term": {               "name.keyword": "Percy"             }           }         }       }     }   } }
   | 
 
inner_hits 也可以用在 has_parent & has_child
Terms lookup mechanism
新增 user & stories 的資料
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
   | # user 1 追蹤 user 2, 3  PUT /users/_doc/1 {   "name": "John",   "following": [2, 3] }
  # 這些文章是由哪些 user 所建立以及文章內容
  PUT /stories/_doc/1 {   "user": 2,   "content": "ya! 2" }
  PUT /stories/_doc/2 {   "user": 3,   "content": "ya! 3" }
  PUT /stories/_doc/3 {   "user": 4,   "content": "ya! 4" }
   | 
 
1 2 3 4 5 6 7 8 9 10 11 12 13
   | # 搜尋 user 1 有在追蹤的人所寫的文章 GET /stories/_search {   "query": {     "terms": {       "user": {         "index": "users",         "id": "1",         "path": "following"       }     }   } }
   | 
 
Join limitation
Join 有以下限制
- 資料必須放在同一個 index,否則效能會非常差
 
- parent & child 必須放在同一個 shard
 
- 每個 index 只能有一個 join 欄位
 
Reference
- Complete Guide to Elasticsearch