💡

DynamoDBテーブル数を少なめで運用するためのテーブル設計

2020/10/27に公開

はじめに

こんにちは!
最近仕事でDynamoDBの設計について議論をしているとき、「DynamoDBのテーブルは一つだけにすることがベストプラクティス」というのが少し盛り上がりました。スキーマレスなDynamoDBならではの議論で面白いですね。実は私が前職で開発をしていた時にこれに近いことを実践していて、なるべくDynamoDBのテーブルは少なくなるような設計を行っていました。

この記事はその手法を共有したいと思います。

本記事の対象読者

  • DynamoDBを使ったことがある方
  • ハッシュキー、レンジキー、LSI、GSIがどういうものか何となく理解できている方

なぜDynamoDBのテーブルは少ないほうが良いのか?

ひと昔前はAWSのDynamoDB開発者ガイド

DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。

と書かれていました。必要なテーブルは 1 つのみ、というのはなかなか衝撃的な言葉です。
しかし、最新のものを見ると記述が少し変わり、

一般的なルールとして、DynamoDB アプリケーションはできるだけ少ないテーブルを維持する必要があります。

となっています。テーブルはなるべく少なくすべきだという表現に緩められています。

なぜテーブルは少ないほうが良いのでしょうか?

調べて分かったことや個人的な意見を加えると、以下がメリットとして挙げられると思います。

  • キャパシティ管理の効率化
    • テーブルがたくさんあると、キャパシティのチューニングもテーブルの数だけ行うことになり、管理が大変
    • テーブルを少なくすることで、キャパシティを合計で考えることができ、シンプルになる
  • テーブル数の上限超過の抑制
    • 大規模なシステムになると上限を超えてしまう恐れがある
    • たとえば一つのアカウントで複数のサービスを運用している場合

では、テーブルを少なくすることによるデメリットはあるのでしょうか?
個人的にあまりないと考えますが、挙げるとすれば以下になると思います。

  • テーブルのインデックス設計などがテンプレート見ただけだとわかりづらいため、別途補助的なドキュメントが必要
  • アクセスが集中する

デメリットを挙げてはみましたが、ドキュメントは結局のところテーブルの数に関係なく用意すべきだと思いますし、アクセスが集中する件はパーティションキーをちゃんと設計すれば回避できることなので大きな問題ではないと感じます。

ということで、テーブルの数は少ないほうがメリットがありそうです。
もちろんシステムの特性によってはそうではない場合もあると思いますので、絶対ではありません。

テーブルを統合する設計方法

テーブルを少なくするためには、複数のテーブルを一つに統合していくというアプローチが一般的だと思います。今回はこの設計手法を説明します。

具体的な話に入る前に、複数のテーブルを一つに統合した後に最低限満たされていないといけない要件をまずは書き出してみます。以下の二つになるかと思います。

  1. クエリ結果に、複数の統合前テーブルのデータが混在しないこと
    • 例えば、AとBというテーブルを統合したテーブルでAのデータを取得する目的でクエリを実行したときに、必ずAの情報のみが取得されること
  2. あとから別のテーブルを簡単に統合できること

ではテーブルを一つに統合していくには実際にどうしたらよいかを例を交えながら説明したいと思います。
ここからはDynamoDBで作成するテーブルを物理テーブルと呼びます。

以下の二つの物理テーブルがあるとします。UserTableがシステムに登録しているユーザの情報、そのシステムでユーザが登録した何かしらの計画情報がPlanTableだと考えてください。

UserTable

キータイプ 属性名
Hash userId
Range birthDate
GSIweight Hash status
GSIweight Range createdAt
属性名
userId string
birthDate string
userName string
weight string
height string
status string
createdAt string

PlanTable

キータイプ/インデックス名 属性名
Hash planId
Range startDate
LSI Range createdAt
GSIStatus Hash status
GSIStatus Range endDate
GSIUser Hash userId
GSIUser Range startDate
属性名
planId string
startDate string
endDate string
planName string
createdAt string
description string
status string
userId string

これらの物理テーブルを一つの物理テーブルDynamoMonoTableに統合します。

これを実現する方法を言葉で先に述べてしまうと、もともと物理テーブルとして存在したUserTablePlanTableDynamoMonoTable内で論理的に分割された論理テーブルとして登録します。論理的に分割されているというのは、キーやインデックスでクエリをかけたときに複数の論理テーブルの情報が混在せず取得できるようになっていることを指します。
これが達成できれば前述の統合後のテーブル要件の1がクリアできます。

また受け皿となるDynamoMonoTableがどんな論理テーブルも登録できるように汎用的な設計にします。
これが達成できれば前述の統合後のテーブル要件の2がクリアできます。

以降、統合されるテーブルを統合元テーブル、受け皿となるテーブルを統合先テーブルと呼びます。

統合先テーブルの各キー・インデックスに設定する属性名を汎用的な名前にする

統合先テーブル(DynamoMonoTable)の各キー・インデックスに設定する属性名を汎用的な名前にしていきます。
インデックスに設定する属性名を汎用的な名前にしたのが以下です。

DynamoMonoTable

キー名・インデックス名 属性名
Hash HASH
Range RANGE
LSI RANGE LSIRANGE
GSI0 HASH GSI0HASH
GSI0 RANGE GSI0RANGE
GSI1 HASH GSI1HASH
GSI1 RANGE GSI1RANGE
GSI2 HASH GSI2HASH
GSI2 RANGE GSI2RANGE

GSIの名前は0からインクリメントしています。判別できればなんでもよいと思います。
LSIのHashキーはテーブルのHashキーを設定する制約があるため、記載を省いています。

CloudFormationのテンプレートにしたのが以下になります。

dynamodb.yml
Resources:
  DynamoMonoTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: DynamoMonoTable-${self:provider.stage}
      AttributeDefinitions:
        - AttributeName: HASH
          AttributeType: S
        - AttributeName: RANGE
          AttributeType: S
        - AttributeName: LSIRANGE
          AttributeType: S
        - AttributeName: GSI0HASH
          AttributeType: S
        - AttributeName: GSI0RANGE
          AttributeType: S
        - AttributeName: GSI1HASH
          AttributeType: S
        - AttributeName: GSI1RANGE
          AttributeType: S
        - AttributeName: GSI2HASH
          AttributeType: S
        - AttributeName: GSI2RANGE
          AttributeType: S
      KeySchema:
        - AttributeName: HASH
          KeyType: HASH
        - AttributeName: RANGE
          KeyType: RANGE
      LocalSecondaryIndexes:
        - IndexName: LSI
          KeySchema:
            - AttributeName: HASH
              KeyType: HASH
            - AttributeName: LSIRANGE
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      GlobalSecondaryIndexes:
        - IndexName: GSI0
          KeySchema:
            - AttributeName: GSI0HASH
              KeyType: HASH
            - AttributeName: GSI0RANGE
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: GSI1
          KeySchema:
            - AttributeName: GSI1HASH
              KeyType: HASH
            - AttributeName: GSI1RANGE
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: GSI2
          KeySchema:
            - AttributeName: GSI2HASH
              KeyType: HASH
            - AttributeName: GSI2RANGE
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      BillingMode: PAY_PER_REQUEST

このようにキー名・インデックス名を汎用的にしておけば、どんな値が入ってきても対応できます。つまり、あとから別のテーブルを統合しようとしても簡単に統合することができます。これで前述の統合後のテーブル要件の2がクリアできました。

ここで試しにこのテーブル定義に先ほどのUserTablePlanTableを当てはめてみたいと思います。
当てはめてみると例えば以下のような中身になると思います。

UserTableSample
{
  "HASH": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",//userId
  "RANGE": "2000-01-01",//birthDate
  "GSI0HASH": "expired",//status
  "GSI0RANGE": "2020-08-01",//createdAt
  "userId": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",
  "birthDate": "2000-01-01",
  "userName": "taro",
  "weight": "70",
  "height": "180",
  "status": "expired",
  "createdAt": "2020-08-01"
}
{
  "HASH": "67b09448-64e9-4ec0-be71-226f95022d28",//userId
  "RANGE": "2000-02-01",//birthDate
  "GSI0HASH": "active",//status
  "GSI0RANGE": "2019-07-11",//createdAt
  "userId": "67b09448-64e9-4ec0-be71-226f95022d28",
  "birthDate": "2000-02-01",
  "userName": "jiro",
  "weight": "80",
  "height": "170",
  "status": "active",
  "createdAt": "2019-07-11"
}
PlanTableSample
{
  "HASH": "9def6275-3903-4382-99cd-3bad452e13e9",//planId
  "RANGE": "2000-01-01",//startDate
  "LSIRANGE": "1999-12-24",//createdAt
  "GSI0HASH": "complete",//status
  "GSI0RANGE": "2020-02-01",//endDate
  "GSI1HASH": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",//userId
  "GSI1RANGE": "2000-01-01",//startDate
  "planId": "9def6275-3903-4382-99cd-3bad452e13e9",
  "startDate": "2000-01-01",
  "endDate": "2020-02-01",
  "planName": "birthDay",
  "createdAt": "1999-12-24",
  "description": "birthDay plan",
  "status": "complete",
  "userId": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9"
}
{
  "HASH": "0579e467-930f-4872-9b7d-92313b71231d",//planId
  "RANGE": "2020-01-01",//startDate
  "LSIRANGE": "2019-12-24",//createdAt
  "GSI0HASH": "active",//status
  "GSI0RANGE": "2020-12-01",//endDate
  "GSI1HASH": "67b09448-64e9-4ec0-be71-226f95022d28",//userId
  "GSI1RANGE": "2020-01-01",//startDate
  "planId": "0579e467-930f-4872-9b7d-92313b71231d",
  "startDate": "2020-01-01", 
  "endDate": "2020-02-01",
  "planName": "xxproject",
  "createdAt": "2019-12-24",
  "description": "xxproject plan",
  "status": "active",
  "userId": "67b09448-64e9-4ec0-be71-226f95022d28"
}

上記のレコードをDynamoMonoTableに登録して、PlanTableにあるデータを取得しようとGSI0 HASH = activeGSI0 Range >= 2000-01-01の条件でクエリをかけてみました。

このようにUserTableのレコードも混じって取得されてしまいました。
このケースではPlanTableのGSI0とGSI1を入れ替えれば解決しますが、さらに別のテーブルを統合しようと思ったときにいちいち細かい調整をしていてはいつか首が回らなくなります。そもそもGSIは一つのテーブルに5つしか設定できないので、どう考えても限界があります。
この状態では前述の統合後のテーブル要件の1がクリアできているとは言えません。

それでは肝となる論理テーブルへの分割をしていきます。

論理テーブル名を決める

統合元物理テーブル(UserTablePlanTable)の論理テーブル名を決めます。
識別できればなんでもいいのですが、ここではそれぞれの頭文字をとって、UserTableUSTPlanTablePLTとしたいと思います。

各キー・インデックスを論理テーブル名と連結したものに変更する

論理テーブル単位でデータをとってくるために、統合元物理テーブルの各キー・インデックスを論理テーブル名と連結したものに変更していきます。

ポイントは以下の二つです。

  1. 他の論理テーブルと区別できるようにしたいキーやインデックスに論理テーブル名を連結する
  2. 必ず論理テーブル単位で全取得するインデックスを用意する

例えばHASHのuserIdやplanIdはuuidなので一意であることは確率的に保証されています。こういう場合は論理テーブル名を連結しなくてOKです(念のために連結しても問題ありません)。逆に先ほどのGSI0のケースではGSI0HASHで一意に定まらなかったので論理テーブル名を連結する必要があります。
論理テーブ単位で全取得するインデックスがなぜ必要かというと、テーブルを全取得して削除なりバッチ処理をする際に必要だからです。設計当初にそういった要件がなくても、あとあと必要になることが多いので、作っておいたほうが無難です。

以上のポイントを踏まえ、先ほどjson形式でお見せしたデータ例を変更したのが以下になります。

LogicalUserTableSample
{
  "HASH": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",//userId
  "RANGE": "2000-01-01",//birthDate
  "GSI0HASH": "UST",//全取得用。Rangeは不要
  "GSI1HASH": "UST|expired",//論理テーブル名とstatusを連結
  "GSI1RANGE": "2020-08-01",//createdAt
  "userId": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",
  "birthDate": "2000-01-01",
  "userName": "taro",
  "weight": "70",
  "height": "180",
  "status": "expired",
  "createdAt": "2020-08-01"
}
{
  "HASH": "67b09448-64e9-4ec0-be71-226f95022d28",//userId
  "RANGE": "2000-02-01",//birthDate
  "GSI0HASH": "UST",//全取得用。Rangeは不要
  "GSI1HASH": "UST|active",//論理テーブル名とstatusを連結
  "GSI1RANGE": "2019-07-11",//createdAt
  "userId": "67b09448-64e9-4ec0-be71-226f95022d28",
  "birthDate": "2000-02-01",
  "userName": "jiro",
  "weight": "80",
  "height": "170",
  "status": "active",
  "createdAt": "2019-07-11"
}
LogicalPlanTableSample
{
  "HASH": "9def6275-3903-4382-99cd-3bad452e13e9",//planId
  "RANGE": "2000-01-01",//startDate
  "LSIRANGE": "1999-12-24",//createdAt
  "GSI0HASH": "PLT",//全取得用。Rangeは不要
  "GSI1HASH": "PLT|complete",//論理テーブル名とstatusを連結
  "GSI1RANGE": "2020-02-01",//endDate
  "GSI2HASH": "PLT|cb823d42-28c8-4a3a-81c9-4513b8cdaeb9",//論理テーブル名とuserIdを連結
  "GSI2RANGE": "2000-01-01",//startDate
  "planId": "9def6275-3903-4382-99cd-3bad452e13e9",
  "startDate": "2000-01-01",
  "endDate": "2020-02-01",
  "planName": "birthDay",
  "createdAt": "1999-12-24",
  "description": "birthDay plan",
  "status": "complete",
  "userId": "cb823d42-28c8-4a3a-81c9-4513b8cdaeb9"
}
{
  "HASH": "0579e467-930f-4872-9b7d-92313b71231d",//planId
  "RANGE": "2020-01-01",//startDate
  "LSIRANGE": "2019-12-24",//createdAt
  "GSI0HASH": "PLT",//全取得用。Rangeは不要
  "GSI1HASH": "PLT|active",//論理テーブル名とstatusを連結
  "GSI1RANGE": "2020-12-01",//endDate
  "GSI2HASH": "PLT|67b09448-64e9-4ec0-be71-226f95022d28",//論理テーブル名とuserIdを連結
  "GSI2RANGE": "2020-01-01",//startDate
  "planId": "0579e467-930f-4872-9b7d-92313b71231d",
  "startDate": "2020-01-01", 
  "endDate": "2020-02-01",
  "planName": "xxproject",
  "createdAt": "2019-12-24",
  "description": "xxproject plan",
  "status": "active",
  "userId": "67b09448-64e9-4ec0-be71-226f95022d28"
}

これをDynamoMonoTableに登録し、先ほどと同じくPlanTableのデータを取るべくGSI1でクエリをかけてみます。

GSI1 HASHがPLT|activeとなるので、必ずPlanTableのデータだけを取得できるようになります。論理テーブルに分割することができました。
これで前述の統合後のテーブル要件の1がクリアできました。

本設計手法を適用するときに気を付けること

今回ご紹介した設計手法を適用するにあたって気を付けるべき点を述べます。

テンプレートを見ただけではキー・インデックスの属性名がわからなくなる

テーブルを統合する前はDynamoDBのテンプレートを見ればどのキー・インデックスにどの属性を指定しているか一目でわかりましたが、統合した場合はテーブルのテンプレートを見てもどんな属性が入るかがわかりません。別途論理テーブルのスキーマをまとめたドキュメントが必要になります。

アクセスが一つのテーブルに集中する

この設計手法固有の問題ではありませんが、テーブルを統合したことによりアクセスが一つのテーブルに集中します。しっかり分散するように、より気を付けてHASHキーを選定する必要があります。

アプリ側のロジックが少し複雑になる

DynamoDBへのCRUDを行うときに論理テーブル名を連結する必要があります。逆にパースする場面もあるかもしれません。そういった処理を行うutility関数の実装が必要になると思います。

おわりに

今回はDynamoDBのテーブルはなるべく少ないほうが良い、という考えのもと、複数のテーブルを一つに統合していく手法を説明しました。
この方法はいくつかある手段の一つに過ぎないと思います。またシステムによってはマッチしない設計手法だとも思います。
ですが比較的広く適用できる設計手法だと思いますので、少しでも皆様の参考になれば幸いです。

今回お見せしたテンプレートやサンプルデータは以下のGithubに置いておきましたので、よろしければ参考にしてください。

https://github.com/nekoze-climber/DynamoMonoTable

Discussion