はじめに

育児と仕事の両立にてんてこまいの遠藤です。

これまで、色々な案件で DynamoDB を使ってきましたが、未だによく忘れて調べ直すことが多いのと、他の人へ教えることも増えてきましたので、自分の知識整理と情報共有を兼ねて、記事を書いていきたいと思います。

読者の対象は DynamoDB について、ちょっと触ったことはある、というくらいの方向けに、設計の際に必要となる基本的な用語の説明や、ポイント、実例などをまとめてみました。

基本

DynamoDB とは

公式ページ の言葉をお借りすると

Amazon DynamoDB は、ハイパフォーマンスなアプリケーションをあらゆる規模で実行するために設計された、フルマネージド、サーバーレスの key-value NoSQL データベース

となります。
NoSQL と書かれている通り、 RDB(リレーショナルデータベース)とは異なるものです。
また、 NoSQL 自体にも明確に定義がなく、製品/サービスによって仕様が異なります。
このあたりも、理解が進みにくいポイントかと思います。

さて、私が DynamoDB の特徴を一言で表現するとすれば、

検索できる Key を2種類まで設定可能な json 構造のデータベース

という説明が一番しっくりくるかなと感じています。

セカンダリインデックスを設定したり、フルスキャンすることでも、もちろん自由に検索はできます(この辺りはあとで詳しく説明します)が、  DynamoDB の性能の最大限活かすためには、上記の認識を持っておくことが大事だと考えています。

それでは設計のポイントを説明する前に、簡単に基本的な DynamoDB の用語について解説します。

Key とは

さて、唐突に 「Key を2種類まで設定可能な」 と書きましたが、この Key にはそれぞれ

  • Partition key (パーティションキー)
  • Sort key (ソートキー)

という名前が付いています。

Partition key

ざっくりいうと、これは RDB の主キーのようなものです。
レコードを一意に特定するためのキーになります。

※ ただし、 Sort Key を指定した場合は、一意である必要はありません。

Sort key

こちらは、オプショナルな Key となっており、設定は必須ではありません。
Sort key を設定した場合は、 Partition key + Sort key で、レコードを一意に特定できる必要があります。

2種類のテーブル

この2つの key を使い、大きく分けて2種類のテーブルを作成することができます。
一つは、Partition key のみのテーブル。
もう一つは、 Partition key + Sort key のテーブルです。
それぞれ役割を見ていきましょう。

Partition key のみのテーブル

このテーブルを選ぶ場合、 Partition key がテーブル内で一意である必要があります。
例えば、社内システムの社員情報を持つテーブルで、社員番号を key にするなど、システム内で一意になっていると推定されるものを選択します。

{
  "1(社員番号)": {
    "name": "endo"
  },
  "2(社員番号)": {
    "name": "yamada"
  }
}

みたいなイメージです。

表にするとこんな感じ。

社員番号 (Partition key) name
1 endo
2 yamada

Partition key + Sort key のテーブル

こちらの場合は、 Partition key だけでは一意にするレコードを特定できない場合に使用します。
例えば、先ほどの社員情報テーブルが、社内システムではなく、複数の企業が使えるサービスだったとします。
すると、企業IDの下に社員番号を持たせたくなってきます。
社員番号は会社毎に重複する可能性がありますので、 Partition key を企業ID, Sort key を社員番号として、一意に特定させます。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo"
    },
    "2(社員番号)": {
      "name": "yamada"
    }
  },
  "B(企業ID)": {
    "1(社員番号)": {
      "name": "tanaka"
    },
    "2(社員番号)": {
      "name": "sato"
    }
  }
}

こんなイメージです。
A の企業の下にも、 B の企業の下にも社員番号 1番が存在していますね。

企業ID (Partition key) 社員番号 (Sort key) name
A 1 endo
A 2 yamada
B 1 tanaka
B 2 sato

セカンダリインデックス

基本的には上記の2種類の Key を使ってデータの書き込み、参照を行いますが、それだけだと用途に合わせた検索ができない!という場合に、セカンダリインデックスを使います。
例えば、ユーザ情報の中に、趣味の項目があり、趣味が同じ人だけでコミュニティを作ろうとなったとします。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo",
      "hobby": "読書"
    },
    "2(社員番号)": {
      "name": "yamada",
      "hobby": "人間観察"
    }
  },
  "B(企業ID)": {
    "1(社員番号)": {
      "name": "tanaka",
      "hobby": "プログラミング"
    },
    "2(社員番号)": {
      "name": "sato",
      "hobby": "読書"
    }
  }
}
企業ID (Partition key) 社員番号 (Sort key) name hoby
A 1 endo 読書
A 2 yamada 人間観察
B 1 tanaka プログラミング
B 2 sato 読書

しかし、 Partition key でも Sort key でも検索することはできません。
こんな時に使えるのがセカンダリインデックスです。

この例でいうと、 hobby を key にしてインデックスを作成することができます。
以下のような別のテーブルが生成されるイメージを持つと良いと思います。

hoby (セカンダリインデックス) 企業ID (Partition key) 社員番号 (Sort key) name
読書 A 1 endo
人間観察 A 2 yamada
プログラミング B 1 tanaka
読書 B 2 sato

すると例えば、

hobby=読書

になっている以下の2レコードを素早く検索することができるようになる、という仕組みです。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo",
      "hobby": "読書"
    }
  },
  "B(企業ID)": {
    "2(社員番号)": {
      "name": "sato",
      "hobby": "読書"
    }
  }
}
企業ID (Partition key) 社員番号 (Sort key) name hoby
A 1 endo 読書
B 2 sato 読書

仮にセカンダリインデックスを指定せずに探すのであれば、テーブルをフルスキャンすることで実現できます。
当然、お金と時間がたくさんかかり、果てはリソースを食い尽くしてエラーを返してきます。

セカンダリインデックスには、2種類のタイプがあります。
1つは、ローカルセカンダリインデックス(LSI)で、もう一つはグローバルセカンダリインデックス(GSI)です。

ローカルセカンダリインデックス(LSI)

ローカルセカンダリインデックスは、Partition key はそのままに、別の Sort key を指定したインデックスを作成します。

もう一度、この例を挙げてみます。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo",
      "hobby": "読書"
    },
    "2(社員番号)": {
      "name": "yamada",
      "hobby": "人間観察"
    }
  },
  "B(企業ID)": {
    "1(社員番号)": {
      "name": "tanaka",
      "hobby": "プログラミング"
    },
    "2(社員番号)": {
      "name": "sato",
      "hobby": "読書"
    }
  }
}

ここで、ローカルセカンダリインデックスの Sort key に hobby を指定します。
すると、

Partition key=A
hobby=読書

といった条件で検索することができるようになります。
すると、以下のレコードがヒットします。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo",
      "hobby": "読書"
    }
}
企業ID (Partition key) 社員番号 (Sort key) name hoby
A 1 endo 読書

元々、分割したパーティション内での検索になるので、 Local ということなのだと思います。

グローバルセカンダリインデックス(GSI)

これは、先に提示した例と重複になってしまいますが、パーティションキーを限定せずに、テーブル全体から検索できるようになります。

hobby=読書

で検索をすると以下の2レコードがヒットします。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo",
      "hobby": "読書"
    }
  },
  "B(企業ID)": {
    "2(社員番号)": {
      "name": "sato",
      "hobby": "読書"
    }
  }
}
企業ID (Partition key) 社員番号 (Sort key) name hoby
A 1 endo 読書
B 2 sato 読書

それぞれ用途に合わせて使い分けましょう。
ただし、 GSI は作成できる上限が決まっており、なんでもかんでもインデックスを作る、ということはできないので注意が必要です。

設計のポイント

以上が DynamoDB を扱うにあたって必要な基本的な用語の説明になります。
さて、2パターンあることは分かったけど、どちらを使えば良いのでしょうか?
また、その時に何を Key にすると良いのでしょうか?
設計する際に、私が意識していることとして、以下の2つのポイントがあります。

どう使うか(検索するか)

私も未だに毎回悩みながら設計していますが、一番考慮しなければいけないのが 「使い方」 です。

もう少し詳しくいうと、どうやって検索するか、によって設計が変わると考えています。
極端な話、全てのレコードに適当な UUID を振って、それを Partition key にしました。というテーブルでも良いわけです。
もちろん、Keyを指定しての検索ができないため、必要なレコードが欲しい時は、テーブルをフルスキャンすることになります。
さすがにそこまで愚かなことはしなくとも、検索が必要になったときに、 GSI を追加して検索します!ということも不可能ではありません。
ですが、それでは、コストも時間もかかりますし、 DynamoDB を使うメリットが全くありません。

RDB の場合、検索する項目に合わせて、インデックスを貼ると思いますが、同じように検索する項目に合わせて Key を設計します。

単一のパーティションに負荷がかからないようにする

もう一点のポイントは、単一のパーティションに負荷がかからないようにする、という点です。
※ ただし、この点は後でも説明しますが、最近ではそこまで気にする必要は無くなりました。

書き込みシャーディングを使用してワークロードを均等に分散する にも記載がある通り、特定のパーティションに負荷がかからない様に設計することが望ましいとされています。

どういうことか。
パーティションとはその名の通り、テーブルのスペースを分割するイメージになります。

例えば、先述したように、企業ID(Partition key)と、社員番号(Sort key)を持つ Organization Table を考えてみます。

{
  "A(企業ID)": {
    "1(社員番号)": {
      "name": "endo"
    },
    "2(社員番号)": {
      "name": "yamada"
    },
    "3(社員番号)": {
      "name": "suzuki"
    },
    ...
  },
  "B(企業ID)": {
    "1(社員番号)": {
      "name": "tanaka"
    },
    "2(社員番号)": {
      "name": "sato"
    }
  }
}

データ構造としては、このような作りになりますね。

パーティションはパーティションキーをハッシュ化して、分割されて保存されます。
いつどのタイミングで、どのように分かれるかのロジックは(おそらく)公開されていませんが
ハッシュ化は、 MongoDBと同じような仕組みだと思われます。

同一の Partition key で保存したデータは、同一のパーティションに保存されていきます。

----------2022-04-16-21.13.48

ここまでは良いですね。
重要なのはこの後で、 DynamoDB では、秒間にどれくらい処理できるかを CU という単位(読み込みに必要な性能を RCU, 書き込みに必要な性能を WCU と言います)で設定するのですが、 設定した容量はパーティション間で、均等に配分されます。
つまり、仮に 10,000 WCU を設定していて、パーティションが 4 分割されていた場合、各パーティションで使える WCU は 2500 WCU になるということです。

例えば、企業ID_1 の社員が 10,000 人いて、企業ID_2 の社員が 10人しかいない。
みたいな場合、 企業ID_1 の属するパーティションにのみ負荷がかかります。
以下がイメージ図です。

----------2022-04-16-21.47.10

なので、特定のパーティションに負荷がかかると、設定した CU には達していないのに、読み取り/書き込みができない、という事態に陥ります。

そうした場合、どのように回避すると良いのか?
選択肢はいくつかありますが...

  • Adaptive Capacity が有効なので、あまり気にしない(後述)
  • そもそも企業IDをパーティションキーにしない
  • 企業ID + 乱数(0 ~ 10 など)をパーティションキーにする
    • 参照する際の指定方法が面倒になるデメリットがあります
  • DynamoDB を使わない

などが対応方法として考えられます。

CU の詳細は以下、公式サイトをご確認ください。

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/HowItWorks.ReadWriteCapacityMode.html

実例

基本的には、上記2点が設計のポイントとなりますが、これだけではまだイメージが湧きにくと思いますので、今までに設計してきたいくつかの実例を、ボカしながら見ていきたいと思います。

アクセストークン管理

要件

  • 対向サービスが発行したアクセストークンを管理する
  • 何度も発行しないように一時保存しておく

設計

  • Partition key
    • service_name
  • Sort key
    • なし
  • attributes
    • service_name
    • token
{
    "service_1": {
        "token": "xxxxxxxxxxxxx"
    },
    "service_2": {
        "token": "yyyyyyyyyyyyy"
    }
}

解説

いくつかの対抗サービスにアクセスするため、トークンを払い出し、それを一時保管しておくために使いました。
Partition key をサービス名にして、トークンを value にセットしています。

これは、 service 名で検索する以外の使い方がなく、 Partition key のみで問題ありませんでした。
今思えば、これは Redis を使っても良かったかもしれません。
でも DynamoDB の方が安いので一長一短ですね。

ログのカウント

要件

  • API リクエスト毎に日別でリクエスト数をカウントする。
  • 参照する画面は、範囲を指定して、グラフで日毎の数字を表示する。

設計

  • Partition key
    • request_name
  • Sort key
    • year_month_day
  • attributes
    • request_name
    • year_month_day
    • count
{
    "api_name_1": {
        "20220414": {
            "count": 100
        },
        "20220415": {
            "count": 150
        }
    },
    "api_name_2": {
        "20220414": {
            "count": 100
        },
        "20220415": {
            "count": 150
        }
    },
}

解説

カウントする際は、 API 名と日付を指定し、 DynamoDB の ADD を使用し、 UpdateItem でインクリメントします。
参照する際は、 request_name に、それぞれの api_name を配列で渡し、 year_month_day を between で範囲指定しています。

ネックとしては、 api_name が増えた時に、渡す配列を増やす必要があるので、メンテナンス性がやや悪いです。
逆に year_month_day を Partition key にすることも出来ましたが、基本的に更新は当日のレコードとなるため、単一のパーティションに負荷がかかることになります。

なお、運用してから分かったことですが、負荷が高くなってくると、 count の更新に失敗することがあります。
例えば一度 sqs に queuing して負荷を平準化するなどの工夫をしても良かったかもしれません。

ユーザ情報を保持するテーブル

要件

  • アプリから使用する
  • 自身のIDは一意のUUIDが発行される
  • 自分の名前や個人情報を持つ
  • 獲得した報酬のレコードを持つ
  • 別の端末に引き継げる

設計

  • Partition key
    • user_id
  • Sort key
    • record_type (USER_INFO/REWARDS/others...)
  • attributes
    • user_id
    • record_type
    • transfer_code(引き継ぎコード)
    • name, records, その他もろもろ
  • GSI(グローバルセカンダリインデックス)
    • transfer_code
{
    "user_uuid_1": {
        "USER_INFO": {
            "name": "endo",
            "birthday": "19870202",
            "transfer_code": "xxxxxxxx",
        },
        "REWARDS": {
            "rewards": ["hoge", "fuga"]
        }
    },
    "user_uuid_2": {
        "USER_INFO": {
            "name": "yamada",
            "birthday": "19870101",
            "transfer_code": "yyyyyyy",
        },
        "REWARDS": {
            "rewards": ["fuga"]
        }
    },
}

解説

  • アプリで発行した、自分の UUID を指定すれば、自身に紐付く全てのレコードを取ってくることができます。
  • 組織などの階層を持たないため、基本的には UUID のみで識別できるテーブルとなっています。
  • 報酬や、その他の必要なレコードだけ欲しい場合は、 Sort key に指定した record_type を指定します。
  • それぞれのレコードサイズがそこまで大きくならない仕様だったために採用した構成です。
    • 例えば報酬の情報だけで膨大になる場合は、別途報酬テーブルを用意したかもしれません。
  • 引き継ぎ時は検索すべき UUID が分からないため、 transfer_code (引き継ぎコード)にグローバルセカンダリインデックスを貼っています。
    • 引き継ぎコードを発行せず、 ユーザ ID の UUID をそのまま引き継ぎコードにしてしまえば、インデックスを追加する必要はありません。
    • ただし、 UUID は長いので、その仕様だとあまりユーザフレンドリーではないですね。
    • また、 UUID が漏洩した場合に誰でも引き継げてしまうので、ワンタイム的にコードを発行する作りはセキュリティ面でもメリットがあると思います。

今と昔の違い

私が DynamoDB を始めて触ったのは、2014年頃で、その頃はまだ機能も少なく、ただただ使いにくいなと思っていました。
とある挑戦的なプロジェクトで採用し大失敗したこともあり、あまり良い印象はありませんでした。。。
ですが、最近ではかなり使い勝手が良くなったなと思っています。
そこで、当時と変わったな、と感じた点をいくつかピックアップしておきます。
もし、同じように昔触ったことがあって、苦手な印象を持っている方がいましたら、ぜひ参考にしてみてください。

命名

たいした話ではありませんが、昔は、

  • Partition key のことを Hash key
  • Sort key のことを Range key

と呼んでいました。
なので、いまでもググって昔の記事がヒットすると、名前が違っていて混乱することがあります。

Auto Scaling

昔は、ユーザ数やデータ容量を計算して、事前にCUを設定する必要がありましたが、今は Auto Scaling があるので、その辺りはあまり気にする必要はありません。
とは言え、想定外のアクセスがあったりすると、(例えば攻撃されているとか)そのままコストに直結する部分でもあるので、有効にするかどうかは、要検討かなと思います。
また、スケールされるまでにはタイムラグがありますので、バッチ処理などのように、急に CU を消費するようなケースでは適さない可能性もあるので、注意が必要です。
Lambda などの API 経由で叩かれると思うので、フロントに WAF を置いておけば、そこまで気にせず有効にしてしまっても良さそうです。

Adaptive Capacity

「単一のパーティションに負荷がかからないようにする」の項目で説明したように、この機能により、パーティションの分割を意識する必要はほとんどなくなりました。
どれかのパーティションが高負荷になっても、設定したCU内であれば、継続して利用することができるようになりました。
しかも即時適用されるようになっています。

というわけで、パーティションキーの分散については、あまり気にする必要はなくなりました。
ただ、現時点でも、

  • ローカルセカンダリインデックス
  • DynamoDB Streams を有効にした、プロビジョニングモードのテーブル

では使えないようなので、パーティションの仕組みは知っておいて損はなさそうです。

Transaction

トランザクションもいつの間にか使えるようになっていました。
今まで、 「NoSQL 使いたいけど、要件にロールバックがなー」 なんていう事例はありませんでしたが、使えることを知っていると、何かで役に立つかもしれないですね。

まとめ

  • Partition key
    • Sort key を指定しない場合、テーブル内で一意のレコードとなるようにする
  • Sort key
    • 指定しないこともできる
    • 指定する場合は Partition key + Sort key でテーブル内で一意のレコードとなるようにする
  • セカンダリインデックス
    • 上記の key 以外で検索したい要件がある場合に設定しましょう
  • テーブル設計
    • 基本的には、用途から考え、検索しやすい key を設定する
    • どんな画面から、何の情報を表示する時に参照されるのか/更新されるのか
    • それぞれ頻度はどの程度あるのか

あたりが設計する際に基本のポイントになるかと思います。
難しい機能もたくさんありますが、 AWS はどんどん便利になっていますので、臆せず色々と触っていけるとよいですね。