はじめに

これは ドリコム Advent Calendar 2021 の25日目です。
24日目は 吉岡ひろき さんの DynamoDBをゲームアプリで使う際の課題と対策(前編) です。

前編では理論を説明しましたので、この後編では実際の適用例について説明していきます。

適用例

ここまでスキーマ設計のための観点を記して来ましたが、アプリケーションの要件によってはすべての観点で最適な選択をできない場合があるので、優先順位の高い条件を満たすスキーマを選択することになります。以下では上述の観点を実際に利用して設計した例をいくつか示します。

例1. ランキング

ユーザ間でスコアを競うイベントのランキングについて考えます。アクセスパターンは次の3つとします。
  • 個々のユーザのスコアを加算する
  • 上位100ユーザのスコアとニックネームと使用中のキャラクタを取得する
  • ニックネームまたは使用するキャラクタ変更
前提としてユーザのニックネームと使用中のキャラクタ保存している下のようなテーブルがあるものとします(例として2人のユーザのデータを入れてあります)。
PK
UserID
attribute 1
Nickname
attribute 2
CharacterID
1560789 Alice 45
1123642 Bob 98
以下ではこのテーブルをユーザテーブルと呼ぶことにします。

スコアの加算

まずスコアを保存する場所ですが、イベント期間中にアクセスが集中し、一定期間が過ぎるとアクセスされなくなる偏りがあるデータと言えますので、イベント期間専用のテーブルを設けることが考えられます。PKとして使えるエンティティは、ここまでユーザしか登場していませんので、ユーザとします。

次にSKですが、スコアはパーティション内の最初のデータであり、他のデータと区別する必要がないので指定しないことにします。ここまでのスキーマを表にすると下のようになります。
PK
UserID
attribute 1
Score
1560789 1230
1123642 780
このテーブルをイベントテーブルと呼ぶことにします。

上位100ユーザ(案1. イベントテーブル、正規化)

上位100ユーザを取得するには何らかのSKとしてスコアを指定する必要があります。スコアは更新される値なのでプライマリキーにはできないことと、パーティションを横断してソートが必要なことから、GSIを使うことになります。GSIのPKとしては何らかの固定値を指定すれば1パーティションで全ユーザのスコアをソートできるので、1アクセスで上位100ユーザのUserIDを取得できます。

例えばイベントのIDをPKに指定すれば下のようなスキーマになります。
PK
EventID
SK
Score
attribute 1
UserID
Event1 1230 1560789
1080 1123642
なお1パーティションに全ユーザのスコアを入れることで応答速度や容量の問題が発生する場合は、GSI Shardingというデザインパターンでパーティションを分けることになります。その場合は上位100ユーザが複数のパーティションに分散するので、パーティションと同じ数のアクセスが必要になります。

ここまでで上位100ユーザのスコアとユーザIDは取得する目処が立ちましたが、要件ではニックネームと使用中のキャラクタも取得しなければなりません。この要件を満たすためにユーザテーブルを引くことが考えられますが、100item分なので最低でも100CUかかります。ランキングをリアルタイムに更新しなくてよいならどこかにキャッシュすることも考えられますが、DynamoDB以外のシステムを使うことになり本稿の範囲を越えるので、別の方法を検討してみます。

上位100ユーザ(案2. ユーザテーブル)

スコアとニックネーム、使用中のキャラクタをすべて同時に取得するには、それらを1itemに保存すれば済むので、ユーザテーブルにスコアを保存することを考えてみます。つまり下のようなスキーマです。
PK
UserID
attribute 1
Nickname
attribute 2
CharacterID
attribute 3
Event1-Score
1560789 Alice 45 1230
1123642 Bob 98 1080
そして案1と同様にGSIを作りますが、プロジェクションとしてニックネームと使用中のキャラクタを指定します。
PK
EventID
SK
Event1-Score
attribute 1
UserID
attribute 2
Nickname
attribute 3
CharacterID
Event1 1230 1560789 Alice 45
1080 1123642 Bob 98
このようなスキーマにすればGSIを引くだけで上位100ユーザのスコアとニックネーム、使用中のキャラクタを取得できます。

一方でこのスキーマの欠点としては下の3点を挙げられます:
  1. イベント関連のアクセスがユーザテーブルのCUを消費してしまう
  2. 1回のイベントに特化しており、別のイベントのランキングを管理するには別のGSIを作らなければならない
  3. ユーザのitemサイズが増えるので、イベントと無関係なアクセスのコストが上がる可能性がある
これらを許容できない場合に回避する方法を次に検討します。

上位100ユーザ(案3. イベントテーブル、非正規化)

イベントテーブルを使いながら1itemに必要な情報を詰めることを考えると、下のようなスキーマが考えられます。イベントも複数回の開催を想定し、attributeにします。例としてAliceがEventID=2のイベントで2,690点を取得した場合の値を入れてあります。
PK
UserID
SK
EventID
attribute 1
Score
attribute 2
Nickname
attribute 3
CharacterID
1560789 1 1230 Alice 45
2 2690 Alice 45
1123642 1 1080 Bob 98
上位100ユーザを取得するにはやはりGSIが必要です。案2までは1回のイベントを想定していたのでPKが固定値でしたが、ここでは複数のイベントをカバーするのでPKとしてイベントIDを選びます。またプロジェクションによりイベントテーブルからニックネームと使用中のキャラクタをコピーします。結果としてGSIは下のようになります。
PK
EventID
SK
Score
attribute 1
UserID
attribute 2
Nickname
attribute 3
CharacterID
1 1230 1560789 Alice 45
1080 1123642 Bob 98
2 2690 1560789 Alice 45
これで案2の欠点をすべて回避できましたが、新たな欠点が現れました。ニックネームと使用中のキャラクタを非正規化したので、ユーザがこれらの情報を変えたとき更新するitemが増えたのです。つまりアプリケーションは非正規化された情報がどこにあるか管理し、更新されたとき整合性を保つようにすべての対象itemを更新しなければなりません。

以上のようにランキングの実装について3つの案を挙げましたが、それぞれ利点と欠点があり、最適な解法はアプリケーションによります。

例2. ギルド加入申請とメンバー一覧

この例の前提は次の通りです。
  • ギルドと呼ばれるユーザのグループがある
  • ギルドにはリーダーがおり、メンバーの加入を認可または拒否できる
  • ユーザは1つのギルドに対してのみ加入を申請、または所属できる
  • 例1と同様のユーザテーブルがある
  • ギルドを区別するIDがある
アクセスパターンは下の通り。
  • 特定ギルドに加入申請している全ユーザのニックネームと使用中のキャラクタを取得する
  • 特定ギルドに所属している全ユーザのニックネームと使用中のキャラクタを取得する
  • ニックネームまたは使用するキャラクタの変更

加入申請一覧

まず、あるユーザが特定のギルドに加入申請していることを表すデータの保存場所について考えます。時期によるアクセスの偏りはない想定なので、このデータ専用のテーブルは不要です。よって既存のテーブル、つまりユーザテーブルに保存することを最初に検討します。

PKとして利用できるエンティティとしては、ユーザとギルドが考えられます。ユーザが加入申請できるギルドは1つという前提なので、パーティションの容量については心配がなさそうです。ユーザが加入申請する頻度を想像すると、アクセスの集中についても問題なさそうです。仮にPKにギルドIDを利用して、人気のギルドができたとしても、レスポンスが悪化することはないでしょう。よってPKにつかうエンティティは他の条件次第でユーザでもギルドでも選べそうです。

最後にSK、つまり保存先のitemについて考えます。PKにユーザIDを使った場合はニックネームなどと同じitemに保存するか、別のitemにするかの選択になります。加入申請一覧のアクセスパターンではニックネームと使用中のキャラクタを読み出すので、同じitemに保存するメリットがあります。デメリットとしては、一つのギルドへの加入申請情報が複数のユーザのパーティションに分散するので、GSIが必要なことが挙げられます。

申請先ギルドIDを表すattributeをAppliedGuildとした場合の例を下に示します。
PK
UserID
attribute 1
Nickname
attribute 2
CharacterID
attribute 3
AppliedGuild
1560789 Alice 45 7
1123642 Bob 98 65
GSIのPKにはギルドIDを指定し、プロジェクションとしてニックネームと使用中のキャラクタを指定すれば下のようになり、1回のアクセスで加入申請一覧に必要な情報を得られるスキーマができます。
PK
AppliedGuild
attribute 1
UserID
attribute 2
Nickname
attribute 3
CharacterID
7 1560789 Alice 45
65 1123642 Bob 98
別のitemにした場合にもGSIは必要であり、さらに1人分のデータを読み出すのに最低でも倍のコストがかかりますから、PKにユーザIDを使う場合はニックネームなどと同じitemに加入申請状況を保存すべきでしょう。

PKにギルドIDを使った場合、ユーザ毎に別のitemに申請情報を保存するか、1つのitemに保存するか検討することになります。ユーザ毎に別のitemの場合は、SKにユーザIDを使い、itemの有無で加入申請を表せるでしょう。itemが分かれているので同時に複数の申請があった場合でも競合を気にする必要がありません。一方で申請者の数だけitemができるので、比例してコストが増えます。

1つのitemに保存する場合は、セットというデータ型を利用できます。セットは重複なく複数の値を保存する型です。セットに値を追加/削除するAPIがあるので処理は十分シンプルになりますし、申請者が増えても別itemの場合に比べてコストの増加は遥かにゆるやかですが、同時に複数の申請を処理する場合の競合に注意する必要があります。

ここでは1つのitemに保存した場合の例を示します([]はセットを表します)。
PK
GuildID
attribute 1
ApplicantIDs
7 [1560789]
65 [1123642]
以上に挙げた案のメリット/デメリットの大きさは、プロダクトの要件やDynamoDB以外のシステムなどによって変化するので、状況に合わせて選ぶことになります。

所属ユーザ一覧

特定ギルドに所属している全ユーザの取得は、前項の加入申請一覧とよく似た課題で、同様のスキーマ案が考えられます。しかし加入申請のGSIが既に存在する場合、同じGSIにデータを保存できるか、できるとしたら十分なメリットがあるかを検討すべきです。

加入申請のGSIはPKにギルドIDを採用しているので、同じパーティションに所属ユーザの情報を保存しても不自然ではありません。ただし加入申請と所属は区別する必要があるので、SKを使うことにします。例えばIDが7のギルドに1人の加入申請と2人の所属者がいた場合、下のような状態になれば要件を満たします。
PK
GuildID
SK
GuildStatus
attribute 1
UserID
attribute 2
Nickname
attribute 3
CharacterID
7 Apply 1560789 Alice 45
Member 2093510 Charlie 62
Member 1284623 Daniel 11
つまり上の表でGuildStatusと書いたギルドに関するステータスがitemにあれば済むので、ベースとなるテーブルは下のようになります。
PK
UserID
attribute 1
Nickname
attribute 2
CharacterID
attribute 3
GuildID
attribute 4
GuildStatus
1560789 Alice 45 7 Apply
2093510 Charlie 62 7 Member
1284623 Daniel 11 7 Member
この案によってユーザのitemはGuildStatusの分だけ大きくなりますが、GSIを1つ節約できます。

ニックネーム/使用中のキャラクタの変更

ギルド加入申請/メンバー一覧のスキーマではニックネームや使用中のキャラクタ情報を非正規化していないので、通常の更新APIで更新すれば済みます。これらの値をプロジェクションに指定しているGSIには、更新が自動的に反映されます。ただし非同期であり、ベースのitem更新後にGSIが更新されるまでに時間差があることは知っておかなければなりません。

その他の発見

スキーマ設計をしてみて気づいた細かな注意点を簡単にまとめます。

管理業務の分析

運営側がゲームに必要なデータを操作したりユーザのデータを閲覧する場合のアクセスパターンは、ユーザ起因のアクセスと異なるものが多いため、スキーマ設計の際に忘れず分析しておく必要がありそうです。

IDの採番

メジャーなRDBにはレコードを追加すると自動的に通し番号を振ってくれる機能がありますが、DynamoDBにはないので、一意なIDが必要な場合は別途しくみを用意しなければなりません。

CU設定の自動化

CUの設定はコストのコントロールそのものですが、時間の経過に伴って値を変えるものなど、予め変更を予定しているものは自動的に設定するしくみを用意すればトラブルを避けられそうです。

まとめ

社内の調査ではDynamoDBを使うときの課題としてスキーマ設計が挙げられることを見出し、それを解決するため、運用中のゲームのクエリを例に公式ガイドに従って実際に設計を試み、課題の解決策として設計の観点を示しました。観点をもう一度簡単にまとめます。
  • テーブルの決定
      • アクセスの平滑化
      • 予測できるアクセスの偏りに合わせたテーブルの割当て
      • GSI/LSIの上限(20個/5個)
  • PKの決定
      • エンティティの粒度を決める
      • パーティション間でのアクセスの分散
      • パーティションの容量(10GB)
      • item同士の関連付け
  • SKの決定
      • itemを分けるか1つにするかを決める
      • アクセス回数(分ければ増える)
      • itemサイズ(1つにすれば増える。最大400KB)
      • GSIによるプロジェクション
      • GSI/LSIの消費
      • 競合(1つにするならトランザクションが必要)
      • 同期(分けるならアプリケーションで実現)
またこれらの観点を使い、アクセスパターンからスキーマを検討する例を示しました。本稿がDynamoDBを初めて使うエンジニアの一助となれば幸いです。

おわりに

ドリコムでは一緒に働くメンバーを募集しています!
募集一覧はコチラを御覧ください!