今年は始めて、re:Inventに参加してきたので、その際に見た「Amazon DynamoDB: Data Modeling and Scaling Best Practices」というセッションの内容を共有したいと思います。
内容をだいぶ端折ってるので、間違っている場合には、びしばしツッコミいただければと思います。
では、まいります。
1. CacheはCashなり
なんでDynamoDBを使うかといえば、やっぱり、ポチポチっと設定するだけで簡単に読み込み、書き込み性能を上げたり、下げたりできるっていうのが大きなポイントかと思います。
ただ、設定した性能も、データのアクセスパターンによっては思い通りの性能が出ないことがあります。
例えば、ReadCapacityを 100から5,000 に上げたとします。そうると、DynamoDBは、「オレ1人では捌き切れない」と思って、パーティションを分割していきます。そして、データも各パーティション毎に再配置されます。(パーティション=サーバー的な感じだと思います。たぶん)
ここで落とし穴があるのですが、2パーティションにわかれた際に、それぞれのパーティションで 5,000 RCUが使えるわけではなく、実際には2,500ずつになります。
なので、特定のパーティションに3,000RCUを消費するようなアクセスが集中してしまうと5,000指定してるのにエラーになる、みたいな理不尽な状況になります。
そのような、特定のアイテムに読み込みリクエストが集中するような場合には、
前段にキャッシュを置くことによって、アクセスを均一化することができ、ReadCapacityを低く抑えることができます。
まとめ: 高頻度にアクセスされるデータはキャッシュすることでコスト削減できるよ。
2. 自分、不器用ですから…
DynamoDBは、とても強くて頼もしいやつなんですが、なんとも融通がきかないんです。
Queryなんてメソッドもあるし、なんかインデックスもはれるみたいだし、RDB的な使い方もできるんじゃないか?あわよくば…ゴクリ
的な考えを持ってしまいがちなのですが、惑わされてはいけません。基本的に、男らしいKey-Valueストアです。
検索できるのは、プライマリキー(ハッシュキー or ハッシュキー+レンジキー)かGlobal Secondary Indexです。
なので、DynamoDBを使ってアプリケーションを作る際にはちょっとした工夫が必要になります。
例えば、オンラインゲームで招待されたゲーム一覧を表示するような機能を作るとします。
下記のような画面です。
Gameテーブルに、対戦相手がハッシュキー、ステータスがレンジキーとなるGlobal Secondary Indexを張ります。
その時、対戦相手が"Bob"で、ステータスが"PENDING" で抽出し、日付でソートしようとした場合、
このインデックス構成では日付でソートすることができません。
DynamoDBでは、レンジキーでしかソートしかできないからです。
このような場合はどうするのが良いでしょうか。
案としては、とりあえず取得してから、アプリケーション側で日付でソートを実装するという方法が考えられますが、
自分でソート実装するとか超めんどくさいし、そんな時間あったら早く帰って酒飲みたい。
そんな場合は、フィルタを活用します。
まず、レンジキーを日付に変更します。これでソートができるようになります。レンジキーを日付にしてしまったので、ステータスが"PENDING"での絞込はできませんが、そこはフィルタで削ぎ落とします。
もう一つのアプローチとして、複合キーを使うやり方もあります。
複合キーとかいっても、そういう機能があるわけではなく、自分で複合キーを作るのです。
こんな感じでステータスと日付をくっつけたカラムを作り、それをレンジキーに指定します。
これによって、対戦相手が"Bob"で、ステータスが"PENDING"で始まるものを"ステータス+日付" でソート というクエリが実現でき、要件が満たせるようになります。
これをベスト・プラクティスと言ってしまっていいのか、という気もしますが、とにかく工夫すれば、結構なんとかなるじゃんというのが伝えたいことだと思います。
まとめ:フィルターとか使ったり、インデックスのはり方を工夫しよう。
3. そ、そんな大きいの、だめぇぇぇ
LINEとか、メールのような、メッセージアプリを作る際のテーブル設計を考えるとします。
まずは、シンプルに、メッセージテーブルを作って、そこに、受信者、日付、送信者、メッセージ本文といったカラムを用意し、
受信者をハッシュキー、日付をレンジキーにしてみます。
こんな感じです。
これはこれで動くのですが、このテーブルでどれくらいのコストがかかるのでしょうか。
それぞれのレコードが平均256KBあって、それを50件取得するクエリの場合、
下記のような計算により、1600RCUが必要なことになります。
Inboxを表示するだけで1600RCUとか恐ろしいですね。
ここで注意しないといけないのが、キャパシティの計算方法です。
クエリにおけるキャパシティは1レコード単位ではなく、4KB毎で1RCUを消費するので、メッセージ本文のような大きなデータを検索されるテーブルに含めてしまうと、それだけでコストが膨らむことになります。
なので、よく検索が走るメッセージテーブルと、大きなデータを持つInboxテーブルの2つに分割することでコストダウンを図ることができます。
4. 部屋は適度に散らかってたほうが便利なこともある。
次に、ソーシャルゲームのランキング機能を作るケースを見てみましょう。
下記は、Aliceさんの友達のランキングを表示する例になります。
まず、Aliceさんの友達を取得し、次に、友達のハイスコアを取得します。
この方法には2つの問題点があります。
1つめは、常に全ての友達を取得しなければならない点です。
すべての友達がゲームをプレイしているとは限りませんし、トップ10のみ取得すればよいのに、全員分取得しなければ結果が出せません。
2つめは、友達毎にGetItemでデータを取得する点です。
クエリでは、4KB毎にRCUを消費しますが、GetItemでは1レコード毎に1RCUを消費します。
このケースの改善策が次になります。
Friendsテーブルの他に、ハイスコアも含め、非正規化したFriendScoreテーブルを用意し、そのハイスコアカラムにLocal Secondary Indexを張ります。
これによって、友達のランキングを1つのクエリで取得することができます。
データを冗長に持つことで、データ取得を容易にできるという例になります。
私はいつでも使えるようにティッシュの配置には冗長性を持たせています。
まとめ: 非正規化により、読み込みのコストと速度が上げられる。ただし書き込みコストもかかるので諸刃の剣。
5. いつも成功すると思うなよ。
先ほどのランキング実装の例ですが、1つ問題があります。
1人が最高得点を出した場合、ハイスコアテーブル1レコードと、その人を友達にしている人のFriendsScoreテーブルの各レコード全てを更新しなければなりません。
上記の例だと、Chuckが最高得点30点を出したので、Chunkを友達にしている、BobとAliceのレコードを更新する必要があります。
このようなケースの場合、複数のレコードを更新している間に更新が失敗する可能性があり、リトライについて考慮しなければなりません。
実にめんどくさいですが、失敗にそなえましょう。
Chuckの最高得点の更新が始まった時点で、HighScoreテーブルにUpdatingをtrueにし、FriendScoreテーブルを更新していきます。
FriendsScoreテーブルの更新がすべて成功した場合には、Updatingを削除します。
もし、エラーが発生した場合には、Updatingのまま残り続けるので、リトライします。
全て成功したら、Updatingを削除します。
このようにして、リトライで不整合の状態を防ぐ仕組みを作ることができます。
あと、Javaには、Client Transaction Libraryとかいうのが提供されているので、これを使えばトランザクション的なことを実現できるようです。
また、先日発表されましたが、DynamoDB Streamという、テーブルの更新情報をStreamとして受け取れる機能が追加になりました。
この機能を使えば、1つのテーブルが更新された際に、他のテーブルを更新するといった処理がシンプルに実装できるようになります。
まとめ: 一度に複数のテーブルを更新する際は、失敗を考慮して設計する。DynamoDB Streamも使えるよ。
6. エージェントスミス作戦。
候補者に投票するようなアプリケーションを作るとします。
その場合、候補者テーブルの得票数カラムをインクリメントする(Atomic Incrementとよばれている)機能を使うことによって、簡単に実装ができます。
このアプリケーションに、スケールする必要性が生じた場合、どうすればよいでしょうか。
ボトルネックとなるのは、特定の候補者に投票が集中するようなケースです。
DynamoDBはハッシュキーにより、パーティションが決定するため、特定のパーティションにアクセスが集中してしまい、設定した性能が出ない可能性が考えられます。
安倍晋三と、又吉イエスが同じ土俵で戦ってたら、まぁそうなりますわ。
そんな時は、1候補者を1レコードとするのではなく、1人を複数に分割します。
安倍晋三_01 安倍晋三_02 安倍晋三_03 ... 安倍晋三_10 のようなイメージです。
これによって、特定の候補者(のレコード)にアクセスが集中することを防ぎます。
投票によりデータを更新する際には、1〜10に分割した安倍晋三のどれかをランダムで更新します。
集計は、SUM(安倍晋三_01, 安倍晋三_02, 安倍晋三_03..) のように、各レコードの値を合算します。(SUM関数はないので、アプリケーション側で実装することになります)
別の方法としては、候補者のレコードを更新するのではなく、生の投票データを
ひたすら挿入していき、それを先ほど紹介したDynamoDB Streamに流すことによって、リアルタイム集計であったり、RedshiftやEMRによる分析になど、用途毎に処理が行えるようになります。
どうでもいいんですが、池上さんの番組の、候補者の紹介は毎回おもしろいですね。
候補者に絶妙にダメージを与えるどうでもいい紹介文が笑えます。
まとめ: 高負荷なアプリでは、パーティションごとのスループットを考慮する。ここでもDynamoDB Stream使えるよ。
補足:パーティション数の求め方
パーティションを考慮しろ、みたいな話が出てきましたが、実際にはいまDynamoDBがいくつのパーティションになっているかは、マネージメントコンソールやAPIからは知るすべがありません。
しかし、計算方法が解説されていたので紹介します。
一言でいうと、「サイズによるパーティション数と、スループットによるパーティション数の大きい方がパーティション数」です。
サイズによるパーティションは、テーブルサイズ(バイト数)を10GBで割った数値です。
また、スループットによるパーティション数は、Read Capacity Unitを3000で割った数+Write Capacity Unitを1000で割った数の合算値です。
こうやってみると、結構なデータサイズ、もしくはスループットがないとパーティションに分割されないことがわかりますね。