QuipperではメインのデータベースとしてMongoDBを活用しており、データサイズは1TBを超えています。
これまでユーザーやデータが増えるたびにスケールアップを繰り返してきたので、AWSの最高性能のサーバを使う状況になっていました。 このような大規模なサービスにおけるMongoDBの運用について書いていきたいと思います。
今回はインデックス編です。
はじめに
当記事で登場するMongoDBの構成
- クラスター管理: MongoDB Cloud Manager & AWS EC2
- ReplicaSet Cluster: Primary, Secondary, Hidden Secondary
- EC2 instance type; i3en.24xlarge (Primary/Secondary)
- RAM: 768GB (Primary/Secondary)
- MongoDB Version: 4.0
MongoDBのインデックスとは
MongoDBはスキーマレスなNoSQLですが、インデックスに関してはRDBのインデックスと同じような役割をします。
例えばデータを検索するときに、インデックスを張っていないフィールドをキーにするとディスク上のすべてのデータを検索しなければなりません。
そこで予め検索したいフィールド、たとえばユーザーIDなどにインデックスを付けておけば、それをキーに必要最小限のデータだけをディスクに取りに行けるようになります。
インデックスに関する一般的な考え方と大規模サービスにおける考え方の違い
以下のようにパフォーマンス・チューニングをすることがあると思います。
事例:クエリが遅い原因を調べてそのクエリが使うキーにインデックスがない場合にインデックスを追加
一般的にこれは正しい対処だと思うし、ぼくも経験があります。
しかし、MongoDBの場合、概ねインデックスをすべてメモリ上に乗せる必要があるという点を考慮しなければなりません。 公式ドキュメントに以下のように書かれています。
Ensure Indexes Fit in RAM
For the fastest processing, ensure that your indexes fit entirely in RAM so that the system can avoid reading the index from disk.
実際にQuipperでは、これが満たされていないときにDBのパフォーマンスが落ちるという事象が発生しているし、MongoDB社のエキスパートにコンサルティングを受けた際にもこの点を指摘されました。
インデックスに必要なサーバメモリの計算
例えば、以下のようなサイズのデータベースがあるとします。
- データサイズ: 200GB
- インデックス合計サイズ: 20GB
インデックスの合計サイズが20GBの場合、サーバのメモリサイズがだいたい50GB以上は必要です。
MongoDBはWiredTigerというストレージエンジンを使います。デフォルトでサーバの物理メモリの50%をWiredTigerキャッシュに割り当てます。残りの50%はOSのファイルシステムキャッシュとして残されています。
そしてWiredTigerキャッシュは使用率が80%を超えるとEvictionというメモリの開放が始まります。
デフォルトのWiredTigerキャッシュ割当: 50% 50GB x 50% = 25GB WiredTigerキャッシュの利用上限の目安: 80% 25GB x 80% = 20GB
WiredTigerキャッシュにはインデックス以外にもデータ自体も乗るので、実際にはもう少し余裕があっても良いでしょう。
AWSの最強サーバを使った場合のインデックスサイズの限界
MongoDBのサーバにはAWS EC2を使っています。
メモリサイズだけで考えれば、一番大きいサーバは4TBぐらいのサーバもありますが、データベースの書き込みが多い場合はメモリサイズだけではなくDisk I/Oも高速である必要があります。
AWSのあらゆるサーバの性能テストを実施した上で、Quipperではi3enインスタンスファミリーを使っています。 i3enの最大メモリはi3en.24xlargeの768GBです。
この場合、WiredTigerキャッシュはだいたい384GBぐらいになります。
デフォルトのWiredTigerキャッシュ割当: 50% 768GB x 50% = 384GB WiredTigerキャッシュの利用上限の目安: 80% 384GB x 80% = 307GB
このとき、WiredTigerキャッシュの80%は307GBです。 インデックスの合計は307GB未満に収めるのが良いでしょう。 実際にはデータやクエリによりますが目安として307GBを超えたら、パフォーマンス障害のリスクが高いと思います。
コレクションごとのインデックスサイズの確認方法
Mongo Shellで以下のコードを実行すると任意のデータベースのすべてのコレクションのインデックスの情報を取得することができます。
use <データベース名>; db.getCollectionNames().forEach(function(collection) { if (collection != "system.users") { indexes = db[collection].stats(); print("DB Stats for " + collection + ":"); printjson(indexes); } });
あるコレクションのインデックス
"nindexes" : 5, "totalIndexSize" : 14610313216, "indexSizes" : { "owner_id_1" : 1905123328, "trackable_id_1" : 1721237504, "context_id_1_context_type_1_key_1_created_at_1" : 3997839360, "recipient_id_1" : 1324498944, "_id_" : 5661614080 },
このコレクションは5のインデックスを持っており、合計でおよそ14.6GBです。 Quipperでは、これをDatadogに送って可視化&監視しています。
限界を超える場合の対処
先ほど計算したように、i3en.24xlargeというEC2サーバを使った場合に、仮にインデックスの合計サイズが307GBを超えるともう後がない状態になります。
非常手段としてはキャッシュメモリの割合をデフォルトの50%から70%まで引き上げる方法があります。 quipper.hatenablog.com
しかし70%まで使うのは最後の非常手段として残しておいたほうが良いかもしれません。(まだスケールアップできる余裕があれば、サーバコストを抑えるために70%に上げることはありだと思います)
限界が来る前に対応する手段としては、まとまりのあるテーブル(コレクション)ごとにデータベースを分割することです。
Quipperでは、別のMongoDBクラスターを作って、データ量の多いテーブルを移動したり、マイクロサービス化してPostgresに移植したりしています。
インデックスサイズの節約
大規模サービスにおけるMongoDBの運用では、単純にインデックスを増やすのはパフォーマンス劣化のリスクがあるということが理解できたでしょうか?
一方でインデックスの合計サイズを減らすことができれば総合的な観点で改善する余地があります。(未来のユーザー増加に余裕を持たせることができる)
そこでインデックスサイズを節約する3つの方法をご紹介します。
- 参照されないインデックスの削除
- 古いデータの削除
- 順序が違う複合インデックスの統合
参照されないインデックスの削除
Mongo Shellで以下のコードを実行すると、最後にmongodが起動してからインデックスが参照された回数を取得できます。
use <データベース名>; db.getCollectionNames().forEach(function(collection) { if (collection != "system.users") { var indexes = db[collection].aggregate({$indexStats:{}}); print("Indexes for " + collection + ":"); printjson(indexes._batch); } });
結果を一部抜粋
[ { "name" : "school_id_1", "key" : { "school_id" : 1 }, "host" : "mongodb-f03.qs.production:27000", "accesses" : { "ops" : NumberLong(464057), "since" : ISODate("2020-11-26T23:03:32.395Z") } }, { "name" : "city_id_1", "key" : { "city_id" : 1 }, "host" : "mongodb-f03.qs.production:27000", "accesses" : { "ops" : NumberLong(0), "since" : ISODate("2020-11-26T23:03:32.395Z") } },
accesses.opsという部分で何回参照されたかわかります。
- school_id_1: 464057
- city_id_1: 0
この場合、school_id_1というインデックスは使われていますが、city_id_1というインデックスは使われていないので削除しても問題ないでしょう。
ただし、参照されていないインデックスはメモリに乗っていません。計算上の合計インデックスサイズは減りますが、実質的に使われているメモリの節約には繋がりません。
古いデータの削除
現在は使われていない古いデータを削除するとメモリの節約効果に繋がります。
先ほど、使われていないインデックスはメモリに乗っていないと説明しました。それなのに古いデータはメモリに乗っているのでしょうか?
例えば以下のようなテーブルがあるとします。
- データサイズ: 100GB
- データ件数: 1億件
- (使われないデータ3,000万件、全体の30%)
- インデックスサイズ: 10GB
この場合、インデックスは10GB全部メモリに乗せる必要があります。
もし3,000万件のデータを削除すれば、インデックスも30%小さくなり7GBぐらいになります。 つまり、3GBのメモリ節約効果に繋がります。
先ほどの使われていないインデックスとの違いは、メモリに乗っていないインデックスを削除するか、メモリに乗っているインデックスをなるべく小さくするかの違いです。
順序が違う複合インデックスの統合
MongoDBでは、複数のフィールドを組み合わせた複合インデックスを作ることができます。
たとえば以下の2つの複合インデックスがある場合、1つにまとめることを検討してください。
- 学校ID-ユーザーID
- ユーザーID-学校ID-更新日付
クエリ単体でパフォーマンスを見る場合は、両方のインデックスがある方がレスポンスが早くなる可能性もありますが、データ件数やインデックスサイズを含めて総合的に考える必要があります。
インデックス1GBあたりのコスト計算
先ほど紹介したi3en.24xlargeの1ヶ月のコストは$9,320(東京リージョン、リザーブなし)です。 本番環境では2台必要なので$18,640です。
これをインデックスに使えるキャッシュメモリサイズで割ってみましょう。
$18,640 / 307GB = $60.7/GB
パフォーマンスを優先すべきか価格を優先すべきかを考える1つの材料としてインデックス1GBごとに$60.7/monthかかるという指標を用いることができそうです。
SREとしてオンライン教育を支える
ぼくらは事業としてサービスを開発運営しているので、信頼性が高ければ高いほど良いわけではないし、コストが安ければ安いほど良いわけでもありません。
ユーザー数や売上が伸びている事業なら競合に差をつけるためにコストをかけて信頼性を高めることがある一方で、文房具すら買えない国の子どもたちに教育を届けるためには、少しでも安くサービスを提供することを考える必要があります。
Quipperでは世界のオンライン教育を支えるエンジニアを探しています。
著: 深尾もとのぶ