サーバーレスアーキテクチャに潜む「見えないコスト」
リレーショナルデータベースの習慣を捨てるのは簡単ではありません。秒間5,000リクエストを処理するプロジェクトで、初めて「正規化」されたDynamoDBスキーマをデプロイした時のことを覚えています。ローカル環境ではすべてが軽快でした。しかし本番環境では、p99レイテンシが30msから200ms以上に急上昇したのです。私はそのスキーマを標準的なPostgreSQLデータベースのように設計していました。ユーザー、注文、商品を別々のテーブルに分けたのです。書類上は綺麗に見えましたが、クラウド上ではパフォーマンスの災難でした。
サーバーレス環境において、レイテンシはコストに直結します。Lambda関数がデータベースのレスポンスを待つミリ秒単位の時間すべてが、あなたの財布から出ていくお金なのです。私の関数は、1つのユーザーダッシュボードを表示するためだけに4回ものネットワークコールを行っていました。実質的にアプリケーションコード内でJOINをシミュレートしていたのです。この戦略は遅く、高価で、そして脆いものででした。
JOINのシミュレーションが大規模環境で失敗する理由
問題はDynamoDBではなく、私がSQLパターンに固執していたことでした。SQLデータベースはクエリ実行時にCPUを使用してテーブルを結合します。対照的に、DynamoDBは水平スケーラビリティと予測可能なパフォーマンスのために構築されています。データが1,000件だろうと100億件だろうと速度が低下しないよう、あえてJOIN演算子を排除しているのです。
リレーショナル構造を無理やりDynamoDBに押し込ようとすると、3つの壁に突き当たります:
- ネットワークの肥大化: 各
GetItem呼び出しは往復通信(ラウンドトリップ)です。1つのクエリを4つに置き換えると、15msの処理が60msのマラソンに変わります。 - 一貫性のギャップ: 5つのテーブル間でデータの同期を保つには
TransactWriteItemsが必要です。これは標準的な書き込みの2倍のコストがかかり、一度に100アイテムまでという厳しい制限があります。 - 設定の罠: 30種類ものテーブルのIAMロール、TTL設定、オートスケーリングを管理するのは運用上の頭痛の種であり、時間の経過とともに増大していきます。
アーキテクチャの転換:すべてを統べる1つのテーブル
eコマースプラットフォームを構築しているなら、道は2つあります。古いやり方に従うか、クラウド向けに最適化するかです。
マルチテーブルの乱立
これは「軽量版リレーショナル」です。Users、Orders、Products テーブルを持ち、顧客が何を購入したかを確認するために3つすべてにアクセスします。これはDynamoDBのコアアーキテクチャを無視しており、データを待つ間Lambda関数をアイドル状態にさせてしまいます。
シングルテーブル統合モデル
シングルテーブル設計では、すべてのエンティティを1つのパーティションに統合します。「ユーザー」と「注文」の区別には汎用的なキーを使用します。これにより、ユーザーのプロフィールと直近5件の注文を1つの Query で取得できます。SQLの経験者には乱雑に感じられるかもしれませんが、これがハードウェアを最も効率的に使用する方法です。
高パフォーマンス・スキーマのためのプレイブック
シングルテーブルモデルへの移行には、設計プロセスを逆転させる必要があります。トラフィック量に関わらず高速に保たれるスキーマの構築方法は以下の通りです。
1. オブジェクトではなく、クエリのために設計する
エンティティ・リレーションシップ図(ERD)は捨ててください。SQLでは、まずデータをモデリングします。DynamoDBでは、まずアクセスパターンをモデリングします。AWSコンソールを触る前に、アプリが必要とするすべてのクエリをリストアップします。例:
- UUIDでユーザープロフィールを取得する。
- ユーザーXの全注文をタイムスタンプ順にリストアップする。
- 「Electronics」カテゴリの50ドル未満の商品をすべて探す。
2. キー・オーバーロードの力
プライマリキーは1組しかないため、2つの役割を持たせます。PK(パーティションキー)や SK(ソートキー)といった汎用的な名前を使用します。これがキー・オーバーロード(Key Overloading)です。
# 一般的なシングルテーブルのデータ構造
[
{"PK": "USER#445", "SK": "PROFILE#445", "Name": "ジェーン・スミス", "Tier": "プレミアム"},
{"PK": "USER#445", "SK": "ORDER#2024-05-01", "Total": 89.99, "Status": "出荷済み"},
{"PK": "USER#445", "SK": "ORDER#2024-05-10", "Total": 12.50, "Status": "保留中"},
{"PK": "PROD#SKU-99", "SK": "DETAIL#SKU-99", "Price": 45.00, "Stock": 12}
]
USER#445 がプロフィールと注文をどのようにグループ化しているかに注目してください。PK = 'USER#445' でクエリを実行することで、ユーザーの身元と注文履歴全体を、わずか10msの1回の通信で取得できます。
3. GSIとスパースインデックスによるフィルタリング
ユーザーを知らずに、ユニークなIDで注文を探す必要がある場合はどうすればよいでしょうか?ここでグローバルセカンダリインデックス(GSI)が真価を発揮します。OrderId だけを新しいインデックスに射影できます。ストレージを節約するために、「保留中(Pending)」の注文に対してのみこのインデックスを作成します。これが**スパースインデックス**(Sparse Index)です。これにより、実際に注意が必要な少数の注文に対してのみコストを支払いながら、何千もの注文をスキャンできるようになります。
4. 隣接リストによる多対多の解決
学生とコースを管理しますか?結合テーブルは使わないでください。隣接リスト(Adjacency List)を使用しましょう。登録ごとに2つのアイテムを保存します:
- PK:
STUDENT#S101, SK:COURSE#C202 - PK:
COURSE#C202, SK:STUDENT#S101
これで、「学生Xはどのクラスを受講しているか?」と「クラスYには誰が登録しているか?」の両方に、全く同じテーブルロジックで答えることができます。
デプロイ前に検証する
何百万行ものデータが蓄積された後では、モデリングエラーの修正には多大なコストがかかります。スキーマを確定させる前に、私はいつも boto3 ライブラリを使用して、想定通りに動くか簡単なスクリプトでテストします。
import boto3
from boto3.dynamodb.conditions import Key
table = boto3.resource('dynamodb').Table('ProductionStore')
# 検証:ユーザーと注文を1回の呼び出しで取得できるか?
def fetch_customer_bundle(user_id):
return table.query(
KeyConditionExpression=Key('PK').eq(f'USER#{user_id}')
)['Items']
# 1回のリクエストで、複数のデータ型が返される。
print(fetch_customer_bundle('445'))
考え方をマスターする
シングルテーブル設計の本質は複雑さではなく、「メカニカル・シンパシー(機械への共感)」にあります。データの持ち方をDynamoDBが実際にディスクに保存する方法に合わせることで、JOINの必要性を排除し、バックエンドを常に超高速に保つことができます。まずは NoSQL Workbench を使って、これらのオーバーロードされたキーを視覚化してみましょう。練習が必要ですが、予算を圧迫せずに真にスケーラブルなサーバーレスシステムを構築するための唯一の方法です。

