ごあいさつ
2018年はDynamoDB・Elasticsearchと共に歩み続けた1年だったので、年の締めくくりに振り返りと反省をしてみようかなと。主に非機能面での振り返りになります。
フューチャー Advent Calendar 2018の16日目の投稿です!(すいませんだいぶ遅くなってしまいました..orz)
はじめに
エンタープライズで利用するデータストアサービスなるものを作っていたのですが、そのサービスにてDynamoDBとElasticsearch(正確にはAmazon Elasticsearch Service)を採用していました。いままでの検討経緯だったり概要構成については下記の資料も合わせて見ていただけると幸いです。
本投稿は上記の後日談として語らせて頂きます。X-tech JAWSとElasticsearch勉強会にて発表しましたが、当時は絶賛開発中のものもあったりで、今振り返るとちょっと違った構成に落ち着いたものもあるなーと思っています。そういったものも含め、非機能面から今年を振り返っていきます!
ということで早速トピックごとに本題へ入っていきますー
親子関係のデータモデリングと性能
エンタープライズでKVS使ってデータの親子関係を表現しようとしたら四苦八苦した話
思えば前回投稿は本投稿と同じく弊社アドベントカレンダーへの投稿でした。その投稿の中でも触れていましたが、NoSQLにおける親子関係の持ち方については本当に四苦八苦しました。そして四苦八苦して採用した方式でさらに今年性能課題が勃発して四苦八苦しました。
DynamoDBとElasticsearchを併用するデータストアサービスの検索思想として、当初より下記のようなものがありました。
- DynamoDBはキーアクセスでの検索に特化させる
- その他多用される一覧検索等の検索はElasticsearchにまかせる
このどちらの思想も正解だったなと思っているのですが、親子関係のデータモデリングだけはこの検索思想に反する形で決断を下してしまった気がします。
- 親子関係はリレーションテーブルという形で外出しする
この決断は、Elasticsearchに検索をかける時に親あるいは子のキー情報を(RDBでいうところの)"結合"して検索できないことを意味したのですが、当時は性能面にそこまで視野を広げることができてなかったなと振り返って思います。
もちろん親子の情報を互いに持たせ始めると、一体どこまで持たせ合うかという話になりキリがないのですが、キーとなる情報は持たせてあげてよかったかなと。(最終的には持たせることにしました!柔軟なERD設計しといて本当に良かった。。)
自分の記事を読み返して思うのですが、当時データの持ち方やテーブル更新回数にフォーカスして検討を進めていましたが、実際にどう使われるかをもう少し踏み込んでデータモデリングを行うのが吉だったなと感じています。
最終的に最適な形で落ち着きましたが、ユースケースにもっと踏み込んで設計しようという自分への戒めの思いと、柔軟に変更可能な定義をできていたことに対する安堵の気持ちが入り混じってちょっと複雑です。笑
OR繋ぎ検索クエリと性能
さて、話はがらっと変わって、Elasticsearchに投げる検索クエリのお話です。
- ORで繋ぐくらいなら分割して複数回投げたほうがレスポンス性能が期待できる
これにつきます。もちろんクエリ次第とは思いますが、私が経験した範囲においてはこの法則に反するものはありませんでした。
前項の話にも少し絡みますが、例えば下記のような場合を考えてみます。
① 親に1500の子が紐づいている
② 親のIDをもとにリレーションテーブルから1500の子のIDリストを取得する
③ 1500の子の中で条件にマッチするもの(仮に「作成日時が2018/12/31以前のもの」とする)をElasticsearchに検索する
親子のキー情報を相互に持つ設計へと変更した今となっては、
query : "parentId:${親のID} AND createdAt<=20181231"
としてあげればOKですが、リレーションテーブルから親子関係をひいてきていた時のクエリは
query : "id:(${子のID_1} OR ${子のID_2} ... OR ${子のID_1500}) AND createdAt<=20181231"
といった形のものでした。1500個もIDが並べばそりゃ凄まじいクエリであることは一目瞭然かと思うのですが、当時検索性能チューニングをする中で先程の
- ORで繋ぐくらいなら分割して複数回投げたほうがレスポンス性能が期待できる
という結論に至りました。
まずElasticsearchのプロパティとして max_clause_count
というものがあり、これは検索時に投げることのできる条件節数の上限を定めるものなのですが、デフォルトでは1024という制限があります。
そのため先程の例で言えば1500個もIDを並べることはそもそもできないのですが、じゃあ条件節の数が1024に収まるようにすればよいかあるいはこのデフォルト値1024を変更して1500個並べることができるようにすればいいかという話になるのですが、結論としてはどちらもNGでした。
(OR検索が内部的にどのような検索経路をたどっているのか、は非常に興味があり、また私自身理解がまだまだ足りない部分なので深ぼっていきたいですが、一旦結果ベースで話を展開することをお許しくださいmm)
大量にORでつないだクエリの実行はElasticsearchのリソース負荷を急激に上げ、一時的にリクエストが受け付けられない状態となってしまいました。
クライアント側でもレスポンスを受け取ることができず、非機能だけでなく機能面でも即NGでした。
(ちなみに利用したリソースは、Amazon Elasticsearch Serviceのr4.2xlarge.elasticsearchです。結構モリモリなやつ使ってたと思うんですけどね。。がっつりアンチパターンを踏んでしまった気がします。)
このOR節の数をたとえば50に減らしてみるとたしかにレスポンス自体は帰ってくるのですが、それでも遅いなーというのが拭えず、結果ORを一切排除した下記のようなクエリをn回投げるほうが期待値としてより低いレイテンシが見込めるという落ちとなりました。
query : "id:${子のID_1} AND createdAt<=20181231"
とはいえElasticsearchへのクエリを1500回連続で投げるの?という話ですし、性能課題どうしたもんかというところの結論が
- 親子関係のキー情報は相互にデータに保持させる
という前項の話に落ち着くわけです。
すいません、出だし全然違う話をするかのような書き出しっぷりでしたが、親子関係の話とOR繋ぎクエリの話はがっつり絡み合っていました。
ほんと前回の投稿といい今回の投稿といい、私は1年間の間この親子関係について頭をひねらせていたことになりますね。笑
でも書きながら思いましたが、Elasticsearch勉強会でも話題にあげていた
- 親子関係ってどうやって表現するのがいいんだろう
という問いに一定の解を見出すことができた気がします。本当に良かったです!
ElasticsearchのRefreshと性能
次こそは本当に別の非機能な話です。といっても変わらずElasticsearchですが。
さて、皆さんは登録したデータをどれくらい後に参照したいでしょうか?
5分後でしょうか?それとも直後でしょうか?
私の今回作った仕組みはデータを登録直後から参照できる必要がありました。
これがとても厄介でした。
Elasticsearchにはrefresh_interval
というプロパティがあります。
これはインデクシングされたデータを検索可能する処理(=Refresh処理)をどの程度のインターバルで実行するか、を設定するものです。
初期値は1秒なので、最悪登録したデータは1秒後に参照可能になります。
これが今回の要件にはマッチしませんでした。
たとえばElasticsearchをいわゆるログ分析の用途で活用します、といった場合であれば1秒後というのは十分許容可能なのかもしれませんが、今回のように検索DBとして利用する場合においては作成直後のデータが即時に参照できる必要がありました。
そのために任意のタイミングでこのRefresh処理を発火させられるようRefreshAPIなるものが用意されています。
私は安直にもこのRefreshAPIをプログラムに仕込むことで"登録直後のデータが参照できる"という要件を満たしました。
開発中は良かったです。凄まじい高TPSな状況にはほとんどさらされることなく、問題はないかのように見えました。
が、性能試験時にRefreshAPIを安易にコールしていた点が問題箇所として浮き彫りになりました。
このRefresh処理、たしかに公式のドキュメント等を見ても「ぽこぽこ安易に叩くAPIではない」との記載があります。
まさにアンチパターンまっしぐらだったのです。しかしいまさら"登録直後のデータが参照できる"という機能要件を捨てるわけにもいかずたどり着いた結論は以下のようなものでした。
- RefreshAPIではなくRefreshオプションを使う
どちらもRefreshだけど何が違うの?という疑問は至極当然ですが、微妙に違いがあります。
RefreshAPIは単発でコールするものですが、RefreshオプションはIndexやUpdateの処理を行う際に設定できるオプションです。
Refresh the relevant primary and replica shards (not the whole index) immediately after the operation occurs, so that the updated document appears in search results immediately.
これはRefreshオプションについての説明です。
端的に言えば、RefreshAPIは全shardに対してRefresh処理を行い、Refreshオプションは現在更新したshardに閉じてこのRefresh処理を行います。
このオプションを利用するように変更することで、性能が大幅に改善しました。
ドキュメントはしっかり隅々まで読んでなきゃだめだなと改めて痛感しました。
RefreshAPIについて - Elasticsearch公式
Refreshオプション - Elasticsearch公式
DynamoDBのCapacityUnitと性能
最後にDynamoDBですが、DynamoDBってほんとに性能面では心配がほとんどないです。
もちろん、今回はキーアクセスしかしない、という使い方のおかげとは思いますが、それでもCUが許す限りにおいて安定した性能を発揮してくれます。ええ、CUが許す限りにおいて。
CU消費をいかに抑えるか、というのはDynamoDBを使う際に常に考え続けなければならないと思います。
もちろんお金が湯水のようにあるのであれば、多めにプロビジョニングしておけばスロットリングも起きませんし良いかもしれませんが、そんなケースはなかなかないでしょう。
結局のところDynamoDBを使っていて直面した性能課題というのは、CU超過によるスロットリングでレスポンス性能が劣化する、というものだけでした。
OnDemand CUあるよ、といった声が聞こえてきそうですが、
本気でエンプラで使うとなれば現状リザーブド契約は必至と思いますし、どうしてもProvisioned CUでやらなかればいけないタイミングはあるかなと思います。
Auto Scaling組めばいいじゃん、という声も聞こえてきそうです。
たしかにそうです。こちらはとても有効ですし助かってます。
現在 DynamoDB では、未使用の読み込みおよび書き込みキャパシティーは最大 5 分 (300 秒) 保持されます
上記は公式ドキュメントからの抜粋ですが、一定レベルまでのスパイクであれば直近5分間の未使用キャパシティとAuto Scalingの発動によってスロットリングをかなりの確率で抑制できます。
(ほんとはAuto Scalingの発動速度も設定できるとうれしいなー、なんて。でもそれってほぼOnDemandってことなのか?。。。悩ましい)
じゃあ性能的にはOKじゃんか、という話ではあるのですが、もし後世にひとつ言葉を残すとすれば、
- DynamoDBの1レコードサイズは小さくしとくのが吉
です。
Auto Scalingの組み方次第でスロットリングからのレスポンス性能劣化はある程度防ぐことができますが、だとしてもスキャンするレコードサイズを小さくできることに越したことはありません。
もし仮に"このデータって特殊なパターン以外では参照されることないよね"といったデータがあれば、それは別テーブルに出すなどしていいかなと思います。
仮にそのデータサイズがとても大きいサイズであればなおのこと外だしすべきと思います。
施策はいろいろありますが、とかくCU消費量まで見据えてデータの持ち方を設計できると後々幸せになれるかもですよ、というお話でした。
おわりに
最後まで読んで頂きありがとうございました!!!
DynamoDB・Elasticsearchと共に歩んできた1年でしたが、非機能面で本当に様々な学びをえることができました。
他にもクロスリージョンレプリケートの行く末の話とかあるのですが、THE年末のタイミングでもう少し掘り下げて振り返っていきたいなと思います。
ではでは(^o^)