DynamoDB の非正規化
SQL から来ると、非正規化は罪のように聞こえます — データの重複、単一の真実の源がない。 DynamoDB ではそれこそが要点です。結合がないので、それを必要とするアイテムに関連データを コピーし、1 回で読み戻すのです。
DynamoDB の非正規化とは何か?
DynamoDB の非正規化とは、それを読むアイテムに関連データをコピーし、単一のクエリがすべてを 1 回で返すようにすることです。DynamoDB には結合がないため、読み取り時にテーブルを縫い合わせる代わりに、書き込み時に事前結合します。トレードオフは古びること(staleness) — めったに変わらない値だけを複製しましょう。
- 結合がないので、書き込み時に事前結合する。 関連する値を、それを読むアイテムに保存し、 クエリが 2 回目の参照を必要としないようにします。
- 2 つの流儀。 ネストしたデータを 1 つのアイテムの 複合属性 に埋め込むか、値を 多数のアイテムに 複製 します。
- 地雷は古びること。 ソースが変わると、ファンアウトして更新するまで、すべてのコピーが 間違ったままになります。めったに変わらない値だけを複製しましょう。
- 書き込みではなく読み取りを買う。 より多くの(そしてより慎重な)書き込みと引き換えに、 安い単一リクエストの読み取りを得ます。
なぜ頼るべき結合がないのか
リレーショナルな JOIN は、正規化された行を読み取り時に再構成します。DynamoDB には結合が
ありません — Query は 1 つの を読み、そこに保存されて
いるものを正確に返します。2 つのテーブルをあなたのために縫い合わせるものは何もありません。
(とはいえ、それは本番の読み取りパスの話です — アドホックな監査やずれの確認なら、
DynoTable の SQL Workbench が DynamoDB に対して本物の JOIN を
クライアントサイドで実行します。)
だからデータは、あらかじめ読み取りの形に整えられていなければなりません。ある画面が投稿と その著者名を必要とするなら、その名前は投稿の読み取りがすでに触れる場所のどこかに存在 しなければなりません。2007 年の Amazon Dynamo の論文はこのトレードを明確にしました。 スケールで予測可能な読み取りを得るためにリレーショナルな機能を捨てる — DynamoDB が今、 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 で上限に達します (Service Quotas)。 有界でないリスト(バイラルな投稿へのすべてのコメント)を埋め込めば、これを超えてしまいます。
パターン 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" |
投稿に対する (たとえば GSI1PK = "POST"、または著者でキー付けしたもの)が、
リスト全体 — タイトルと著者 — を行ごとの参照なしにレンダリングします。パーティションキーに
対する begins_with は存在しません。Query はパーティションキーの等価を必要とするので、
投稿ごとのパーティションキーではリストは POST# に対する Query ではなく 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" と
言っています。
だから複製された値の更新は、一行では済まない ファンアウト書き込み です。影響を受ける すべてのアイテムをクエリし、それぞれを書き換えます — 理想的には、まだ古い値を保持している 行だけに触れるようガードして。
UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"
その authorName に対する条件付き SET を 式ビルダー
で組み立て、生成された UpdateExpression と ConditionExpression をそのままコードに
コピーできます。
ファンアウト自体は、アイテムごとの書き込みです。その著者の投稿を著者でキー付けした GSI で クエリし、それから更新を発行します。手順は次のとおり。
データを複製するコスト:ソースへのすべての変更が、コピーごとにクエリ 1 回と書き込み 1 回に なります。DynoTable では、ファンアウトはまず ステージングエリア に入り、 アイテムごとに属性単位の差分としてレビューできます — 何かが反映される前に、変更されようと しているすべてのコピーを確認できます。
だからこそルールは めったに変わらない値だけを複製する です。表示名、プランのティア、 カテゴリラベル — 問題ありません。ライブのカウンターや頻繁に編集されるフィールド — やめて おきましょう。ファンアウトがあなたを食い尽くします。
ファンアウト書き込みのコスト
更新するコピーはそれぞれ別の課金書き込みです。us-east-1 オンデマンドでは、著者リネーム後に投稿アイテム 50 件を更新すると 50 × WCU — 各投稿行が ≤ 1 KB なら通常アイテムあたり 1 WCU/KB — がかかります。読み取り側は1回の Query のまま。書き込み側は維持する複製の数に比例して伸びます。両方の経路を 料金計算機 で見積もってください。
正規化がなお勝つとき
値が頻繁に変わるか、あるアイテムが本当に予測不能なパターンで読まれるなら、正規化したまま にして余分な読み取りを受け入れましょう。非正規化は 既知の、読み取り中心の アクセス パターンのための最適化であって、どこにでも適用するデフォルトではありません。実際に実行する 読み取りを事前結合し、残りは放っておきましょう。
これらの複製された属性が どこに 住むかを決めるには、まずアクセスパターンをモデル化 しましょう — シングルテーブル設計 と、トレードの読み取り側に ついては Query と Scan を参照してください。
DynoTable をダウンロード して、非正規化されたテーブルを調べ、どのコピーが
ずれてしまったかを見つけ、自分のデータに対してファンアウト更新を実行しましょう。その
SQL Workbench なら、真実のソースをコピーに対して JOIN して、
ずれを 1 つのクエリで見つけることさえできます。