今更 DynamoDB 入門

# あいさつ

[Serverless(2) Advent Calendar 2016](http://qiita.com/advent-calendar/2016/serverless2) 15日目の記事です。

こんにちは。GS2 CEO の丹羽(@kazutomo)です。
サーバレスアーキテクチャでゲームサーバを開発し、AWSのサービスのように1時間あたり数円〜利用できる [Game Server Services](https://gs2.io/) を提供しています。

このシステムのバックエンドDBの多くには DynamoDB を利用しており、サーバレスアーキテクチャと DynamoDB は切っても切り離せない関係にあると感じています。
これからサーバレスアーキテクチャを勉強しようと思っている皆さんの中には 「まだDynamoDBとかよくわかってないんだよなあ」 という人も多くいらっしゃるとおもいます。

この記事ではプログラムのコードを示して使い方を説明するというよりは、
DynamoDB とはどういうものか？どういうことに気をつけて使う必要があるか？といった情報を文字で説明したいとおもいます。

# DynamoDB とは

DynamoDB は AWS が提供するスケーラブルな KVS です。
簡単なクエリが投げられるなど、比較的柔軟性が高く、使いこなせば RDBMS の代わりにもなる強力な存在です。

# 料金体系

書き込みスループットと読み込みスループットを予約します。

> 書き込みスループット: $0.00742 :10 ユニットの書き込み容量あたり/1 時間
> （1 時間あたり最大 36,000 回の書き込みを実行するために十分な容量）*
> 読み込みスループット: $0.00742 : 50 ユニットの読み込み容量あたり/1 時間
> （1 時間あたり最大 180,000 回の強力な整合性のある読み込み、または最大 360,000 回の結果整合性のある読み込みを実行するために十分な容量）*

データストレージの利用料に応じても費用が発生します。

> First 25 GB stored per month is free
> $0.285 : 超過分、毎月 GB あたり

キャパシティはリザーブドキャパシティというプランが有り、1年/3年間のキャパシティを予約することで割安にサービスを利用することも出来ます。

# ユニット

料金のところで 書き込み・読み込みスループット の予約に **ユニット** という単位が出てきました。
ユニットとは何でしょうか。

> 書き込みに必要な容量のユニット数 = 1 秒あたりの項目書き込み回数 x 項目のサイズ (1 KB ブロック)
> 読み込みに必要な容量のユニット数* = 1 秒あたりの読み込み回数 x 項目のサイズ (4 KB ブロック)

というわけで、書き込みは1KB/秒あたり1ユニット。
読み込みは4KB/秒あたり1ユニット消費することになります。

現在のユニットの使用状況は API の応答にも含まれていますし、CloudWatch で確認することも出来ます。

# HASHキー と RANGEキー

テーブルを作成するときには HASHキー と RANGE キーを指定する必要があります。
HASHキーは要するに主キーとなるカラムです。RANGEキーは HASHキーが重複する場合の複合キーとして使うカラムです。
HASHキーのみ指定してテーブルを作成することも出来ます。

**ユーザデータベース**

| <font color="OrangeRed">ユーザID | 名前 | 登録日時 |
|------:|------|------|
| <font color="OrangeRed">1 | 太郎 | 2016-12-01 10:00 |
| <font color="OrangeRed">2 | 二郎 | 2016-12-05 12:00 |
| <font color="OrangeRed">3 | 花子 | 2016-12-16 09:00 |

このテーブルであれば **ユーザID** を HASHキー とするのが適切です。

**ログイン履歴**

| <font color="OrangeRed">ユーザID | <font color="OrangeRed">ログイン日時 | ブラウザ |
|------:|------|-----|
| <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-18 0:00 | Chrome |
| <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-20 5:00 | Safari |
| <font color="OrangeRed">2 | <font color="OrangeRed">2016-12-21 8:00 | Chrome |
| <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-24 10:00 | Chrome |
| <font color="OrangeRed">3 | <font color="OrangeRed">2016-12-26 12:00 | Edge |
| <font color="OrangeRed">2 | <font color="OrangeRed">2016-12-29 14:00 | Firefox |

このデータであれば、 **ユーザID** を　HASHキー に。 **ログイン日時** に RANGEキー を指定するのが適切です。

## RANGEキーでの絞り込み

RANGEキー は名前の通り、値の範囲での絞り込みが行えます。
この例で言えば、ユーザID 1 のユーザの検索履歴を検索するだけでなく、
ユーザID 1 のユーザが 2016-12-20 00:00 〜 2016-12-31 23:59 までにログインした履歴を取得することが出来ます。

**取得結果**

| ユーザID | ログイン日時 | ブラウザ |
|------:|------|-----|
| 1 | 2016-12-20 5:00 | Safari |
| 1 | 2016-12-24 10:00 | Chrome |

## フィルタによる絞り込み

キーを設定していないフィールドで絞り込みたい場合はどうすればいいでしょうか。
この例であれば Chrome からのログイン履歴のみ抽出したい場合です。
そういう場合は フィルタを利用できます。

**取得結果**

| ユーザID | ログイン日時 | ブラウザ |
|------:|------|-----|
| 1 | 2016-12-18 0:00 | Chrome |
| 2 | 2016-12-21 8:00 | Chrome |
| 1 | 2016-12-24 10:00 | Chrome |

### RANGEキーによる絞り込み と フィルタによる絞り込み の違い

一見すると、RANGEキーによる絞り込みとフィルタによる絞り込みに違いがないように見えます。
だったら、インデックスを指定しなくていいフィルタのほうが楽なのでは？と思うかもしれません。

しかし、フィルタという名前から察することができるように、こいつはなかなか曲者です。
というのも、フィルタはあくまで取得したデータをフィルタリングして応答しているに過ぎないためです。

課金体系を思い出してください。

> 読み込みに必要な容量のユニット数* = 1 秒あたりの読み込み回数 x 項目のサイズ (4 KB ブロック)

今回の場合 RANGEキーの場合は本当に必要な行しかデータを読み込んでいません。
つまり以下の*印の付いた行に対する読み込みユニットが必要となります。

|  | ユーザID | ログイン日時 | ブラウザ |
|:---:|------:|------|-----|
|  | 1 | 2016-12-18 0:00 | Chrome |
| <font color="OrangeRed">* | <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-20 5:00 | <font color="OrangeRed">Safari |
|  | 2 | 2016-12-21 8:00 | Chrome |
| <font color="OrangeRed">* | <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-24 10:00 | <font color="OrangeRed">Chrome |
|  | 3 | 2016-12-26 12:00 | Edge |
|  | 2 | 2016-12-29 14:00 | Firefox |

フィルタの場合はどうでしょうか。
インデックスが張られていないカラムに対するフィルタの場合全行をスキャンし、必要なデータのみ抽出します。
つまり、以下の*印の付いた行に対する読み込みユニットが必要となります。

|  | ユーザID | ログイン日時 | ブラウザ |
|:---:|------:|------|-----|
| <font color="OrangeRed">* | <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-18 0:00 | <font color="OrangeRed">Chrome |
| <font color="OrangeRed">* | <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-20 5:00 | <font color="OrangeRed">Safari |
| <font color="OrangeRed">* | <font color="OrangeRed">2 | <font color="OrangeRed">2016-12-21 8:00 | <font color="OrangeRed">Chrome |
| <font color="OrangeRed">* | <font color="OrangeRed">1 | <font color="OrangeRed">2016-12-24 10:00 | <font color="OrangeRed">Chrome |
| <font color="OrangeRed">* | <font color="OrangeRed">3 | <font color="OrangeRed">2016-12-26 12:00 | <font color="OrangeRed">Edge |
| <font color="OrangeRed">* | <font color="OrangeRed">2 | <font color="OrangeRed">2016-12-29 14:00 | <font color="OrangeRed">Firefox |

なんと、裏ではフルスキャンをしてしまっています。
アウトプットにのみ注目しているとユニットの消費量が爆発してしまうことがご理解いただけたでしょうか。

# グローバルセカンダリインデックス

どうしても ブラウザでも絞り込む必要があって、効率よく読み込みたい！ という場合があるとおもいます。
そういうときに活用できるのが グローバルセカンダリインデックス(GSI)です。

グローバルセカンダリインデックスは キー構成の違うレプリカを作成することで実現されます。
たとえば、 **ブラウザ** を HASHキー **ログイン日時** にRANGEキーに指定することで。

|  | <font color="OrangeRed">ブラウザ | <font color="OrangeRed">ログイン日時 | ユーザID |
|:---:|-----|------|------:|
| <font color="OrangeRed">* | <font color="OrangeRed">Chrome | <font color="OrangeRed">2016-12-18 0:00 | <font color="OrangeRed">1 |
|  | Safari | 2016-12-20 5:00 | 1 |
| <font color="OrangeRed">* | <font color="OrangeRed">Chrome | <font color="OrangeRed">2016-12-21 8:00 | <font color="OrangeRed">2 |
| <font color="OrangeRed">* | <font color="OrangeRed">Chrome | <font color="OrangeRed">2016-12-24 10:00 | <font color="OrangeRed">1 |
|  | Edge | 2016-12-26 12:00 | 3 |
|  | Firefox | 2016-12-29 14:00 | 2 |

*印のついたカラムのみ読み込んで目的のデータを抽出できるようになりました。

## セカンダリインデックステーブルとプライマリテーブルのデータのずれ

しかし、気をつけなければならない点がもう一点あります。ユーザ単位であればログイン時間が重複しないだろう。という想定でテーブルを設計していましたが
複数ユーザのログイン日時が重複することは起こりえます。するとどうなるでしょうか？

**プライマリテーブル**

| ユーザID | ログイン日時 | ブラウザ |
|------:|------|-----|
| 1 | 2016-12-18 0:00 | Safari |
| 1 | <font color="OrangeRed">2016-12-20 5:00 | Chrome |
| 2 | <font color="OrangeRed">2016-12-20 5:00 | Chrome |
| 1 | 2016-12-24 10:00 | Chrome |
| 3 | 2016-12-26 12:00 | Edge |
| 2 | 2016-12-29 14:00 | Firefox |

**セカンダリインデックステーブル**

| ブラウザ | ログイン日時 | ユーザID |
|------:|------|-----|
| Safari | 2016-12-18 0:00 | 1 |
| <font color="OrangeRed">Chrome | <font color="OrangeRed">2016-12-20 5:00 | <font color="OrangeRed">2 |
| Chrome | 2016-12-24 10:00 | 1 |
| Edge | 2016-12-26 12:00 | 3 |
| Firefox | 2016-12-29 14:00 | 2 |

複合キーが重複しているのでセカンダリインデックステーブルから ユーザID 1 のログイン情報がなくなってしまいました。
こういうときは、力技で対応する必要があります。

| ユーザID | ログイン日時 | ブラウザ | <font color="OrangeRed">力技カラム |
|------:|------|-----|-----|
| 1 | 2016-12-18 0:00 | Safari | <font color="OrangeRed">1-2016-12-18 0:00 |
| 1 | 2016-12-20 5:00 | Chrome | <font color="OrangeRed">1-2016-12-20 5:00 |
| 2 | 2016-12-20 5:00 | Chrome | <font color="OrangeRed">2-2016-12-20 5:00 | 
| 1 | 2016-12-24 10:00 | Chrome | <font color="OrangeRed">1-2016-12-24 10:00 |
| 3 | 2016-12-26 12:00 | Edge | <font color="OrangeRed">3-2016-12-26 12:00 |
| 2 | 2016-12-29 14:00 | Firefox | <font color="OrangeRed">2-2016-12-29 14:00 | 

こうして、 ユーザID と ログイン日時を結合した <font color="OrangeRed">**力技カラム**</font> を追加します。
**ブラウザ** をHASHキーに **力技カラム** をRANGEキーにします。

**セカンダリインデックステーブル**

| ブラウザ | 力技カラム | ログイン日時 | ユーザID |
|------:|------|-----|-----|
| Safari | 1-2016-12-18 0:00 | 2016-12-18 0:00 | 1 |
| <font color="OrangeRed">Chrome | <font color="OrangeRed">1-2016-12-20 5:00 | <font color="OrangeRed">2016-12-20 5:00 | <font color="OrangeRed">1 |
| <font color="OrangeRed">Chrome | <font color="OrangeRed">2-2016-12-20 5:00 | <font color="OrangeRed">2016-12-20 5:00 | <font color="OrangeRed">2 |
| Chrome | 1-2016-12-24 10:00 | 2016-12-24 10:00 | 1 |
| Edge | 3-2016-12-26 12:00 | 2016-12-26 12:00 | 3 |
| Firefox | 2-2016-12-29 14:00 | 2016-12-29 14:00 | 2 |

DynamoDB では複数のカラムの値を対象に検索するような事ができませんので、
カラムの値を結合したカラムを用意することは日常的に行うことになります。

## グローバルセカンダリインデックスのキャパシティ予約

グローバルセカンダリインデックス はインデックス違いの別テーブルを作るということで、別途キャパシティを買う必要があります。
基本的に書き込みユニットは プライマリテーブル と同じ値を設定することになります。
読み込みユニットは インデックスを使ってどれだけ検索されるのかによって決めればいいでしょう。

# ローカルセカンダリインデックス

DynamoDB にはローカルセカンダリインデックスという機能があります。

**ログイン履歴**

| ユーザID | ログイン日時 | ブラウザ |
|------:|------|-----|
| 1 | 2016-12-18 0:00 | Chrome |
| 1 | 2016-12-20 5:00 | Safari |
| 2 | 2016-12-21 8:00 | Chrome |
| 1 | 2016-12-24 10:00 | Chrome |
| 3 | 2016-12-26 12:00 | Edge |
| 2 | 2016-12-29 14:00 | Firefox |

この例で、先程は RANGE キーに ログイン時間を指定しました。
しかし、特定のユーザが Chrome でログインした結果を取り出したい場合はどうすればいいでしょうか。
これまでの説明に従えば、**ユーザID** で検索をして、 **ブラウザ** でフィルタをすることになるでしょう。

しかし、その場合は以下の行が読み込み対象になります。

|  | ユーザID | ログイン日時 | ブラウザ |
|:---:|------:|------|-----|
| * | 1 | 2016-12-18 0:00 | Chrome |
| * | 1 | 2016-12-20 5:00 | Safari |
|  | 2 | 2016-12-21 8:00 | Chrome |
| * | 1 | 2016-12-24 10:00 | Chrome |
|  | 3 | 2016-12-26 12:00 | Edge |
|  | 2 | 2016-12-29 14:00 | Firefox |

そこで登場するのがローカルセカンダリインデックスです。
ローカルセカンダリインデックスは HASHキーが共通な場合に定義できるインデックスで、最大5個まで定義できます。
**ユーザID** を HASHキーに **ログイン時間** を RANGEキーに指定したテーブルに
**ユーザID** を HASHキーに **ブラウザ** を RANGEキーに指定したローカルセカンダリインデックスを作成します。

|  | ユーザID | ログイン日時 | ブラウザ |
|:---:|------:|------|-----|
| * | 1 | 2016-12-18 0:00 | Chrome |
|  | 1 | 2016-12-20 5:00 | Safari |
|  | 2 | 2016-12-21 8:00 | Chrome |
| * | 1 | 2016-12-24 10:00 | Chrome |
|  | 3 | 2016-12-26 12:00 | Edge |
|  | 2 | 2016-12-29 14:00 | Firefox |

すると、上記行に対するスキャンで、ユーザID 1 のユーザが Chrome を使ってログインした履歴を取得できます。

**ただし、グローバルセカンダリインデックスは後から追加・削除できますが、ローカルセカンダリインデックスはテーブルの作成時にしか作成できません。**

# 一貫性のある読み込み と 結果整合な読み込み

DynamoDB のデータにアクセスする場合 一貫性のある読み込み と 結果整合な読み込み が選択できます。
一貫性のある読み込み とは 直近の更新内容が絶対に反映された読み込みです。
結果整合な読み込み は直近の更新内容が反映されていないかもしれない読み込みです。
一貫性のある読み込みは 結果整合な読み込みとくらべて2倍のユニットを消費します。

その他、グローバルセカンダリインデックスに対しては一貫性のある読み込みが出来ないなど制約があります。

# 加算更新

DynamoDB はデータの更新時に加算更新が出来ます。
たとえば、更新回数カラムがあったとすれば、更新時にその値に ADD: 1 というパラメータで更新するとで、カラムの値を加算することが出来ます。

# 条件付き更新

DynamoDB はデータの更新時に条件付き更新ができます。
たとえば、ショッピングのシステムで在庫を ADD: -1 して購入する処理があるとします。
ここで同時に注文が入ってうっかり、在庫以上の販売トランザクションができてしまったら目が当てられません。

そこで、DynamoDB の更新時 ADD: -1 するんだけど、在庫の数が 0 を下回りそうならエラーとする。という条件をつけて更新することが出来ます。

# スロットリング

キャパシティを超えて読み書きをしようとするとどうなるでしょうか？
実はしばらくはバーストします。しかし、およそ5分でキャパシティ内に収まるようにAPIが失敗するようになります。
キャパシティは多めに確保して、なるべく超えないようにするべきでしょう。

# ホットキー

**DynamoDB は読み込みキャパシティを購入して読み込むことになりますが、かならずキャパシティを使い切れる保証はありません。**

それはなぜか？ DynamoDB は HASHキーを利用して複数のサーバにデータを分散して保管します。
データが少ないうちは1つのサーバに全てのデータが保存されているので、キャパシティをキレイに使い切れるかもしれませんが
データが増えてきて、10台のサーバに分散してデータを保管するとどうなるでしょうか？

DynamoDB は 10台のサーバにそれぞれ読み込みキャパシティを均等に割り当てます
予約した読み込みキャパシティが1000だったとしたら 各サーバに100ずつ割り当てる。ということです。
すると、特定のサーバにアクセスが集中すると、予約したキャパシティの 1/10 しか使っていないのにスロットリングが発生することになります。

**特定のサーバにアクセスを集中させないためには、特定の HASHキー に対する読み込みに偏る(ホットキー)を避ける必要があります。**

# DynamoDB ストリーム

DynamoDB にはもう一つ特徴的な機能として **DynamoDB ストリーム** という機能があります。
これは DynamoDB に対する書き込み処理をイベントとして AWS Lambda を起動できる機能です。

これを使うことで **ユーザデータベース** からユーザが削除されたときに
**ログイン履歴** から削除されたユーザの行を削除する。というような処理を行うことが出来ます。

## 応用例 1

応用例として、行の UPDATE の場合は変更前の値と変更後の値が取得できますので、

**サービス監視**

| 監視対象URL | 状態 |
|------|------|
| http://example.com/ | UP |
| http://github.com/ | UP |

のようなテーブルがあったとして、5分毎に監視対象URLにアクセスして問題がなければ 状態をUP として更新、問題があればDOWNで更新。としたとします。
このテーブルを DynamoDB ストリーム の対象とすると 5分毎に行の更新イベントが Lambda に届くことになります。
すると、Lambda では更新前の状態と更新後の状態に変化があったときに SNS に通知する。という簡単なロジックで状態通知が実装できます。

## 応用例 2

あらゆる更新をイベントとして受け取れますので、これを使ってクロスリージョンレプリケーションを実現できます。
これで、日本が沈没しても安心ですね！ もちろん、キャパシティは二倍必要です。