DynamoDB2年本番運用で痛感した、Single Table Designの本当の使い所

RDS感覚で設計したら月60万円の請求。DynamoDBで2年失敗と工夫を重ねて見えた、本当に必要な設計パターンと避けるべき落とし穴を実例で解説します。

DynamoDBとの付き合い始めた当初の失敗

うちのチームが本格的にDynamoDBを導入したのは2024年の春。マイクロサービスのプロジェクトで、従来のRDSだと遅い・スケールしない、みたいな判断だった。だけど正直、最初の3ヶ月は地獄だった。

テーブル設計を甘く見てました。RDSの感覚で「ユーザーテーブル」「注文テーブル」「アイテムテーブル」みたいに分けて、複数のGSI(グローバルセカンダリインデックス)を張りまくったんです。そしたら本番で請求書を見て絶望。月60万円ですよ。

原因は簡単で、無駄なキャパシティプロビジョニングと、読み書き両方にプロビジョニング容量を確保してたこと。オンデマンドモード使えばよかった、と何度思ったか。

Single Table Design、本当に必要な時とそうじゃない時

先日、チームで読み直す機会があって思ったのは、Single Table Designは「銀の弾」じゃないってこと。巷では「DynamoDBならSingle Table Design!」みたいに言われますが、実際に運用してみるとめっちゃ複雑になります。

去年の中盤、僕らが試したのはこんな構成。

テーブル構成:
PK: EntityType#EntityId (例: USER#user123, ORDER#order456)
SK: Timestamp#DataType (例: 2025-06-13T10:30:00Z#PROFILE, 2025-06-13T10:30:00Z#ITEMS)
GSI1PK: EntityType
GSI1SK: CreatedAt
GSI2PK: Status
GSI2SK: UpdatedAt

理屈としては効率的です。でも運用してて気づいたのは、複数のエンティティタイプを同じテーブルに詰め込むと、以下の問題が出てくる:

1. アクセスパターンが複雑になる

クエリが増えれば増えるほど、どのGSIを使うべきか判断が難しくなる。チーム内で「あ、このクエリはGSI2の方が効率的だった」みたいな知見が共有されないまま、無駄な読み込みが発生する。

2. 容量計画が困難

複数のエンティティを詰め込むと、どのタイプのアクセスが多いのか把握しにくくなります。結果、オーバープロビジョニングになりやすい。

3. フィルタリングが増える

DynamoDBはクエリ結果のフィルタリングが無料じゃない。取得後に EntityType でフィルタリングすると、読み込み容量を無駄に消費しちゃうんです。

4ヶ月運用した結果、僕らは戦略を変更しました。「アクセスパターンが明確に分離できるエンティティはテーブルを分ける」 という判断ですね。

現状こんな感じで運用してます。

テーブル名用途PKSKキャパシティモード
usersユーザーマスタUserIdCreatedAtオンデマンド
orders注文データOrderIdCreatedAtオンデマンド
user-ordersユーザー別注文一覧UserIdOrderIdオンデマンド
inventory在庫管理ProductIdLastUpdatedプロビジョニング(予測可能)

実装としては複数テーブルにはなりますが、各テーブルのアクセスパターンが明確になって、容量計画が圧倒的に楽になりました。月の請求額も40万円まで下がったので、これはデカい。

キャパシティモード、本当の使い分け

オンデマンドとプロビジョニングで2年悩み続けてます。

オンデマンドはシンプルですよ。スパイク対応が必要な場合、読み書きのトラフィックが予測不可能な場合、本当に最高です。ただしコストは割高。オンデマンドでの読み込みは $1.25/100万RCU だから、トラフィックが多いと痛い。

プロビジョニングは安いけど、キャパシティを正確に予測しないと地獄。去年、在庫テーブルで朝9時に定期バッチが走るんですけど、それを考慮せずプロビジョニング構成にしたら、その時間帯だけ ProvisionedThroughputExceededException が頻発して、本番障害になりました。運用チームから怒られました。

現在のうちのチーム基準はこう。

  • オンデマンド推奨:API系(ユーザー検索、注文取得)、トラフィックスパイクが大きい場合
  • プロビジョニング推奨:バッチ処理、定時実行ジョブ、予測可能なトラフィックパターン

ただし、Autoscaling設定を絶対忘れるな という教訓。プロビジョニング選んだら、CloudWatchメトリクスを見て自動スケーリング設定するのが必須です。

# boto3でプロビジョニング設定
dynamodb = boto3.client('dynamodb')

dynamodb.update_table(
    TableName='inventory',
    ProvisionedThroughputDescription={
        'ReadCapacityUnits': 10,
        'WriteCapacityUnits': 10
    }
)

# オートスケーリング設定(別途)
autoscaling = boto3.client('application-autoscaling')
autoscaling.register_scalable_target(
    ServiceNamespace='dynamodb',
    ResourceId='table/inventory',
    ScalableDimension='dynamodb:table:WriteCapacityUnits',
    MinCapacity=10,
    MaxCapacity=1000
)

ローカル開発、DynamoDB Localは本当に使えるのか

DynamoDB Localで開発してから本番に上げたら動かない、みたいな経験ありませんか? 僕らもあります。まじで。

Localで問題になるのはこんなところ:

1. スループットモデルの差異

Localはスループット制限がないので、本番で容量超過になることを検出できない。開発では余裕だったのに本番でエラーになるんです。

2. TTL機能

Localはスキャンすると有効期限切れアイテムが削除されない。本番と挙動が違う。

3. GSIの一貫性

Localのeventually consistent読み込みは、実装レベルでは違ってくることがある。

正直、LocalでOKでも本番で失敗することはある。だから今は、本番環境のDynamoDBをテーブル単位で作って、テスト時にそこに直結させてます。開発用AWS環境のコストは増えますが、本番障害を減らす方が安いってのが判断ですね。

# テスト用に開発環境DynamoDBを使う例
import boto3
from moto import mock_dynamodb  # Localテストは軽い検証だけ

@mock_dynamodb
def test_query_locally():
    """LocalテストはAPIロジックの検証程度"""
    pass

def test_integration():
    """本物のDynamoDBで統合テスト"""
    table = boto3.resource('dynamodb', region_name='ap-northeast-1').Table('dev-users')
    # 実際のテーブルに対してテスト

TTL・Global Tables、本番で痛感した落とし穴

TTL設定は簡単に見えます。属性に ExpirationTime を足して、自動削除させるだけ。でも本番で問題が出たのは、削除にラグがある ってこと。

公式ドキュメントでは「通常、有効期限切れアイテムはExpiration時刻から48時間以内に削除される」って書いてあります。つまり、削除されるまでに数時間〜48時間かかる可能性があるわけですよ。

僕らの場合、セッションテーブルでTTLを使ってたんですけど、ある日ユーザーが「ログアウト後、60時間たった後でもセッション有効だった」って報告が来た。確認したら、TTLで消える前にスキャンで引っかかってたんです。げんなりしました。

対策としては、クエリする時に有効期限を確認する ロジックを入れることにしました。

from datetime import datetime, timezone

def get_session(user_id, session_id):
    table = boto3.resource('dynamodb').Table('sessions')
    response = table.get_item(Key={'UserId': user_id, 'SessionId': session_id})
    
    if 'Item' in response:
        item = response['Item']
        # TTL確認:削除されるまでの間、無効判定する
        if item.get('ExpirationTime', 0) < int(datetime.now(timezone.utc).timestamp()):
            return None  # セッション無効
        return item
    return None

Global Tablesについても、マルチリージョン対応で導入したんですが、これは 最終的一貫性 が想像以上に難しい。レプリケーション遅延は通常100ms以下ですが、ネットワーク混雑時は1秒超えることもあるんです。クライアント側で書き込み直後に別リージョンから読むと、古いデータが返ってくる可能性がある。

そういう場合は、クライアント側で読み込み時に「書き込み時刻」を保存しておいて、レプリケーション完了を待つロジックを入れるしかない。めんどくさいですが、マルチリージョン対応するなら必須だと思います。

スケーリング、予測できない急増への対応

2025年年末に、新機能ローンチで急にトラフィック3倍になったことがありました。プロビジョニング容量では足りず、オンデマンドに一時的に切り替えた。その時のコストが月150万円。地獄ですよ。

急速なスケーリングに対応するには、こんな対策が有効。

1. 事前にオンデマンドへ切り替え

スパイク予測がある場合、あらかじめオンデマンドに変更しておく。スイッチは数分で可能だから、リスク回避としては強い。

2. DAX(DynamoDB Accelerator)の導入

キャッシュレイヤーを入れると、読み込み頻度の高いアイテムへのアクセスが圧倒的に早い。月の追加コストは5〜10万円程度ですが、本番障害を避けられるなら安い投資。

# DAXの設定例
import amazondax

client = amazondax.AmazonDaxClient.resource(
    region_name='ap-northeast-1',
    endpoints=['my-dax-cluster.2fdiqe.clustercfg.dax.amazonaws.com:8111']
)

response = client.Table('users').get_item(Key={'UserId': 'user123'})
# DAXがキャッシュしてるので、2回目以降のリクエストは数msで返る

実装してみた結果、読み込みレイテンシが平均40msから5msに改善。本当に効きます。

2026年時点でのベストプラクティス、正直な感想

DynamoDBの設計ベストプラクティスは毎年変わっています。2026年現在のうちのチームの基準はこんな感じ。

graph TB
    Start[DynamoDB導入検討]
    Start --> Q1{アクセスパターン<br/>予測可能?}
    Q1 -->|Yes| Q2{QPS<br/>1000以上?}
    Q1 -->|No| OD[オンデマンド推奨]
    Q2 -->|Yes| Prov[プロビジョニング推奨]
    Q2 -->|No| Q3{複数テーブルで<br/>分離可能?}
    Q3 -->|Yes, Clear Separation| Multi[マルチテーブル設計]
    Q3 -->|No or Unclear| Single[Single Table Design]
    Prov --> AS[Autoscaling設定必須]
    Multi --> GSI[GSI設計を慎重に]
    Single --> Idx[インデックス戦略を整理]
    AS --> DAX{読み込み<br/>集中型?}
    GSI --> DAX
    DAX -->|Yes| DaxImpl[DAX導入]
    DAX -->|No| Monitoring[CloudWatch監視]
    DaxImpl --> Monitoring
    Monitoring --> Done[本番投入]

実装のコツとしては、こんなところが大事だと思う。

  • テーブル設計は最初が全て。後から変更するのはめちゃくちゃ大変(実際に2回壊した)
  • クエリアクセスパターンを最初にドキュメント化。それが設計の指針になる
  • 本番環境でカオスエンジニアリングを実施。スパイク想定で容量超過テストをやる
  • オンデマンドとプロビジョニングの切り替えは簡単だから、最初はオンデマンドで様子見する が無難

実務としては、「DynamoDBはスケーラブル」というのは正しいですが、「設計次第」という条件付きなんです。設計が悪いと、PostgreSQLより遅くなることもありますからね。

まとめ

DynamoDBを本番で2年運用して学んだことは、結局 設計が全て ということ。NoSQL自体の特性もあるけど、アクセスパターン理解が甘いと本番で大火傷します。

  1. Single Table Designは銀の弾じゃない。アクセスパターンが明確に分離できるなら、テーブルを分ける方が運用は楽だし、コストも削減できる

  2. キャパシティモードの選択は重要。オンデマンドで安心買うか、プロビジョニングでコスト削減するか、トレードオフを理解してから選ぶ必要がある

  3. TTL・Global Tables・Autoscalingは細部が重要。ドキュメント読むだけじゃダメ。本環境で検証必須だと痛感した

  4. DAXはコスパ最強。読み込み集約的なワークロードなら、本当に検討する価値あり

  5. ローカル開発と本番の環境差異は大きい。テスト時は実環境のDynamoDBを使うくらいの覚悟で

DynamoDBは本当に強力ですが、RDBの感覚では設計できません。これから導入する人は、小さいテーブルから始めて、失敗を重ねる方が結果的に近道だと思いますよ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事