はじめに
こんばんは、mirukyです。
突然ですが、私の一番好きなAWSサービスは、Amazon DynamoDB(DynamoをよくDyamoと打ち間違える)です。なぜかと言うと、一言で言えば「非常に扱いやすいから」ですね。RDBMSほどデータの厳密性が必要の無い場面ではかなり重宝するサービスで、設計は必要ではありますが基本的にスキーマレスなのでいくつか主要なキーを決めれば、後はアプリケーション側で自由にカラムを作れますし、あとサーバレスなのでインフラ部分を意識する必要がほぼありません(個人の感想)。
今回は、正直今更ではあるかもしれませんが、意外とまとまっている記事が少ない印象を受けたDynamoDBの設計で押さえておきたい勘所を、パーティションキー、GSIとLSI、シングルテーブル設計の3つを軸に、サクッとまとめます。
RDB系を見たい方は、こちらの記事をどうぞ。
では、始めましょう。
目次
- アクセスパターンから考えるという前提
- パーティションキー設計
- ソートキーで階層と範囲を表現する
- GSIとLSI、似ているようで別物
- シングルテーブル設計という思想
- 読み書きのお作法
- 容量とリージョン、周辺機能について
1. アクセスパターンから考えるという前提
RDBを設計するときは、場合にもよりますが、基本的にまずデータの構造を正規化してから、後でどう検索するかを考えることが多いです。JOINがあるので、テーブルを分けておいてもそこまで困りません。
DynamoDBには、この後半部分がありません。JOINができないので、後から検索方法を考えるという順番が通用しないのです。ここは少し痛いですが、仕方ないです。だからDynamoDBの設計は、アプリケーションが必要とするアクセスパターン(クエリパターン)を先に全部洗い出し、そのアクセスパターンに合わせてテーブルとインデックスを組み立てる、といういわば逆向きの手順になります。
この前提だけ覚えておけば、この後のパーティションキーやインデックスの話が、なぜそう設計するのかまで含めて割と腑に落ちるはずです。
2. パーティションキー設計
DynamoDBのプライマリキーは、パーティションキー単体か、パーティションキーとソートキーの組み合わせのどちらかで構成します。パーティションキーだけを主キーにする場合は、RDBの主キーとほぼ同じ役割で、値そのものがテーブル全体で一意である必要があります。ソートキーを組み合わせる場合は、一意性を保証する単位がパーティションキー単体ではなく、パーティションキーとソートキーの組み合わせ全体に変わります。
同じパーティションキー値を持つアイテムを複数持てるようになる代わりに、そのパーティションキーの中ではソートキーの値が重複しないようにする、という役割分担です。
2-1. 高カーディナリティと均一な分散
先ほど、ソートキーを組み合わせる場合は2つセットで一意になればよい、とお伝えしました。ただ、一意性を満たすことと、アクセスのされ方は別の話ですよね。組み合わせとして重複していなくても、パーティションキーの値そのものが偏っていれば、読み書きは同じ場所へ集まってきてしまいます。ここからはその偏りの話に入ります。
DynamoDBは、パーティションキーの値をもとにデータを物理的なパーティションへ振り分けています。パーティションキーの値の種類が少なかったり、特定の値にアクセスが集中したりすると、その値を持つパーティションだけが混雑します。これがいわゆるホットパーティションとかいうやつです。
注文データを、注文日をパーティションキーにして格納するとします。
| PK(注文日) | SK(注文ID) | ユーザーID | 金額 |
|---|---|---|---|
| 2024-01-20 | ORDER#00001 | USER#101 | 3,200円 |
| 2024-01-20 | ORDER#00002 | USER#204 | 1,800円 |
| 2024-01-20 | ORDER#00003 | USER#309 | 5,000円 |
3件ともPKが2024-01-20で揃っています。セールなどで当日の注文が跳ね上がると、その日のパーティションだけにアクセスが集中し、2-2で説明する秒間の上限に達する可能性が高まります。
同じデータを、注文IDをパーティションキーにして格納し直してみます。
| PK(注文ID) | SK | ユーザーID | 注文日 | 金額 |
|---|---|---|---|---|
| ORDER#00001 | METADATA | USER#101 | 2024-01-20 | 3,200円 |
| ORDER#00002 | METADATA | USER#204 | 2024-01-20 | 1,800円 |
| ORDER#00003 | METADATA | USER#309 | 2024-01-20 | 5,000円 |
今度はPKが注文ごとにバラバラです。特定の日に注文が集中しても、アクセス先のパーティションは分散したままです。ユーザーIDや注文IDのように、値の種類が多く、アクセスも自然に散らばるものをパーティションキーに選ぶのが基本です。このあたりはRDBとも共通の考え方です。
2-2. 物理パーティションあたり3,000RCUと1,000WCU
DynamoDBの各物理パーティションには、秒間3,000の読み取りユニットと、秒間1,000の書き込みユニットという上限があります。1読み取りユニットは4KBまでのアイテムを1秒間に1回、強整合性で読み取れる量です。1書き込みユニットは1KBまでのアイテムを1秒間に1回書き込める量です。
ここでいう強整合性とは、読み取りを始める前までに成功した書き込みが、必ず反映された値を返す読み方です。更新直後に読み返しても古い値が返らない、と覚えてください。
この上限は、必ずしも1つのパーティションキー値だけにかかるものではなく、あくまで物理パーティション単位の上限です。ただし、単一アイテムへのアクセスが集中するケースや、LSI(後述します)を持つテーブルのように同じパーティションキー値のアイテムが常に1つの物理パーティションへまとまるケースでは、この上限がそのままパーティションキー値あたりの実質的な壁になります。テーブル全体のキャパシティに余裕があっても関係なく、特定の物理パーティションへアクセスが集中すれば、そこだけがスロットリングされます。
パーティション設計がボトルネックになるのは、たいていこの壁にぶつかったときです。
2-3. 書き込みシャーディングでホットパーティションを避ける
それでも、どうしても特定の値にアクセスが集中してしまう設計になる場合があります。よくあるのが、投稿へのいいね数のように、最新の合計だけを1件のアイテムで持たせるカウンターですね。この形だとパーティションキーはPOST#001の1種類しかないので、何人が同時に押しても書き込み先は1か所のままです。
| PK | SK | いいね数 |
|---|---|---|
| POST#001 | LIKES | 9,999 |
2-2で挙げた秒間1,000WCUの壁に、これは一直線で向かっていきます。そこで使われるのが、書き込みシャーディングという考え方です。パーティションキーの末尾に0から3までの数字を付け足して、1種類だったキーを4種類へ増やしてしまいます。
| PK | SK | いいね数 |
|---|---|---|
| POST#001#0 | LIKES | 2,410 |
| POST#001#1 | LIKES | 2,533 |
| POST#001#2 | LIKES | 2,488 |
| POST#001#3 | LIKES | 2,568 |
書き込むときは、0から3のどれかをアプリケーション側で選んでUpdateItemします。読み取るときは4件すべてを取得して、アプリケーション側で合計します。1回で済んでいた読み取りが4回になる代わりに、書き込みを4つの論理キーへ分散する、という交換だと捉えてください。
末尾の数字の決め方は、大きく2通りあります。
| 方式 | 数字の決め方 | 読み取り |
|---|---|---|
| ランダムサフィックス | 書き込むたびに乱数で選ぶ | 全シャードを読んで合算する |
| 計算したサフィックス | アイテムの属性から計算する | 特定のアイテムなら対象シャードを狙って読める |
合計値さえ取れればよいカウンターなら、乱数で十分です。特定のアイテムを後から引き直したい場合は、ユーザーIDのハッシュを分割数で割った余りを使うといった計算方式にしておくと、読み取りを1回に戻せます。
3. ソートキーで階層と範囲を表現する
ソートキーは、同じパーティションキー値の中でデータを並べ、範囲で絞り込むための鍵です。単体でも使えますが、真価を発揮するのは複合的な文字列として組み立てたときです。
パーティションキーをUSER#123に固定し、ソートキーへ用途ごとの接頭辞を付けて格納した例が、次の表です。同じ日に複数回注文することもあるため、ソートキーの末尾には注文IDまで含めています。
| PK | SK |
|---|---|
| USER#123 | PROFILE |
| USER#123 | ORDER#2024-01-20#00001 |
| USER#123 | ORDER#2024-02-14#00002 |
PK = "USER#123" AND begins_with(SK, "ORDER#")のようなキー条件を指定すれば、PROFILEを除いた注文だけをまとめて取り出せます。ソートキーは単なる並び順ではなく、1人のユーザーに紐づく複数の種類のデータを、同じパーティションキーの中でまとめるための階層構造そのものだと考えていただければと思います。この考え方は、5章のシングルテーブル設計の土台にもなります。
4. GSIとLSI、似ているようで別物
AWS認定試験でよく出るあれです。グローバル(G)とローカル(L)の2種類がある、2つ目のインデックス、つまりセカンダリインデックス(SI)ですね。
そもそもインデックスが必要になるのは、Queryではプライマリキーの属性しか条件に使えないからです。注文をユーザーID起点で引く前提のテーブルを作ったあとで、商品IDから注文を探したくなると、そのままでは全件を走査するしかありません。別の切り口で引き直すための代替キーを用意しておく仕組みが、セカンダリインデックスです。
2つの違いは、パーティションキーを差し替えられるかどうかに集約されます。LSIはパーティションキーを固定したままソートキーだけを別の属性へ変え、GSIはパーティションキーごと自由に変えられます。
この一点の差が、作成タイミングや整合性の制約にまで広がっていきます。下記表で表した違いなどは、AWS認定試験でも頻出です。
| 観点 | LSI | GSI |
|---|---|---|
| パーティションキー | ベーステーブルと同じ | 自由に設定できる |
| 作成タイミング | テーブル作成時のみ | いつでも追加・削除できる |
| 強整合性の読み取り | できる | できない |
| サイズ上限 | パーティションキー値ごとに10GB | 上限なし |
| テーブルあたりの上限 | 5個まで | デフォルト20個まで |
| キャパシティ | ベーステーブルの分を消費 | モードはベーステーブルと同じ、プロビジョンドはRCU/WCUを個別設定 |
4-1. LSIは後から作れない、削除もできない
LSI(ローカルセカンダリインデックス)は、パーティションキーはベーステーブルと同じまま、ソートキーだけを別の属性に差し替えられるインデックスです。これはかなり大事な部分です。
あと重要なのは、LSIはテーブルを作成するときにしか追加できず、しかも一度作ると削除もできないという制約です。後から、やっぱりこのソートキーでも検索したいと思っても、LSIでは対応できません。テーブル設計の初期段階で、本当に必要なソートキーの候補を洗い出しておく必要があります。
4-2. LSIだけが持つ強整合性という利点
LSIには、GSIにはない利点があります。強整合性の読み取り(ConsistentRead=true)を選べます。
理由は置き場所にあります。LSIはパーティションキーがベーステーブルと同じなので、インデックスの実体もベーステーブルと同じパーティションへ同居しています。書き込みと同じタイミングで更新できるため、直後に読んでも最新の値が返ります。GSIのほうは別のパーティションへ非同期に複製される仕組みなので、どうしても遅れが出ます。
ただし、LSIには1つのパーティションキー値あたり10GBという上限があります。同じパーティションキー値について、ベーステーブルとすべてのLSIを合わせたサイズが対象です。将来的に1つのパーティションキー値へデータが際限なく増えていく可能性がある設計では、LSIではなくGSIを検討しておくと安心です。
4-3. GSIの自由度と結果整合性という制約
GSI(グローバルセカンダリインデックス)は、パーティションキーそのものを別の属性に差し替えられます。テーブル作成後からでも追加・削除ができ、サイズの上限もありません。1つのテーブルに持てるGSIはデフォルトで最大20個です。
現在のGSIでは、パーティションキーとソートキーを、それぞれ最大4つの属性で構成できます。複数の属性を1つの文字列へ連結した合成キーをアプリケーション側で用意せず、既存の属性をそのまま組み合わせられるようになりました。ベーステーブルとLSIのキーは、従来どおりパーティションキーとソートキーを1属性ずつ指定します。
その代わり、GSIへのクエリは結果整合性の読み取りしかできません。結果整合性とは、書き込みが成功した直後には反映前の古い値が返る場合があり、少し時間がたつと各コピーの内容がそろう読み方です。ベーステーブルへの書き込みが、GSI側に反映されるまでにはわずかな遅延があります。書き込み直後に同じデータをGSI経由で読み返すような設計になっていないか、一度確認しておくとよいところです。
4-4. プロジェクション設計のトレードオフ
GSIもLSIも、インデックスにどの属性(カラム)を複製するかを3つのうちから選びます。
| プロジェクション | インデックスに載る属性 | ストレージと書き込みコスト |
|---|---|---|
| KEYS_ONLY | ベーステーブルとインデックスのキーだけ | 最小 |
| INCLUDE | KEYS_ONLYに指定した属性を足したもの | 指定した分だけ増える |
| ALL | ベーステーブルの全属性 | 最大 |
必要な属性を絞れば、インデックスのストレージと書き込みコストを抑えられます。ただ、載せなかった属性を後から求めたときの挙動は、GSIとLSIで割れます。
| 載せていない属性を要求したとき | LSI | GSI |
|---|---|---|
| 取得できるか | できる | できない |
| 内部の動き | DynamoDBがベーステーブルまで取りに行く | GSIからは返らず、ベーステーブルも自動取得されない |
| 追加でかかるもの | 読み取りキャパシティとレイテンシ |
GetItemやBatchGetItemの自前呼び出し |
GSIの場合は、インデックスから取得した主キーをもとに、ベーステーブルをもう一度読み直す実装を書くことになります。よく使うクエリほど、必要な属性をあらかじめプロジェクションへ含めておくと、この往復を避けられます。
5. シングルテーブル設計という思想
5-1. なぜ1つのテーブルに複数のエンティティを詰め込むのか
RDBの感覚では、ユーザーと注文は別々のテーブルに分けるのが当たり前です。ER図で複雑な関係をもっているたくさんのテーブルは見覚えがあるのではないでしょうか。DynamoDBの世界では、あえて1つのテーブルにユーザーも注文も注文明細も、全部まとめて入れてしまう設計がよく使われます。これがシングルテーブル設計です。
理由は単純で、関連するデータを1回のクエリで取ってこられるようにするためです。ユーザーと、そのユーザーの注文を、パーティションキーを揃えておけば1回のクエリでまとめて取得できます。テーブルを分けていたら、ユーザーを取得してから注文を取得して、という往復が発生していた場面です。
5-2. PKとSKをオーバーロードする
複数のエンティティを1つのテーブルへ詰め込むために、パーティションキーとソートキーには、汎用的な名前を付けておきます。割と見かけるのはPKとSKという名前で、その中身は用途ごとに意味を変えます。3章で使ったUSER#123の例に、注文明細のアイテムを加えて広げてみます。
| PK | SK | 中身の例 |
|---|---|---|
| USER#123 | PROFILE | 名前、メールアドレス |
| USER#123 | ORDER#2024-01-20#00001 | 注文日、合計金額 |
| USER#123 | ORDER#2024-01-20#00001#ITEM#001 | 商品名、数量 |
同じPKの値に対して、SKの接頭辞を変えるだけで、プロフィールなのか、注文なのか、注文明細なのかを区別しています。この、キーの意味を用途ごとに使い回す発想を、オーバーロードと呼びます。
5-3. 隣接リストパターンで関係を表現する
1人のユーザーが複数の注文を持つような1対多の関係は、親をパーティションキーにし、子を別々のソートキーで同じパーティションキーの下へ並べるだけで表現できます。5-2の例が、まさにこの形です。
やや込み入るのが、ユーザーとグループのような多対多です。1人が複数のグループへ入り、1つのグループに複数人がいる関係ですね。RDBでは連関エンティティを設定して無理やり1対多の関係を複数つくり、多側から1側に外部キーとして参照させます。
DynamoDBの場合、ここで使うのが隣接リストパターンで、関係そのものを1件のアイテム(エッジ)として持たせ、一方のエンティティをパーティションキー、もう一方をソートキーにして格納します。
| PK | SK | 意味 |
|---|---|---|
| USER#123 | GROUP#A | ユーザー123はグループAに所属 |
| USER#123 | GROUP#B | ユーザー123はグループBに所属 |
| USER#456 | GROUP#A | ユーザー456はグループAに所属 |
この形なら、PK = "USER#123"で123さんの所属グループを引けます。ただ、逆向きに、グループAへ誰がいるのかを知りたくなると、PKがユーザーIDのままでは手が出ません。そこで、ソートキーをパーティションキーにしたGSIを別途張ります。
| GSIのPK | GSIのSK |
|---|---|
| GROUP#A | USER#123 |
| GROUP#A | USER#456 |
| GROUP#B | USER#123 |
キーを入れ替えただけの同じデータですが、これでPK = "GROUP#A"から所属メンバーを引けます。RDBで多対多を表現するときの中間テーブルにあたる役割を、DynamoDBではエッジのアイテムと反転GSIの組み合わせに担わせている、という感じです。
5-4. シングルテーブル設計の功罪
もちろん、良いことばかりではありません。1つのテーブルに何種類ものエンティティが混ざるので、テーブルの中身を見ただけでは構造がつかめません。アクセスパターンが増えるたびにキー設計を見直す必要も出てきますし、チームに新しく入った人がテーブルの意図を理解するまでに時間がかかることもあります。
| 観点 | テーブルを分ける | シングルテーブル |
|---|---|---|
| 関連データの取得 | 複数の読み取りが必要 | 同じPKなら1回のQueryでまとめて取れる |
| 構造の把握 | テーブル名と属性名から確認できる | キー設計とアイテムの種類を確認する |
| アクセスパターンの追加 | 対象テーブルのキーやGSIを見直す | 共有しているキーやGSIを見直す |
| 向いている場面 | 複数のエンティティを一緒に取得しない | 複数のエンティティを一緒に取得する |
もちろん、すべてのアプリケーションでシングルテーブル設計が正解というわけではありません。判断の基準になるのはエンティティの数ではなく、複数種類のデータを同じアクセスパターンで一緒に取得するかどうかです。一緒に取得しないならテーブルを分け、ユーザー情報と注文のようにまとめて取得する処理が多いなら、シングルテーブルを候補にするのが実務的かな、と思います。
6. 読み書きのお作法
6-1. QueryとScanは読む範囲が違う
Queryはパーティションキーの条件で対象を特定してから読みに行くのに対して、Scanはテーブルを端から端まで読んでから絞り込みます。絞り込みが読み取りの前にあるか後にあるかの違いです。
| 観点 | Query | Scan |
|---|---|---|
| 読む範囲 | PKの等価条件と任意のSK条件に合うデータ | テーブル全体 |
| 消費する読み取りキャパシティ | キー条件に合うデータサイズの分 | 評価した全データサイズの分 |
| データ量が増えたとき | 同じPKで読む量が増えれば重くなる | テーブル全体の増加に比例して重くなる |
6-2. トランザクションは2倍のコストで整合性を買う
DBで必ず聞くトランザクションです。
トランザクションは、複数の処理をひとまとまりとして扱い、途中の1つだけが反映された状態を防ぐ仕組みです。たとえば、在庫を1個減らす処理と注文を1件作る処理を同じトランザクションに入れると、両方成功するか、どちらも反映されないかのどちらかになります。
複数のアイテムを1つの操作としてまとめたいときは、TransactWriteItemsとTransactGetItemsが使えます。どちらも内部では準備と確定の2段階で処理されるため、キャパシティも2倍かかります。
| 観点 | TransactWriteItems | TransactGetItems |
|---|---|---|
| 最大アイテム数 | 100 | 100 |
| 合計サイズ | 4MB | 4MB |
| 条件式 | 指定できる | 指定できない |
| 一部が成立しないとき | 全体が失敗する | 存在しないアイテムのデータが返らないだけ |
| 消費キャパシティ | 1KBあたり2WCU | 4KBあたり2RCU |
気をつけたいのは、トランザクションがキャンセルされてもキャパシティは消費されることです。ConditionCheckが偽になって全体が失敗したときも、成功した場合と同じ読み書きキャパシティを支払います。
6-3. 整合性の選択とコストの関係
読み取りの整合性は、デフォルトが結果整合性で、ConsistentReadを指定すれば強整合性へ切り替えられます。コストは整合性の強さに比例します。
| 読み取りの種類 | 4KBあたりの消費 | 結果整合性を1としたとき |
|---|---|---|
| 結果整合性 | 0.5 RCU | 1倍 |
| 強整合性 | 1 RCU | 2倍 |
| トランザクション | 2 RCU | 4倍 |
トランザクション読み取りは、強整合性と比べれば2倍ですが、デフォルトの結果整合性と比べると4倍です。何気なくTransactGetItemsへ置き換えると、想定の4倍のRCUを消費している場合があります。
なお、4章で触れたとおり、GSIへのクエリは強整合性を選べません。整合性が要る読み取りをGSI経由で組んでしまうと、そもそもこの表の選択肢が使えなくなるので、設計の段階で切り分けておきたい部分です。
7. 容量とリージョン、周辺機能について
キャパシティには、あらかじめ読み書きの量を確保しておくプロビジョンドモードと、使った分だけ課金されるオンデマンドモードがあります。2024年には、ウォームスループットという機能が追加されました。テーブルとGSIがすぐに処理できる読み書きの量を、オンデマンドとプロビジョンドの両モードで確認でき、大きなアクセス増加が見込まれるときには前もって値を引き上げておけます。現在値の確認は無料ですが、明示的に引き上げる場合は課金対象になり、一度上げた値は下げられません。
複数リージョンにテーブルを複製するグローバルテーブルは、長らく結果整合性でのレプリケーション(MREC)が前提でしたが、現在はリージョンをまたいだ強整合性のモード(MRSC)も選べます。MRECでは、トランザクションはあくまで操作を実行したリージョン内で完結し、他のリージョンへは1つのまとまりとして複製されません。MRSCに至っては、トランザクション操作そのものが使えません。MRSCはちょうど3リージョンでの構成が必須で、LSIとTTLにも対応しておらず、整合性モードは作成後に変更できない点も押さえておきたいところです。
このほか、有効期限を過ぎたアイテムを非同期で削除するTTL(即座には消えず、数日以内に削除されます)、書き込みの変更を検知してLambdaなどへ渡せるDynamoDB Streams、読み取りの多いアクセスを高速化するDAXといった周辺機能もあります。どれもテーブル設計そのものというより、設計したテーブルをどう運用に乗せるかという話になるので、興味があれば公式ドキュメントを覗いてみてください。
おわりに
ここまでお付き合いいただきありがとうございます。
DynamoDBの設計は、アクセスパターンを先に決め、そのパターンに合わせてパーティションキー、ソートキー、GSIやLSIを組み立てるという、RDBとは逆向きの手順で進みます。特にLSIは後から作り直せないので、テーブル設計の初期段階でどれだけアクセスパターンを洗い出せるかが、後々の設計の自由度を左右します。
ではまた、お会いしましょう。
参考リンク
パーティションキーとキャパシティ
- DynamoDB でパーティションキーを効率的に設計し、使用するためのベストプラクティス - AWS
- 書き込みシャーディングを使用して DynamoDB テーブルでワークロードを均等に分散させる - AWS
- DynamoDB のデータモデリングの構成要素 - AWS
- DynamoDB ウォームスループットについて - AWS
セカンダリインデックスとシングルテーブル設計
- DynamoDB でのセカンダリインデックスを使用したデータアクセス性の向上 - AWS
- ローカルセカンダリインデックス - AWS
- マルチ属性キーパターン - AWS
- DynamoDB のデータモデリングの基盤 - AWS
- DynamoDB テーブルで多対多リレーションシップを管理するためのベストプラクティス - AWS