CosmosDBのスキーマー設計は、RMDBと異なる部分があります。 RMDBの感覚でスキーマー設計をすると、RUを大きく消費したり、性能がでないケースがあります。
本入門では、Facebook的なアプリをサンプルにCosmosDBらしいスキーマ設計とは何かを学んでいただけます。
本資料は、MicrosoftのCosmosDBチームのGithubで公開されているCosmosDB-DataModeling.pptxと、アメリカで開催されたセミナーの内容をもとに書き起こし&意訳&追記したものになります。
多くのユーザーがいて、ユーザーには友達がいて、記事を投稿できて、そこにLikeやコメントができ、ユーザーのトップページは記事一覧になっているアプリです。 このアプリをまずはRMDB的なスキーマー設計で作成し、それぞれの機能でのRU消費量とレスポンスを確認します。そこから各クエリとコマンドの結果を改善するには、どうスキーマーを書き換えて、どういうストアドプロシージャを作成するといいのかを見ていき、最後には同じアプリなのに設計(スキーマー、ストアドプロシージャ)が変わることで、消費RUが大幅に改善することを理解できます。
ユーザー
は、 投稿
を作成できる。
ユーザーは、投稿に、 Like
ができ、 コメント
をつけられる。
トップページを表示すると最近の投稿一覧が表示される。
特定ユーザーのすべての投稿を一覧表示できる。投稿のすべてのコメントを一覧表示でき、Likeを押したユーザーを見れる。
投稿は、投稿者の名前とコメント数とLike数が表示される。コメントは、コメントの投稿者の名前も表示される。
一覧が表示されたとき、投稿は概要だけが表示される。
この要件をみたすアプリケーションをモデルリングすると、下記のようなスキーマー構成にするかと思います。
しかし、CosmosDBでは、このモデリングでは性能が十分にでなかったり、課金額が高くなってしまいます。 この資料では、CosmosDBとしては、どうモデリングをしたらいいのかを考えていきます。
このアプリケーションを実現するには、どのようなリクエストがあるでしょうか。 大きく2つに分類できます。
- Command (書き込み処理)
- Query (読み取り専用)
この分類に基づいて、アプリで必要な処理を設計してみます。
- <C1> ユーザー情報の作成と編集
- <Q1> ユーザー情報の検索
- <C2> 投稿の作成と編集
- <Q2> 投稿の検索
- <Q3> ユーザー投稿の一覧
- <C3> コメントの作成
- <Q4> 投稿のコメントの一覧
- <C4> 投稿へのLike
- <Q5> 投稿のLike一覧
- <Q6> 直近X件の投稿一覧
こういった処理がCosmosDBに対して実行されることになります。
CosmosDBデータベースでは、ドキュメント をコンテナー に保存します。 Likeはテーブルに行を保存するのでしょか。
このように、ドキュメントとコンテナーを1対1で紐づけることを考えるかもしれません。
CosmosDBでは、こちらのほうがいいでしょう。
予測性能は、プロビジョニングに依存します。 プロビジョニングは、秒間リクエスト単位・Request Units per second (RU/s)で表現できます。 CPU、メモリ、I/Oのリクエストコストを代替しています。
性能はプロビジョニングができます。プロビジョニング単位は、データベースレベルとコンテナーレベルで制御できます。プロビジョニング性能は、API経由でプログラムで変更できます。
この性能については、コスト削減には重要な話です。
CosmosDBは、データをパーティショニング分割して保存することで、水平スケーラビリティがあります。 コンテナーのパーティションキーに基づいて、データを論理的にパーティショングループに分けます。
素晴らしいパーティションキーは、ストレージの観点やスループットなどからとてもバランスが取れたパーティションのときです。 読み取りクエリは、一つのパーティションからすべての結果を取得すべきです。
- 100,000ユーザー
- 5-50 投稿/ユーザー
- 0-20 コメント/投稿
- 0-100 like/投稿
ユーザーコンテナー(users)に、IDをパーティションキーにしてユーザードキュメントを保存します。
投稿コンテナー(posts)に、それぞれドキュメントを保存します。例えば投稿に関するドキュメントを保存した場合。
こちらは、コメントに関するドキュメントを保存した場合。
そして、Likeに関するドキュメントを保存した場合。
userドキュメントをusesコンテナーに格納します。これは特に問題ありません。
usersコンテナーからパーティションキーのidで検索してuserを取得しています。これは特に問題ありません。
投稿をpostsコンテナーに登録します。これも特に問題ありません。
投稿者のIDで、usersコンテナーからPKであるidでフィルターして、ユーザー名を取得します。これは特に問題ありません。
投稿とコメント数とLike数を取得するために、postsコンテナーからPKであるpostIdでフィルターして、情報を取得します。これも特に問題ありません
Facebookのトップページの用にフォローしているユーザーの投稿一覧と各投稿のコメント数とLike数を取得します。 各投稿の投稿者名とコメント数、Like数を取得するために、投稿数分繰り返しクエリを実行します。 これは読み取り数が多くなり性能問題につながります。
投稿を取得するために、postsコンテナーからPKではないuerIDでフィルタリングをしています。これは全件アクセスする必要があり、非常にコストがかかります。
コメントをpostsコンテナーに登録します。これは特に問題ありません。
特定の投稿につけられたコメント一覧を取得するために、postsコンテナーからpkのpostIDでフィルタリングして情報を取得しています。これは特に問題ありません。
コメント者名を取得するために、コメント数分usersコンテナーからpkでフィルタリングして結果を取得しています。これはN+1になるのでコストがかかり問題があります。
Likeをpostsコンテナーに登録します。これは特に問題ありません。
特定の投稿に紐づいたLikeをpostsコンテナーからPKのpostIdでフィルタリングして結果を取得しています。これは特に問題ありません。
Like者名を取得するために、Like数分usersコンテナーからpkでフィルタリングして結果を取得しています。これはN+1になるのでコストがかかり問題があります。
各投稿の投稿者名とコメント数、Like数を取得するために、投稿数分繰り返しクエリを実行します。 これは読み取り数が多くなり性能問題につながります。
直近の投稿を取得するために、postsコンテナーからpkではないtypeでフィルタリングして結果を取得しています。これは非常にコストがかかります。
一回のリクエストで複数のクエリを実行する問題がありました。また、パーティションキーではない条件で絞り込む、パーティションスキャンを引き起こす問題のあるクエリもありました。 それでは、それぞれ改修していきましょう。
改修をするには非正規化を活用します。 ストアドプロシージャーを使うことで、同じロジカルパーティション内で非正規化ができます。
- Javascriptで書く
- 一つの論理パーティションを対象とする
- アトミックトランザクションとして実行する
Facebookのトップページの用にフォローしているユーザーの投稿一覧と各投稿のコメント数とLike数を取得します。
そして問題となったのは下記でした。
各投稿の投稿者名とコメント数、Like数を取得するために、投稿数分繰り返しクエリを実行します。 これは読み取り数が多くなり性能問題につながります。
問題の本質的には、各投稿がコメント数とLike数を保持していないために、そのデータを取得するのに追加でクエリを実行しなければいけないからです。言い換えれば、各投稿にコメント数とLike数があれば、クエリ発行数を減らすことができます。
この問題を解決するために、非正規化をして、各刀投稿にコメント数、Like数を持たせます。
非正規化をした後は、Azure Functionで、userデータの更新をトリガーにして、postsのドキュメントも更新するようにします。ユーザー名が変更された場合は、Functionで各投稿のユーザー名を反映させます。
改修前がこれでした。
改修をすると、Postsコンテナーから情報を一度取得すればいいだけになり、性能改善が実現できました。
130ms/619.41RU
から、 28ms/201.54RU
に改善しました。
これには次の問題がありました。
コメント者名を取得するために、コメント数分usersコンテナーからpkでフィルタリングして結果を取得しています。これはN+1になるのでコストがかかり問題があります。
改修前はこのような実装でした。
改修後はシンプルな実装となりました。
23ms/27.72RU
から、 4ms/7.72RU
に改善しました。
これには次の問題がありました。
Like者名を取得するために、Like数分usersコンテナーからpkでフィルタリングして結果を取得しています。これはN+1になるのでコストがかかり問題があります。
改修前はこのような実装でした。
改修後はシンプルな実装となりました。
59ms/58.92RU
から、 4ms/8.92RU
に改善しました。
各投稿の投稿者名とコメント数、Like数を取得するために、投稿数分繰り返しクエリを実行します。 これは読み取り数が多くなり性能問題につながります。
改修前はこのような実装でした。
改修後はシンプルな実装となりました。
306ms/2063.54RU
から、 83ms/532.33RU
に改善しました。
再びQ3を見てみましょう。これには次の問題が残っていました。
投稿を取得するために、postsコンテナーからPKではないuerIDでフィルタリングをしています。これは全件アクセスする必要があり、非常にコストがかかります。
正規化を崩して情報をユーザーコンテナーにも投稿情報をもたせます。
上記のデータを入れるのに合わせて、userドキュメントに項目を追加します。
この対応により、参照先のコンテナーがUsersに変わります。
データ抽出が、UsersコンテナーのパーティションキーuserIdとなるため性能が改善します。
28ms/201.54RU
から、 4ms/6.46RU
に改善しました。
最後に次の問題がまだ残っています。
直近の投稿を取得するために、postsコンテナーからpkではないtypeでフィルタリングして結果を取得しています。> これは非常にコストがかかります。
ここで必要なデータは、直近で更新されたデータでした。そこで、Change Feedを利用し、パーティションキーをtypeにします。
Azure CosmosDBは、Change Feedを提供しており、変更されたドキュメントは変更された順に並べ替えられた一覧として出力されます。
データが更新されると、feedコンテナーが更新されるようにします。
改修前は次のようでした
この対応により、次のような処理に変わり、パーティションキーによるフィルタリングが聞くようになり性能改善ができました。
83ms/532.33RU
から、 9ms/16.97RU
に改善しました。
改修した結果、次のようなコンテナーとドキュメントを保存するようになりました。
これを実現させるためにストアドプロシージャーを使用し、データフローは次のようになりました。
これらの対応により、性能はドキュメント数、ユーザー数、投稿数に依存しなくなりました。 10ユーザーでも100万ゆーざーでもクエリレイテンシーはいつでも同じになります。