Cloud Intelligence™Cloud Intelligence™

Cloud Intelligence™

DynamoDB:コードを書く前に、多くのチームがつまずく設計の落とし穴

このページはEnglishDeutschEspañolFrançaisItalianoPortuguêsでもご覧いただけます。

By Joseph AllamJul 20, 20268 min read
Joseph Allam

About Joseph Allam

Databases have been my craft for a long time — long before the cloud, and deep into it across AWS and GCP. I know what breaks, what costs too much, and what needs rethinking before it hurts. I also know how to bring AI into the work: into how databases are designed, operated, and accessed by the intelligent systems being built on top of them.

My personal page

2025年のPrime Dayにおいて、Amazon DynamoDBは毎秒1億5,100万リクエストというピークを記録しました。地球上で最も過酷なワークロードを支えるデータベースであり、その歴史は20年近くに及びます。AWSで開発するエンジニアの多くはその名を知っており、実際に使った経験のある方も少なくないでしょう。しかし、私が最近AWSチームと共同で開催したDynamoDB Immersion Dayでは、ほぼすべてのセッションで同じ問題が浮かび上がりました。DynamoDBを選んだ理由は正しいのに、スキーマ設計のアプローチを誤っているために苦戦しているチームが多かったのです。

本記事のテーマはまさにその点です。DynamoDBの基礎(それは他の記事に譲ります)ではなく、DynamoDBをうまく使いこなすチームと、コストの暴走やスケールしないテーブルに悩まされるチームを分ける「設計思想」についてお話しします。

PerfectScale™ for Kubernetes

Ready to optimize?

Get your free Kubernetes savings analysis

DynamoDBは正しい選択か?

設計の話に入る前に、まず向き不向きを率直に整理しておきましょう。DynamoDBが適しているのは、ほぼあらゆる規模で一貫した1桁ミリ秒のレイテンシーが必要で、アクセスパターンを事前に把握・予測でき、ワークロードが分析的に複雑なものではなく読み書き中心である場合です。好例としては、ユーザー向けAPI、セッションストア、リーダーボード、ショッピングカート、IoTデータの取り込み、イベントソーシングなどが挙げられます。

一方、多くのディメンションにまたがるアドホックなクエリが必要な場合、アクセスパターンがまだ固まっていない場合、複雑なリレーショナル結合を扱う場合には不向きです。レポーティング用データベースの置き換えや、クエリ要件が毎月変わるようなシステムを構築するのであれば、DynamoDBとは終始格闘することになるでしょう。

アイテムサイズは想像以上に重要

DynamoDBの採用を決める前に、コストを正しくモデル化しましょう。DynamoDBの書き込みキャパシティは1KB単位(切り上げ)で課金されます。オンデマンドモードでは20KBのアイテムは20 Write Request Unit(WRU)を消費し、プロビジョンドモードでは同じ書き込みが20 Write Capacity Unit(WCU)を消費します。読み取りも同様に、Read Capacity Unit(RCU)あたり4KB単位で課金されるため、アイテムサイズはキャパシティコストの両面を左右します。

単純な話に聞こえますが、チームがDynamoDBのコストを見積もる際、アイテムサイズは最も見落とされがちな変数のひとつです。書き込みに対するバイト単位課金という概念が存在しないPostgreSQLなどのリレーショナルデータベースからの移行事例を私たちは頻繁に目にしますが、アイテムサイズを正しく考慮していないために、当初のコスト見積もりが大きく外れているケースが少なくありません。採用を決める前にこの数字を正確に把握できるかどうかで、コストの構図はまったく変わってきます。

重要なのは、DynamoDBが高価だということではありません。コストモデルが多くのエンジニアの慣れ親しんだものとは異なるということ、そしてそれを左右する変数(アイテムサイズ、アクセスパターンの効率、オンデマンドかプロビジョンドか)を、採用後ではなく採用前に理解しておく必要があるということです。

すべてを変えるメンタルモデルの転換

リレーショナルデータベースの経験を持つエンジニアの多くは、DynamoDBに対してもこう考えます。まずスキーマを設計し、その後でクエリを考える、と。

リレーショナルデータベースなら、それでおおむねうまくいきます。データを正規化し、インデックスは後から作り、テーブルを結合するクエリを書く。クエリの複雑さの大部分はデータベースエンジンが処理してくれます。

DynamoDBはそうではありません。リレーショナルデータベースと異なり、クエリプランナーによって任意のクエリを最適化することはありません。代わりに、パーティションキーのハッシュを使って、各リクエストをデータを保持するパーティションへ直接ルーティングします。これこそ、テーブルサイズにかかわらずDynamoDBが一貫して1桁ミリ秒のレイテンシーを実現できる理由のひとつです。そのトレードオフとして、このデータベースはただひとつのこと、すなわちプライマリキーまたはセカンダリインデックスを通じた、キーベースのアクセスパターンによる効率的なアイテム取得に最適化されています。それ以外のすべて(フィルタリング、キー以外の属性によるソート、エンティティ間の関係をまたぐクエリ)は、コストが高くつくか、事前に設計された追加のインデックスを必要とします。

求められる転換はこうです。テーブルを設計する前に、アクセスパターンを把握しておく必要があります。それも、おおまかにではなく具体的に。「あるユーザーのすべての注文を日付順で取得する」は設計のインプットです。「特定のアカウントでエスカレーションされた直近10件のサポートチケットを取得する」も設計のインプットです。スキーマはデータの形からではなく、これらの問いから逆算して構築します。

実際には、1つのDynamoDBテーブルに複数のエンティティタイプを並べて格納し、特定の問いに効率的に答えられるようパーティションキーとソートキーを構成することがよくあります。正規化されたSQLの世界から来た人には間違いに見えるでしょう。しかし間違いではありません。それこそが狙いなのです。

複合ソートキー

よくある例を見てみましょう。あるユーザーの注文を、ステータスと日付で検索するケースです。素朴なアプローチでは、ステータスと日付を別々の属性として保存し、読み取り時にフィルタリングします。その結果、そのユーザーの全注文を読み取り、条件に合わないものを捨てることになります。

アクセスパターン優先のアプローチでは、クエリをソートキーに直接エンコードします。

PK SK
user#123 ACTIVE#2024-07-15
user#123 ACTIVE#2024-07-01
user#123 COMPLETED#2024-06-20
user#123 COMPLETED#2024-06-10

これで、BEGINS_WITH("ACTIVE#")を使った1回のクエリで、完了済みの注文を1件も読み取ることなく、アクティブな注文だけを日付順で取得できます。支払うのは、実際に取得した分だけです。

これが機能する理由は、BEGINS_WITHがソートキーに対するプレフィックスクエリだからです。DynamoDBが範囲の絞り込みに使えるよう、最も粗い粒度のグルーピングを先頭に置く必要があります。一般的な方法は、最も広い条件を左側に置き、より具体的な要素を右側へと段階的に配置していくことです。こうすることで、プレフィックスクエリで目的のサブセットを効率よく絞り込めます。

スパースなグローバルセカンダリインデックス

アクセスパターン優先の考え方から自然に導かれるもうひとつのパターンがあります。条件を満たすアイテムがごく一部に限られる場合(エスカレーションされたチケット、フラグ付きレコード、レビュー待ちのアイテムなど)、その条件を「該当するアイテムにのみ存在する属性」としてモデル化します。その属性を基に構築したGSIは、属性が存在するアイテムだけをインデックス化します。

ここで鍵となる挙動は、インデックス対象の属性が存在しない場合(nullではなく、アイテムから完全に欠落している場合)、DynamoDBはそのアイテムをGSIに含めないという点です。つまり、インデックス化したくないアイテムでその属性を省略するだけで、意図してスパースなGSIを実現できるのです。

チケットの2%がエスカレーションされているなら、GSIにはテーブルの2%だけが含まれます。テーブル全体をスキャンしてフィルタリングするより、はるかに安価にクエリできます。このGSIは偶然ではなく、設計によってスパースなのです。

Your cloud bill shouldn't be a mystery

Optimization, automation, expertise. In one platform.

請求書に跳ね返るミス

これらをあえて個別に取り上げるのは、いずれもよくあるミスであり、コストレポートを確認するまで表面化せず、しかもDynamoDBをそれなりに理解しているエンジニアによって持ち込まれることが多いからです。

フィルタ式

DynamoDBは、取得後のクエリ結果のフィルタリングをサポートしています。問題は、FilterExpressionが消費するキャパシティユニットを減らすわけではないという点です。減るのは呼び出し元に返されるデータだけです。フィルタが実行される前にクエリが500アイテムを読み取っていれば、呼び出し元に返るアイテム数にかかわらず、その時点までに読み取ったすべてのデータに対して課金されます。

これを結果の絞り込みの主戦略にすると、コストは急速に膨らみます。SQLのWHERE句のような感覚で使えるため、コストを意識せずつい手が伸びてしまいがちです。対処法はほぼ常に、データベースが必要なものだけを取得するようキー構造を再設計することです。フィルタ式には使いどころがありますが、クエリ戦略の屋台骨にしてはいけません。

時間ベースのキーによるホットパーティション

時系列データを構築する際によくあるパターンとして、現在のタイムスタンプや現在の時刻(時間単位)をパーティションキーに使うことがあります。時間バケットで分割するという発想は直感的に思えます。問題は、すべての書き込みが同じ論理パーティションキーに集中し、DynamoDBが書き込みトラフィックをパーティション間に均等に分散できなくなることです。ある程度の書き込み量になると、これはスロットリングにつながります。

DynamoDBにはAdaptive Capacityがあり、トラフィックが集中しているパーティションへスループットを自動的に再配分してくれます。偏りが中程度であれば、多くの場合この問題を意識せずに済みます。しかし、単一のパーティションキーに極端なトラフィックが継続的に集中する状況は、Adaptive Capacityがどれだけあっても補いきれません。高い書き込み負荷がかかる時間バケットキーは、まさにその状況なのです。

対処法は書き込みシャーディングです。パーティションキーにランダムなサフィックスを付加し(例:EVENTS#4。サフィックスは0からNの間の数値)、書き込みを複数の論理パーティションに分散させます。読み取り時には各シャードを並列にクエリして結果をマージします。アプリケーションコードは増えますが、高スループットの書き込みワークロードが単一パーティションでボトルネックにならないようにするための手法です。

テーブルスキャン

3つ目に挙げておきたいのが、フルテーブルスキャンです。DynamoDBはScanオペレーションをサポートしており、GSIを使い切ったときや、インデックスのない属性でクエリする必要があるとき、エンジニアはつい手を伸ばしがちです。問題は、スキャンが条件に一致する件数にかかわらずテーブル内の全アイテムを読み取ることです。大規模なテーブルでは、遅いうえに高くつきます。

移行、エクスポート、管理ジョブ、レイテンシーが問題にならないバックグラウンド処理であれば、スキャンはまったく妥当です。問題は、ユーザー向けのリクエストパスで使うことです。アプリケーションのクリティカルなフローにスキャンが含まれている場合、それはほぼ間違いなく、スキーマ設計でアクセスパターンが十分に考慮されていなかったというシグナルです。解決策は通常、より速いスキャンではなく、新しいGSIまたはソートキーの再設計です。

おわりに

DynamoDBは本当に強力なデータベースであり、適切なワークロードに対してはAWSが提供する中でも屈指の選択肢です。しかし、「正しいデータベースを選んだ」と「正しく設計した」の間には大きな隔たりがあり、その隔たりは何よりも先にAWSの請求書に現れます。コストモデルも設計プロセスも、そして陥りがちなミスも、多くのエンジニアがこれまで経験してきたものとは異なるのです。

新しいワークロードでDynamoDBを検討している場合も、すでに本番環境にテーブルがあり、そのコストの説明に苦労している場合も、これはまさにDoiTがAWSのお客様と日々取り組んでいる仕事です。100名を超えるクラウドエキスパートのチームが、アクセスパターンの検証、設計上の問題の早期発見、そしてすでにお支払いいただいているサービスの最大活用を支援します。デモを予約する