はじめに
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(二次索引) |
gsi1:gsi1pk × 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つ書き出してみる。「この画面は何を、どの単位で取り出すか」。それが固まれば、テーブルを分けるべきか、同じパーティションに同居させるべきかが、自然と見えてくる。スキーマは、その答え合わせにすぎない。