中級読了 3 分

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 つのアイテムに。

PKSKauthortags
POST#9f3META{id: U#12, name: "Mara Vance"}["dynamodb","aws"]

1 回の GetItem が、投稿、タグ、著者ブロックを一緒に返します。2 回目の読み取りはありません。 これは親に 所有され、サイズが有界なデータ — ひと握りのタグ、1 つの著者スナップショット — に最適です。

守るべき制限:単一の DynamoDB アイテムは、属性名と値を含めて 400 KB で上限に達します (Service Quotas)。 有界でないリスト(バイラルな投稿へのすべてのコメント)を埋め込めば、これを超えてしまいます。

パターン 2 — 値をアイテム間で複製する

ブログのケースは教科書的なものです。投稿を一覧し、各行に著者の表示名を出したい — でも それを取得するために投稿ごとに 2 回目の読み取りはしたくありません。

そこで投稿が作成されるときに 著者名を各投稿アイテムに書き込みます

PKSKauthorIdauthorNametitle
POST#9f3METAU#12"Mara Vance""Modeling 1:N"
POST#a71METAU#12"Mara Vance""Sparse GSIs"
POST#b04METAU#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 回の GetItem1 回の Query
更新コストその 1 つの親アイテムを書き換えすべてのコピーにファンアウト
サイズリスク400 KB のアイテム上限アイテムごとにはなし

子が常に親と一緒にしか現れないときは 埋め込み に手を伸ばしましょう。多数の独立した アイテムが同じ共有の値を表示する必要があるときは 複製 に手を伸ばしましょう。

地雷:古びたコピー

ここが噛みつく部分です。Mara が自分を「Mara V.」に改名します。あなたは USER#12 を更新 します。すべての投稿アイテムは、あなたが直しに行くまで、依然として "Mara Vance" と 言っています。

だから複製された値の更新は、一行では済まない ファンアウト書き込み です。影響を受ける すべてのアイテムをクエリし、それぞれを書き換えます — 理想的には、まだ古い値を保持している 行だけに触れるようガードして。

UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"

その authorName に対する条件付き SET式ビルダー で組み立て、生成された UpdateExpressionConditionExpression をそのままコードに コピーできます。

ファンアウト自体は、アイテムごとの書き込みです。その著者の投稿を著者でキー付けした GSI で クエリし、それから更新を発行します。手順は次のとおり。

"DynamoDB"アプリ"DynamoDB"アプリ"USER"著者の投稿を Query""POST"各 authorName を更新"

データを複製するコスト:ソースへのすべての変更が、コピーごとにクエリ 1 回と書き込み 1 回に なります。DynoTable では、ファンアウトはまず ステージングエリア に入り、 アイテムごとに属性単位の差分としてレビューできます — 何かが反映される前に、変更されようと しているすべてのコピーを確認できます。

だからこそルールは めったに変わらない値だけを複製する です。表示名、プランのティア、 カテゴリラベル — 問題ありません。ライブのカウンターや頻繁に編集されるフィールド — やめて おきましょう。ファンアウトがあなたを食い尽くします。

ファンアウト書き込みのコスト

更新するコピーはそれぞれ別の課金書き込みです。us-east-1 オンデマンドでは、著者リネーム後に投稿アイテム 50 件を更新すると 50 × WCU — 各投稿行が ≤ 1 KB なら通常アイテムあたり 1 WCU/KB — がかかります。読み取り側は1回の Query のまま。書き込み側は維持する複製の数に比例して伸びます。両方の経路を 料金計算機 で見積もってください。

正規化がなお勝つとき

値が頻繁に変わるか、あるアイテムが本当に予測不能なパターンで読まれるなら、正規化したまま にして余分な読み取りを受け入れましょう。非正規化は 既知の、読み取り中心の アクセス パターンのための最適化であって、どこにでも適用するデフォルトではありません。実際に実行する 読み取りを事前結合し、残りは放っておきましょう。

これらの複製された属性が どこに 住むかを決めるには、まずアクセスパターンをモデル化 しましょう — シングルテーブル設計 と、トレードの読み取り側に ついては Query と Scan を参照してください。

DynoTable をダウンロード して、非正規化されたテーブルを調べ、どのコピーが ずれてしまったかを見つけ、自分のデータに対してファンアウト更新を実行しましょう。その SQL Workbench なら、真実のソースをコピーに対して JOIN して、 ずれを 1 つのクエリで見つけることさえできます。

更新日