DynamoDB のテーブル設計をしてみた

# はじめに

Amplify のチュートリアルを触ってみて、その中で一番難しい（ん？ってなった）のは DynamoDB だったので、ドキュメントなどを見ながら、DynamoDB ではどういう風にテーブル設計すれば良いのか考えてみました。[^1]

何かのコンセプトを学ぶ時は、縛りプレイをして苦しむのが良いというのが持論です。

DynamoDB のドキュメント曰く「設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。」らしいので、テーブル1つ縛りで縛りプレイすることにしました。 [^2]

後は、ドキュメントを読んでいるとスキャンしたら負け、フィルタ使ったら負けみたいな雰囲気があるので、スキャンやフィルタも禁じることにします。

# 想定するアプリケーション

イベントを管理するアプリケーションを想定して設計してみます。

[DynamoDB でリレーショナルデータをモデル化するための最初のステップ](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-modeling-nosql.html) や、[AWS Black Belt Online Seminar 2018 Amazon DynamoDB Advanced Design Pattern](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwii7J2R7ujsAhWUBIgKHbp6DH4QFjABegQIAxAC&url=https%3A%2F%2Faws.amazon.com%2Fjp%2Fblogs%2Fnews%2Fwebinar-bb-dynamodb-advanced-design-pattern-2018%2F&usg=AOvVaw2C0izVDm8MZEEfg8b5hFft) を参考に、まずはアクセスパターンを分析することにしました。

ドキュメントに

>　DynamoDB の場合は対照的に、答えが必要な質問が分かるまで、スキーマの設計を開始しないでください。ビジネス上の問題とアプリケーションのユースケースを理解することが不可欠です。

と記載がある通り、DynamoDB は、パーティションキーとソートキーしか検索条件に使えず、かつ、パーティションキーは完全一致の指定しかできない制約があるので、無計画にテーブルを作ると、いざアプリケーションを書いてみたら検索したかった条件で検索ができないということになりかねません。

スキーマレスとは言いつつも、最初から考えないといけないことがいろいろあって、想像と違ってた部分があって面食らいました。

# アクセスパターン

数が少ないですが、ミニマムで以下のようなアクセスパターンを考えました。

|ID|アクセスパターン|ソート|
|:---|:---|:---|
|1|参加者としてすべてのイベントを取得する|開催日|
|2|参加者として自分が参加申し込みしたイベント一覧を取得する|申込日|
|3|参加者として特定の期間に開催されているイベント一覧を取得する|開催日|
|4|管理者として特定のイベントのイベント参加者一覧を取得する|申込日|
|5|管理者として特定の参加者が参加申し込みしているイベント一覧を取得する|申込日|
|6|管理者として締切間近のイベント一覧を取得する|申込締切日|

# 最初の案

## テーブル

テーブル1つ縛りな時点で、主キーは、[パーティションキーを効率的に設計し、使用するためのベストプラクティス](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-partition-key-design.html) の冒頭の文章で言うところの「複合 (パーティションキーとソートキーの組み合わせ) で構成」することになりそうです。

SKに `event-master` と記載したアイテムはいわゆるマスターテーブルのデータを表したアイテムとして扱い、SK に `user-{{userId}}` と記載したアイテムはいわゆるトランザクションテーブルのデータを表したアイテムとして扱うことにしました。

この最初の案を考える際に[多対多の関係を管理するためのベストプラクティス](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-adjacency-graphs.html)を参考にしました。

具体的には「隣接関係のリスト設計パターン」を使っています。

最初の案のテーブルの内容は下記です。

|PK|SK|タイトル|内容|開催日|申込締切日|申込日|
|:--|:--|:--|:--|:--|:--|:--|
|event-1|event-master|イベント1のタイトル|イベント1の内容|2020-12-01|2020-11-24||
|event-1|user-1|||||2020-11-01|
|event-1|user-2|||||2020-11-01|
|event-2|event-master|イベント2のタイトル|イベント2の内容|2020-12-24|2020-12-10||
|event-2|user-1|||||2020-11-01|
|event-2|user-2|||||2020-11-05|
|event-2|user-3|||||2020-11-02|
|event-2|user-4|||||2020-11-03|

## インデックス

なし

## クエリ

アクセスパターン (ID=4) は、PK に対象のイベントID, SK に begins_with(user-) を指定すれば一見いけそうなのですが、今のままだと申込日によるソートや絞り込みはできません。

そこで、LSI（ローカルセカンダリインデックス）を作成することにします。

|ID|アクセスパターン|ソート|クエリ|
|:---|:---|:---|:--|
|1|参加者としてすべてのイベントを取得する|開催日||
|2|参加者として自分が参加申し込みしたイベント一覧を取得する|申込日||
|3|参加者として特定の期間に開催されているイベント一覧を取得する|開催日||
|4|管理者として特定のイベントのイベント参加者一覧を取得する|申込日||
|5|管理者として特定の参加者が参加申し込みしているイベント一覧を取得する|申込日||
|6|管理者として締切間近のイベント一覧を取得する|申込締切日||

# カイゼン1

アクセスパターン (ID=4) のための LSI を作成します。

## テーブル

変更なし

## インデックス

|インデックス|PK|SK|
|:--|:--|:--|
|LSI-1|PK|申込日|

## クエリ

LSI を追加するとアクセスパターン (ID=4) が実現できましたが、まだまだ実現したいアクセスパターンはあります。今回の要件には GSI（グローバルセカンダリインデックス）が必要不可欠です。（隣接関係のリスト設計パターンは GSI ありき）

|ID|アクセスパターン|ソート|クエリ|
|:---|:---|:---|:--|
|1|参加者としてすべてのイベントを取得する|開催日||
|2|参加者として自分が参加申し込みしたイベント一覧を取得する|申込日||
|3|参加者として特定の期間に開催されているイベント一覧を取得する|開催日||
|4|管理者として特定のイベントのイベント参加者一覧を取得する|申込日|LSI-1 を使って、PK=特定のイベントID, SK=全期間 を指定する|
|5|管理者として特定の参加者が参加申し込みしているイベント一覧を取得する|申込日||
|6|管理者として締切間近のイベント一覧を取得する|申込締切日||

# カイゼン2

SK を PK にした GSI をいくつか作るといろんなクエリが書けます。

## テーブル

変更なし

## インデックス

GSI-1 ～ GSI-3 を追加します。

|インデックス|PK|SK|
|:--|:--|:--|
|LSI-1|PK|申込日|
|GSI-1|SK|開催日|
|GSI-2|SK|申込日|
|GSI-3|SK|申込締切日|

## クエリ

一応これで、当初考えていたアクセスパターンが全部実現できそうです。

|ID|アクセスパターン|ソート|クエリ|
|:---|:---|:---|:--|
|1|参加者としてすべてのイベントを取得する|開催日|GSI-1 を使って、PK=event-master, SK=全期間 を指定する|
|2|参加者として自分が参加申し込みしたイベント一覧を取得する|申込日|GSI-2 を使って、PK=自身のユーザーID, SK=全期間 を指定する|
|3|参加者として特定の期間に開催されているイベント一覧を取得する|開催日|GSI-1 を使って、PK=event-master, SK=特定期間 を指定する|
|4|管理者として特定のイベントのイベント参加者一覧を取得する|申込日|LSI-1 を使って、PK=特定のイベントID, SK=全期間 を指定する|
|5|管理者として特定の参加者が参加申し込みしているイベント一覧を取得する|申込日|2. の PK が特定のユーザーIDに変わるだけ|
|6|管理者として締切間近のイベント一覧を取得する|申込締切日|GSI-3 を使って、PK=event-master, SK=締切日1週間前など を指定する|

# カイゼン3

GSI をたくさん作っちゃったので財布が不安です。

属性の使い方を工夫すればもう少し GSI を削減できそうなので、ちょっとやってみます。

具体的にはアイテム毎にソートに使用するデフォルトの日付を定めるイメージです。
イベントの情報を管理するアイテム（SKが `event-master` であるアイテム）であれば開催日、イベントの申込状況を管理するアイテム（SKが `user-{{userId}}` であるアイテム）であれば申込日とします。

ただ、ここのカイゼン案を適用すると、データの持ち方が冗長になるし、わかりにくくなるので、人によっては改悪と思う改修かもしれません。

## テーブル

|PK|SK|ソート日付|タイトル|内容|開催日|申込締切日|申込日|
|:--|:--|:--|:--|:--|:--|:--|:--|
|event-1|event-master|2020-12-01 (開催日と同じ値)|イベント1のタイトル|イベント1の内容|2020-12-01|2020-11-24||
|event-1|user-1|2020-11-01 (申込日と同じ値)|||||2020-11-01|
|event-1|user-2|2020-11-01 (申込日と同じ値)|||||2020-11-01|
|event-2|event-master|2020-12-24 (開催日と同じ値)|イベント2のタイトル|イベント2の内容|2020-12-24|2020-12-10||
|event-2|user-1|2020-11-01 (申込日と同じ値)|||||2020-11-01|
|event-2|user-2|2020-11-05 (申込日と同じ値)|||||2020-11-05|
|event-2|user-3|2020-11-02 (申込日と同じ値)|||||2020-11-02|
|event-2|user-4|2020-11-03 (申込日と同じ値)|||||2020-11-03|

## インデックス

GSI-1 と GSI-2 を GSI-4 として統合できました。

ちなみに LS1-1 の SK は申込日をそのまま使う必要があります。申込日はイベントの申込状況を管理するアイテム（SKが `user-{{userId}}` であるアイテム）にしか含まれていない属性なので、SK を申込日にすれば、申込日が属性として存在するアイテムだけを検索対象とできます。この場合、申込日が [Take Advantage of Sparse Indexes](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-indexes-general-sparse-indexes.html) で言うところの Sparse インデックスに相当することになります（多分）。

|インデックス|PK|SK|
|:--|:--|:--|
|LSI-1|PK|申込日|
|GSI-4|SK|ソート日付|
|GSI-3|SK|申込締切日|

## クエリ

|ID|アクセスパターン|ソート|クエリ|
|:---|:---|:---|:--|
|1|参加者としてすべてのイベントを取得する|開催日|GSI-4 を使って、PK=event-master, SK=全期間 を指定する|
|2|参加者として自分が参加申し込みしたイベント一覧を取得する|申込日|GSI-4 を使って、PK=自身のユーザーID, SK=全期間 を指定する|
|3|参加者として特定の期間に開催されているイベント一覧を取得する|開催日|GSI-4 を使って、PK=event-master, SK=特定期間 を指定する|
|4|管理者として特定のイベントのイベント参加者一覧を取得する|申込日|LSI-1 を使って、PK=特定のイベントID, SK=全期間 を指定する|
|5|管理者として特定の参加者が参加申し込みしているイベント一覧を取得する|申込日|2. の PK が特定のユーザーIDに変わるだけ|
|6|管理者として締切間近のイベント一覧を取得する|申込締切日|GSI-3 を使って、PK=event-master, SK=締切日1週間前など を指定する|

# カイゼンn

ドキュメントを読み込むと、まだ適用できそうなベストプラクティスがいくつかあります。

1. SK（GSI-3, 4 の PK）の `event-master` のホットパーティション化問題
    - [選択的なテーブルクエリに対してグローバルセカンダリインデックスの書き込みシャーディングを使用する
](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-indexes-gsi-sharding.html) を適用する
2. クエリ条件（GSI）がもっと増えていく問題
    - [Overloading Global Secondary Indexes](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-gsi-overloading.html) を適用する

とは言え、この辺を適用してしまうとアプリケーション側の実装がやたらめったら複雑になってしまいそうなので、今回のお試しテーブル設計ではこの辺までにしておこうかなと思います。

# まとめ

RDB よりも考えることが多そうで難しいと思いました。上記が正解なのかも正直よくわかりません。

DynamoDB の特性ありきのハイコンテキストなスキーマなので、こういう風にできたスキーマを理解して、正しく使って、維持するのは大変そうだなという印象です。

ただ、縛りプレイのお試しテーブル設計をしたことで少しだけ DynamoDB についての理解が深まった気はしました。

以上。

[^1]: Amplify は LSI を設定したり、REST API モデルだと GSI を後から追加したりができないっぽく（属性を定義するタイミングでならできる）て辛い感じでした。
[^2]: いろんな記事で公式ドキュメントの文言として上記の文言が引用されているものの、最新版の公式ドキュメントからは削られていました。今は事情が違う？