弱いエンジニアの備忘録

自分的に気になった技術に関するメモや備忘録です。Elasticsearchに関する記事が多くなりそうです。

elasticsearch6.xの親子関係を整理してみた

この記事はElastic stack Advent Calendar 2017 の17日目の記事となります。

概要

elasticsearchで、親子関係を持ったデータを扱うには、幾つかの方法があります。
例えば

  1. 親の情報を冗長に保持して、親子を1対1の関係にする
  2. object配列として子供を保持する
  3. nested data typeを用いる
  4. parent-childの関係を用いる

等があると思います。

elasticsearch6.0から、_typeがindexに対して単一になりました。(ver 7では_typeが無くなる予定)
それに伴って、parent-childの使い方も変わったので、改めて整理しておきます。

目次

  1. サンプルデータの概要
  2. 親の情報を冗長に保持して、親子を1対1の関係にする
  3. object data type
  4. nested data type
  5. join data type
  6. まとめ

サンプルデータの概要

ブログサービスのデータを扱うことを想定します。
(ダミーデータはhttp://www.databasetestdata.com/で生成させてもらいました)

親子関係にある2つのindexがあり、片方はユーザーの情報を表すuser index
もう片方は、ブログの投稿単位でindexしたblog_post indexです。
ブログは一人のユーザーが複数回投稿しうるので、userとblog_postは1対Nの親子関係にあると言えます。

user情報を示すuser table。
fieldとしては、FullName,Id,Country,registered_time,Emailなどがあります。
1ドキュメント取り出してみると下記のような構造になっています。

{
        "_index": "user",
        "_type": "doc",
        "_id": "QLwKYGABLuVBi7nej178",
        "_score": 1,
        "_source": {
          "Email": "Felicita@eliane.co.uk",
          "host": "Macintosh.local",
          "Country": "Gambia",
          "registered_time": "12/17/1985",
          "Id": "81",
          "@version": "1",
          "Full Name": "Ms. Judd Tromp",
          "@timestamp": "2017-12-16T15:56:32.767Z"
        }
}


それに対して、blog_postの内容は下記のようになっています。

{
        "_index": "blog_post",
        "_type": "doc",
        "_id": "jr09YGABLuVBi7neyjL2",
        "_score": 1,
        "_source": {
          "@version": "1",
          "Summary": """
facere nobis eum minima perspiciatis
sit cum debitis commodi est quia distinctio molestiae
ut praesentium autem occaecati
 
	nemo et asperiores itaque quis optio harum
ratione ex vitae dignissimos consequatur vel aliquam accusantium
doloribus optio sint provident praesentium sunt laboriosam ullam quo
tempore ratione vel dolorum accusantium suscipit blanditiis
distinctio unde dolores commodi
 
	recusandae numquam perspiciatis
ducimus dicta blanditiis sed sint dolore unde eos distinctio
facere veritatis vitae veniam qui harum voluptatem
dolorem nam quam
""",
          "Id": 3,
          "@timestamp": "2017-12-16T16:52:30.135Z",
          "Title": """
eius a quam
sequi nulla unde et laboriosam perferendis
ut id accusamus corporis ipsam quisquam vero
""",
          "Created At": "2008-09-12T13:14:46.235Z",
          "host": "Macintosh.local",
          "Body": """
quia unde quod
distinctio odit commodi odio
non qui libero perferendis quos
 
	expedita voluptatem id odio ut consequatur
minus sit odio est aspernatur
vel numquam accusamus laborum eligendi dolore corporis voluptate itaque
vel aut repudiandae enim saepe fuga voluptatem sed quis
 
	quia voluptatem eos in commodi nisi mollitia occaecati qui
sint amet unde iusto enim qui voluptate recusandae
dolores quia quam eum
at ut et cupiditate odit dolores nulla voluptas
"""
        }
}


これらの二つのindexはId fieldで紐付いていて、user:blog_postsが1対Nになっています。

親の情報を冗長に保持して、親子を1対1の関係にする

ドキュメントの構造という観点で、この方法は非常にシンプルになります。
ただし、親ドキュメントの情報が冗長になるので、データサイズの増加や、検索時の結果重複などのデメリットが考えられます。
(今回はuserに比べてblog_postが大きかったので、ほとんどデータサイズは増えませんでした)

つまりデータ構造としては、
親ドキュメント

{"Id" : 10, "user" : "A"}

子ドキュメント

{"Id" : 10, "Title" : "aaa"}, {"Id" : 10,  "Title" : "bbb"}, {"Id" : 10,  "Title" : "ccc"} 

という2つのindexをjoinした結果として、

{"user" : "A", "Id" : 10, "Title" : "aaa"}, {"user" : "A", "Id" : 10,  "Title" : "bbb"}, {"user" : "A", "Id" : 10,  "Title" : "ccc"} 

という1つのindexに統合します。

一部のフィールドを省略していますが、userというfieldが冗長であることがわかります。
(user indexのサイズが大きくなるほどデータサイズなどへの影響が顕著です。)

検索時の結果重複という面でのデメリットは、次のような状況が想像できます。
例えば「Edytheという名前のユーザーが何人いるかを検索したい場合」です。

queryはこんな感じでしょうか

GET join/_search
{
  "query": {
    "match": {
      "Full Name": "Edythe"
    }
  }
}

レスポンスはこうです↓

"hits": {
  "total": 20,
  "max_score": 5.1217155,
  "hits":[...]
}

これだけ見ると、Edytheさんは20人登録されているように見えますが、実際は1人しか登録されていません。
Edytheさんのブログ投稿が20件あったため、20件のドキュメントが"Full Name":"Edythe"という条件に当てはまりヒットしてしまいます。

object data type

上記を改善するために思いつく方法として、
ブログ投稿のデータであるblog_postを、配列形式で1つのドキュメントに詰め込む方法があります。
つまり下記のようなデータ構造です。

{
  "_index": "object",
  "_type": "doc",
  "_id": "jL20YGABLuVBi7neE7yx",
  "_score": 1,
  "_source": {
    "Email": "Kendall@reymundo.me",
    "@timestamp": "2017-12-16T15:56:32.759Z",
    "registered_time": "6/24/2012",
    "host": "Macintosh.local",
    "@version": "1",
    "Full Name": "Kale Pouros",
    "Country": "Sudan",
    "Id": "19",
    "posts": [
      {blog_post 1},{blog_post 2},{blog_post 3}....{blog_post n}
    ]
  }
}

ある程度見通しが良くなりました。
検索結果が重複する問題も解決します。

しかし、この構造にも欠点があります。
elasticsearchのobject型は、内部的には、「各object要素の配列」が保存されます。
つまり

{
  "posts" : [ 
    {
      "Id" : "1",
      "Title" :  "AAA"
    },
    {
      "Id" : "2",
      "Title" :  "BBB"
    }
  ]
}

というドキュメントがあったとしても....

{
  "posts.Id" : [ "1", "2" ],
  "posts.Title" :  [ "AAA", "BBB" ]
}

というように保持されているのです。

結果として、このドキュメントは
"Title":"AAA" AND "Id":"2"
という条件で検索してもヒットしてしまいます。

nested data type

上で述べた、object datatypeの欠点を解消するためのものがnested datatypeです。
ユーザーから見ると、object data typeを使っている場合とドキュメント構造は全く同じに見えます。
しかし内部的にnested部分は、別のドキュメントとしてindexされます。

使い方としては、事前にデータ型の定義をしておくだけです。

PUT nested
{
  "mappings": {
    "doc":{
      "properties": {
        "posts":{
          "type": "nested"
        }
      }
    }
  }
}

こうすることで、nestした個々のオブジェクトに対して検索をかけることができます。

見かけ上は100ドキュメントでも、cat APIで確認すると、10100件保持していることがわかります↓

green  open   object    aFeBCUA0SI6xAQAgwFspdQ   5   0        100            0     11.7mb         11.7mb
green  open   nested    KMCi8GTQS6eLmofmlRgBcQ   5   0      10100            0     12.4mb         12.4mb

強いてデメリットを挙げるとすれば、nested data typeを使っている場合は、
query, aggregation, sort, highlightにおいて、nested専用のものを使う必要があるというところでしょうか。
(慣れていないと書き方が少し難しい&面倒)

join data type

ここまで挙げてきた方法はいずれも、スキーマが異なる情報を、1つのドキュメントとして統合するものでした。
(nested data typeは内部的には別ドキュメントですが)

それに対して、join data typeは同一index内の異なるドキュメント同士の親子関係を定義するためのものです。
以前は複数の_typeを用いて実現していたparent-childの関係が、
バージョン6以降ではjoin data typeのフィールドを用いて実現するように変更されました。

使い方としては、
事前に親子関係の識別子となるフィールドを定義しておきます。

PUT parent-child
{
  "mappings": {
    "doc": {
      "properties": {
        "user_or_post": { 
          "type": "join",
          "relations": {
            "user": "post" 
          }
        }
      }
    }
  }
}


あとはdocument登録の際に、親には "user_or_post":"user"というフィールドを追加し....

{
  "_index": "parent-child",
  "_type": "doc",
  "_id": "-b3jYGABLuVBi7neE9XR",
  "_score": 1,
  "_source": {
    "@version": "1",
    "user_or_post": "user",     #<= ここ
    "@timestamp": "2017-12-16T19:53:02.295Z",
    "host": "Macintosh.local",
    "Full Name": "Charity Pollich V",
    "registered_time": "11/22/2011",
    "Id": "2",
    "Country": "Svalbard and Jan Mayen",
    "Email": "Demetrius@estel.com" 
  }
}


子供側には
"user_or_post": {"name": "post", "parent": "親のドキュメントID"}
というフィールドを追加します。

{
  "user_or_post": {   
    "name": "post",   #<= ここ
    "parent": "98"     #<= ここ
  },
  "Body": "xxxxx",
  "Summary": "xxxxx",
  "@version": "1",
  "Title": "xxxxxx",
  "Id": 98,
  "@timestamp": "2017-12-16T20:12:12.361Z",
  "host": "Macintosh.local",
  "Created At": "1987-12-11T14:58:59.947Z"
}


これで親子関係を持つことができます。
検索時には、has_child queryやhas_parent queryを使います。

例えば「1996-08-12T15:28:25.327Z」にブログを投稿したユーザーを調べたければ下記のように検索します。
(要するにchildの条件で絞り込んで、parentのdocumentを返したい)

GET parent-child/_search
{
  "query": {
    "has_child": {
      "type": "post",
      "query": {
        "term": {
          "Created At": {
            "value": "1996-08-12T15:28:25.327Z"
          }
        }
      }
    }
  }
}
"hits": {
    "total": 1,
    "max_score": 1,
    "hits": [
      {
        "_index": "parent-child",
        "_type": "doc",
        "_id": "93",
        "_score": 1,
        "_source": {
          "host": "Macintosh.local",
          "@timestamp": "2017-12-16T19:57:38.872Z",
          "registered_time": "5/1/1998",
          "user_or_post": "user",
          "Country": "Indonesia",
          "Email": "Kylie_Kiehn@ernestina.com",
          "@version": "1",
          "Full Name": "Dannie Schiller",
          "Id": "93"
        }
      }
    ]
  }

無事検索できました。

まとめ

ここまで、親子関係を持つデータの構造について何パターンか整理しました。
各方法とも一長一短あると思うので、状況によって使い分けたいものです。

以上です。読んでいただきありがとうございました。

その他

  • 何かツッコミがあればコメントお願いします
  • 検索パフォーマンスとかをしっかり検証していないので、データ増やしたり階層増やすとどうなるか、そのうち試してみたい(やってみた人が既にいたら是非教えてください)