データベースとしてのElasticsearch

  • 20
    いいね
  • 5
    コメント

はじめに

Elasticsearchはスケーラビリティに優れた全文検索エンジンですが、Relational Database(以下RDB)が持つ汎用性や機能の豊富さも追求しているように思います。この記事ではRDBの基本機能がどこまでElasticsearchで実現できるかをまとめました。データベースの知識だけで、全文検索を知らなかった私がElasticsearchを勉強し始めた頃に意外に感じた事を中心に両者の違いを比較しています。APIについては言語ごとの違いは言及せず、REST APIについてのみ述べています。特にバージョンの記述がない場合はElasticsearch 5.1を前提にしています。RDBは近年ポピュラーなOracle, SQLServer, DB2, Sybase, PostgreSQL, MySQLなどが準拠しているSQL92標準を前提としています。

基本的な違い

RDBはデータを安全に保管し汎用的に利用するための機能が豊富な一方でElasticsearchは全文検索用のライブラリApache Luceneを利用したデータストアで、検索のパフォーマンスとスケーラビリティが強い。SQLは表現力が豊かで豊富な機能があるが、ElasticsearchはLuceneを使った全文検索にフォーカスしており、機能の有無だけで比較するとElasticsearchはやや不利になる項目が多い。ただしパフォーマンスやスケーラビリティの観点からはRDBはデータの一貫性保護や結合処理のためのオーバーヘッド・制約が多く、ElasticsearchやNoSQLデータベースを越えられない壁がある。

用語

RDBではデータベース内に複数のテーブルがあり、テーブル内に行と列があった。Elasticsearchでは下記のような用語が対応する。

RDB Elasticsearch
データベース インデックス
テーブル マッピングタイプ
カラム(列) フィールド
レコード(行) 文書(ドキュメント)

RDBの"インデックス"が検索を最適化するために後から構成可能な構造だったのに対して、Elasticsearchでは入れ物のような存在になっている。Elasticsearchにデータを送って格納することを「インデックスする」という。英語だとindexという動詞以外にもingestという言葉が使われることもある。

RDBではデータベースを複数に分けるという状況はあまりないが、Elasticsearchではユーザーごとにインデックスを別々にしたり、時系列のデータをある時間間隔で区切ったりとインデックスを分けることが多い。search APIなども複数インデックスをまたいで一括で実行できるようになっている。RDBは一旦運用を始めたらデータベースをドロップすることは稀だが、Elasticsearchはこのようにデータを区切って利用し、不要になったインデックスを削除するということも多い。典型的なユーザーシナリオとして、RDBはマスターとなるデータを半永久的に保管するのに対し、Elasticsearchは分析や検索用のデータを保管するという使い方の違いから来ているものと思われる。

テキスト処理

RDBになくElasticsearchにある重要な機能の一つがテキストデータをトークンに分けて検索に必要な情報を切り出すTokenizationやAnalysisなどと呼ばれる機能である。ElasticsearchではTokenizerとToken FilterとCharacter FilterをまとめてAnalyzerという名前で取り扱う。これらは名前のとおりテキストデータをトークンに切り分けるだけでなく、トークンや文字を変換したり除去することができる。

下記はtextタイプのフィールドf1, f2に対して送信したデータがデフォルトのStandard Analyzerによってどのような形に変換・格納されるかを表すものである。

フィールド 受信データ 実際に格納されるデータ 説明
f1 AAA aaa case-insensitiveな検索のために、文字列を小文字に変換する。
f2 This is a pen. this
is
a
pen
空白などの区切り文字で文字列をトークンに分ける。ピリオドなどの検索に不要とされる句読点記号(punctuation)は除去される。

このようにテキストデータ処理用のフィールド(v2まではanalyzed=trueなstringタイプのフィールド、v5からはtextタイプのフィールド)へ挿入されたテキストデータはanalyzeされて位置情報などとともに格納されるので、RDBと異なり挿入時のデータがそのまま格納されている訳ではない。クエリ時にデータを元の形で取得するためにはフィールドをstore=trueという設定にするか、_sourceという特殊フィールドを利用する。前者の場合(stored fieldと呼ぶ)は挿入データがそのままの形でも保管される。_sourceフィールドも同様の用途に使える特殊フィールドだが、入力時のドキュメント全体(つまり全フィールド)が保持される。設定でデータ圧縮をすることも可能。いずれも検索用のデータとは別に取得用のデータを格納することになるので「検索条件では指定するが値を取得する必要がないフィールド」についてはこれらをdisableすることでインデックスのサイズを小さくする事ができる。

また、Analyzerはテキストデータをデータ投入時にトークンに分けるだけでなく、クエリ実行時、検索条件に指定されたテキストデータも必要なトークンに変換するのに使用される。上記のf1, f2のデータでドキュメントがインデックスされた場合、代表的なterm/match/match_phraseクエリでそのドキュメントがヒットするかどうかを下記の表にまとめた。

クエリ ヒットする 説明
"term": {"f2": "pen"}
"term": {"f2": "PEN"} × termクエリはAnalyzerを使わないので大文字で指定してもヒットしない
"match": {"f2": "PEN a"} matchクエリはAnalyzeするのでpenとaを両方含む文書がヒットする。順序は問わない。
"match_phrase": {"f2": "a PEN"} match_phraseクエリはAnalyzerとトークンの位置情報を使用する。a penという順序で現れる文書がヒットする
"match_phrase": {"f2": "pen a"} × 位置情報を使用するのでトークンの位置が逆になっているものはヒットしない

CREATE/DROP/ALTER TABLE

テーブルに対応するものはElasticsearchではインデックスのマッピングタイプというもの。(ただし、下記で述べるようにRDBのようにテーブル単位での削除やカラム定義はできないので、最近はテーブル=マッピングとは言わないようになってきている。)1つのインデックスは複数のマッピングタイプを含むことができる。マッピングタイプはそれぞれ別々のスキーマ(マッピングタイプ定義)を持つことができるが、同名のフィールドが異なるマッピングタイプに存在する場合は同一のフィールド定義を持つ必要がある。

Elasticsearchには受け取ったデータを元にスキーマを自動的に生成するdynamic mappingという機能もあるが、明示的にスキーマ(マッピングと呼ぶ)を定義しておくことが推奨されている。RDBと同様に豊富なデータタイプがサポートされている。
https://www.elastic.co/guide/en/elasticsearch/reference/5.1/mapping-types.html
DATE, TIME, TIMESTAMPなどはdateにまとめられている。CHARとVARCHARのような固定長・可変長の区別はないし、長さ(最大長)も通常指定しないで使える。RDBではvarcharとnvarcharの区別があったりDBの文字コードを指定してデータを格納したりできたが、Elasticsearchでは文字セットはUnicodeのみを取り扱う。必要な場合はクライアント側がUnicodeとの変換をすることになる。

RDBと異なり制約(Constraint)という仕組みはないのでユニークキーや主キーを指定してキーのユニークネスを強制することはできない。唯一の例外はドキュメントを一意に定めるためのドキュメントIDを持つ_idと_uidフィールドである。_idはドキュメントのIDを保持するフィールドで、同一マッピングタイプ内で一意の値、_uidはマッピングタイプ名と_idの値を区切り文字#で連結したもので同一インデックス内で一意の値が保持される。ドキュメントのIDはindex APIではURLの一部として、bulk APIでは_idフィールドに指定することができる。指定のない場合はElasticsearchが自動的に適当な文字列を生成する。

SQLのDROP TABLE文に対応するものはない(v1まではマッピングタイプの削除も可能だったが問題がありv2以降ではできないようになった)ので、あるマッピングタイプに作られたすべてのドキュメントを削除したい場合には下記のようにindex名とマッピングタイプを指定してdelete by queryを実行する必要がある。

POST /index1/_delete_by_query
{
  "query": {
    "type": {
      "value": "type1"
    }
  }
}

マッピングタイプは一旦作成したら変更は基本的にできない(一部例外あり)のでSQLのALTER TABLEに対応するものはないと思ってよい。マッピングタイプを変更したい場合はインデックスの再作成とデータの再投入が必要になる。

Elasticsearchではフィールドは暗黙的に配列型になっており、同じ型の複数の値を持つことができる。1個の値は要素数1の配列と等価であるように扱われる。これは基本的にsingle-valuedなRDBと異なり、全文検索エンジンでは受け取ったテキストデータをトークンごとに切り分けて格納するためmulti-valuedな挙動がデフォルトになっているのだと思われる。
ソートなどでフィールドの値を使用する場合には配列から平均値、最大値、中央値など、どのように値を決定するかを指定することができる。

JSONで表現されるオブジェクトをほぼそのままの形でインデックスするためのObjectタイプもサポートしている。例えば下記の例はドキュメントのフィールドf1にプロパティp1, p2を持つオブジェクトを入れている。

{
  "f1" : { "p1": 1, "p2": "ABC" } 
}

この場合フィールドは内部的には下記のようにフラットな構造に変換されて扱われる。

{
  "f1.p1" : 1, 
  "f1.p2": "ABC" } 
}

ただし、f1の値が単一のオブジェクトでなくオブジェクトの配列になる場合、単にフラットな構造へ変換するだけでは配列の何番目のオブジェクトかという情報が落ちてしまいうまく扱えないので、f1を複数オブジェクトを扱うためのnestedというデータタイプにする必要がある。

デフォルト値

RDBではフィールドに対してデフォルト値を指定することができた。INSERTやLOAD時にフィールドの値がない場合にこのデフォルト値が使われ、デフォルト値の指定もない場合はNULLが入れられる。

Elasticsearchにはデフォルト値はないが、マッピングタイプ定義でnull_valueというオプションを指定するとデフォルト値に少し近いことができる。ただしnullという値を明示的に指定された場合のみ有効で、nullと等価な空の配列[]を挿入された場合やフィールドが全く指定されない場合などは有効にならないので注意。

また、avg, sumなどのmetric aggregationやソートではmissingというオプションでフィールドがない場合に替わりに使う値を指定することができる。

INSERT

単一の文書をElasticsearchのインデックスに追加するにはindex APIが使用できる。ここではインデックス名、マッピングタイプ、ドキュメントIDを指定して次のようなリクエストを送信する。マッピングタイプ定義時にpropertiesで指定した全フィールドが指定されていなくて一部のフィールドだけでもいい。

POST /index1/document1/100
{
  "f1": 1, 
  "f2": "ABC"
}

デフォルトではindex APIはINSERTというよりはUPSERTとして動作する。つまり同じidのドキュメントがすでにあれば上書き、なければ追加する。op_typeというパラメーターか_createというURLエンドポイントを使用することで、同じidで既存のドキュメントがある場合にエラーにすることもできる。

データの更新は内部的にはPostgreSQLのように追記型で管理される。つまり既存のドキュメントには削除フラグが立てられ、新しいドキュメントが追加される。既存のドキュメントは即座に削除されるわけではなく、マージのタイミングでクリーンアップされる。マージは明示的に実行することも可能であるが、バックグランドでも定期的に実行される。

RDBでは「値がない」又は「不定である」ということをNULLという値で表現した。Elasticsearchへインデックス時にはJSONのnullという値でフィールドに入れる値がないという事を表現できる。nullをフィールドに指定した場合(かつnull_valueが指定されていない場合)、フィールドには値が挿入されない。ここで微妙に異なるのは、RDBではNULLという値があったが、Elasticsearchではnullという値が格納される訳ではなく、そのドキュメントがフィールドを持たないという状態になる。RDBではテーブルの全レコードは同じフィールドを持ち、値がない場合にNULLという値で表現されたが、Elasticsearchは同じマッピングタイプでもドキュメントごとにフィールドがあったりなかったりする事が可能なので、値がないことをnullではなく、フィールドがない、という事で表現する。フィールドがないドキュメントはマッピングタイプの定義上ではstore=trueなフィールドだったとしても取得時にフィールドの値が返されない。termクエリやmatchクエリなどフィールドを指定してのクエリにもヒットしない。フィールドの有無でドキュメントをサーチする場合にはexistsクエリを使用する。

下記はインデックス時にf1へnullを指定する例だが、これらは全て等価になる。LukeなどをつかってLuceneインデックスを直接確認するとインデックスされたドキュメントにフィールドf1が存在しない事が分かる。ただし_sourceフィールドには下記の字面どおり保存されるので、将来この挙動に変更が加えられてreindexなどで_sourceフィールドの値を利用するとその変更による影響を受ける可能性はある。

f1:null
f1:[]
f1:[null]
f1:[null, null]

Oracleなど一部のRDBは空の文字列(長さ0の文字列)とNULLを同一のものとして扱うが、Elasticsearchにはそのような区別はなく、文字列が空であるかそうでないかとフィールドの有無は直行する概念である。ちなみにanalyzerが処理するフィールドの場合analyzerが空白を処理する事が多いので、保存された文字列が厳密に空か、空白を含むかをクエリで調べるのは少し難しい。下記投稿でいくつかの案が紹介されているが、厳密にするには取得してアプリ側で長さを確認するか、スクリプトで長さを取得する。
http://stackoverflow.com/questions/25561981/find-documents-with-empty-string-value-on-elasticsearch

先に述べたように、フィールドは基本的に配列型であり、複数の値を格納する。下記はtext/keywordタイプのフィールドf1へ値を指定する例である。

データ 説明
f1:"ABC" フィールドf1にABCという文字列を指定
f1:["ABC"] 上と同じ意味になる
f1:["AAA", "BBB", "CCC"] フィールドはデフォルトで配列型
f1:["AAA", 1, 2.0] 配列の各要素が異なるタイプを持つinvalidなデータ。挿入しようとするとエラーになる。

UPDATE

Update APIを使うとRDBでのUPDATE文と同様に既存のドキュメントのフィールドの一部もしくは全部を更新できる。
フィールドに直接値を指定することも可能であり、更新ロジックの記述にはスクリプトも使える。クエリを指定してヒットした文書を更新するUpdate By Query APIもv2.3から追加された。

DELETE

Delete APIを使うとドキュメントIDを指定してドキュメントを削除できる。queryを指定してヒットしたものを削除するDelete By Query APIもある。Delete By Queryはv1の頃は問題がありv2でプラグインでのサポートだったが、v5からビルトインでのサポートに戻った。

LOAD

Bulk APIでデータをまとめてElasticsearchへ送信することができる。ドキュメントの追加だけでなく、削除や更新も可能。複数インデックス・マッピングタイプを同一のリクエストに含めることもできる。

Bulk APIはElasticsearchのREST APIでは珍しく、HTTPのリクエストボディにJSONでないもの(改行コードで区切られたJSONのリスト)を送るようになっている。これはリクエスト全体が読み終わる前に別ノードへのリクエスト転送などの処理を開始するためと思われる。

SELECT

ElasticsearchのSearch APIQuery DSLも表現力が高く、さまざまなクエリを実行することができるが、RDBに比べ圧倒的に文字列に関連するものが多い。RDBでは文字列を使用した検索で主なものはLIKE句や正規表現によるマッチング、文字列を処理する関数などを組み合わせたものだったが、Elasticsearchではより高度なAnalyzerによるテキストの事前処理ができるので、位置情報を用いたphraseクエリやprefix, wildcard, regexp, spanクエリなど、豊富な種類のクエリを効率よく実行することができる。また、RDBのようにヒットするかしないかだけではなく、様々な要素によってスコアリングを行い適合率の高いものをランキングできる。

RDBの場合は特に指定がなければクエリは条件にヒットするものを全件取得するが、全文検索では先頭のN件を取得すれば十分なことが多いので、_search APIのデフォルトの挙動も先頭10件を返すようになっている。

RDBでは更新トランザクションが完了すればそのトランザクションの変更は別トランザクションからも見えるが、Elasticsearchではリクエストが完了して制御がクライアント側に戻っても、リフレッシュのタイミングまではデータが検索可能にならない(Get APIでドキュメントIDを指定して取得することは可能)。デフォルトではリフレッシュは1秒間隔で自動的に実行されるが、refresh_intervalで変更も可能。v5からはwait_forというオプションでリフレッシュの完了までブロックする(クライアントに制御を戻さない)という事も可能になった。

RDBにない機能だが、Elasticsearchでは_allというフィールドを使って全フィールドを対象にクエリを実行することもできる。全フィールドのデータが連結されて_allフィールドに格納され適当なAnalyzerで前処理される。フィールドごとにAnalyzerを別々に定義しているような場合は本来の想定しているAnalyzerと異なるもので処理される可能性があるので注意が必要だが、データを入れてとにかく手軽に全フィールド対象に検索を行いたいという場合には便利である。

GROUP BY

ElasticsearchではクエリDSLのaggregationsを使うことで集約を実行することができる。aggregationsの記述は2つのパートからなる。bucketing aggregationという部分でクエリの結果をどういうグループ(bucket)に分けるかを指定し、metric aggregationpipeline aggregationでbucketingされたあとの結果の利用方法を指定する。

SQLのGROUP BYでは各グループは交わりのない集合だったが、バケツどうしはそうではない。つまりある文書をbucketingした結果、複数のbucketsにその文書が属するということもある。例えばTerms aggregationはbucketing aggregationのひとつだが、フィールドに含まれるタームごとにバケツを作りそのタームを含むドキュメントをそのバケツに入れる。フィールドに複数のタームが入る場合はそのドキュメントはその複数のタームが作る複数のバケツに所属することになる。

下記に代表的なmetrics aggregationの挙動を例示する。

文書ID フィールド
1 {"f1": [100,300]}
2 {"f1": 200}
3 {"f1": 200}
4 {"f1": []}

これらのドキュメントを集約(すべての文書を1つのバケツ)した結果は下記のようになる。配列がどう扱われるか、フィールドのないドキュメントがどうカウントされるかなどが分かる。

集約 結果
sum 800
max 300
min 100
avg 200
cardinality 3
value_count 4

ORDER BY

Elasticsearchはフィールドの値やスコアをもとに文書を昇順、降順にソートすることができる。文字列型のフィールドの値をもとにしたソートではAnalyzerがサポートする照合順序を使うことができる。例えばcase sensitiveなソートをしたい場合はKeyword Analyzerをサーチ時のanalyzerとして使えばよい。
ICU pluginを使えばUnicode Collation Algorithmに従ってソートさせることもできる。v2対象に書かれたものだが下記ドキュメントが詳しい。https://www.elastic.co/guide/en/elasticsearch/guide/current/sorting-collations.html

また、Elasticsearchには文書をスコアリングするための豊富な機能やパラメーターが提供されており、スクリプトを使用してスコアの計算式を記述することも可能なのでRDBで行っていたソートはもちろん、豊富なスコアリングのバリエーションを使用してソートすることが可能である。

RDBでのソートは言語ごとの文字列の照合順序などに従った比較的と「カチッとした」厳密なソートであり理由も説明しやすいが、Elasticsearchはより広範囲にカスタマイズ可能な自由なソートをサポートしている。ただしその分、ソートオーダーがなぜそうなったか厳密に説明するのは難しいこともあるかもしれない。このあたりにもRDBと検索エンジンがターゲットとするユーザーシナリオの違いが現れている。

ジョイン、副照会

RDBと異なり、Elasticsearchは複数の検索結果をジョインするのはあまり得意ではない。これはNoSQLデータベースと同様ドキュメントIDをキーとして複数シャードにデータを分散するという構成のため。
どうしてもジョインが必要な場合は次の方法がある。
- クライアントアプリケーション側でクエリ結果を取得してジョインする。結果の文書数が大きい場合にはあまり現実的でない。
- ジョインが必要となる子ドキュメントをnestedタイプのフィールドにオブジェクトとしていれてやる
- ジョインが必要となる子ドキュメントのマッピング定義で_parentを指定してparent-childドキュメントとすることで1-Nの関係を作ることができる。

nestedとparent-childの違いは、nestedは親ドキュメントをインデックスする時点で子ドキュメントも一緒にいれてやる必要がある。parent-childは親子が独立して(同じシャードに含まれないとならないという制限はある)おり、子ドキュメントをあとから追加したり、子ドキュメントだけを更新するということができる。同じ事をnestedでやろうとすると親と子の全てをもう一度インデックスしなおしになる。ジョインクエリのパフォーマンスはnestedの方がいい。

クエリの中でクエリをネストさせることはできないので、副照会に対応するものはない。

SELECT DISTINCT

ビルトインで重複除去の機能はないが、aggregationを使って複数の結果を1つの結果にすることで実現できる。
http://stackoverflow.com/questions/25448186/remove-duplicate-documents-from-a-search-in-elasticsearch

カーソル

RDBではクエリの取得結果をカーソルを使って分割して取得することができた。Elasticsearchではクエリのsize, fromオプションを指定することでページ分割して結果を取得することができる。size, from以外でも、v5からsearch_afterというオプションで直前に取得した結果のsort valueをベースに次ページをリクエストすることもできるようになった。いずれの場合でもリクエストごとに再度クエリが実行されるので、並行してインデックスを更新する別のクライアントが動いていると取得結果が一貫しない可能性がある。scroll APIを利用するとクエリの実行は一回だけで、取得データをページングすることができる。

ユーザー定義関数、ストアドプロシージャー

クエリの条件を記述するに当たって複数フィールドを使って演算したりカスタムのロジックを書きたい場合はスクリプトで記述することができる。サーバー側でカスタムのコードを実行するストアドプロシージャーは対応するものはない。しいてあげるとElasticsearchのプラグインを書いてインストールすることでJavaコードをサーバーサイドで動かすこともできる。どのカテゴリのプラグインで何がどこまでできるかは明確に公開されていない部分も多いが、少しづつドキュメント等が整備されてきている。例えばNative script pluginはスクリプト処理をJavaで書き、プラグインとしてインストールすることができる。

トランザクション/ACID/リカバリー

Elasticsearchにはトランザクションはないが、複数のクライアントが同じドキュメントを更新しようとしたときに更新内容を喪失しないようにOptimistic concurrency controlの仕組みがある。具体的には、Elasticsearchにインデックスした文書には内部的に_versionというフィールドにバージョンがつけられるので、そのバージョンの値を読み込んで、他のクライアントが変更していなければ、自分が変更するという様に更新系のAPIを使うことができる。他のクライアントが変更していればバージョン番号が更新されていることがdetectされて、エラーになる。

トランザクションがないため、ACID性はあまり議論にならないが、Durabilityはある。デフォルトでは、クライアントからの更新リクエストを受けてプライマリシャードとレプリカシャードを更新後にtranslog(write ahead log)をディスクにfsyncしてから制御をクライアント側に戻す。つまりcommitではなくクライアント側に制御が戻った時点で送ったデータは少なくともtranslogの形でディスクに書かれており、永続化されることが保証される。fsyncをリクエストごとでなくasyncにする設定もあるが、推奨されていないように思われる。

インデックスのリカバリーは、定期的に取得したスナップショット(差分取得が可能)から戻すことができる。REDOログに対応するものがないので、ロールフォワードはできない。

参考文献

本記事ではElasticsearchとRelational Databaseを比較した。NoSQLとの比較は下記の記事が参考になる。
Elasticsearch as a NoSQL Database
https://www.elastic.co/blog/found-elasticsearch-as-nosql