DynamoDB(NoSQL)の設計 〜より多くの検索条件で検索するためには〜

# 概要
ここ数年、AWSのNoSQLの1つのサービスであるDynamoDBを利用する上で設計の知見が溜まったので書いていこうと思います。NoSQLは最初の設計をミスると後から回収するのが大変です。なので慎重に設計する必要があると感じました。
それと、DynamoDBはkey-valueのNoSQLなので検索やインデックスの貼り方がとても難しいとも感じました。SQLしか設計したことがなかったので初めは苦戦しました。
でも、なぜそこまでしてDynamoDB(NoSQL)を使いたいのかは、「[DynamoDBを使いこなして精神的安定を手に入れた](https://qiita.com/walkers/items/9b39d752cc5b8df7b4b5)」こちらの記事を読んでいただければと思います。また、基本的な用語などがわからないときも読んでみてください。

では、検索にどのような癖があるのかを説明し、どの様に設計すれば回避できるのかを説明します。設計に良い影響を与えられると思うので、ぜひ読んでみてください。

# 必要な知識のおさらい
DynamoDBには、ローカルセカンダリインデックス、グローバルセカンダリインデックスというインデックスが存在します。以下のAWSドキュメントに詳しいことが書いてあるので、詳細は説明しませんがドキュメントにも書いてあるように __一般的にはグローバルセカンダリインデックスを使用します__ 。なので本記事では、グローバルセカンダリインデックスを利用する前提の話をします。
https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/bp-indexes-general.html

# 課題: 検索条件はパーティションキーとソートキーの2つでしか絞り込めない
SQLであればWhere句で3つ以上の条件を指定し検索することが容易にできますが、DynamoDBではそうはいきません。DynamoDBでは、グローバルセカンダリインデックスを使った場合、パーティションキーとソートキーの2つでしか検索条件を設定できません。
以下のようなQiitaの記事のようなデータセットが合ったとします。

__items__

| id    | userId | title        | body      | status | createdAt        |
|-------|--------|--------------|-----------|--------|------------------|
| 4b1b1 | 5db97  | 〇〇について | 〇〇は... | public | 2020-02-01T00:00 |
| 0fc32 | e5763  | xxxxについて | xxxxは... | draft  | 2020-02-09T00:00 |
| ac269 | 5db97  | yyyyについて | yyyyは... | draft  | 2020-03-15T00:00 |
| 54b34 | 5db97  | zzzzについて | zzzzは... | public | 2020-03-17T00:00 |
| 9d582 | 5db97  | aaaaについて | aaaaは... | public | 2020-04-04T00:00 |

userIdが `5db97` のユーザがQiitaにログインし、そして自分の記事の一覧を表示するページを開いたとします。その場合、userIdが `5db97` である記事で作成日の降順で表示する必要があるので、パーティションキーがuserIdのソートキーがcreatedAtのグローバルセカンダリインデックスを使用してデータを取得する必要があります（パーティションキーは `=` でしか絞り込みができない）。そして、そのグローバルセカンダリインデックスを利用してデータを取得すると以下のようにデータを取得できます。
※この場合ソートキーはソート順のみで利用しているので実質検索条件はuserIdの1つという考えになります

__items__

| id    | userId | title        | body      | status | createdAt        |
|-------|--------|--------------|-----------|--------|------------------|
| 9d582 | 5db97  | aaaaについて | aaaaは... | public | 2020-04-04T00:00 |
| 54b34 | 5db97  | zzzzについて | zzzzは... | public | 2020-03-17T00:00 |
| ac269 | 5db97  | yyyyについて | yyyyは... | draft  | 2020-03-15T00:00 |
| 4b1b1 | 5db97  | 〇〇について | 〇〇は... | public | 2020-02-01T00:00 |

いい感じに取得できましたね。ただ上記の結果には下書きも含まれてしまっています。Qiitaの自分の記事の一覧を表示するページには下書きは出てきません。なので、statusがpublicな記事でも絞り込まなければなりません。
DynamoDBにはFilterという式を追加することで更に条件を指定して取得の結果を絞り込むことができます。しかし、このFilterはクエリ実行後の結果に対して実行されるので、例えばLimitを3にして3件取得してFilterでstatusをpublicで絞り込むと、2件のpublic記事しか取得されないという結果になります。SQLであれば3件のpublic記事が取得されるのですが。もちろんこのような絞り込みで問題ないケースであればFilterを使った絞り込みでもいいと思います。

## 解決: begins_withを利用しより多くの検索条件で絞り込む
Filterではなくインデックスで絞り込みたいと言ったときに使えるのが、 `begins_with` 関数です。これはソートキーに使える `=` などの比較演算子と同様に絞り込みで使える関数の1つです。この関数を使ってどの様に絞り込みを実現するかを説明します。
先程のテーブルに、 `status-createdAt` 属性(カラム)を追加します。これは、 statusとcreatedAt属性を結合した値を保持します。そして、パーティションキーがuserIdのソートキーがstatus-createdAtのグローバルセカンダリインデックスを追加します。

__items__

| id    | userId | title        | body      | status | createdAt        | status-createdAt        |
|-------|--------|--------------|-----------|--------|------------------|------------------------|
| 9d582 | 5db97  | aaaaについて | aaaaは... | public | 2020-04-04T00:00 | public#2020-04-04T00:00 |
| 54b34 | 5db97  | zzzzについて | zzzzは... | public | 2020-03-17T00:00 | public#2020-03-17T00:00 |
| ac269 | 5db97  | yyyyについて | yyyyは... | draft  | 2020-03-15T00:00 | draft#2020-03-15T00:00 |
| 4b1b1 | 5db97  | 〇〇について | 〇〇は... | public | 2020-02-01T00:00 | public#2020-02-01T00:00 |

そのグローバルセカンダリインデックスを利用して検索するときにパーティションキーを `:userId = '5db97'` 、ソートキーを `begins_with('public', :status-createdAt)` とするとどうでしょうか。userIdとstatusで項目(レコード)を絞り込めます！もちろん、ソートキーに対して昇順/降順の指定もできます！降順指定で実際に取得できる項目は以下の通りです。そして更に月で絞り込みたい場合は、 `begins_with('public#2020-04', :status-createdAt)` とすればいいのです。これでより多くの条件で絞り込むことが実現できます。

__items__

| id    | userId | title        | body      | status | createdAt        | status-createdAt        |
|-------|--------|--------------|-----------|--------|------------------|------------------------|
| 9d582 | 5db97  | aaaaについて | aaaaは... | public | 2020-04-04T00:00 | public#2020-04-04T00:00 |
| 54b34 | 5db97  | zzzzについて | zzzzは... | public | 2020-03-17T00:00 | public#2020-03-17T00:00 |
| 4b1b1 | 5db97  | 〇〇について | 〇〇は... | public | 2020-02-01T00:00 | public#2020-02-01T00:00 |

もちろんいくつか注意点が必要です。お気づきだとは思いますが上記のようなテーブルの属性にすると、statusが二重管理になっています。もし上記のように実現する場合は、statusとstatus-createdAtの整合性を保つことに注意しなければなりません。二重管理を避けたい場合は、status属性を無くすのもありです。そして、status-createdAtの名前はユースケースに合わせた `sortKeyForTopPage` という名称とかにしてもいいと思います。これらは設計する際にどの選択肢が自分たちのアプリケーションに合っているかを考察してみてください。

参考: https://aws.amazon.com/jp/blogs/news/using-sort-keys-to-organize-data-in-amazon-dynamodb/

# まとめ
上記のようにうまく設計することで、実現できることが増えたかと思います。もちろん、DynamoDB(NoSQL)にも不得意なことはたくさんありますので、SQLやDocument型のNoSQLを利用する選択肢も考えてみてください。


技術系や起業関連のツイートを良くしています。是非フォローお願いいたします。 [@walkersumida](https://twitter.com/walkersumida)
GitHubには、[nuxtjs](https://github.com/walkersumida/nuxtjs-sample), [flutter](https://github.com/walkersumida/flutter_sample), [rails](https://github.com/walkersumida/rails-api-for-front)などのサンプルや独自のgem([dynamodb-api](https://github.com/walkersumida/dynamodb-api))を開発していますので応援していただけると嬉しいです。