DynamoDBのテーブル・インデックス設計について考えてみた

# 概要
サーバレスアプリケーションで利用されることの多いAmazon DynamoDBについてまとめてみました。DynamoDBの利用を検討されている方やDynamoDBのテーブル・インデックス設計を考えられている方の参考になると幸いです。

# Amazon DynamoDB
## DynamoDBの特徴
- フルマネージド型の NoSQL データベースサービス
- データは3つの Availability Zone に同期的にレプリケートされるため可用性とデータ耐久性が高い
- 性能要件に応じて、テーブルごとに読み込み・書き込みキャパシティを設定可能。キャパシティの Auto Scalingやオンデマンドキャパシティにも対応
- ストレージの容量制限がない。使った分だけの従量課金制
    
## 基礎知識
![ss_20231220_205437.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379973/1db78748-9ac6-f2a6-210b-b341bc643de3.jpeg)
テーブルは各Itemを一意に識別できるPrimary Keyを持ちます。Primary Keyは、Partition KeyおよびSort Keyで構成されます。Partition Keyのみで各Itemを一意で識別できる場合はSort Keyは必須ではありません。Primary Key以外の項目は不揃いでも構いません。

### Primary Key
Primary Keyは、テーブル内の各Itemを一意に識別できる必要があります。DynamoDBは2種類のプライマリキーをサポートします。
- Partition Key(PK)
    - PKが重複する項目は登録できません。
- Partition Key(PK) & Sort Key(SK)
    - PKは重複しても構いません。
    - PKとSKがともに重複する項目は登録できません。

### Secondary Index
Primary Keyを用いたクエリに加えて、柔軟なクエリを実現するためにDynamoDBは2種類のSecondary Indexをサポートしています。

#### Local Secondary Index(LSI)
- SK以外に絞り込み検索を行うためのIndexです。
- Primary KeyのPKと同一ですが、SKが異なる属性による検索のために利用します。
- LSIはテーブル作成時にのみ作成可能です。

#### Global Secondary Index(GSI)
- Primary Keyの代わりとなる、PKをまたいだ検索を行うためのIndexです。
- GSIにより、Primary Keyに依存しない新たなPK・SKを持つことができます。
- テーブル作成後でも作成可能です。

### キャパシティユニットについて
- Read Capacity Unit (RCU)
    - 結果整合性：1ユニットにつき、最⼤4KBのデータを1秒に2回読み込み可能です。
    - 強い整合性：1ユニットにつき、最⼤4KBのデータを1秒に1回読み込み可能です。
- Write Capacity Unit (WCU)
    - 1ユニットにつき、最⼤1KBのデータを1秒に1回書き込み可能です。

# クエリパターン
具体例がないとわかりにくいと思うので、Primary Key(PK, SK)、LSI, GSIを用いたクエリパターンをご紹介します。

## DynamoDBのクエリにおける大前提
- PKとSKを用いてのみ検索が行なえます。その他の属性に対して検索をかけることはできません。
- PKは必ず検索条件に指定する必要があります。SKのみを検索条件にすることはできません。
- PKの検索条件は完全一致条件のみです。
- SKの検索条件は完全一致および部分一致による検索が可能です。

## シンプルな例（セカンダリインデックスを使用しない）
Primary Keyのみでクエリを行う最もシンプルな検索です。

#### ブログ記事テーブル （Primary Key：PKのみ）
|ArticleId(PK)|Category|AuthorId|Title|
|--------|---|---|---|
|A001|Tech|U001|Agents for Amazon BedrockがGAしました|
|A002|Tech|U002|DynamoDBもベクター検索に対応|
|A003|Food|U001|東京のラーメン有名店10選|

このデータから、「ArticleIdがA001である記事」を取得することを考えます。ArticleIdはPKなので、シンプルにArticleIdがA001である記事を取得できます。

#### ブログ記事 いいねテーブル（Primary Key：PKとSK）
|ArticleId(PK)|UserId(SK)|LikedAt|
|--------|---|---|
|A001|U001|2023-08-21T14:15:35.095+09:00|
|A001|U002|2023-08-27T19:02:01.598+09:00|
|A003|U001|2023-04-06T17:21:26.776+09:00|

このデータから、「ArticleIdがA001である記事」を取得することを考えます。ArticleIdはPKなので、シンプルにArticleIdがA001である記事を取得できます。ただし注意点として、取得するデータはArticleIdがA001であるすべての記事であるため上記の場合は2記事取得されます。ArticleId(PK)/UserId(SK)の両方を指定することで該当する1記事が取得されます。

### メリット
- 検索要件がPrimary Keyの一致のみで満たせる場合はシンプルな構成となる。
- 追加のインデックスを必要としない。

### デメリット
- Primary Key（PK,SK）を用いた検索しかできない。
- Scan + Filterで他属性に対しても検索できるがコストパフォーマンスが悪い

## ローカルセカンダリインデックス(LSI)を使用
#### ブログ記事 いいねテーブル（Primary Key：PKとSK）
|ArticleId(PK)|UserId(SK)|LikedAt|
|--------|---|---|
|A001|U001|2023-08-21T14:15:35.095+09:00|
|A001|U002|2023-08-27T19:02:01.598+09:00|
|A003|U001|2023-04-06T17:21:26.776+09:00|

このデータから、「ArticleIdがA001で、2023/08にいいねされた」データを取得することを考えます。仕様上から、セカンダリインデックスなしではLikedAtを検索条件にすることはできません。この問題は、LikedAtをローカルセカンダリインデックスにすることで解決できます。

|ArticleId(PK)|UserId(SK)|LikedAt(LSI-1-SK)|
|--------|---|---|
|A001|U001|2023-08-21T14:15:35.095+09:00|
|A001|U002|2023-08-27T19:02:01.598+09:00|
|A003|U001|2023-04-06T17:21:26.776+09:00|

この例だと、「ArticleIdがA001で、LSIに"2023-08"(部分一致)が含む記事」を検索できます。繰り返しになりますが、LSIはテーブル作成時にのみ作成可能なので注意です。

### メリット
- Primary KeyのSKに加えてLSI-SKを追加できクエリの柔軟性が向上する

### デメリット
- テーブル作成時にしか作成できない。
- LSIは１つのテーブルあたりに最大5個まで。

## グローバルセカンダリインデックス(GSI)を使用
### 基本的なGSI使用例
|ArticleId(PK)|Category|AuthorId|Title|
|--------|---|---|---|
|A001|Tech|U001|Agents for Amazon Bedrockが<br>GAしました|
|A002|Tech|U002|DynamoDBもベクター検索に対応|
|A003|Food|U001|東京のラーメン有名店10選|

このデータから、「AuthorIdがU001である記事」を取得したいとします。このままの状態では仕様上、AuthorIdをkeyとして検索することはできません。AuthorIdがU001である記事に対してArticleId(PK)は異なるので、LSIも使用することはできません。この問題は、AuthorIdをPKとするグローバルセカンダリインデックスを作成することで解決できます。

|ArticleId<br>(PK)|Category|AuthorId<br>(GSI-1-PK)|Title|
|--------|---|---|---|
|A001|Tech|U001|Agents for Amazon BedrockがGAしました|
|A002|Tech|U002|DynamoDBもベクター検索に対応|
|A003|Food|U001|東京のラーメン有名店10選|

これにより、GSI-1を用いてAuthorIdがU001である記事を取得できます。

また、GSIにはPKに加えてSKを設定することもできます。
|ArticleId(PK)|Category|AuthorId|Title|
|--------|---|---|---|
|A001|Tech|U001|Agents for Amazon BedrockがGAしました|
|A002|Tech|U002|DynamoDBもベクター検索に対応|
|A003|Food|U001|おすすめレストランがあります|

このデータから、「AuthorIdがU001であり、CategoryがTechである記事」を取得したいとします。このニーズは、「AuthorIdをPK、CategoryをSK」とするグローバルセカンダリインデックスを作成することで解決できます。

|ArticleId<br>(PK)|Category<br>(GSI-1-SK)|AuthorId<br>(GSI-1-PK)|Title|
|--------|---|---|---|
|A001|Tech|U001|Agents for Amazon BedrockがGAしました|
|A002|Tech|U002|DynamoDBもベクター検索に対応|
|A003|Food|U001|東京のラーメン有名店10選|

これにより、GSIを用いて「AuthorIdがU001であり、CategoryがTechである記事」を取得できます。

#### メリット
- Primary Key(PK,SK)に依存せず、新規でPK,SKを作成しそれを用いて検索できる
- GSIは、テーブル作成後に後から作成可能

#### デメリット
- GSIが増えることによって利用料および管理コストが増加。
- GSIは１つのテーブルあたりに最大20個まで。

### GSI OVERLOADING
前述したGSIを利用したテーブル・インデックス設計は、検索要件の増加に応じてGSIが増加するデメリットがあります。例えば、Category、AuthorIdなど各属性に対してそれぞれ検索したい場合は各属性ごとにGSIを作成する必要があります。この問題はGSI OVERLOADINGというスキーマレスなDynamoDBらしいテーブル設計パターンで解決することができます。
具体的には以下のdataValueのようにひとつのGSIに複数の検索要件をもたせることができます。
|ArticleId<br>(PK, GSI-1-SK)|DataType<br>(SK)|DataValue<br>(GSI-1-PK)|Title|
|--------|---|---|---|
|A001|categorty|Tech|
|A001|authorId|U001|
|A001|title||Agents for Amazon BedrockがGAしました|
|A002|categorty|Tech|
|A002|authorId|U002|
|A002|title||DynamoDBもベクター検索に対応|
|A003|categorty|Food|
|A003|authorId|U001|
|A003|title||東京のラーメン有名店10選|

### メリット
- 少ないGSIで複数検索要件を満たすことができ、コストやクエリを最適化できる

### デメリット
- テーブル構成が複雑になる

# まとめ
- サーバレスアプリケーションでは、コストやスケーラビリティの観点からDynamoDBが使いやすい
- DynamoDBは同時に2つ(PK, SK)のkeyのみを用いてクエリする必要があるため、Primary KeyやLSI/GSIの設計が鍵
- クエリに使用するKey, Index
    - Primary Key(SK, PK)
    - LSI
    - GSI, GSI OVERLOADING

よいサーバレスライフを！