22Inc. サービス開発日誌

スタンプスのサービス開発チームが日々の業務で得たノウハウ、経験の共有ブログです。

Elasticsearchのjoin datatypeを使ってみた

f:id:yang22:20191031195727p:plain

@kaibaと申します。業務委託として手伝わせていただいております。 ​ Stampsでは導入店舗様が様々な条件で、来店してくれたアプリユーザにお知らせ、クーポン、アンケートなどを送ることができます。
サービスが育つとデータ量も増え、RDBMSでは厳しくなってきました。 今回はElasticsearchで高速化する話を書きます。

はじめに。Elasticsearchとは?

​ ElasticsearchはElastic社がメンテしているOSSの全文検索エンジンです。ドキュメント指向のデータストアで、RDBが苦手とする部分一致検索や複雑な検索を高速に実行できます。Elastic cloud、Amazon Elasticsearch Serviceなどマネージドサービスにもなっていることも多く、環境構築と運用はすごく楽になりました。 ​ RDBと大きく異なるのは「ドキュメント」を扱うことです。ドキュメントとは雑にいうと1つのJSONです。Twitterを例に、ツイートを検索する例を考えると、以下のようなドキュメントを挿入することになります。 ​

{
    "id" : "6789",
    "full_text" : "こんにちは!",
    "created_at": "2019-01-01T04:00:00",
    "user" : {
        "id" : "1234",
        "name" : "kaiba",
        "screen_name" : "kaiba",
    }
}

​ RDBではユーザの情報は別のテーブルに保持して正規化するのが一般的ですが、 Elasticsearchではこのように非正規化した形でドキュメントを保持します。 ​ 「screen nameが変更になったら不整合がおきないか」という不安が湧いてきます。 悩ましいところですが以下の案があります。 ​

  • screen name変更時にすべてのドキュメントを更新してやる
    • サーバのコストや負荷が気になります
  • screen nameを保持しないようにする
    • screen nameでの検索を諦める
    • screen nameからuser IDに変換して検索する
  • join datatypeを使う(今回はこれを説明します) ​

StampsとElasticsearch

​ Stampsには様々な条件でアプリユーザにクーポン、お知らせ、アンケートなどをお届けできます。 例えば以下のようなケースです。​

  • 来店頻度によりお客様にクーポンを送る
  • イベントに来ていただいたアプリユーザにアンケートをお送りし業務の改善に活かす
  • 今月誕生日を迎えるアプリユーザに誕生日お祝いのクーポンをお送りする ​ 条件は複数組み合わせることができ、かなり複雑なクエリになっており、RDBだとどうしても速度と負荷の問題がありました。 ​

設計

​ Elasticsearchのドキュメントには検索に使用する項目だけを入れるのが一般的です。 今回検索に使用したい項目は大きく分けると以下になります。 ​

  • アプリユーザ(以後、単にユーザと呼びます)
  • 来店情報

    • ユーザに対して来店情報は複数あり、どのようなindexにするか悩みました。 今回は、join datatypeを使用したのですが、以下も検討しました。 ​
  • ユーザに来店情報を配列で持たせる

    • ユーザが来店すればするほどユーザのドキュメントが肥大化する
    • 一部分だけ更新したいのに巨大なドキュメントを更新する必要がでてくるためリソースに不安がある
  • 来店情報にユーザ情報を紐付ける
    • ユーザの情報が更新されたときに大量のデータを同期する必要がある
    • ユーザを探したいのに得られるのは来店情報になってしまい、複雑になる
  • indexをユーザと来店情報に分割してしまう
    • 2回Elasticsearchに対して検索することになる
    • 「人気店のA店に1回以上来店した男性」のようなケースだと、「A店に1回以上来店した」ユーザが大量になるケースがあり、リソースに不安がある
    • リクエストの実装が複雑になる ​

join datatype

​ Elasticsearchはjoin datatypeを 使用することでドキュメントに親子関係を持たせることができます。 ​ ただ、パフォーマンスに関して以下のような記載があり、ここが気になるところでした。 (僕なりの翻訳になります) ​

join datatypeはRDBのように使うべきではありません。  
Elasticsearchでは非正規化してドキュメントを作ることで高いパフォーマンスが得られています。  
​
join datatypeが意味を成すのは1つのエンティティに対して大量のデータが含まれるケースです。  
例えば製品と製品の注文です。この場合、製品を親、注文を子としてドキュメントにすることは適しています。  

​ 実際にstagingの数百万のレコードからドキュメントを構築してみたところ、十分なパフォーマンスが得られました! ​

どのように使うのか?

​ 先程のTwitterの例でjoin datatypeを構築してみます。 今回は「ユーザを検索する」のを目的に、ユーザとツイートが1対多の関係になるように構築します。 ​ 以下の形式でドキュメントを1つのindexに入れます。 ​

{
    "user" : {
        ...
    }
}

{
    "tweet" : {
        ...
    }
}

​ mapping設定です。 ​

{
    "mappings": {
        "users_tweets": {
            "properties": {
                "user": {
                    "type": "object",
                    "properties": {
                        "name": {
                            "type": "text"
                        },
                        "screen_name": {
                            "type": "text"
                        }
                    }
                },
                "tweet": {
                    "type": "object",
                    "properties": {
                        "user_id": {
                            "type": "text"
                        },
                        "full_text": {
                            "type": "text"
                        },
                        "created_at": {
                            "type": "date"
                        }
                    }
                },
                "users_tweets_join_field": {
                    "type": "join",
                    "relations": {
                        "user": "tweet"
                    }
                }
            }
        }
    }
}

​ userとtweetの型情報とusers_tweets_join_fieldに親子関係を定義しています。 ここではuserが親で子をtweetにしています。 ​ 続いて挿入するドキュメントを見ます。 ​

{
    "user": {
        "name": "kaiba",
        "screen_name": "kaiba"
    },
    "users_tweets_join_field": {
        "name": "user"
    }
}

{
    "tweet": {
        "id": "6789",
        "user_id": "1234",
        "full_text": "こんにちは!",
        "created_at": "2019-01-01T04:00:00"
    },
    "users_tweets_join_field": {
        "name": "tweet",
        "parent": "1234"
    }
}

​ 親は自分が親であることを宣言し、子は親のIDを指定しています。 型定義をし、ドキュメントを挿入していきます。 userとtweetのIDをドキュメントのIDとしたいのですが、IDが被る場合を考慮する必要があります。 tweetのIDは tweet + tweet.id の形式にしました。 ​

curl -X PUT -H "Content-Type: application/json" http://b.lvh.me:9200/users_tweets -d @mapping.json
{"acknowledged":true,"shards_acknowledged":true,"index":"users_tweets"}%
<200b>
curl -X PUT -H "Content-Type: application/json" http://b.lvh.me:9200/users_tweets/users_tweets/1234 -d @user.json
{"_index":"users_tweets","_type":"users_tweets","_id":"1234","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}%
<200b>
curl -X PUT -H "Content-Type: application/json" http://b.lvh.me:9200/users_tweets/users_tweets/tweet6789\?routing\=1234 -d @tweet.json
{"_index":"users_tweets","_type":"users_tweets","_id":"tweet6789","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":1,"_primary_term":1}%

​ 条件なしで検索して確認します。 ユーザとツイートが得られました。 RDBに慣れていると気持ち悪いですね。 ​

curl -X POST -H "Content-Type: application/json" http://b.lvh.me:9200/users_tweets/users_tweets/_search\?pretty
{
  "took" : 9,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 2,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "users_tweets",
        "_type" : "users_tweets",
        "_id" : "1234",
        "_score" : 1.0,
        "_source" : {
          "user" : {
            "name" : "kaiba",
            "screen_name" : "kaiba"
          },
          "users_tweets_join_field" : {
            "name" : "user"
          }
        }
      },
      {
        "_index" : "users_tweets",
        "_type" : "users_tweets",
        "_id" : "tweet6789",
        "_score" : 1.0,
        "_routing" : "1234",
        "_source" : {
          "tweet" : {
            "id" : "6789",
            "user_id" : "1234",
            "full_text" : "こんにちは!",
            "created_at" : "2019-01-01T04:00:00"
          },
          "users_tweets_join_field" : {
            "name" : "tweet",
            "parent" : "1234"
          }
        }
      }
    ]
  }
}

​ 「こんにちは」とツイートしているユーザを探してみます。
has_child クエリを使います。ドキュメント挿入は難しかったですがここはすごくわかりやすいですね。一度入れてしまえば、検索はまさにElasticです。 ​

{
  "query": {
    "bool": {
      "must": [
        {
          "has_child": {
            "type": "tweet",
            "query": {
              "match": {
                "tweet.full_text": "こんにちは"
              }
            },
            "min_children": 1
          }
        }
      ]
    }
  }
}

curl -X POST -H "Content-Type: application/json" http://b.lvh.me:9200/users_tweets/users_tweets/_search\?pretty -d @search3.json
{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "users_tweets",
        "_type" : "users_tweets",
        "_id" : "1234",
        "_score" : 1.0,
        "_source" : {
          "user" : {
            "name" : "kaiba",
            "screen_name" : "kaiba"
          },
          "users_tweets_join_field" : {
            "name" : "user"
          }
        }
      }
    ]
  }
}

​ ばっちり! 良さそうですね! ​

プログラムからの呼び出し

​ StampsのバックエンドではRailsを使用しています。
RailsはDBのテーブルと密接に関わるため、今回のようにテーブルとindexが1:1でない場合、設計の難易度が上がります。 ​ Elasticsearch-railsのテストコードが綺麗に書けているので参考にすると良いでしょう。 ​

まとめ

​ Elasticsearchのindex設計は「何を探したいか」に着目するとうまくいく気がします。
Stampsでは「来店したアプリユーザ」を様々な条件で検索したかったので、ユーザをベースに設計しました。 複雑な検索条件を柔軟に記載し、高速にできるようになりました。