オウチーノ開発者ブログ

「株式会社オウチーノ」の社員によるブログです。

join datatypeとnested datatypeの比較

こんにちは、オウチーノの山本です。この記事はくふうカンパニー Advent Calendar 2018の17日目として書いています。

はじめに

以前の記事 ( 漸進的なシステムリプレイス(STTMeetup システムリプレイスNight))内の資料でも触れているのですが、オウチーノでは物件の検索にElasticsearchを、Elasticsearchへの登録には、Embulkを使用しています。

このElasticsearchに登録している物件の情報ですが、階層構造を持つ必要があるので、現在は Nested datatype を使用しています。

# Embulkのconfigから一部抜粋
estate:
  _all:
    enabled: false
  dynamic: false
  properties:
    estate_id:
      type: keyword
  traffics:
    type: nested
    properties:
      traffic_id:
        type: keyword

nested datatypeを使う場合、estate(物件の基本情報)とtraffics(最寄駅などの交通機関の情報)は、1つのドキュメント上に存在する必要があるのですが、RDBのテーブル上は別々ですので、

  1. RDBの物件テーブルからestateを登録
  2. 交通情報テーブルを元にtrafficsを追記

のように、2回Embulkを実行して作成しています。

しかも追加+更新なので、並列で実行する事ができません

いやいや、それは普通だろう、と思われる方もおられるかも知れませんが、Elasticsearchには join datatypeというものが存在するので、別々に登録したドキュメント間で階層構造を作ることが出来ます。

ただ、公式ドキュメントに以下のような記述があります。

The join field shouldn’t be used like joins in a relation database. In Elasticsearch the key to good performance is to de-normalize your data into documents. Each join field, has_child or has_parent query adds a significant tax to your query performance.

このように釘を刺されていたので、今まで使っていませんでした。
それでも、最近、並列で実行したいという思いが高まってきたので、試しにやってみる事にしました。

前提

今回、検証を行なった環境は以下の通りです。

  • Embulk
    • v0.9.6
    • 実行はローカル(MacBook Pro 13-inch 2016 Core i7 3.3GHz 16GB High Sierra)
    • input-plugin
      • embulk-input-mysql
    • output-plugin
      • embulk-output-elasticsearch_using_url
  • Elasticsearch
    • AWS Elasticsearch service 6.3

Embulkでjoin datatypeを登録

まず最初に、少なくとも以下の条件をクリアするプラグインが必要だったのですが、見つかりませんでした。

  1. 項目の値をJsonでパースする必要がある
    • {"traffics": "{\"name\": \"traffic\", \"parent\": 1}"} こうではなく
    • {"traffics": {"name": "traffic", "parent": 1}} こうしたい
  2. メタ項目である_routing を指定する必要がある

1.embulk-output-elasticsearch_using_urlで解決できるのですが、2.がどうしても解決できなかったので、とりあえず修正しました

後は、階層構造で記述していた定義を平坦化して、join datatypeの項目を追加すれば、登録できるようになります。

  • 平坦化してjoin datatypeを追加
# Embulkのconfigから一部抜粋
estate:
  _all:
    enabled: false
  dynamic: false
  properties:
    estate_id:
      type: keyword
    traffic_id:
      type: keyword
    traffics:
      type: join
      relations:
        estate: traffic
  • 親ドキュメントはtrafficsestateを指定
# estate のconfigから一部抜粋
filters:
  - type: column
    add_columns:
      - {name: traffics, type: string, default: "estate"}

out:
  type: elasticsearch_using_url
  bulk_actions: 2000
  request_timeout: 300
  id_format: "%d"
  id_keys:
    - "estate_id"
  routing_format: "%d"
  routing_keys:
    - "estate_id"
  • 子ドキュメントはtrafficstrafficと、親ドキュメントのIDを設定。
# traffic のconfigから一部抜粋
filters:
  - type: column
    add_columns:
      - {name: traffics, type: string, default: ""}
  - type: ruby_proc
    requires:
      - json
    columns:
      - name: traffics
        proc: |
          ->(col, record) do
            { name: "traffic", parent: "#{record['estate_id']}" }.to_json
          end
        type: string

out:
  type: elasticsearch_using_url
  bulk_actions: 2000
  request_timeout: 300
  id_format: "%d-%d"
  id_keys:
    - "estate_id"
    - "traffic_id"
  routing_format: "%d"
  routing_keys:
    - "estate_id"
  array_columns:
    - {name: traffics, parse_json: true, error_skip: true}

実際に、物件を12万件ほど登録してみたところ、結果は以下の通りでした。

nested join
実行時間(estate + traffic) 2:05 + 1:52 2:02 + 2:24
データサイズ 102.2MB 85.6MB

join datatypeの場合、nested datatypeに比べて、交通情報の登録に時間がかかっていますが、並列で動かせるので問題はなさそうです。 データサイズが小さくなるのは意外でした。

検索速度の検証

登録はできたので次は検索を実行します。
まずはnested datatypeのインデックスに対して、駅徒歩10分の物件を検索してみます。

GET nested_estate/_search
{
  "query": {
    "nested": {
      "path": "traffics",
      "query": {
        "range": {
          "traffics.walk_time_station": {
            "lte": 10
          }
        }
      }
    }
  }
}

平均6ms程で結果が返ってきます。

{
  "took": 6,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,
    "max_score": 1,
    "hits": [
      {

次にjoin datatypeのインデックスに対して、同じ検索をしてみます。

GET join_estate/_search
{
  "query": {
    "has_child": {
      "type": "traffic",
      "query": {
        "range": {
          "walk_time_station": {
            "lte": 10
          }
        }
      }
    }
  }
}

平均13ms程。確かにnested datatypeに比べたら遅いです。倍くらいの時間がかかっています。

{
  "took": 13,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,

色々検索条件を変更してみましたが、nested datatype は1桁msで返ってくるのですが、join datatypeはひどい時だと50msほどかかったりしました。
しかし、Embulkが並列実行できるのは結構なメリットなので、これくらい目を瞑るかと思っていた所、ある問題に気づきました。

返却値の違い

nested datatype は1つのドキュメントに全てのデータが入っているので意識していなかったのですが、join datatypeestatetrafficが別のドキュメントなので、返ってくる値にtrafficが入っていない事に気づきました。

    "hits": [
      {
        "_index": "nested_estate",
        "_type": "estate",
        "_id": "1",
        "_score": 1,
        "_source": {
          "estate_id": 1,
          "traffics": [     <---- この部分が返ってこない
            {
              "walk_time_station": 16,
              "traffic_id": 1
            },
            {
              "walk_time_station": 5,
              "traffic_id": 2
            }
          ]

これはいけない、という事でtrafficの値も返ってくるようにオプションをつけたのですが、

GET join_estate/_search
{
  "query": {
    "has_child": {
      "type": "traffic",
      "query": {
        "range": {
          "walk_time_station": {
            "lte": 10
          }
        }
      },
      "inner_hits": {}  <-- inner_hits option
    }
  }
}

検索で対象となったものしか返ってきません。

    "hits": [
      {
        "_index": "join_estate",
        "_type": "estate",
        "_id": "1",
        "_score": 1,
        "_source": {
          "estate_id": 1,
        "inner_hits": {
          "traffic": {
            "hits": {
              "total": 1, <-- 1件しか返ってこない
              "max_score": 1,
              "hits": [
                {
                  "_source": {
                    "estate_id": 1,
                    "walk_time_station": 5,
                    "traffic_id": 2,
                }
              ]

inner_hits オプションを別の所につけたらいいような気がして、試しにmatch_allを追加してみた所、

GET join_estate/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "has_child": {
            "type": "traffic",
            "query": {
              "range": {
                "walk_time_station": {
                  "lte": 10
                }
              }
            }
          }
        },
        {
          "has_child": {
            "type": "traffic",
            "query": {
              "match_all": {}  <-- match_all を追加
            },
            "inner_hits": {}
          }
        }
      ]
    }
  }
}

返ってくるようにはなったのですが、平均で20msほどかかるようになってしまいました。

{
  "took": 20,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 30616,
    "max_score": 2,

まとめ

nested datatypeに比べ、最終的に3倍以上の時間がかかるようになってしまったので、確かにjoin datatypeは遅いです。
並列で実行したいと思っていましたが、今回はちょっと見送る事にしました。

ただ、検索速度の遅延が問題にならず、データ投入の時間を短縮したい場合には非常に有効な手段だと思いますので、今後そういう機会があれば、再チャレンジしたいと思います。