AWS DynamoDBのテーブル設計における注意点

## この記事は何か
- AWS DynamoDBをメインのDBとして利用した開発の中でぶち当たったDynamoDBの制約についてまとめた記事

DynamoDBの特徴をまとめてくれている記事はたくさんあるが、実際にDynamoDBが技術選定の土俵に上がった際の明確な選定基準を提供する記事は少ないため、少しでもヒントになればと思い執筆していく。

※間違いなどあればコメントなどで教えて頂けると幸いです

## TL;DR
- DynamoDBは「それらの指定でレコード(Item)が一意に特定できない2つ以上のカラム(Attribute)での条件検索を伴うクエリが頻繁に投げられる」要件には向かないので注意しよう

## DynamoDBのテーブル設計における前提条件
#### 各テーブルにHash Key と Sort Keyを指定することができる
 - 1. Hash Keyのみ
 - 2. Hash KeyとSort Key

 のいづれかのパターンでプライマリーキーを設定する。

Hash KeyはPartition Keyとも呼ばれ、Partition Keyごとにテーブルがシャーディングされる。
そのため、クエリが均一に分散するようなkeyをPartition Keyにすると良い。

Sort KeyはRange Keyとも呼ばれる。

#### Hash Key単体 またはHashKey&SortKeyでUnique Keyにならなければならない
プライマリーキーなので当然だが、 Hash Key単体またはHash Key&Sort Key(複合プライマリーキー)で一意にレコードを特定できなければならない。

![スクリーンショット 2020-12-20 20 41 35](https://user-images.githubusercontent.com/26994507/102712785-e70a2a80-4306-11eb-8668-0deb2b33c863.png)
- 参考：https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html

- ユニークキーがduplicateなものをinsertしにいくと、該当レコードが上書き(update)される

#### HashKey&SortKeyの複合プライマリキーの場合は、Hash Keyだけで複数件のItemを取ってくることもできる
- Sort Keyも合わさって初めて一意なのでHash Keyだけでは複数件のItemを取得する事になる
- Hash Key指定のクエリで複数件Item取ってきた結果を、その後filterする処理をアプリケーション側でやらなくてもDynamoDB側でやってくれるQuery Filterという機能がある
    - ただし、DBに対するQuery自体はHash Key指定だけなのでscan(パーティション跨ぎの全レコードチェック)よりはマシだが、パーティション内での全レコード取得になるため、パフォーマンスは悪い
    - 参考: https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/LegacyConditionalParameters.QueryFilter.html


- それ以外の条件検索は基本的にscanで引いた後にアプリケーション側でfilterすることになる
    - scanはレコード数が多くなればなるほどパフォーマンスが低下する&Read Capacity Unitが大きくなり料金も嵩むため、できるだけ避けたい

#### Global Secondary Index (GSI) が作成できる
 - GSIはHash KeyもSort Keyも変えたテーブルが作れるよという機能
    - ベーステーブル更新時に非同期でGSI側も更新しに行くため、結果整合性のある読み込みのみしか担保できない
- テーブル作成時だけではなく、後から自由に追加したり削除したりできる
- GSIではHash KeyとSort Keyで一意にレコードを指定できる必要はない
- GSIのHash Key / Sort Keyに設定した項目以外のベーステーブルのどの属性をGSIに射影するかは選択することができる(KEYS_ONLY, INCLUDE, ALL)
- GSIのスループット設定はベーステーブルとは独立しており、ベーステーブルよりも大きくする必要がある
- GSIは結果生合成の読み取りであるため、RCUを1/2にしてくれてる
- GSIがあるテーブルへの書き込みは、GSIに射影している属性を更新する場合には単純にGSIにも書き込むためWCUは2倍になる
- ストレージ課金の方は、単純にGSIに射影して持っている容量分増える

#### Local Secondary Index (LSI) が作成できる
 - LSIはHash KeyはそのままにSort Keyだけ変えたテーブルが作れるよという機能
     - LSIに対してはHash Keyが共通で同期的に書き込みを行うため、常に最新の更新された値が返される「強力な整合性」のある読み取りが利用できる- テーブル作成時にしか作成できず、後から削除もできない (削除や変更を行う場合はテーブルを作り直す必要がある)
- 1つのテーブルに対して5つまでしか作成できない
- LSIではHash KeyとSort Keyで一意にレコードを指定できる必要はない
- ベーステーブルのSort Keyは必ずLSIの非キー属性として射影されるが、それ以外の射影項目は選択することができる(KEYS_ONLY, INCLUDE, ALL)
- LSIでは射影していない属性をクエリした場合、透過的にベーステーブルからフェッチしてくれるが、これはレイテンシーとスループットコストが大きくなるので避ける
- Query オペレーションの ConsistentReadパラメータを使用して「結果整合性」か「強力な整合性」かを選択してクエリできる
- LSIがあるテーブルには、パーティションキーの値ごとに 10 GB のサイズ制限がある
- ベーステーブルと同様にLSIも、結果整合性のある読み込みを選択したクエリではRCUは1/2が消費される
- LSIの場合は、ベーステーブルにプロビジョニングされたWCUが消費される

## テーブル設計入門
DynamoDBでは上記のような前提条件のもと、**アプリケーションで発生しうるクエリから逆算してテーブルを設計していく**。

つまり、


- 1. 必要なテーブル(データ)を列挙する
- 2. アプリケーションで想定されるクエリを列挙する
- 3. 列挙したクエリから各テーブルのキー設計/インデックス設計を行う

という手順でテーブル設計を行う。

以下で、単純なバトルゲームを例にして様々な要件のテーブル設計を考えてみよう。

### 単一キーによるクエリのみ想定されるテーブル
ユーザーテーブルを考える。
マイページ情報やcurrentUser情報の取得など、ユニークなIdをキーとしてGetするクエリ要件のみが想定されるとすると、

- Hash Key
    - userId

- Sort Key
    - なし

- その他項目
    - level
    - ...
    - createdAt
    - updatedAt

Hash KeyであるuserIdで一意にレコードを特定できる。
userIdをプライマリーキーとして指定し、Get/Create/Update/Deleteを行う。

### 複合プライマリーキーでGetするクエリ要件のみ想定されるテーブル
バトルテーブルを考える。
userは一人の相手と一度しか戦わない想定で、対戦相手を指定してバトル結果をGetするクエリのみ想定されるとすると、

- Hash Key
    - userId

- Sort Key
    - opponentId

- その他項目
    - winner
    - ...
    - createdAt
    - updatedAt

Hash KeyであるuserIdとSort KeyであるopponentIdで一意にレコードを特定できる。
userIdとopponentIdを複合プライマリーキーとして指定し、Get/Create/Update/Deleteを行う。

### 3つ以上の項目を指定して一つのレコードを検索するクエリ要件が想定されるテーブル
バトルテーブルを考える。
userは一人の相手と1日に一度しか戦わない想定で、対戦相手と対戦日を指定してバトル結果をGetするクエリのみが想定されるとすると、

この場合、単純なHashKey & Sort Keyのみでは同時にクエリ条件にできるのは2つのカラムであるため、要件を満たせない。
Hash Keyで複数Itemを引いてきて、その結果をfilterする方法はあるが、一人のuserがたくさんの相手と対戦している場合、Read Capacity Unitが非常に大きくなってしまうため、できるだけ避けたい。

このような「それらのkeyでレコードがuniqueに指定できる３つ以上のキーでの検索」を行いたい場合は、**Composite Key (結合キー) パターン** というテーブル設計をする事で実現できる。

- Hash Key
    - userId

- Sort Key
    - opponentId#battleDate

- その他項目
    - winner
    - ...
    - createdAt
    - updatedAt

上記のようにSort KeyをopponentIdとbattleDateを結合した文字列とすることでuserId、opponentId、battleDateで一意にレコードを指定することができる。

ただし、もちろんopponentIdとbattleDateは独立したカラムではなくなってしまうという副作用もある。
(個人的にはこれが設計パターンの一つと言えるのか？という気持ち)

### 複数の項目を指定して複数のレコードを取得するクエリ要件が想定されるテーブル
バトルテーブルを考える。
userは一人の相手と1日に何度でも戦える想定で、対戦相手と対戦日を指定して、戦歴をGetするクエリのみが想定されるとすると、

この場合、「userId, opponentId, battleDateの3つを指定して複数のレコードを取得するクエリ」はHash KeyとSort Keyによる検索だけでは不可能。

Hash KeyであるuserIdで複数件のItemを引いてきて、その結果をopponentIdやbattleDateでfilterする事になる。
この場合、Hash Keyのみ または Hash KeyとSort Keyの両方で一意にレコードを指定できる必要があるため、以下のようにSort KeyをbattleUniqueIdとし、一意にレコードを特定できるようにする必要がある。

- Hash Key
    - userId

- Sort Key
    - battleUniqueId

- その他項目
    - opponentId
    - battleDate
    - winner
    - ...
    - createdAt
    - updatedAt

前述の通り、一人のuserがたくさんのバトルを行っている場合、Read Capacity Unitが非常に大きくなってしまうが、それを許容する必要がある。
このようなクエリが頻繁に走る場合には、Dynamo DBは向いていないため、RDBを利用するかElastic Searchなどにデータをindexingして検索機能を肩代わりしてもらう必要がある。

#### テーブル分割
- 小手先のテクニックとして、Hash Keyで複数件Item取得する場合のRead Capacity Unitが少なくなるため、シンプルにテーブル分割できるものは分割する
    - 今回の場合であれば、草原や雪原、市街地などのバトルフィールドごとにテーブルを分割する

- ただし、あるuserに対するバトルフィールドをまたいだバトル一覧の取得などは「それぞれのテーブルにクエリして取ってきたものをアプリケーション側でjoinする」ことになりやりづらくなるため、そのようなクエリがある場合は向かない


## まとめ： DynamoDBのテーブル設計における注意点
「uniqueにはなり得ない複数のキーで検索するクエリ」が頻繁に走る場合は、DynamoDBは向いていない。

そのようなケースは、RDBを利用するかElastic Searchなどにデータをindexingして検索機能を肩代わりしてもらう必要がある。


## 参考文献
- https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.CoreComponents.html
- https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/LegacyConditionalParameters.QueryFilter.html
- https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-general-nosql-design.html
- https://www.ragate.co.jp/blog/articles/3410
- https://speakerdeck.com/paypay/paypaydefalsedynamodbhuo-yong-shi-li-nituite?slide=26
- https://www.techcrowd.jp/dynamodb/query-2/
- https://qiita.com/yu-croco/items/66fd1a662dd64b8eb348
- https://qiita.com/inouet/items/bcf9467a65b27c362ecf#%E3%81%BE%E3%81%A8%E3%82%81%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF%E3%83%BC%E3%81%A8%E3%81%8B%E4%BD%BF%E3%81%A3%E3%81%9F%E3%82%8A%E3%82%A4%E3%83%B3%E3%83%87%E3%83%83%E3%82%AF%E3%82%B9%E3%81%AE%E3%81%AF%E3%82%8A%E6%96%B9%E3%82%92%E5%B7%A5%E5%A4%AB%E3%81%97%E3%82%88%E3%81%86
- https://qiita.com/nh321/items/9f11cff0afc1d31586c7