욱'S 노트

Elasticsearch - Modeling Your Data:Parent-Child Relationship 본문

Programming/Elasticsearch

Elasticsearch - Modeling Your Data:Parent-Child Relationship

devsun 2016. 1. 18. 17:45

부모-자식 관계는 본질적으로 내포된 모델과 비슷하다. 그러나 차이가 있다면 내포된 오브젝트는 하나의 도큐먼트에 저장되는 반면, 부모-자식에서는 부모와 자식이 완전하게 다른 도큐먼트로 저장된다는 것이다.


부모-자식 기능은 하나의 도큐먼트 타입이 다른 것과 연관되게 해준다. 내포된 오브젝트와 비교한 이점은 다음과 같다.


자식 도큐먼트의 리인덱싱 없이 부모 도큐먼트를 업데이트 할 수 있다.

부모와 다른 자식의 영향 없이 자식 도큐먼트를 추가 변경 삭제할 수 있다. 이것은 특히 자식 도큐먼트들이 다수이고 자주 변경되고 추가될때 유용하다.

자식 도큐먼트는 검색의 결과로 리턴될 수 있다.

엘라스틱서치는 부모와 연관된 자식을 매핑을 유지하고 있어서 쿼리타임에 빠르게 조인할 수 있다. 그러나 이 경우 부모 자식 관계의 한계도 존재하는데 부모 도큐먼트와 자식 도큐먼트는 같은 샤드상에 존재해야 한다.


Parent-Child Mapping


부모-자식 관계를 설정하기 위해 자식 타입의 parent에 도큐먼트 타입을 명시해야 한다. 이것은 인덱스 생성 시점에 수행되거나 자식 타입이 생성되기 전에 update-mapping API가 수행되어야 한다.


예를들어 많은 도시의 지점을 가진 회사가 있다고 가정해보자. 우리는 각 지점에 근무하는 종업원들을 찾기를 원할 수 있다. 물론 부모-자식관계 대신에 application-side-joins이나 데이터 비정규화를 사용할 수 있다. 그러나 우리의 목적은 부모-자식관계를 이용하여 데모를 해보는 것이다.


엘라스틱서치에 종업원 타입에 부모가 지점 타입이라고 알려줘야 한다.


PUT /company

{

  "mappings": {

    "branch": {},

    "employee": {

      "_parent": { (1)

        "type": "branch" 

      }

    }

  }

}

(1) 종업원 타입의 도큐먼트는 지점 타입의 자식이다.


Indexing Parents and 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" }


자식 도큐먼트를 인덱싱할 때에서는 연관된 부모 도큐먼트의 ID를 반드시 명시해야 한다.


PUT /company/employee/1?parent=london 

{

  "name":  "Alice Smith",

  "dob":   "1970-10-24",

  "hobby": "hiking"

}



직원 도큐먼트는 런던 지점의 자식이다.


이들의 부모 ID를 제공하는 것은 두가지 목적이 있다. 하나는 부모와 자식간의 링크를 생성하는 것이고, 두번째는 부모와 자식을 같은 샤드에 저장하기 위함이다.


샤드의 도큐먼트를 탐색할때 우리는 엘라스틱서치에게 어떤 라우팅 값을 사용할지를 설명한다. 디폴트 값은 도큐먼트의 _id이며 이는 도큐먼트에 딸린 샤드를 판단하기 위해서이다. 라우팅 값은 단순한 수식으로 표현된다.


shard = hash(routing) % number_of_primary_shards


하지만 만약 부모ID를 명시했다면 본인의 id 대신에 부모ID를 라우팅 값으로 사용하게 된다. 이 말은 부모와 자식이 같은 라우팅 값을 사용하게 된다는 것을 의미하며 이것은 또한 같은 샤드에 값이 저장된다는 것을 의미한다.


부모 ID는 모든 싱글 도큐먼트 요청에 명시되어야 한다. 자식 도큐먼트에 조회하는 GET 요청 또는 자식 도큐먼트를 인덱싱하고, 업데이트하고 삭제할 때 항상 전달 되어야 한다. 그러나 검색 요청과 같이 모든 샤드에 적용되는 요청이 아닐 경우에는 요청은 도큐먼를 가지고 있는 하나의 샤드로 전송된다.


부모아이디도 물론 벌크 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" }


Warning


만약 자식 도큐먼트의 부모를 변경하기를 원한다면 자식 도큐먼트를 리인덱싱하거나 업데이트 하는 것으로는 불충분하다. 새로운 부모 도큐먼트가 다른 샤드에 저장되어 있을 수 있기 때문이다. 대신에 우리는 오래된 자식을 삭제하고 새로운 자식을 인덱싱해야 한다.


Finding Parents by Their Children


has_child 쿼리 및 필터는 자식 컨텐츠를 기반으로 부모 도큐먼트를 찾을 수 있게 해준다. 예를 들어 1980년 이후에 태어난 직원이 근무하는 지점을 찾기 위한 쿼리는 다음과 같다.


GET /company/branch/_search

{

  "query": {

    "has_child": {

      "type": "employee",

      "query": {

        "range": {

          "dob": {

            "gte": "1980-01-01"

          }

        }

      }

    }

  }

}


내포된 쿼리와 같이 has_child 쿼리는 몇몇의 자식 도큐먼트들과 매치과 될 수 있다. 그리고 각각의 다른 연관 스코어를 가진다. 하지만 이러한 스코어는 싱글 스코어로 감소하여하면 이는 부모 도큐먼트의 scrore_mode 파라미터에 의존한다. 디폴트 세팅은 none이고 이것은 자식 스코어를 무시하고 부모에게 1.0에 스코어를 부여한다. 그러나 avg, min, max 그리고 sum과 같은 세팅을 할 수 잇다.


다음 쿼리는 런던과 리버풀을 리턴한다. 그러나 런던은 더 좋은 스코어를 가지게 된다. 이유는 Alice Smith가 Barry Smith보다 더 잘 매치가 되기 때문이다.


GET /company/branch/_search

{

  "query": {

    "has_child": {

      "type":       "employee",

      "score_mode": "max",

      "query": {

        "match": {

          "name": "Alice Smith"

        }

      }

    }

  }

}


Tip

디폴트 스코어 모드가 확실리 다른 모드 보다 빠르다. 이유는 엘라스틱서치가 각 자식 도큐먼트에 대한 계산을 할 필요가 없기 때문이다. 다른 모드로 세팅한다면 스코어를 잘 관리해야 할 것 이다.


min_children and max_children


has_child 쿼리와 필터는 min_children과 max_children 파라미터를 적용할 수 있다. 특정한 범위에 해당하는 자식의 숫자에 해당하는 도큐먼트에 리턴한다.


이 쿼리는 적어도 두명의 직원이 근무하는 저점의 매치 될 것이다.


GET /company/branch/_search

{

  "query": {

    "has_child": {

      "type":         "employee",

      "min_children": 2, 

      "query": {

        "match_all": {}

      }

    }

  }

}



has_child Filter


has_child 필터는 has_child 쿼리와 동일하게 동작한다. 그러나 score_mode 파라미터를 적용할 수 없다. 이것은 오직 필터링을 위해서만 사용되기 때문이다.

has_child 필터의 결과는 캐싱되지 않는 반면 has_child 내의 필터에는 일반적은 캐싱 룰이 적용된다.


Finding Children by Their Parents


내포된 쿼리는 항상 하나의 루트 도큐먼트를 하나의 결과로 리턴하지만, 부모와 자식 관계의 도큐먼트의 독립적이며 각각 독립적으로 쿼리가 가능하다. has_child 쿼리는 그들의 자식을 기초로하여 부모는 찾는 쿼리가 가능했으며, has_parent 쿼리는 부모를 기반으로 자식 데이터를 리턴하는 것이 가능하다.


이것은 has_child 쿼리와 매우 유사하다. 다음 예제는 UK에 근무하는 직원을 리턴한다.


GET /company/employee/_search

{

  "query": {

    "has_parent": {

      "type": "branch", 

      "query": {

        "match": {

          "country": "UK"

        }

      }

    }

  }

}


has_parent 쿼리는 score_mode를 지원하는데 두가지 세팅만 할 수 있다. : none, socre. 각 자식은 하나의 부모만 가질 수 있으므로, 자식을 위해 다수의 스코어를 하나의 스코어로 만들 필요는 없다. 선택은 단순할 것이다.


has_parent Filter


has_parent 필터는 has_parent 쿼리와 동일하게 동작한다. 그러나 역시 score_mode를 지원하지 않는다. 단지 필터링을 위해서만 사용된다

has_parent 필터의 결과는 캐싱되지 않는 반면 has_parent 필터내의 필터에는 일반적인 캐싱룰이 적용된다.


Children Aggregation


부모-자식 관계에서는 Nested Aggregation에서 논의된 것 처럼 자식 어그리게이션이 지원된다. 부모 어그리게이션은 지원되지 않는다.


다음 예제는 각 나라의 우리 직원들이 어떻게 구성되지는 확인 할 수 있다.


GET /company/branch/_search

{

  "size" : 0,

  "aggs": {

    "country": {

      "terms": { 

        "field": "country"

      },

      "aggs": {

        "employees": {

          "children": { 

            "type": "employee"

          },

          "aggs": {

            "hobby": {

              "terms": { 

                "field": "employee.hobby"

              }

            }

          }

        }

      }

    }

  }

}


Grandparents and Grandchildren


부모-자식 관계는 한 세대를 넘어 확장 될 수 있다. 그리고 이러한 도큐먼트들은 모든 같은 샤드에 저장된다.


이제 지점 타입의 부모로 국가 타입을 만들어 보자.


PUT /company

{

  "mappings": {

    "country": {},

    "branch": {

      "_parent": {

        "type": "country" 

      }

    },

    "employee": {

      "_parent": {

        "type": "branch" 

      }

    }

  }

}


국가와 지점은 단순한 부모-자식 관계를 가지고 있다. 그래서 우리가 했던 똑같은 방식으로 부모와 자식을 인덱싱할 수 있다.


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" }


부모 아이디는 각 지점 도큐먼트가 부모 국가 도큐먼트와 같은 샤드의 경로에 위치함을 보장해준다. 하지만 자식 손자에 똑같은 기술을 적용했을때 어떠한 일이 발생하는지 살펴보자.


PUT /company/employee/1?parent=london

{

  "name":  "Alice Smith",

  "dob":   "1970-10-24",

  "hobby": "hiking"

}



직원 도큐먼트의 샤드 경로는 부모 아이디 london에 의해 결정된다. 그러나 런던 도큐먼트의 그의 부모아이디인 UK에 의해서 결정된다. 결국엔 부모와 부모자식이 다른 샤드에 위치하는 일이 발생할 수 있으며 같은 샤드에 부모자식 매핑이 위치해야 된다는 것이 위반될 수 있다.


대신에 우리는 추가적인 라우팅 파라미터가 필요한다. 세 세대를 같은 샤드에 위치하기 위해선 다음과 같이 인덱스를 수행해야 한다.


PUT /company/employee/1?parent=london&routing=uk 

{

  "name":  "Alice Smith",

  "dob":   "1970-10-24",

  "hobby": "hiking"

}


라우팅이 값은 부모 값을 오버라이딩한다.


부모 파라미터는 각 부모의 도큐먼트 링크를 위해 사용되며, 라우팅 파라미터는 그의 부모 세대들과 같은 샤드에 위치하게 하는 것을 보장한다. 라우팅 값은 모든 싱글 도큐먼트 요청에 필요하다.


쿼리나 어그리게이션 연산에는 각 세대에 걸친 스텝이 필요하다. 예를들어 하이킹을 즐기는 지구언들이 근무하는 국가를 찾기 위해서는 우리는 국가와 지점, 지점과 직원간의 조인을 수행할 필요가 있다.


GET /company/country/_search

{

  "query": {

    "has_child": {

      "type": "branch",

      "query": {

        "has_child": {

          "type": "employee",

          "query": {

            "match": {

              "hobby": "hiking"

            }

          }

        }

      }

    }

  }

}


Practical Considerations


Parent-child 조인은 인덱스 타임 성능이 검색 타임 성능보다 중요할때 관계를 관리하기 위해 유용한 기술이다. 그러나 명백하게 비용이 많이 든다. 부모-자식 쿼리는 내포된 쿼리보다 5배에서 10배 정도 느리다.


Memory Use


부모 자식의 아이디 매핑은 메모리에 유지된다. 이것들은 도큐먼트의 값을 사용하는 대신에 매핑을 바꾸기 위한 계획이다. 모든 부모 도큐먼트 아이디를 자식 도큐먼트의 유지해야 하기 때문에 각 8 바이트의 메모리가 요구된다. 실제로는 압축이 되지만 이것은 러프하게 생각해본것이다.


우리는 얼마나 많은 메모리가 자식-부모 관계 캐쉬로 사용되는지를 확인할 수 있다. 이 때 indices-stats API 또는 node-stats API를 활용할 수 있다.


GET /_nodes/stats/indices/id_cache?human 


사람이 읽기 쉬운 형태로 노드에 의해 사용되고 있는 ID 캐쉬의 메모리 사용이 리턴된다.


Global Ordinals and Latency


부모-자식은 조인 속도를 높이기 위해 글로벌 서수를 사용한다. 부모-자식 맵이 인메모리에 있던 디스크에 있던 상관없이 글로벌 서수는 인덱스에 대한 어떤 변경에 의해 재구성될 필요가 있다.


샤드에 더 많은 부모가 있을 수록 더 많은 글로벌 서수가 빌드를 의해 사용된다. 부모-자식은 각 부모에 많은 지식이 있는 경우가 많은 부모에 적은 자식이 있는 경우보다 더 잘 맞는 상황이다.


기본적으로 글로벌 서수는 레이지 빌드 된다. 리프레쉬 후 첫번째 부모-자식 쿼리 또는 어그리게이션이 글로벌 서수를 빌드하는 트리거가 된다. 이것은 유저들에게 명확하게 지연되고 있다는 것을 느끼게 할 것이다. 우리는 eager_global_ordinals를 이용하여 변경 타임에 글로벌 서수를 빌드하는 비용을 사용하게 할 수 있다.


PUT /company

{

  "mappings": {

    "branch": {},

    "employee": {

      "_parent": {

        "type": "branch",

        "fielddata": {

          "loading": "eager_global_ordinals" 

        }

      }

    }

  }

}


_parent 필드를 위한 글로벌 서수는 새로운 세그먼트가 검색에 노출되기 전에 빌드될 것이다.


많은 부모가 있다면 글로벌 서수는 빌드하는데 수초를 소요한다. 이 경우 refresh_interval을 지정할 수 있는데 이것을 지정하면 리프레쉬 횟수를 줄어들게 할 수 있다. 이는 매초 글로벌 서수를 리빌딩하는데 드는 비용을 확실하게 감소시킬수 있다.


Multigenerations and Concluding Thoughts


다수 세대에 의한 조인은 매력적이게 들릴지 모르나 다음과 같은 비용이 수반된다.


더 많이 조인할수록 성능은 나빠질 것이다.

각 세대는 부모의 _id 필드를 저장하게 됨으로 많은 수의 메모리를 사용하게 된다.


관계 스키마를 부모-자식 관계를 사용하기로 했다면 다음과 같은 사항들을 고려해야 한다.


부모-자식 관계를 절약해서 사용하라. 부모가 많은 자식을 가질 경우에만 사용하라.

하나의 쿼리에서 다수의 부모-자식 관계의 조인을 피하라.

has_child 필터를 사용할 때 정렬하지 말고, has_child 관계를 사용할때 score_mode를 none으로 세팅하라.

부모 ID를 짧게 유지하라. 메모리를 절약할 수 있을 것이다.

부모-자식 관계를 사용하기 전에 항상 먼저 다른 관계를 사용하는 것을 고려하라.


Comments