DynamoDBテーブル数を少なめで運用するためのテーブル設計
はじめに
こんにちは!
最近仕事でDynamoDBの設計について議論をしているとき、「DynamoDBのテーブルは一つだけにすることがベストプラクティス」というのが少し盛り上がりました。スキーマレスなDynamoDBならではの議論で面白いですね。実は私が前職で開発をしていた時にこれに近いことを実践していて、なるべくDynamoDBのテーブルは少なくなるような設計を行っていました。
この記事はその手法を共有したいと思います。
本記事の対象読者
- DynamoDBを使ったことがある方
- ハッシュキー、レンジキー、LSI、GSIがどういうものか何となく理解できている方
なぜDynamoDBのテーブルは少ないほうが良いのか?
ひと昔前はAWSのDynamoDB開発者ガイドに
DynamoDB アプリケーションではできるだけ少ないテーブルを維持する必要があります。設計が優れたアプリケーションでは、必要なテーブルは 1 つのみです。
と書かれていました。必要なテーブルは 1 つのみ、というのはなかなか衝撃的な言葉です。
しかし、最新のものを見ると記述が少し変わり、
一般的なルールとして、DynamoDB アプリケーションはできるだけ少ないテーブルを維持する必要があります。
となっています。テーブルはなるべく少なくすべきだという表現に緩められています。
なぜテーブルは少ないほうが良いのでしょうか?
調べて分かったことや個人的な意見を加えると、以下がメリットとして挙げられると思います。
- キャパシティ管理の効率化
- テーブルがたくさんあると、キャパシティのチューニングもテーブルの数だけ行うことになり、管理が大変
- テーブルを少なくすることで、キャパシティを合計で考えることができ、シンプルになる
- テーブル数の上限超過の抑制
- 大規模なシステムになると上限を超えてしまう恐れがある
- たとえば一つのアカウントで複数のサービスを運用している場合
では、テーブルを少なくすることによるデメリットはあるのでしょうか?
個人的にあまりないと考えますが、挙げるとすれば以下になると思います。
- テーブルのインデックス設計などがテンプレート見ただけだとわかりづらいため、別途補助的なドキュメントが必要
- アクセスが集中する
デメリットを挙げてはみましたが、ドキュメントは結局のところテーブルの数に関係なく用意すべきだと思いますし、アクセスが集中する件はパーティションキーをちゃんと設計すれば回避できることなので大きな問題ではないと感じます。
ということで、テーブルの数は少ないほうがメリットがありそうです。
もちろんシステムの特性によってはそうではない場合もあると思いますので、絶対ではありません。
テーブルを統合する設計方法
テーブルを少なくするためには、複数のテーブルを一つに統合していくというアプローチが一般的だと思います。今回はこの設計手法を説明します。
具体的な話に入る前に、複数のテーブルを一つに統合した後に最低限満たされていないといけない要件をまずは書き出してみます。以下の二つになるかと思います。
-
クエリ結果に、複数の統合前テーブルのデータが混在しないこと
- 例えば、AとBというテーブルを統合したテーブルでAのデータを取得する目的でクエリを実行したときに、必ずAの情報のみが取得されること
- あとから別のテーブルを簡単に統合できること
ではテーブルを一つに統合していくには実際にどうしたらよいかを例を交えながら説明したいと思います。
ここからは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
に統合します。
これを実現する方法を言葉で先に述べてしまうと、もともと物理テーブルとして存在したUserTable
とPlanTable
をDynamoMonoTable
内で論理的に分割された論理テーブルとして登録します。論理的に分割されているというのは、キーやインデックスでクエリをかけたときに複数の論理テーブルの情報が混在せず取得できるようになっていることを指します。
これが達成できれば前述の統合後のテーブル要件の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のテンプレートにしたのが以下になります。
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がクリアできました。
ここで試しにこのテーブル定義に先ほどのUserTable
、PlanTable
を当てはめてみたいと思います。
当てはめてみると例えば以下のような中身になると思います。
{
"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"
}
{
"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 = active
、GSI0 Range >= 2000-01-01
の条件でクエリをかけてみました。
このようにUserTable
のレコードも混じって取得されてしまいました。
このケースではPlanTable
のGSI0とGSI1を入れ替えれば解決しますが、さらに別のテーブルを統合しようと思ったときにいちいち細かい調整をしていてはいつか首が回らなくなります。そもそもGSIは一つのテーブルに5つしか設定できないので、どう考えても限界があります。
この状態では前述の統合後のテーブル要件の1がクリアできているとは言えません。
それでは肝となる論理テーブルへの分割をしていきます。
論理テーブル名を決める
統合元物理テーブル(UserTable
、PlanTable
)の論理テーブル名を決めます。
識別できればなんでもいいのですが、ここではそれぞれの頭文字をとって、UserTable
をUST
、PlanTable
をPLT
としたいと思います。
各キー・インデックスを論理テーブル名と連結したものに変更する
論理テーブル単位でデータをとってくるために、統合元物理テーブルの各キー・インデックスを論理テーブル名と連結したものに変更していきます。
ポイントは以下の二つです。
- 他の論理テーブルと区別できるようにしたいキーやインデックスに論理テーブル名を連結する
- 必ず論理テーブル単位で全取得するインデックスを用意する
例えばHASHのuserIdやplanIdはuuidなので一意であることは確率的に保証されています。こういう場合は論理テーブル名を連結しなくてOKです(念のために連結しても問題ありません)。逆に先ほどのGSI0のケースではGSI0HASHで一意に定まらなかったので論理テーブル名を連結する必要があります。
論理テーブ単位で全取得するインデックスがなぜ必要かというと、テーブルを全取得して削除なりバッチ処理をする際に必要だからです。設計当初にそういった要件がなくても、あとあと必要になることが多いので、作っておいたほうが無難です。
以上のポイントを踏まえ、先ほどjson形式でお見せしたデータ例を変更したのが以下になります。
{
"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"
}
{
"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に置いておきましたので、よろしければ参考にしてください。
Discussion