はじめに

これは ドリコム Advent Calendar 2021 の24日目です。
23日目は AnD00 さんの プロジェクトの行動規範を作ってチームの理想を描いてゆく です。

弊社内ではユーザの主要なデータの保存先としてRDBが使われるケースが多く、NoSQLを中心としたシステムを開発した経験のあるエンジニアが限られています。しかし新たなシステムを開発するとき選択肢としてNoSQLを選べることは有用だと思われるので、NoSQLを選択したときどのような課題があるか調査し、対策を検討しておくことにしました。

大小様々な課題があることは想像できますが、本稿ではAmazon DynamoDBのスキーマ設計に焦点を当て、実際に設計して直面した課題と、その解決方法を紹介します。

対象読者

RDBを使ったアプリの開発/運用経験があり、DynamoDBを使ってみたい人

背景

冒頭の通り、社内ではRDBが主に使われています。しかし弊社が現在主に扱っているのはゲームアプリで、一般にゲームは書込みアクセスの割合が多いシステムです。

またこれも一般的な傾向ですが、書込みが多いシステムにはNoSQLが適しているとも言われています。よって今後NoSQLがふさわしい案件が発生する可能性も十分考えられ、その時のために課題を調べ対策を検討しておくのは価値がありそうです。

調査対象の候補としてAmazon DynamoDBとGoogle Cloud Bigtableを検討し、今回はDynamoDBを選択しました。理由は本題から外れるため省きますが、バックアップやレプリケーションの機能や、インデックスの柔軟性が主な判断材料になりました。

本稿の調査はゲームのバックエンドを前提にしており、社内のエンジニア向けに始めたものですが、一般に役立つ点もあるかもしれませんので公開します。

アプローチ

まず事前調査として、社内の業務でDynamoDBを使った経験のあるエンジニアにヒアリングしました。結果は後述しますが、結論としてはスキーマ設計が最大の課題、つまりRDBのように考えてしまい、DynamoDBに最適なデータ構造を作れないことがフォーカスすべき問題と考えられました。

そこで社内で運用中のゲームをDynamoDBで作り直すことを想定し、公式の開発ガイドに沿ってスキーマ設計してみることにしました。実際にやってみると途中でガイドの他の部分や仕様書を読み直すことが多く、より粒度が細かい、コンテキストに合ったガイドの必要性が感じられました。

以下では公式ガイドだけでは判断しにくい点を解説しながら、ゲームによくある例も挙げて解決方法を提案します。

DynamoDBの簡単な説明

調査結果の前に、前提となるDynamoDBの用語や仕様について紹介します。

テーブル

RDBのカラムのように共通の属性値を持つデータの集合という点ではRDBの「テーブル」と同じですが、DynamoDBにはRDBの「データベース」に該当する単位がないため、DyanmoDBでは最大のデータの単位になります(例えばレプリケーションなどの操作はテーブル単位です)。テーブルには複数のパーティション(次項)が含まれます。

パーティション

物理的なストレージの単位で、パーティションが異なると、ほとんどの場合物理的に別のマシンや別のストレージデバイスに保存されていると考えられます。パーティションが異なるデータには並列で効率的にアクセスできます。設計の段階ではデータの保存先となるパーティションを意識しますが、DynamoDBはいわゆるクラウドサービスですので、コードを書いて読み書きする段階ではあまり物理層を意識する必要はなくなります。パーティションには1つまたは複数のitem(次項)が含まれます。

item

主要なデータの単位で、RDBの「レコード」に相当し、複数のattribute(次項)を内包できます。カタカナで書くとゲームの文脈では別の概念を想起しがちなので、本稿ではアルファベットで書きます。

attribute

データの最小単位で、RDBの「カラム」に近いものですが、RDBと異なりitem毎に異なる型、異なる数のattributeでitemを構成できます。型としては文字列、数値、セット、マップなどを選択できます。カタカナで書くと(個人的に)読みにくいので、本稿ではアルファベットで書きます。

PK(パーティションキー)

itemが保存されるパーティションを決定づけるattributeです。文字列/数値/バイナリ型のattributeを指定できます。

SK(ソートキー)

パーティション内に複数のitemを配置する場合、itemの区別と並び順のために指定するattributeです。オプションであり、SKを指定しないテーブルでは1パーティションにつき1itemになります。

プライマリキー

テーブル作成時に設定する、itemを特定するためのattributeを指すので、SKを設定する場合はPK(パーティションキー)とSKのペアがプライマリキーとなり、SKを設定しない場合はPKだけでプライマリキーとなります。プライマリキーとして指定したattributeは内容を更新できません。

GSI

Global Secondary Indexの略で、テーブル内の全itemに対して付けられるPKやSKとは別のインデックスです。プライマリキーと同様に、PKとSKを指定できます。RDBの副次的なインデックスと異なり、GSIを引いたとき必ず得られるのはベースとなるテーブルのプライマリキーのみで、それ以外のattributeを取得するにはベーステーブルを引き直すか、プロジェクションと呼ばれる機能で必要なattributeをGSIへコピーするよう指定しておく必要があります。GSIに指定したattributeは内容を更新できます。

LSI

Local Secondary Indexの略で、パーティション内のitemに付けられるSKとは別のインデックスです。LSIに指定したattributeも内容を更新できます。

CU

capacity unitsの略で、課金の単位です。アクセスの種類やサイズによって数値が変わります。特に読出しに適用される単位をread capacity unit、書込みに適用される単位をwrite capacity unitと呼び、書込みの方がコストが高く設定されています。設計にあたって合計CUを抑えることが大きな判断材料の一つになります。本稿ではこの略語を使いますが、公式ドキュメントでは略さず書かれています。

ヒアリング

過去にDynamoDBを業務で使ったことのあるエンジニアが社内に3名(3プロジェクト)見つかったため、ヒアリングさせてもらうことができました。それぞれの案件について、以下のような内容を中心に教えていただきました。
  • 用途
  • DynamoDBを採用した理由
  • ソフトウェア構成
  • 課題
ヒアリング結果は機密を含むため、詳細がわからないように抽象化していますがご了承ください。

プロジェクト1

1件目のプロジェクトではユーザと1対1に紐付いた1種類のデータを保存するためにDynamoDBを使いました。用途が単純だったため、特に課題は見つからなかったとのことでした。

プロジェクト2

2件目のプロジェクトでは主要なストレージとしてDynamoDBを採用しました。ActiveRecord風DBアダプタであるDynamoidも合わせて使うなど、ヒアリングした中では最も規模の大きな開発でした。その中で以下のような課題があったとのことでした。

1. RDBのような考え方をしてしまった

RDBでは基本的にデータ構造を正規化し、データの種類毎にテーブルを用意しますが、DynamoDBでは様々な種類のデータを1つのデーブルに保存でき、テーブルを共有することにメリットもあります。RDBのような考え方をしてしまったためにDBアクセスが増えてしまったとのことでした。アクセスが増えたのであれば当然コードの複雑さも増しているはずですし、何よりDynamoDBの利用料が増大してしまったはずです。

2. インデックスの張り忘れがあった

公式ライブラリを直接使って開発している限りインデックスの張り忘れは発生しませんが、Dynamoidは適切なインデックスがない場合にScanと呼ばれる全item検索をしてしまいます。そういうケースがあったとのことですが、これはDynamoidの欠点とも言えますし、「RDBのような考え方をしてしまった」ことも原因の根底にあると考えられます。

プロジェクト3

3件目のプロジェクトでは容量を気にせず使えるストレージとしてDynamoDBを採用しました(パーティションには10GBの上限がありますが、テーブルには容量制限がありません)。主要なデータの保存先としては、別途RDBを使ったそうです。課題は以下の通りでした。

1. DBが別れている煩雑さ

サイズを予測できないデータだけDynamoDBに保存し、メタデータやアプリの主要なデータをRDBに置いていたため、1つの処理のために同期を気にしながら両方にアクセスする必要があり、面倒だったとのことです。

2. CUの調整

DynamoDBの課金体系にはアクセス量(CU)を予約して確保するモードがありますが、コストを最適化するためCUを調整するのが面倒だったとのことでした。

ヒアリングの結論

プロジェクト2の課題は根本的にDynamoDBの仕様にあったスキーマを設計できなかったことに起因していると言えます。プロジェクト3の「DBが別れている」件も、全体をDynamoDBで設計できれば発生しなかった課題であり、DynamoDBに対する習熟度を上げることで解決できる可能性があります。「CUの調整」については、実はプロジェクト2では自動調整するスクリプトを作成して解決しているので社内で解決できる課題と言えますし、現在はオートスケーリング機能もあります。

よって以降の調査ではスキーマ設計に焦点を当てることとしました。

スキーマ設計の試み

公式サイトにはコンセプトの説明やチュートリアルからベストプラクティスまで様々なドキュメントがあり、ヒアリングの前までに以下の項目にはざっと目を通していました。 その中にBest Practices for Modeling Relational Data in DynamoDBという3ベージから成るガイドがありましたので、これに従って設計することにしました。題材は現在自分が運用に携わっているゲームで、リリースから7年経過してなお人気のタイトルです。

アクセスパターンの列挙

First Stepsには「Important」という見出しと赤線の囲みで下のように書かれています:
NoSQL design requires a different mindset than RDBMS design. For an RDBMS, you can create a normalized data model without thinking about access patterns. You can then extend it later when new questions and query requirements arise. By contrast, in Amazon DynamoDB, you shouldn’t start designing your schema until you know the questions that it needs to answer. Understanding the business problems and the application use cases up front is absolutely essential.
最後の2文だけ訳すと次のようになります:
対象的にAmazon DynamoDBでは、答える必要がある質問がわかるまでスキーマを設計すべきではない。業務の課題とアプリケーションのユースケースを最初に理解することが絶対に不可欠だ。
つまりDBアクセスの目的がわからないと設計できないということで、DynamoDBを使おうとするとすぐにこれを実感します。 よって最初のステップとして、アプリケーションが必要とするアクセスパターンを列挙します。既存のRDBをDynamoDBに置き換える場合は、アプリケーションが実際に発行しているクエリを見て、その目的を書き出すことになります。すべてのクエリを調べるのは時間的に無理だったので、いわゆるガチャなどゲームによくある機能から始め、途中から設計が複雑になりそうな機能を意識して調べました。結果的に下のようなアクセスパターンのリストができました。
No. 内容
1 ユーザの詳細をロード
2 特定ユーザの特定ガチャ利用回数をロード
3 運営から特定ユーザ向けのメールをチェック
4 消費トランザクションの開始
5 消費トランザクションの参照/更新
6 ゲーム内通貨を消費する
7 イベントのスコアの記録
8 イベントのランキング
9 フレンド一覧
10 ギルド加入リクエスト一覧
11 ギルドメンバー一覧
12 ニックネームの更新

キーの決定

次にガイドではAdjacency List Design Patternの適用について説明しています。このデザインパターンを適用するには、アプリケーションの対象領域から主要なエンティティを見出す必要があります。前出のアクセスパターンを見ると、「ユーザ」「ガチャ」「イベント」「ギルド」などがエンティティの候補として考えられます。

そしてエンティティを一意に決定するattributeをPKとし(1つのパーティションを割り当て)、そのエンティティ自身、および関連付けられる大小のエンティティをitemとして保存するとのことです。各エンティティにはやはり一意に区別がつくattributeをSKとして設定します。

例えばユーザを一意に判別するために通し番号を付け、「User<番号>」というPKを設定することが考えられます。この場合、最初のユーザのPKは「User1」となります。RDBと異なり1つのテーブルに様々な種類のエンティティを保存するため、エンティティの種類を区別するために「User」のような接頭辞が必要になります(1つのテーブルに複数の種類のエンティティを保存するメリットについては後述します)。

そしてそのユーザによるガチャの利用データには同じPKを設定し、SKとしてはガチャのID、例えば「Gacha1」を設定することが考えられます。なお、ユーザ自身を表すitemのSKは、PKと同様の「User1」で問題ありません。このスキーマにデータを入れた場合の例を下表に示します(値に意味はありません)。
PK SK attribute 1 attribute 2
User1 User1 nickname: “Alice” CharacterID: 45
User1 Gacha1 count: 3
以上のようにPKとSKを決定すると書かれていますが、実際には一つPKを決めるにも迷い、ベストプラクティスや仕様を確認するためにガイド内を行き来することになりました。

例えばメールはユーザと同じパーティションに置くべきかとか、いわゆるトランザクションを実装するときどのような機能が使えてどのような制限があるかなど、即座に判断できない問題が次々と挙がってきました。このステップがスキーマ設計の最大の難関で、解決できればスムーズに進むことがわかりました。本稿で提案する対策は後述することにして、先に公式ガイドの手順を最後まで説明します。

クエリ条件の決定

最後のステップとして、最初に作ったアクセスパターンそれぞれについて、どのようなキーを使うか、どのような条件で絞り込むかなどを決定します。公式ガイドでは最初のリストに「クエリ条件」の列を加えた表が示されていますが、以下に示すのは個人的にわかりやすくした本稿オリジナルの構成です。今回の例では下のような内容になりました。
No. アクセスパターン エンティティ テーブル インデックス クエリ条件
1 ユーザの詳細をロード ユーザ ユーザ Primary PK=ユーザID
2 特定ユーザの特定ガチャ利用回数をロード ガチャ利用情報 ユーザ Primary PK=ユーザID, SK=ガチャID
3 運営から特定ユーザ向けのメールをチェック メール ユーザ Primary PK=ユーザID
4 消費トランザクションの開始 消費トランザクション ゲーム内通貨 Primary PK=ユーザID
5 消費トランザクションの参照/更新 消費トランザクション ゲーム内通貨 Primary PK=ユーザID, SK=開始日時
6 ゲーム内通貨を消費する ゲーム内通貨ロット ゲーム内通貨 LSI-1 PK=ユーザID, SK=残高
7 イベントのスコアの記録 ユーザ ユーザ Primary PK=ユーザID, SK=イベントID
8 イベントのランキング イベント ユーザ GSI-1 SK=イベントID, Limit 100
9 フレンド一覧 ユーザ ユーザ Primary PK=ユーザID, SK begins_with “Friend”
10 ギルド加入リクエスト一覧 ギルド ユーザ GSI-Guild Guild=ギルドid, ギルドステータス begins_with “Request”
11 ギルドメンバー一覧 ギルド ユーザ GSI-Guild Guild=ギルドid, ギルドステータス begins_with “Member”
12 ニックネームの更新 ユーザ ユーザ Primary PK=ユーザID, SK=ユーザID / SK begins_with “Friend”
開発の円滑化のため作成する資料は他にも考えられますが、上のアクセスパターンとクエリ条件の表を作成するのがDynamoDBのスキーマ設計の主なゴールになります。

課題と対策

上述の通り、DynamoDBのスキーマ設計において、想定されるアクセスパターンに対して適切なキーを設定することが大きな課題だとわかりました。またBest Practices for Modeling Relational Data in DynamoDBは1つのテーブルを使うことを前提に書かれていますが、実際には複数のテーブルを使うケースも考えられるため、キーの前にテーブルを決定するステップもあることになります。よって以下ではテーブル、PK、SKを決めるための考え方を示し、対策としたいと思います。

テーブルの決め方

アクセスの偏り

前提としてDynamoDBではできるだけテーブルの数を少なくすることが推奨されています。このことはFirst Steps for Modeling Relational Data in DynamoDBの最下部に「Important」という見出しと共に明記されています。ただし理由は書かれていないため、推察すると、アクセス頻度の平滑化によってコストを抑えるためではないかと思われます。

DynamoDBの「プロビジョニングされたキャパシティ」の料金モードではテーブル毎に1秒当たりに受け付けるCUを設定し、そのCUによって料金が決定されます。この設定は処理能力の瞬間的な上限になるので、最もアクセスが集中する瞬間を想定して設定することになります。

しかしアクセスが少ないときはCUに空きができ、余分な処理能力に対して料金を払うことになってしまいます。よってアクセスの平滑化はコスト最適化の一つの観点となります。エンティティ毎にテーブルを分けるとアクセスが偏り、CUに無駄な空きができる可能性が高くなるので、少ないテーブルが推奨されていると考えられます。

一方でアクセスの偏りはテーブルを分ける根拠にもなります。ゲームに限らず、データが作られた直後は頻繁にアクセスされ、また高速な応答が求められ、時間が経つとほとんどアクセスされない、あるいは応答速度を求められないケースがあります。

例えば期間限定イベントの記録や、実世界なら商品の注文から発送までの管理が挙げられるでしょう。このような場合は直近のデータに十分なCUを割り当て、古いデータには最小限のCUのみを割り当てるのが合理的です。CUの割り当てはテーブル(またはGSI)単位ですので、期間毎にテーブルを区切り、経過した時間によってCUを変えていくことでコストとパフォーマンスを最適化できます。この時系列データのデザインパターンはBest Practices for Handling Time Series Data in DynamoDBに詳しく書かれています。

アクセス頻度によるテーブルの決め方をまとめると下のようになります。
  • アクセス頻度を予測できない、優先順位の高いデータは、まとめて1つのテーブルに保存する
  • 時系列データなど、アクセス頻度や優先順位が変化するデータは、アクセス頻度/優先順位毎のテーブルに分ける

GSI / LSIの上限

アクセスの偏り以外にテーブルを分ける要因としては、GSI/LSIの個数制限が挙げられます。1つのテーブルに対し設定できる個数はGSIが20個、LSIが5個までなので、アクセスパターンが多くなれば上限に達し、テーブルを分ける必要が生じるかもしれません。

PKの決め方

PKの決定は、1パーティションに保存するエンティティの粒度を決めることになります。あるデータAが別のデータBに包含されている、あるいは所属しているとき、Aに固有のPKを付ければ粒度は細かく、Bに付ければ大きいと言えます。Bにパーティションを割り当てた場合、AはBのパーティションに保存するのが自然です。

例えばユーザが複数のキャラクタを持っているとき、キャラクタに固有のPKを付ければ粒度は細かい、ユーザに付ければ大きいとなります。それぞれの場合にどのような性質を持つか検討していきます。

エンティティの粒度を大きくすると、そのエンティティに関わる様々なデータを同一パーティションに保存し、SKで区別することになります。例えば入手したキャラクタに対し順番にCharacter1Character2Character3…というSKをつけることが考えられます。またそのユーザのイベントの成績を保存するにはEvent1Event2Event3のようにSKを付けて区別できます。

データを読み出すときにはSKの先頭文字列を指定して一致するitemを複数読み出せるので、例えばCharacterを指定すれば1回のアクセスで所持するすべてのキャラクタを読み出せます(ただし1回のレスポンスのサイズは最大1MBなので、分割が必要な場合もあります)。パーティション内のitemはSKによってソートされているので、SKの範囲を指定して複数のitemを読み出すこともできます。SK以外のattributeで並び替えたい場合はLSIを設定すれば、同様に先頭文字列や範囲で読み出せます。

ユーザのパーティションに所持するキャラクタとイベントの成績を保存する場合の例を下に示します。
PK SK attribute 1
User1 Character1 CharacterID: 1
Character2 CharacterID: 4
Character3 CharacterID: 6
Event1 Score: 275
Event2 Score: 90
エンティティの粒度を大きくするときの注意の一つは、負荷の集中です。パーティションは物理的なストレージの単位なので、アクセスが集中すればレスポンスが落ちます。アクセスの集中が予想される場合はPKの付け方を再検討する必要があります。

もう一つの注意点として、パーティションの容量が挙げられます。1パーティションの容量は最大10GBなので、アプリケーションの寿命の間にデータ量が10GBを超えないか予測しておく必要があります。

エンティティの粒度を小さくするほど負荷の集中や容量の心配は減りますが、データの関連付けやアクセス回数についての懸念が生じます。上述の通り同一PKを持つitemの関連付けは明確ですが、PKが別だった場合には関連を示すattributeを持つ必要があります。例えばキャラクタに個別のPKを振った場合、所有者を示すattributeを持たせ、GSIを設定する必要があるでしょう。またPKは先頭文字列や範囲で指定できないので、複数のitemを読み出すために別々にリクエストを送らなければならないかもしれません。アクセス回数が増えると、料金が変わらなくても処理時間が伸びることになります。

PKを決める要素をまとめると下のようになります。
  • 負荷の分散
  • パーティションの容量
  • item同士の関連付け、アクセス回数

SKの決め方

前述の通りSKはパーティションの中でitemを区別する役割を持つので、SKの検討は、データを独立したitemにするか、それとも既存のitemのattributeにするかの判断とも言えます。データにパーティションの中で固有のSKを振れば独自のitemになりますが、既存のitemに組み込んでしまう選択肢もあるわけです。

例えばユーザが特定のガチャを利用した回数を記録するとき、ユーザの他の情報、つまりニックネームやレベルと同じitemに保存するか、独立したitemにするかという問題が考えられます。RDBにおける、テーブルにカラムを増やすか独立したテーブルにするかの判断にも似ています。それぞれの場合の性質を明らかにしておきましょう。

一つのitem

他のデータと合わせて一つのitemにした場合に明確なメリットは、同時に読み書きできることです。読出しの場合4KBまで、書込みの場合1KBまでは同一料金、つまり1CUでカバーできるので、同時にアクセスすることが多いデータは一つのitemにすればコストのメリットがあります。

また、アクセスのラウンドトリップ時間が1回分で済むので、全体の処理時間も短くなります。ただしitemのサイズが1CUでアクセスできる範囲を超えると、一部のattributeを読み書きするだけでも2CUかかるので注意が必要です。

その他の注意点としては、容量制限が挙げられます。1itemの容量は最大400KBです。よって運用と共に増えるデータを一つのitemに保存する場合は、アプリケーションの寿命を予想した上で十分な余裕を持たなければなりません。

GSI/LSIについても考慮が必要です。SKにしないということは、そのattributeをキーとしてitemにアクセスできないことになるので、そういうアクセスパターンがあるならGSIまたはLSIを使うことになります。例えばユーザが受け取ったメールのSKを受信日時に基づいて決定する場合、それ以外のattribute、つまり差出人などにはキーがありませんので、そういったattributeに基づいたアクセスパターンがあるならLSIを設定する必要があるでしょう。

さらに、アクセスの競合にも注意しなければなりません。一つのitemに同時に複数のアクセスがあったとき動作を保証するにはトランザクションを使う必要があります。常にトランザクションを使えば済むように思えるかもしれませんが、トランザクションはコストが2倍かかるため、本当に必要なときだけ使うべきでしょう。

別のitem

別のitemとして保存する場合は、一つのitemにする場合に対してメリットとデメリットがほぼ逆転します。

まず一つのitemのとき1回で済んだアクセスが2回になり、データのサイズの合計が1CU以下でも2CUかかってしまう上に、処理時間も伸びます。この点についてはデータを非正規化して1回で済ませることも考えられます。非正規化の判断については後述します。

一方でitemの容量については余裕ができますし、GSI/LSIを節約できる可能性もあります。ただし競合については注意深く検討する必要があります。なぜならDynamoDBのトランザクションは複数のitemを一度に更新できないため、その他の機能と組み合わせてアプリケーションレベルで整合性を保証する必要があるからです。

なおトランザクションによって1つのitemの更新と別のitemの作成をカバーしたり、1つのitemの更新と1つの条件の検査をアトミックに行うことはできます。 アクセスパターンが異なるデータを一つのitemに保存する場合と別のitemに保存する場合のメリットとデメリットを下表にまとめます。
観点 一つのitemに保存 別のitemの保存
アクセス回数 1回 2回(正規化した場合)
コスト(合計サイズが1CU以下で、両方のデータにアクセスする場合) 1CU 2CU
コスト(1CU以下のデータ2個、合計で2CUの場合) 常に2CU 片方のデータだけなら1CU
GSI/LSI(両方のデータをキーにする場合) 必要 不要
競合(個々のデータを別のアクセスで書き込む場合) DynamoDBが提供するトランザクションで保護する トランザクション不要
競合(両方のデータを書き込む場合) トランザクション不要 アプリケーションレベルで保護する
後編では適用例を説明します。

おわりに

25日目は 吉岡ひろき さんの記事です。

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