DynamoDB における非正規化
SQL の出身者には、非正規化は罪のように聞こえます — データの重複、唯一の真実の源の不在。DynamoDB では、それこそが本質です。結合がないので、それを必要とするアイテムに関連データをコピーして、1回で読み戻します。
DynamoDB における非正規化とは?
DynamoDB における非正規化とは、関連データをそれを読み取るアイテムにコピーしておき、1回のクエリですべてをまとめて返せるようにすることです。DynamoDB には結合がないため、読み取り時にテーブルをつなぎ合わせるのではなく、書き込み時にあらかじめ結合しておきます。トレードオフは陳腐化です — めったに変わらない値だけを複製しましょう。
- 結合がないということは、書き込み時に事前結合するということ。 それを読むアイテムに関連する値を保存し、クエリが2回目の参照を必要としないようにします。
- 2つの流儀。 ネストされたデータを1つのアイテムの複雑な属性に埋め込むか、ある値を多数のアイテムにわたって複製します。
- 罠は陳腐化。 ソースが変わると、更新をファンアウトするまで、すべてのコピーが間違ったままになります。めったに変わらない値だけを複製しましょう。
- これは読み取りを買うのであって、書き込みを買うのではない。 より多くの(そしてより慎重な)書き込みを、安価な単一リクエストの読み取りと引き換えにします。
なぜ頼れる結合がないのか
リレーショナルな JOIN は、正規化された行を読み取り時に再構成します。DynamoDB には結合がありません — Query は1つのアイテムコレクションを読み、そこに保存されているものを正確に返します。2つのテーブルをつなぎ合わせてくれるものはありません。
だからデータは、読み取りのためにあらかじめ形作られていなければなりません。ある画面が投稿とその著者名を必要とするなら、その名前は投稿の読み取りがすでに触れる場所のどこかに存在しなければなりません。2007年の Amazon Dynamo 論文はこのトレードを明示的にしました。スケール時に予測可能な1桁ミリ秒の読み取りを得るために、リレーショナルな機能を捨てるのです。
パターン 1 — 複雑な属性で埋め込む
DynamoDB の属性はスカラーだけでなく、ネストされたマップやリストを保持できます。だから非正規化の一般的な形の1つは、子オブジェクトに独自のアイテムを与える代わりに、その親アイテムの中に直接詰め込むことです。
投稿、そのタグ、小さな著者スナップショットを、すべて1つのアイテムに:
| PK | SK | author | tags |
|---|---|---|---|
| POST#9f3 | META | {id: U#12, name: "Mara Vance"} | ["dynamodb","aws"] |
1回の GetItem で、投稿、タグ、著者ブロックがまとめて返ります。2回目の読み取りはありません。これは、親に所有され、サイズが有界なデータ — 数個のタグ、1つの著者スナップショット — に最適です。
守るべき限界: 単一の DynamoDB アイテムは属性名と値を含めて 400 KB が上限です(サービスクォータ)。有界でないリスト(バズった投稿のすべてのコメント)を埋め込むと、それを超えてしまいます。
パターン 2 — 値をアイテムにわたって複製する
ブログのケースは教科書的なものです。投稿を一覧表示し、各行に著者の表示名を表示したい — しかし、それを取得するために投稿ごとに2回目の読み取りはしたくありません。
そこで、投稿が作成されるときに著者名を各投稿アイテムに書き込みます:
| PK | SK | authorId | authorName | title |
|---|---|---|---|---|
| POST#9f3 | META | U#12 | "Mara Vance" | "Modeling 1:N" |
| POST#a71 | META | U#12 | "Mara Vance" | "Sparse GSIs" |
| POST#b04 | META | U#88 | "Lio Tan" | "Query vs Scan" |
これで Query PK begins_with "POST#"(または投稿に対する GSI)が、行ごとの参照なしに、タイトルと著者を含むリスト全体をレンダリングします。著者名は非正規化されています。正規のコピーは USER#12 にあり、すべての投稿が自分のコピーを持ちます。
トレードはそこにあります。N+1 の読み取りを1回の読み取りに変えた代わりに、"Mara Vance" を N+1 箇所に保持しています。
埋め込み vs. 複製 — どちらか
| 埋め込み(複雑な属性) | 複製(アイテムにわたってコピー) | |
|---|---|---|
| 形 | 親の中にネストされた子 | 同じ値を多数のアイテムに |
| 最適な対象 | 有界で親に所有されるデータ | 多数のアイテムが表示する共有値 |
| 読み取り | 1回の GetItem | 1回の Query |
| 更新コスト | 親アイテム1つを書き換え | すべてのコピーにファンアウト |
| サイズのリスク | 400 KB のアイテム上限 | アイテムごとには無し |
子が常に親とともにしか現れないときは埋め込みに手を伸ばしましょう。多数の独立したアイテムが同じ共有値を表示する必要があるときは複製に手を伸ばしましょう。
罠: 陳腐化したコピー
ここが噛んでくる部分です。Mara が自分の名前を "Mara V." に変更します。USER#12 を更新します。すべての投稿アイテムは、あなたが修正しに行くまで依然として "Mara Vance" のままです。
だから、複製された値の更新は1行で済むものではなく、ファンアウトの書き込みです。影響を受けるすべてのアイテムを照会し、それぞれを書き換えます — 理想的には、まだ古い値を保持している行だけに触れるようガードして:
UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"
その authorName に対する条件付き SET は Expression Builder で組み立て、生成された UpdateExpression と ConditionExpression をそのままコードにコピーできます。
ファンアウトそのものはアイテムごとの書き込みです。著者の投稿を照会し、それから更新を発行します。その手順:
データを複製するコストはこうです。ソースへのあらゆる変更が、コピーごとに1クエリと1書き込みになります。
これが、ルールがめったに変わらない値だけを複製するである理由です。表示名、プラン階層、カテゴリラベル — 問題ありません。ライブのカウンタや頻繁に編集されるフィールド — やめましょう。ファンアウトが生きたまま食い尽くします。
正規化がなお勝つとき
値が頻繁に変わる場合や、1つのアイテムが本当に予測不能なパターンで読まれる場合は、正規化したままにして余分な読み取りを受け入れましょう。非正規化は既知の読み取り中心のアクセスパターンのための最適化であって、どこにでも適用するデフォルトではありません。実際に実行する読み取りを事前結合し、残りはそのままにしておきましょう。
これらの複製された属性がどこに存在するかを決めるには、まずアクセスパターンをモデル化しましょう — シングルテーブル設計を、そしてトレードの読み取り側については Query と Scan の比較を参照してください。
DynoTable をダウンロードして、非正規化されたテーブルを検査し、どのコピーがずれてしまったかを見つけ、自分のデータに対してファンアウトの更新を実行しましょう。