0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RDB 出身者のための DynamoDB シングルテーブル設計 ― 実プロダクトのスキーマで腹落ちさせる

0
Posted at

はじめに

RDB(Relational Database、テーブルを正規化して JOIN で組み合わせる従来型のデータベース)に慣れた身体には、染みついた直感がある。「ユーザーは users テーブル、投稿は posts テーブル、ライブラリは library テーブル」。関心ごとにテーブルを割り、正規化し、必要なときに JOIN でつなぐ。きれいだ。

ところが DynamoDB(AWS のフルマネージドな NoSQL〔非リレーショナル〕データベース)にこの直感をそのまま持ち込むと、たいていアンチパターンになる。理由は一つ。DynamoDB には JOIN が無いからだ。

この記事では、実際に動いている SaaS(Software as a Service)のスキーマを使って、「なぜ User / Post … とテーブルを分けず、1本のシングルテーブルに集約しているのか」を解剖する。取り上げるのは、私が開発している X(旧 Twitter)自動投稿 SaaS「Aboardix」だ。pk/sk 設計やアクセスパターンは実物のまま載せる(機密ではなく、設計知見だからだ)。

この記事を貫くテーマは、次の二つだ。

  • JOIN の無い DynamoDB はアクセスパターン駆動:一緒に取りたいものは、同じパーティションに置く。
  • シングルテーブルは“全か無か”ではない:責務はコードで分離済み。要件が分岐したエンティティだけ、後で剥がせばいい。

「1テーブルに全部入れる」と聞くと身構える。その違和感を、具体スキーマで一つずつ解きほぐしていく。


1. 題材システム ― 何を作っているか

アクセスパターンを語るには、まずアプリの形を共有しておく必要がある。DynamoDB の設計は、アプリが「データをどう取り出すか」から逆算されるからだ。

Aboardix は、ざっくり言うと Google Drive のドキュメントから AI が X 投稿を量産し、ユーザーが承認したものを自動投稿する SaaS だ。流れはこうなる。

バックエンドは DDD(Domain-Driven Design、ドメイン駆動設計)の4層構成だ。さらに Bounded Context(境界づけられたコンテキスト、業務領域ごとに区切ったまとまり)に分かれている。identity / posts / sources / library / analytics / billing の6つだ。各コンテキストは、自分専用のリポジトリ(永続化の出入口)を持つ。

ここで早くも伏線を一つ置いておく。**コードの世界では、関心はすでにきれいに分かれている。**にもかかわらず、データの保存先は DynamoDB のシングルテーブル1本(+例外が2つ)だ。コストを抑えたい MVP(Minimum Viable Product、最小機能の検証版)なので、運用と課金はできるだけ小さくしたい背景もある。

「ドメインは分かれているのに、テーブルは1本」。間取り(コード)はきっちり仕切られているのに、土地(テーブル)は1区画、というイメージだ。この一見ねじれた状態が、実は合理的だという話を、これからしていく。


2. 実際のスキーマ ― メインテーブル1本

メインテーブルの基本設定は、こうだ。

項目
課金モード PAY_PER_REQUEST(オンデマンド。使った分だけ課金)
パーティションキー pk(String)
ソートキー sk(String)
GSI(二次索引) gsi1gsi1pk × gsi1sk(全項目射影・スパース=疎な索引。詳細は後述)
TTL(Time To Live、一定時間で項目を自動削除) ttl 属性(メトリクス項目だけに付与)
バックアップ Point-in-Time Recovery(PITR)有効

肝は、pk/sk をオーバーロードしている点だ。オーバーロードとは、複数のエンティティ種別を同じ pk/sk のカラムに相乗りさせる手法を指す。pk の接頭辞(USER# POST# …)で種別を見分ける。RDB の「1テーブル1エンティティ」とは、真逆の発想になる。

実際に同居しているエンティティを並べると、こうなる。

エンティティ pk sk 所属 BC
ユーザープロフィール USER#{userId} PROFILE identity
投稿(候補/承認/公開) USER#{userId} POST#{postId} posts
投稿の出典ファイル POST#{postId} SOURCE#{fileId} posts/sources
投稿メトリクス(時系列) POST#{postId} METRICS#{capturedAt} analytics
分析ロールアップ USER#{userId} ANALYTICS#{postId} analytics
Drive フォルダ USER#{userId} SOURCE#FOLDER#{folderId} sources
Google 連携 USER#{userId} INTEG#GOOGLE identity
X 連携 USER#{userId} INTEG#X#{handle} identity
ライブラリ USER#{userId} LIBRARY#{…} library

表を眺めて気づいてほしいのは、USER#{userId} というパーティションに、そのユーザーの profile / posts / library / sources / integrations / analytics が全部同居していることだ。DynamoDB では、この「同じ pk を共有する項目の集まり」を item collection(アイテムコレクション)と呼ぶ。1ユーザー分のデータが、物理的に隣り合って並んでいるイメージだ。

もう一つの工夫が、投稿の両面モデリングだ。投稿は「ユーザー一覧用」の USER#{userId} / POST#{postId} と、「明細用」の POST#{postId} / SOURCE#{fileId}POST#{postId} / METRICS#{capturedAt} という、二つの顔で載っている。一覧からも明細からも引けるように、わざと二面で持たせている。

GSI1 ― オーバーロード+スパースの合わせ技

テーブル本体のキーだけでは「ユーザーをまたいだ横断クエリ」が取れない。そこで GSI(Global Secondary Index、別のキーで引き直せる二次索引)を1本だけ足している。

  • gsi1pk = STATUS#{status}gsi1sk = publishAt
  • 用途は「公開予約済み(STATUS#approved)の投稿を、publishAt(公開時刻)の昇順で引く」。スケジュール画面の表示と、スケジューラの発火に使う。

ここで効いているのがスパース GSI だ。スパース(疎)とは、「GSI のキー属性を持つ項目だけが索引に載る」性質を指す。publishAt を持つのは予約された投稿だけなので、承認前の投稿も、他のエンティティも、この索引には一切載らない。索引が小さくなり、その分だけ安く・速くなる。「公開待ちキュー」だけを切り出した、専用の細い窓口というわけだ。


3. アクセスパターン ― 1ユーザー = 1パーティション = 1 Query

ここまでのスキーマは、すべて主要アクセスパターンから逆算されている。DynamoDB 設計の出発点は「どんなテーブルにするか」ではなく「どんな取り出し方をするか」だ。順序が RDB と逆だと言っていい。

実際の主要アクセスパターンを並べる。

  • pk=USER#{u}begins_with(sk, "POST#") → そのユーザーの全投稿
  • pk=USER#{u}begins_with(sk, "LIBRARY#") → ライブラリ
  • pk=USER#{u}begins_with(sk, "INTEG#X#") → X 連携状態
  • pk=USER#{u}begins_with(sk, "SOURCE#FOLDER#") → 接続フォルダ
  • pk=USER#{u}sk="PROFILE" → プロフィール
  • pk=POST#{id}begins_with(sk, "SOURCE#") → 投稿の出典
  • gsi1pk=STATUS#approved(GSI1, publishAt 昇順) → 公開待ちの投稿

見てのとおり、ほとんどが USER#{u} という一つのパーティションへの 1 Query で取れる。ダッシュボードに必要な「プロフィール・投稿・ライブラリ・連携状態」を、一度の問い合わせでまとめて引ける。

コードにすると、たとえばこうだ。

// 1ユーザーの投稿一覧(1パーティションへの 1 Query)
Query({
  KeyConditionExpression: 'pk = :pk AND begins_with(sk, :sk)',
  ExpressionAttributeValues: { ':pk': `USER#${userId}`, ':sk': 'POST#' },
});

種別を混ぜて「ユーザーの全関連データ」を一括で取ることもできる。sk の接頭辞で種別が分かるので、返ってきた item collection をアプリ側で振り分ければいい。

// 1ユーザーの“全関連データ”を item collection としてまとめ取り
Query({
  KeyConditionExpression: 'pk = :pk',
  ExpressionAttributeValues: { ':pk': `USER#${userId}` },
}); // PROFILE / POST# / LIBRARY# / INTEG# / ANALYTICS# … が一括で返る

「公開待ち」の横断クエリは、先ほどのスパース GSI を引くだけだ。

// 公開待ち(承認済み)の投稿を publishAt 昇順で(スパース GSI)
Query({
  IndexName: 'gsi1',
  KeyConditionExpression: 'gsi1pk = :pk',
  ExpressionAttributeValues: { ':pk': 'STATUS#approved' }, // gsi1sk=publishAt で自然に時刻順
});

ここで、もし RDB 流にテーブルを割っていたらどうなるかを並べてみる。

RDB 版は、ダッシュボードを描くだけで複数テーブルへ何度も往復する。DynamoDB 版は、USER#{u} への 1 Query で済む。**JOIN が無い世界での答えは、「一緒に取りたいものを、最初から同じパーティションに置いておく」**だ。これが二本柱の一つ目、アクセスパターン駆動の核心になる。


4. なぜ「同じパーティションに置く」がベストプラクティスなのか

ここまで「1 Query で取れる」と繰り返してきた。だが、なぜそれが速くて安いのか。なぜ AWS 自身がシングルテーブルを推すのか。答えは DynamoDB の物理モデルにある。

パーティションキーは「物理的な置き場所」を決める

DynamoDB は、パーティションキーのハッシュ値をもとに、データを内部の物理パーティション(ストレージの区画)へ配る。**同じ pk を持つ項目は、その一区画に固まって並ぶ。**item collection が「物理的に隣り合う」とは、文字どおりこの意味だ。

だから USER#{u} への 1 Query は、ほぼ一つの物理パーティションへの 1 往復で完結する。連続して並んだ項目を順に読むだけなので、レイテンシは一桁ミリ秒で安定し、データが増えても急に遅くならない。これが「近くに置くと速い」、参照の局所性(locality of reference)の正体だ。

JOIN が無いぶん、結合を「書くとき」に前払いしている

RDB は、正規化でバラした断片を、読む瞬間に JOIN で組み立てる。結合のコストを、毎回の読み取りで払う方式だ。DynamoDB には JOIN エンジンが無い。代わりに、関連データを最初から同じパーティションに同居させ、**結合を設計時・書き込み時に前払いしている。**読むときは、もう組み上がった item collection を一度に持ってくるだけでいい。

弁当にたとえると分かりやすい。RDB は注文のたびに、ごはん・おかず・汁物を別々の棚から集めて盛り付ける(読み取り時に結合)。DynamoDB は、あらかじめ一つの弁当箱に詰めておき、注文が来たら箱ごと渡す(設計時に結合)。手間を払うタイミングが、ちょうど逆になっている。

速さと安さは「往復を減らす」一点から同時に出る

DynamoDB の課金と性能は、突き詰めればリクエスト数とデータ量で決まる。N 個のテーブルに散らせば、1画面を描くのに N 回のリクエストが要る。1パーティションに同居していれば、1 リクエストで済む。**レイテンシの低さも、コストの低さも、「ネットワーク往復を減らす」という同じ一点から同時に生まれる。**だから AWS のベストプラクティスは、はっきり「テーブルはできるだけ少なく保て」と書いている。

データが増えても、なぜ遅くならないのか

ここで、RDB の直感が最もつまずく点に触れておく。「1テーブルに全部入れたら、データが増えたとき遅くなるのでは?」という不安だ。RDB なら、テーブルが太るほどインデックスは深くなり、フルスキャンは重くなる。だから「大きくなったら分割」が定石だった。

DynamoDB では、この前提が崩れる。Query の速さは、テーブル全体の大きさではなく、その1回で読む item collection の大きさで決まる。 USER#{u} への Query は、テーブルに 1,000 ユーザーいようが 1 億ユーザーいようが、そのユーザーの項目しか触らない。1ユーザーの投稿数が変わらなければ、クエリの重さも変わらない。総量は、ほぼ無関係だ。

なぜそうなるのか。データが増えると、DynamoDB は自動で物理パーティションを分割し、横へ広げていく。これをオートシャーディング(負荷とデータを複数ノードへ自動分散する仕組み)と呼ぶ。1台のノードを大きくするのではなく、台数を増やして並べる。各パーティションのサイズには上限があり、超えると勝手に割れる。だから、ある pk を引くときにたどる深さは、総量が増えてもほぼ一定のままだ。

図書館にたとえると分かりやすい。蔵書が10倍に増えても、棚と分館を増やして対応するので、目当ての1冊にたどり着くまでの歩数は変わらない。DynamoDB の「総量が増えても1クエリは速いまま」は、これと同じ理屈だ。

ここから、分割が速さを生まない理由もはっきりする。**個々の Query は、もともとテーブルの総量に依存しない。**だから「テーブルを小さく割れば速くなる」という RDB 的な効果は、DynamoDB では得られない。むしろ User / Post / Library に割れば、さっきの「1往復」が「テーブルごとに N 往復」へ増えるだけで、かえって遅くなる。テーブルを割っても、性能の面で得することは何も無い。

唯一の例外は、ひとつのパーティション(item collection)だけが、際限なく膨らむケースだ。総量ではなく、特定の pk の項目数が爆発すると、その Query だけが重くなり、パーティションの上限にも近づく。これがメトリクス(METRICS#)で起こりうる現象で、「そこだけ別テーブルに剥がす」判断につながる。問題になるのは総量ではなく、特定パーティションへの一点集中だ。その線引きは次章で扱う。

なお、ここまでの話はすべて OLTP(Online Transaction Processing、オンライン取引処理)を前提にしている。アクセスパターンが既知で、低レイテンシと低コストを重視する状況だ。前提が変われば、答えも変わる。


5. なぜ分割しないのか ― 6つの理由

ここまでで土台はできた。改めて「なぜ物理テーブルを割らないのか」を、6つの理由で積み上げる。

① JOIN が無いから、同居で解くしかない。 RDB なら正規化して JOIN するところを、DynamoDB は「同じパーティションに置く」で解く。User / Post / Library を別テーブルにすると、1ユーザーのダッシュボードを作るのに、それらをまたいで何度もクエリが要る。往復のレイテンシも、リクエスト課金も増える。

② 責務分離は“コード層”で達成済み。 「テーブルを分けたい」動機の多くは、責務や関心の分離だ。だが本システムは Bounded Context ごとにリポジトリが分かれている。**テーブルは永続化の実装詳細にすぎない。**物理テーブルを割らなくても、ドメインはコードできれいに分離されている。ここを混同すると、「コードの整理整頓」と「ストレージの物理分割」を取り違える。

③ AWS 公式のベストプラクティスそのもの。 前章で見た物理モデルの帰結として、AWS は「テーブルは最小限に」と明記している(ベストプラクティスシングルテーブル vs マルチテーブル設計/Rick Houlihan のre:Invent 2019 講演)。テーブルを RDB 的に割るのは、item collection や GSI オーバーロードという DynamoDB の強みを、自分から捨てる方向になる。

④ コストと運用が小さい。 1テーブルなら、キャパシティ(オンデマンド)も1系統、PITR も1つ、CloudWatch アラームも1セット、IAM(アクセス権限)ポリシーも1つで済む。テーブルを増やすほど、バックアップ・監視・権限の管理対象が掛け算で増えていく。

MVP のコスト制約では、この「運用面の軽さ」が地味に効く。

⑤ 投稿の“両面モデリング”が自然に収まる。 投稿は USER#/POST#(一覧)と POST#/SOURCE#POST#/METRICS#(明細)の両面で載っている。これを別テーブルに割ると、「投稿はどっちのテーブルに置く?」という不毛な問いが生まれて破綻する。シングルテーブルなら、接頭辞で両者が自然に共存する。

⑥ 横断クエリも GSI オーバーロードで賄える。 「公開待ち投稿を時刻順に」のようなパターン違いの取り出しも、テーブルを分けずに GSI1(スパース)を1本足すだけで実現できる。**別の取り出し口が欲しくなっても、テーブルではなく索引で足す。**これも分割しない理由を支える。


6. それでも分けるべきケース

ここで止めると「シングルテーブル万歳」になってしまう。それは正確ではない。**分けた方がいい場面も、確かにある。**教条的にならないために、限界もはっきり書く。

可読性と学習コスト(最大の弱点)。 シングルテーブルは慣れが要る。1テーブルに種別が混在するので、コンソールでのアドホックな(その場限りの)クエリがしにくい。RDB のように「テーブルを見れば構造が分かる」とはいかず、新しく参画したメンバーには分かりづらい。ここは正直、最大の弱点だ。

エンティティ間でスケール特性が大きく違うとき。 例がメトリクス(METRICS#)だ。これは時系列で、大量に書き込まれ、TTL で寿命を管理する。低頻度のプロフィールとは、まるで性質が違う。量が増え、キャパシティやライフサイクルを独立に調整したくなったら、メトリクスだけ専用テーブルへ切り出すのは妥当な判断になる。

チームやブラスト半径の分離が要るほど大きくなったとき。 ブラスト半径(blast radius、障害や事故の影響範囲)を絞りたい規模になれば、テーブル分割が選択肢に入る。

そして大事なのは、本システムも既に一部は分けている点だ。waitlist(順番待ちリスト)は別テーブルに置いているし、埋め込みベクトル(意味検索用の数値表現)は DynamoDB の外、S3 Vectors(S3 にベクトルを格納・検索できる機能)に逃がしている。「分けるべきものは、ちゃんと分けている」。だから教条ではない。

結論はこうだ。**シングルテーブルは“全か無か”ではない。まずシングルテーブルで始め、特定エンティティの要件が分岐したら、そのときそれだけ剥がす。**これが現実的な落としどころで、二本柱の二つ目にあたる。


おわりに

最後に二本柱を振り返る。

  • JOIN の無い DynamoDB はアクセスパターン駆動:「一緒に取りたいものは同じパーティションに置く」。USER#{u} への 1 Query で関連データをまとめ取りできるからこそ、テーブルを割らない。
  • シングルテーブルは“全か無か”ではない:責務はコード(DDD リポジトリ)で分離済み。テーブルは実装詳細にすぎず、要件が分岐したエンティティだけ後で剥がせる。

RDB の「正規化して分ける」は、JOIN があるからこそ成り立つ作法だ。JOIN の無い DynamoDB では、設計の起点が「テーブル」から「アクセスパターン」へ移る。発想がまるごと裏返る。

次の一歩はシンプルだ。自分のアプリで、画面やバッチが必要とする主要アクセスパターンを5つ書き出してみる。「この画面は何を、どの単位で取り出すか」。それが固まれば、テーブルを分けるべきか、同じパーティションに同居させるべきかが、自然と見えてくる。スキーマは、その答え合わせにすぎない。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?