DynamoDB Single Table Designを本番導入して1年——失敗から学んだアクセスパターン設計

「テーブル1つに全部入れる?」と懐疑的だったチームで強行したSingle Table Design。GSI設計のやらかしや過負荷パーティション問題など、1年半の本番運用でぶつかったリアルな失敗談をまとめました。

DynamoDB Single Table Designを本番導入して1年——アクセスパターン設計の失敗と学んだこと

正直に言うと、Single Table Design(以下STD)を最初に提案したとき、チームの誰もが懐疑的だった。「テーブル1つに全部入れる? RDBの正規化と真逆じゃん」という反応は当然で、自分も確信があったわけじゃない。でも、Lambdaを中心にしたサーバーレスシステムを設計する中で、RDBの感覚でDynamoDBを使い続けることへの限界を感じていたのも事実だった。

あれから1年半。本番で踏んだ失敗も、「これは本当に使えるな」と思った瞬間も、正直に書いておこうと思う。Lambda Cold Startで本番が死んだ話と同様に、事前に知っておけば避けられた失敗が多かったので。

そもそもなぜSingle Table Designなのか、改めて整理する

DynamoDBはNoSQLの中でも、アクセスパターンを先に決めてテーブル設計するという思想が徹底されている。RDBでは「とりあえず正規化してJOINで柔軟に」という設計が許されるけど、DynamoDBにJOINはないし、後からインデックスを増やすにも上限がある(GSIは最大20個、LSIは5個)。

STDの核心は、複数エンティティを1テーブルに同居させることで、1回のリクエストで複数種類のデータを取得できるようにすること。マイクロサービスのサーバーレス環境では、これが劇的にレイテンシを下げる。

flowchart LR
    A[Multi Table Design] --> B[User Table]
    A --> C[Order Table]
    A --> D[Product Table]
    B --> E[3回のQuery]
    C --> E
    D --> E
    E --> F[アプリで結合]
    
    G[Single Table Design] --> H[1つのTable]
    H --> I[1回のQuery]
    I --> J[全データ取得]
    
    style G fill:#2ecc71,color:#fff
    style I fill:#2ecc71,color:#fff

これは理論上の話なので、「実際にどのくらい違うの?」という話をすると、うちのケースでは注文詳細ページの表示に必要なAPIコールが3回から1回になって、P99レイテンシが約40%改善した。ただし、この改善を得るためにアクセスパターン設計に2週間かけたのも事実。「設計2週間 → 実装が楽になる」というトレードオフで、個人的には悪くない投資だったと思っている。

アクセスパターン設計、最初に全部洗い出さないと後で地獄になる

STDで最初にやるべきことはアクセスパターンを全量洗い出すことなんだけど、これを「後でGSIで対応できるでしょ」と軽く見たのが失敗の始まりだった。

実際に使ったスキーマの一部を公開する。ECサイト的なシステムで、UserOrderProductが主なエンティティだった。

# PK/SK設計の基本形
PK                    SK                        データ
USER#user-123         METADATA                   ユーザープロフィール
USER#user-123         ORDER#2026-05-01#ord-abc   注文ヘッダ
USER#user-123         ORDER#2026-04-15#ord-xyz   注文ヘッダ
ORDER#ord-abc         ITEM#prod-001              注文明細
ORDER#ord-abc         ITEM#prod-002              注文明細
PRODUCT#prod-001      METADATA                   商品情報
PRODUCT#prod-001      CATEGORY#electronics       カテゴリ紐付け

この設計で対応できるアクセスパターンとGSI要否をまとめると:

アクセスパターンPKSK条件GSI必要?
ユーザー情報取得USER#{userId}= METADATANo
ユーザーの注文一覧(新しい順)USER#{userId}begins_with(ORDER#)No
注文の明細取得ORDER#{orderId}begins_with(ITEM#)No
商品情報取得PRODUCT#{productId}= METADATANo
カテゴリ別商品一覧CATEGORY#{category}Yes (GSI1)
ステータス別注文検索STATUS#{status}Yes (GSI2)
商品別注文数集計PRODUCT#{productId}ORDER#Yes (GSI3)

GSI1とGSI2は最初から想定できていたんだけど、GSI3は後から「商品別の売上レポートが欲しい」という要件が追加されて、スキーマを変更することになった。これが地味に痛かった。既存データのマイグレーションをLambdaで流す作業が発生して、1日がかりだった。

教訓:アクセスパターンは実装前にステークホルダー全員から聞き出す。後からのGSI追加はデータ移行コストが重い。

GSI設計のコード実装例

CDKで定義したテーブル定義はこんな感じ。2026年時点ではaws-cdk-lib/aws-dynamodbのv2 APIが安定していて使いやすい。

import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';

export class SingleTableStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new dynamodb.TableV2(this, 'MainTable', {
      tableName: 'app-single-table',
      partitionKey: { name: 'PK', type: dynamodb.AttributeType.STRING },
      sortKey: { name: 'SK', type: dynamodb.AttributeType.STRING },
      billing: dynamodb.Billing.onDemand(), // PAYGで始めるのがおすすめ
      pointInTimeRecovery: true,
      encryption: dynamodb.TableEncryptionV2.awsManagedKey(),
      removalPolicy: RemovalPolicy.RETAIN,
      globalSecondaryIndexes: [
        {
          indexName: 'GSI1',
          partitionKey: { name: 'GSI1PK', type: dynamodb.AttributeType.STRING },
          sortKey: { name: 'GSI1SK', type: dynamodb.AttributeType.STRING },
          projectionType: dynamodb.ProjectionType.ALL,
        },
        {
          indexName: 'GSI2',
          partitionKey: { name: 'GSI2PK', type: dynamodb.AttributeType.STRING },
          sortKey: { name: 'GSI2SK', type: dynamodb.AttributeType.STRING },
          projectionType: dynamodb.ProjectionType.KEYS_ONLY, // コスト意識して必要最小限に
        },
      ],
    });
  }
}

TableV2Tableの後継で、2026年時点ではこちらが推奨。billing.onDemand()でキャパシティ管理から解放されるのが地味に便利で、スパイクトラフィックが読めないサービス初期には特にありがたかった。

AWS構成図:サーバーレスSTDシステムの全体像

実際に運用しているシステムの構成はこんな感じ。Lambda + DynamoDB + EventBridgeを中心にしたサーバーレス構成で、イベント駆動アーキテクチャ実装ガイドでも紹介したイベント駆動パターンを組み合わせている。

graph TB
    subgraph Internet
        Client[クライアント]
    end

    subgraph AWS_Account[AWS Account]
        subgraph API_Layer[API Layer]
            APIGW[API Gateway v2<br/>HTTP API]
        end

        subgraph Lambda_Layer[Lambda Layer]
            ReadFn[Read Lambda<br/>Node.js 22.x]
            WriteFn[Write Lambda<br/>Node.js 22.x]
            StreamFn[Stream Processor<br/>Lambda]
        end

        subgraph Data_Layer[Data Layer]
            DDB[(DynamoDB<br/>Single Table)]
            DDBStream[DynamoDB Streams]
        end

        subgraph Event_Layer[Event Layer]
            EB[EventBridge<br/>Custom Bus]
            SQS[SQS DLQ]
        end

        subgraph Cache_Layer[Cache Layer]
            DAX[DynamoDB DAX<br/>クラスター]
        end

        subgraph Monitoring[Monitoring]
            CW[CloudWatch<br/>Logs & Metrics]
            XRay[X-Ray]
        end
    end

    Client --> APIGW
    APIGW --> ReadFn
    APIGW --> WriteFn
    ReadFn --> DAX
    DAX --> DDB
    WriteFn --> DDB
    DDB --> DDBStream
    DDBStream --> StreamFn
    StreamFn --> EB
    StreamFn --> SQS
    ReadFn --> CW
    WriteFn --> CW
    StreamFn --> XRay

DAXを挟んでいるのは、読み取りが多いエンドポイント(商品詳細ページ)のキャッシュ目的。DAXのキャッシュヒット率が90%を超えたあたりから、DynamoDBへの直接読み取りコストが大幅に下がった。ただしDAXはVPC内にしか置けないので、LambdaもVPCに入れる必要があって、Cold Startへの影響は別途対策が必要だった(VPC Lambda + SnapStartの話はLambda SnapStart導入3ヶ月を参照)。

本番で踏んだホットパーティション問題と対処法

STDで一番怖いのはホットパーティションだと思う。DynamoDBはデータをパーティションに分散させるが、PK設計が悪いとリクエストが特定パーティションに集中して、スロットリングが起きる。

うちで実際に起きた例を挙げると、フラッシュセール時に人気商品ページへのアクセスが集中して、PRODUCT#prod-bestsellerのパーティションがスロットリングされた。読み取りは最終的にDAXに移したので解決したんだけど、書き込み(在庫更新)は直接DynamoDBに当たるので別の対策が必要だった。あのときのアラートは今でも覚えている——深夜2時にSlackが鳴り響いた。

採用した解決策は書き込みシャーディング

// 在庫更新時にPKをシャーディング
const SHARD_COUNT = 10;

async function updateInventory(productId: string, delta: number): Promise<void> {
  const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
  const shardedPK = `PRODUCT#${productId}#shard${shardIndex}`;
  
  const command = new UpdateItemCommand({
    TableName: 'app-single-table',
    Key: {
      PK: { S: shardedPK },
      SK: { S: 'INVENTORY' },
    },
    UpdateExpression: 'ADD #stock :delta',
    ExpressionAttributeNames: { '#stock': 'stock' },
    ExpressionAttributeValues: { ':delta': { N: String(delta) } },
  });
  
  await ddbClient.send(command);
}

// 在庫読み取り時はシャードを集計
async function getInventory(productId: string): Promise<number> {
  const keys = Array.from({ length: SHARD_COUNT }, (_, i) => ({
    PK: { S: `PRODUCT#${productId}#shard${i}` },
    SK: { S: 'INVENTORY' },
  }));
  
  // BatchGetItemで一括取得
  const command = new BatchGetItemCommand({
    RequestItems: {
      'app-single-table': { Keys: keys },
    },
  });
  
  const result = await ddbClient.send(command);
  const items = result.Responses?.['app-single-table'] ?? [];
  
  return items.reduce((sum, item) => {
    return sum + Number(item.stock?.N ?? 0);
  }, 0);
}

この方式、書き込みは分散できるけど読み取りがBatchGetItemで10回分になるので、読み取りの頻度が高い場合はDAXキャッシュと組み合わせるのが現実的。在庫数は30秒キャッシュ程度の鮮度で十分なユースケースなら、これで大幅に改善できた。正直、シャーディングの実装自体はそんなに難しくないんだけど、「そもそもシャーディングが必要な設計になっている時点でPKを見直すべきでは?」と後から思ったりもした。

運用1年で計測したコストとパフォーマンスの実績

xychart-beta
    title "マルチテーブル vs Single Table: 月次コスト比較(USD)"
    x-axis [1月, 2月, 3月, 4月, 5月, 6月, 7月, 8月, 9月, 10月, 11月, 12月]
    y-axis "コスト (USD)" 0 --> 500
    bar [380, 395, 410, 420, 390, 350, 320, 310, 295, 280, 275, 260]
    line [380, 395, 410, 420, 390, 350, 320, 310, 295, 280, 275, 260]

移行前(マルチテーブル構成)の月次コストが平均420ドルだったのに対し、STD移行後の6ヶ月平均は約310ドル。約26%削減できた。主な要因は不要なGSIの統廃合と、複数テーブルへの重複書き込みがなくなったこと。コスト削減はおまけのつもりだったけど、地味にマネージャー受けがよかった。

パフォーマンス面の改善も数字に出た:

メトリクス移行前移行後改善率
注文詳細ページ P50180ms85ms▲53%
注文詳細ページ P99620ms380ms▲39%
DynamoDB読み取りコスト/月$280$195▲30%
スロットリングエラー率0.8%0.05%▲94%

スロットリングエラー率の改善はDAX導入とシャーディング対策の複合効果なので、STD単体の効果とは言い切れないけど、全体的には満足している。

正直、STDが合わないケースもある

ここは好みが分かれるかもしれないし、正直まだ検証中の部分もあるんだけど、STDを全部のユースケースに適用するのは危ないと感じている。

うちのチームで「これはSTDより別テーブルの方がよかった」となったのは、集計・分析クエリが多いエンティティだった。具体的には売上分析テーブル。アクセスパターンが事前に確定できない分析用途では、GSIを増やし続けることになって、管理コストが増えた。最終的にDynamoDB StreamsでS3に流してAthenaで分析するアーキテクチャに切り替えた。BigQuery vs Athena vs Redshift の比較でも書いたけど、分析用途はS3+Athenaが圧倒的に柔軟。

flowchart TD
    DDB[DynamoDB Single Table] -->|Streams| Lambda[Stream Processor]
    Lambda -->|JSONに変換| S3[S3 Data Lake]
    S3 --> Athena[Athena]
    S3 --> QuickSight[QuickSight]
    
    DDB -->|リアルタイム読み取り| App[アプリケーション]
    
    style S3 fill:#FF9900,color:#fff
    style Athena fill:#8C4FFF,color:#fff

トランザクショナルな操作はDynamoDB STD、分析はS3+Athenaというパターンが、2026年時点で自分が最もおすすめできる構成だ。

STDに向いている/向いていないケースを整理すると:

観点STDが向いているSTDが向いていない
アクセスパターン比較的固定・要件が明確事前に確定できない
クエリの種類トランザクション処理が中心複雑なJOIN・集計が頻発
チーム習熟度NoSQL設計の知識があるNoSQL設計の知識がほぼない
スキーマの安定性変更頻度が低いエンティティ関係が複雑で頻繁に変わる
優先事項低レイテンシ・コスト最適化分析の柔軟性

皆さんはどうしてます? STDを選んだ判断基準って、けっこうチームによって違いそうだなと思っていて、X(旧Twitter)で「#DynamoDBSTD」で検索するとコミュニティの議論が面白い。

まとめ

1年半の本番運用を振り返って、STDを選んで後悔はしていない。ただ、「DynamoDBはSTDが正解」という教科書的な理解で始めると必ず痛い目を見る——実際に痛い目を見たので断言できる。

教訓具体的な対策
アクセスパターンは全量洗い出す実装前にステークホルダー全員にヒアリング。後からのGSI追加はデータ移行が重い
GSIのProjectionTypeを意識するALLは避け、KEYS_ONLYかINCLUDEで必要なものだけに絞る
ホットパーティション対策はセットでDAX(読み取り)+書き込みシャーディングの両方が必要
分析用途はSTDに無理やり入れないDynamoDB Streams → S3 → Athenaに逃がす
STDはすべての問題を解決しないチームの習熟度とユースケースをちゃんと評価してから導入する

次のアクション:

  • まずは小さなマイクロサービス1つで試してみる(全面移行はリスクが高い)
  • Alex DeBrieの「The DynamoDB Book」は2026年版でアップデートされていて読む価値がある
  • AWS公式の「DynamoDB Design Patterns」ドキュメントは2025年末に大幅改訂されたので再確認推奨
  • CloudWatch Contributor Insightsを有効化してホットキーを事前に監視する

うちのチームはまだDAXのキャパシティ設計を最適化している最中で、正直ここは試行錯誤が続いている。「STDを本番で使っているよ」という方、どんな失敗をしたか教えてもらえると嬉しい。

U

Untanbaby

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

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

関連記事