DynamoDBと仲良くなる方法

# はじめに
この記事は [Recruit Engineers Advent Calendar](https://adventar.org/calendars/3063) 2018の23日目です。

AWS Lambdaを中心としたサーバレスアーキテクチャも割と普及してきた世の中ですが、それに付随してDynamoDBもよく使われるようになってきたのかな、と最近感じています。

上記のようなアーキテクチャの中でDynamoDBが選定される理由の一つとしてLambdaとRDSの相性の悪さ（これに関しては至る所で語り尽くされていると思うのでここでは割愛します）、つまり本当はRDBMSを使いたいが消極的な理由で(仕方なく)DynamoDBを選択し運用しているケースをよく見かけます。

こういった方々がRDBMSのようにDynamoDBを扱おうとした結果、思ったように扱えず困り果てるというケースを何回か見たことがあるので、（そんな人たちがどれくらいいるのかわかりませんが）今回は自分がDynamoDBを扱うときに気をつけていることをまとめてみることにしました。

なので今現時点で問題なく普通に使えている人には正直あまり学びのない記事になってしまったかもしれません。

# DynamoDBとは
え、そっからかよ、って感じかもしれませんがおさらいです。
DynamoDBはスキーマレスなNoSQLです。（ドキュメント指向でもKVSっぽくでも使えます）
重要なのはRDBMSではない、という点です。

詳細は[ドキュメント](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/Introduction.html)を見ると良いです。

# 仲良くなる方法
個人的にDynamoDBを使うときに意識している点を書き出してみました。

## RDBMSの代替品として考えない
当たり前ですがこれが一番大事です。
違うものだということを認識してドキュメントをしっかり読み、特徴をちゃんと把握することが重要です。


## 必要に応じて非正規化を行う
DynamoDBではテーブルの結合はサポートされてません。テーブル同士のリレーションを定義することがサポートされていないNoSQLであるDynamoDBにおいて、正規化を行うことは必ずしも正解ではありません。

この点ではAWSの[公式のブログ](https://aws.typepad.com/sajp/2016/12/should-your-dynamodb-table-be-normalized-or-denormalized.html)でも基本的には非正規化して設計していくことをガイドラインとして打ち出しています。

例えばQiitaを例に出すと、もしかしたら記事についているタグ情報はArrayで記事テーブルに持ってしまってもいいかもしれません。（もちろん、タグ名の変更で全てのタグの名前が一斉に変わる必要がある、などの同期・生合成の要求がないことが前提です。）
記事取得の際には複数のテーブルにアクセスすることなく、記事のIDで引いてこれますし、登録の際にもタグ情報もアトミックに保存することが可能です。
(待望の[トランザクション](https://aws.amazon.com/jp/blogs/news/new-amazon-dynamodb-transactions/)がサポートされたのでテーブルが別れていても原子性を担保することはできるようになったと思ってはいますが、まだ自分ががっつり検証したわけではないのでここではトランザクションに関しては言及しません。)

ちなみに[AWSの公式のベストプラクティス](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-general-nosql-design.html#bp-general-nosql-design-vs-relational)には `DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。` と書かれています。後者は理想論だとは思いますが、なるべくテーブル数を少なく保つというのは意識した方が良いです。

ただ1点、1レコードの最大サイズが400KBとそこまで大きいわけではないので、定常的に増え続けるデータを配列で持ったり、何でもかんでも非正規化して横持ちしていくのは控えた方がよいと思います。

## 結果整合を受け入れる
DynamoDBは高可用性を担保するために3つのデータセンターで常にデータをレプリケーションしています。
その結果基本的にDynamoDBの操作は結果整合になってしまうことを意識する必要があります。

データの書き込みを例に挙げると、例えば一つのレコードを追加しようとするとDynamoDB的には3回書き込みをすることになります。
しかし全部を待っているとレスポンスに影響が出るのでDynamoDBは2箇所の書き込みを終えた時点でAckを返します。
つまり最後の1箇所は **まだ正しいデータが書き込まれているかわからないが、いずれ正しく書き込まれる** 状態になってしまい、これを結果整合と言います。(DynamoDBのドキュメントには「通常は 1 秒以内」とあります)

上の部分で **基本的に** 結果整合になる、といったのは読み込み時に強整合性を担保して読み込むことができる[ConsistentReadというオプション](https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.ReadConsistency.html)が用意されているからです。

ただ、だからといって全ての読み込みに`ConsistentRead`をつけるようなことはオススメしません。
強い整合性を持った読み込みはキャパシティユニットを2倍消費するので、強整合性が必要なケースにのみつけることをオススメします。
実は作っているアプリケーションで常に最新の情報が読み込まれないといけない箇所は多くないかもしれません。（もちろん、アプリケーションの特性によりますが。）

## キャパシティユニットを(大体)理解する
DynamoDBを使い始めるとRCU(読み込みキャパシティユニット)とWCU（書き込みキャパシティユニット）を設定するところで、なんだこれ？ってなると思います。
正直キャパシティユニットに関しては僕も完全に理解しているわけではないですが、書き込みと読み込みそれぞれのキャパシティ要件を定義するとDynamoDB側でそれに耐えられるために準備をしてくれる、くらいに思っておけば良いです。

なぜこいつを大体理解しておいた方がいいのかというと、アプリの性能面で問題が出たときに大体こいつらがボトルネックになってることが多いからです。逆にいうとキャパシティユニットを上げさえしてしまえばDynamoDBのスループットの問題は解決することがほとんど(もちろん解決しないケースもあります)なので素晴らしい仕組みとも言えます。

一点**　急激にキャパシティユニットを上げて下げると性能劣化が起こるケースがある**のでそれは注意が必要です。
「定義されたキャパシティに耐えられる準備」と書きましたがあれは実は中でパーティション分割を行っており、キャパシティユニットが増えた段階でパーティションを増やし、減らす際にパーティション数は落ちずにそれぞれのパーティションにあてがわれるキャパシティユニットだけが減っていくという仕組みになっているかららしいです。

と、ここまで説明したものの先日のre:inventで[DynamoDB On-Demand](https://aws.amazon.com/jp/blogs/aws/amazon-dynamodb-on-demand-no-capacity-planning-and-pay-per-request-pricing/)の発表がありましたので今後はあまり考える必要がないかもしれません。このアップデートはRCU, WCUの事前設定をせずに消費した分のみ請求される、といった夢のような内容です。まだがっつり検証したわけではありませんが、今の所 `DynamoDB On-Demand` にワークロード的に問題なさそうなので今後これが主流になっていくんじゃないかな、と個人的には思ってます。（急激なスパイクなどは未検証）

## インデックスを理解する
DynamoDBにはGSI（グローバルセカンダリインデックス）とLSI（ローカルセカンダリインデックス）の2種類のインデックスがあります。
ここではそれらの詳細を説明するというより(ドキュメントを参照した方が早いです)はそもそもDynamoDBのインデックスがどのように作られているかを説明したいと思います。

誤解を恐れずいうとDynamoDBでインデックスを作ることは、ほぼテーブルを作ってることと同一視してよいです。例えば以下のようなPrimaryにNameを持ったテーブルとレコードがあるとします。

この時点ではDynamoDBではNameでしか検索できません。

| Name | Color | Size|
|:-----:|:------------------:|:------------------:|
| Apple             |              red |        medium        |
| Orange           |            orange |       small       |
| Banana             |              yellow |        big        |

ではColorで検索するためにColorをHashkeyとするGSIを作って上げるとします。

そうすると内部的には以下のようなテーブル(のようなインデックス)が作られます。これによってColorで検索できるようになってるだけです。
実際にはインデックスを作成する際にどのカラムを射影するか選べるのでそこで削っていけば以下のように全てのカラムにはならないのですが、検索で使ったりするユースケースでは結局ほぼ全てのカラムが欲しくて全て射影してしまうケースが多いと思います。

| Color | Name | Size|
|:-----:|:------------------:|:------------------:|
| red    |  Apple | medium        |
| orange | Orange | small       |
| banana | Banana | big        |

つまりインデックスをドカドカ作っていくとその分n倍にデータが溜まっていきます。当然そのテーブルのI/Oのコストも上がります。
インデックスは使っていくことにはなると思いますが、射影するカラムをきちんと選択しスリムなインデックスを目指し、（これはRDBでもそうですが）不要なインデックスは作らないようにしましょう。

ちなみに、上で取り上げた、強い整合性を持った読み込み`ConsistentRead`ですが、GSIに対してはサポートされていません。**GSIは必ず結果整合になる**、という点は注意が必要です。

## 検索は苦手だということを把握しておく
DynamoDBでも検索はできます。が柔軟な検索は苦手だということを理解しておく必要があります。

例えば以下のような生徒ごとの宿題を管理するためテーブルを作りたいとします。
メインのユースケースとして生徒ごとの宿題の一覧を取得する、というものがあるのでプライマリキーとしてパーテーションキーに`Student`、ソートキーに`CreatedAt`を持たせるとします。
これにより `Query` を使って各生徒ごとの宿題を宿題を出した日で検索やソートをして取得することができます。

| Student | CreatedAt | Subject | Status| DueDate |
|:-------:|:---------:|:-------:|:-----:|:-------:|
| John    | 1545831410|  math   | done  | 20200101|
| John    | 1545831420| English | undone| 20200101|
| Smith   | 1545831430| English | done  | 20200101|

ただ実際のユースケースでは教科ごとの宿題の状態を見たい、などの要件がでてくることは容易に想像できます。
これは先ほども説明したようにインデックスを使うことで解決できます。ここでは`Subject`をパーテーションキーとしたグローバルセカンダリインデックスを作る(かつ`DueDate`をソートキーにしたり。その辺は要求によってお好みで)ことになるでしょう。

さあ次は「来週の月曜日に提出予定の理科の宿題をやってる生徒、やってない生徒を閲覧したい」というような要求を考えて見ます。
つまり「特定の`DueDate`、かつ特定の`Subject`の宿題を `Status` で検索したい」という要求になります。
DynamoDBはパーテーションキーでパーテーションを特定し、ソートキーで検索・ソートする、という動作が基本なので`Query`の条件式に上記の３つの条件を入れることは不可能です。

もちろん実現できないわけではありません。DynamoDBの`Query`では通常の条件式（パーテーションキーとソートキーしか指定できない）とは別に `FilterExpression`というものに条件を指定することができます。なので先ほどの例でいうと`Subject`をパーテーションキー、`DueDate`をソートキーのグローバルセカンダリインデックスから、`FilterExpression`で特定のステータスを指定してクエリを流れば期待する値は返ってきます。
しかし、`FilterExpression`というのはあくまでも取得結果を `Filter` しているにすぎず、 `Subject`と`DueDate`検索をかけた値をDynamoDBから取得し、その後で特定のステータスのレコードのみを返却しています。
つまりいくら`FilterExpression`をつけてもアクセスするレコード量は減るわけではなく、この挙動を知らずに`FilterExpression`に頼りっぱなしになると思わぬ性能懸念が生まれます。

さらに厄介なのが上記の通り一度データベースアクセスしてから `Filter` しているだけなので`Limit`が必ず期待通りの動きをするかわからなくなってしまいます。仮に`Limit=3`で「教科が数学、提出日が明日でまだ未対応の生徒を検索する」場合、まずDynamoDBは「教科が数学、提出日が明日」で検索をかけ(Queryの条件式)、そして`Limit=3`ここにかかります。その後取得してきた3件に対して初めて`FilterExpression`でステータスのフィルタを行うので例えば、その3件が`done`ステータスだった場合は、本当は`undone`ステータスのものがデータベースには存在していても返却は0件になってしまいます。

上記のような観点から個人的にはこのようなケースはコンポジットキーを作ってしまうのがよいと思っております。
つまり以下の `SubjectDueDate`のような`Subject`と`DueDate`をくっつけたようなものを作ってしまい、それをパーテーションキー、ソートキーには`Status`のグローバルセカンダリインデックスを作る、ということです。これにより普通にパーテーションキー+ソートキーの条件式のみで検索することが可能です。

| Student | CreatedAt | Subject | Status| DueDate | SubjectDueDate |
|:-------:|:---------:|:-------:|:-----:|:-------:|:--------------:|
| John    | 1545831410|  math   | done  | 20200101|math-20200101   |
| John    | 1545831420| English | undone| 20200101|English-20200101|
| Smith   | 1545831430| English | done  | 20200101|English-20200101|

ただ実際の業務では、これ以上に複雑な要求は容易に考えられます。基本的にはうまく非正規化したり、GSI、LSIの活用で大多数の検索要求には答えられるとは思っておりますが、やはり検索がそれほど得意でないことに変わりはないので個人的には検索要求が肥大化したタイミングで`Elasticsearch`などの検索エンジンに検索の責務を持たせてしまった方がよいと思っています。(`DynamoDB`のドキュメント指向なデータの持ち方だと`Elasticsearch`への連携も楽ですし)

特にLike検索っぽいことをやりたくなったら検索エンジンの導入の検討タイミングだと思っています。([転置インデックスを使うという選択肢](https://dev.classmethod.jp/cloud/aws/dynamodb-inverted-index/)もあるようです。)

ちなみにパーテーションキーとソートキーという言葉を使ってきましたが、これらは昔のハッシュキー、レンジキー、と呼ばれていたものです。CFnの設定などはいまだに`HASH`と`RANGE`なので混乱しがちですが、この記事ではパーテーションキーとソートキーを使います。

# 最後に
まだまだ書ききれないのですがこれ以上はTips集みたいになりそうなのでやめておきます。
割と書いて見たら当たり前のことをつらつら書いてるだけになってしまったので、どれだけの方に役にたつかはわかりませんが、少しでも役に立てれば幸いです。