DynamoDB Single Table Designを本番2年使って正直に書く|RDS出身者が感じた壁と突破口

「テーブル1個にまとめるって設計の敗北じゃない?」RDS10年超のエンジニアが抱いた疑問から始まり、GSI設計の失敗・アクセスパターン定義のコツまで泥臭く語ります。

DynamoDB Single Table Designと向き合った2年間

うちのチームがDynamoDB Single Table Design(以下STD)を本番に投入したのは2年ちょっと前の話だ。最初は正直かなり懐疑的だった。「テーブルを1個にまとめるって、設計の敗北じゃないの?」って思ってた。RDSで10年以上やってきたエンジニアとしては、どうしても正規化された複数テーブル設計が「正しい」と頭に刷り込まれてたんですよね。

でも今は完全に考えが変わった。適切なアクセスパターンを事前に定義さえすれば、STDはサーバーレスアーキテクチャと相性が抜群だということを実感している。問題は「適切に」という部分で、ここで相当ハマった。この記事はその失敗から学んだ知見をまとめたものだ。

参考までに、うちのサービス全体のアーキテクチャはこんな感じ。API Gateway + Lambda + DynamoDBという典型的なサーバーレス構成に、非同期処理でEventBridgeとSQSを組み合わせている。

graph TB
    subgraph Client
        WEB[Web App]
        MOB[Mobile App]
    end

    subgraph AWS_Cloud[AWS]
        subgraph Edge
            CF[CloudFront]
            WAF[AWS WAF]
        end

        subgraph API_Layer[API Layer]
            APIGW[API Gateway v2\nHTTP API]
        end

        subgraph Compute[Compute - Lambda]
            LF1[Lambda\nUser Service]
            LF2[Lambda\nOrder Service]
            LF3[Lambda\nProduct Service]
            LF4[Lambda\nStream Processor]
        end

        subgraph Data[Data Layer]
            DDB[(DynamoDB\nSingle Table)]
            DDB_STREAM[DynamoDB Streams]
        end

        subgraph Async[非同期処理]
            EB[EventBridge]
            SQS[SQS + DLQ]
        end

        subgraph Monitoring[Observability]
            CW[CloudWatch]
            XRAY[X-Ray]
        end
    end

    WEB --> CF
    MOB --> CF
    CF --> WAF
    WAF --> APIGW
    APIGW --> LF1
    APIGW --> LF2
    APIGW --> LF3
    LF1 --> DDB
    LF2 --> DDB
    LF3 --> DDB
    DDB --> DDB_STREAM
    DDB_STREAM --> LF4
    LF4 --> EB
    EB --> SQS
    SQS --> LF2
    LF1 --> CW
    LF2 --> CW
    CW --> XRAY

まず最初の失敗——アクセスパターンを「後から」考えようとした

STDで一番重要なのはアクセスパターンの事前定義だ。これはあらゆる入門記事に書いてある。でも実際には「わかってるつもり」で甘く見ていた。

最初にやらかしたのは、既存のRDBスキーマをそのままDynamoDBに移植しようとしたことだ。usersテーブルの感覚で設計を始めて、後から「あのクエリができない」「このGSIが足りない」を繰り返した。DynamoDBはRDBじゃない。RDBの感覚でデータモデルを考え始めると確実に詰まる。

最終的に落ち着いたのは、アクセスパターン一覧表を最初に全部洗い出してからテーブル設計を始めるというフロー。具体的にはこんな感じで整理した。

アクセスパターンPKSKGSI頻度
ユーザー情報取得(ID指定)USER#<userId>PROFILE-非常に高
ユーザーの注文一覧USER#<userId>ORDER#<timestamp>-
注文詳細取得ORDER#<orderId>DETAIL-
商品別注文一覧--GSI1: PK=PRODUCT#<productId>
ステータス別注文取得--GSI2: PK=STATUS#<status> SK=<timestamp>
配送先住所の注文--GSI3: PK=ADDRESS#<addressId>

この一覧が完成してからスキーマ設計を始めないと確実に後悔する。うちの場合、最初の設計見直しで3週間潰れた。

PK/SKの設計パターン——実際に動くコードで見る

PK/SKの設計でよく使うパターンを実際のコードで見ていこう。うちはNode.js(TypeScript)+ DynamoDB Document Clientで実装している。

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
  DynamoDBDocumentClient,
  PutCommand,
  QueryCommand,
  GetCommand,
  TransactWriteCommand,
} from '@aws-sdk/lib-dynamodb';

const client = new DynamoDBClient({ region: 'ap-northeast-1' });
const ddb = DynamoDBDocumentClient.from(client);

const TABLE_NAME = 'MyAppTable';

// ユーザーとその注文を同一テーブルで管理するヘルパー関数群

// キー生成ヘルパー
const Keys = {
  user: (userId: string) => ({
    PK: `USER#${userId}`,
    SK: 'PROFILE',
  }),
  userOrder: (userId: string, timestamp: string) => ({
    PK: `USER#${userId}`,
    SK: `ORDER#${timestamp}`,
  }),
  order: (orderId: string) => ({
    PK: `ORDER#${orderId}`,
    SK: 'DETAIL',
  }),
};

// ユーザー作成
async function createUser(user: {
  userId: string;
  name: string;
  email: string;
}) {
  const now = new Date().toISOString();
  await ddb.send(
    new PutCommand({
      TableName: TABLE_NAME,
      Item: {
        ...Keys.user(user.userId),
        EntityType: 'USER',
        name: user.name,
        email: user.email,
        // GSI用属性
        GSI1PK: `EMAIL#${user.email}`,
        GSI1SK: `USER#${user.userId}`,
        createdAt: now,
        updatedAt: now,
      },
      ConditionExpression: 'attribute_not_exists(PK)',
    })
  );
}

// ユーザーの注文一覧を取得(最新順)
async function getUserOrders(
  userId: string,
  limit = 20
): Promise<Order[]> {
  const result = await ddb.send(
    new QueryCommand({
      TableName: TABLE_NAME,
      KeyConditionExpression: 'PK = :pk AND begins_with(SK, :skPrefix)',
      ExpressionAttributeValues: {
        ':pk': `USER#${userId}`,
        ':skPrefix': 'ORDER#',
      },
      ScanIndexForward: false, // 降順(最新が先頭)
      Limit: limit,
    })
  );
  return result.Items as Order[];
}

// 注文作成(ユーザーの注文一覧エントリも同時作成)
// Transactional Writeでアトミックに
async function createOrder(order: {
  orderId: string;
  userId: string;
  productId: string;
  amount: number;
  status: string;
}) {
  const now = new Date().toISOString();
  await ddb.send(
    new TransactWriteCommand({
      TransactItems: [
        {
          Put: {
            TableName: TABLE_NAME,
            Item: {
              ...Keys.order(order.orderId),
              EntityType: 'ORDER',
              orderId: order.orderId,
              userId: order.userId,
              productId: order.productId,
              amount: order.amount,
              status: order.status,
              // GSI2: ステータス別検索用
              GSI2PK: `STATUS#${order.status}`,
              GSI2SK: now,
              createdAt: now,
              updatedAt: now,
            },
            ConditionExpression: 'attribute_not_exists(PK)',
          },
        },
        {
          Put: {
            TableName: TABLE_NAME,
            Item: {
              ...Keys.userOrder(order.userId, now),
              EntityType: 'USER_ORDER',
              orderId: order.orderId,
              amount: order.amount,
              status: order.status,
              createdAt: now,
            },
          },
        },
      ],
    })
  );
}

このコードで押さえておきたいポイントがいくつかある。

EntityType属性を必ず付けること。STDでは複数のエンティティが1テーブルに混在するので、アプリケーション側でフィルタリングしやすくするためにエンティティ種別を明示しておく。これを後から追加するのは地味に面倒なので最初から入れること。

SKのプレフィックスを工夫すること。ORDER#<timestamp>のようにすることで、begins_withを使った効率的なクエリが可能になる。

GSI用の属性(GSI1PKGSI2PKなど)は汎用的な名前にすること。この命名規則はAlex DeBrieのパターンを参考にしたけど、実際に運用してみると確かに管理しやすい。

GSI設計でハマった落とし穴

正直これが一番しんどかった。GSIのキャパシティとコストについて、最初は全然意識していなかった。

まずGSI数の問題。DynamoDBのGSIは1テーブルあたり20個まで作れるが(2026年時点)、増やせばいいというものじゃない。GSIはメインテーブルのデータを非同期でレプリケートするため、書き込みコストが増大する。うちの場合、当初8個のGSIを定義したら書き込みコストが予想の2.3倍になった。体感としては「これくらいで済むだろう」という見積もりが、実際に請求書を見て青ざめる感じ。

xychart-beta
    title "GSI数とWrite CU消費量の関係(実測値)"
    x-axis ["GSI 0個", "GSI 2個", "GSI 4個", "GSI 6個", "GSI 8個"]
    y-axis "相対的なWrite CU消費" 0 --> 300
    bar [100, 130, 160, 200, 230]

対策として落ち着いたのが「Sparse Index」の活用だ。すべてのアイテムにGSI用の属性を持たせるのではなく、必要なアイテムにだけ属性を追加する設計にした。DynamoDBはアイテムにGSIのキー属性が存在しない場合、そのアイテムをGSIに含めない。これを使うとGSIのサイズとコストを大幅に削減できる。

// Sparse Indexの例
// 配送完了した注文にだけGSI属性を付与
async function markOrderDelivered(orderId: string) {
  const now = new Date().toISOString();
  await ddb.send(
    new UpdateCommand({
      TableName: TABLE_NAME,
      Key: Keys.order(orderId),
      UpdateExpression:
        'SET #status = :status, GSI3PK = :gsi3pk, GSI3SK = :gsi3sk, updatedAt = :now',
      ExpressionAttributeNames: {
        '#status': 'status',
      },
      ExpressionAttributeValues: {
        ':status': 'DELIVERED',
        ':gsi3pk': 'DELIVERED_ORDERS',
        ':gsi3sk': now,
        ':now': now,
      },
    })
  );
}
// → 配送完了注文だけがGSI3に存在するので、スキャン効率が高い

もう一つのハマりどころはGSIの結果整合性。RDSのJOINに慣れているとつい忘れるけど、GSIへの書き込みは非同期なので、メインテーブルへの書き込み直後にGSIをクエリしても最新データが返ってこない場合がある。うちは一時期、「書き込んだはずのデータが検索で出てこない」バグとして報告されて、原因特定に1日かかった経験がある。これはRDB脳のまま設計してると絶対にハマる。

2年運用して分かったアンチパターン集

実際に運用してみてやらかしたこと、「これはやめておけ」というパターンをまとめておく。

1. ホットパーティション問題

PKの設計が悪いと特定のパーティションにアクセスが集中する。うちの場合、最初はDATE#2024-01-15のような日付をPKにしていた期間があった。当然、今日の日付のパーティションへのアクセスが突出して集中し、スロットリングが頻発した。

PKは十分なカーディナリティ(多様性)を持つ値にすること。ユーザーIDや注文IDのようなUUIDが理想的。

2. FilterExpressionの乱用

// ❌ こういうのをやっていた(全データを取得してからフィルタリング)
const bad = await ddb.send(
  new QueryCommand({
    TableName: TABLE_NAME,
    KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
    FilterExpression: '#status = :status AND amount > :minAmount',
    ExpressionAttributeNames: { '#status': 'status' },
    ExpressionAttributeValues: {
      ':pk': 'USER#xxx',
      ':prefix': 'ORDER#',
      ':status': 'COMPLETED',
      ':minAmount': 1000,
    },
  })
);
// → FilterExpressionはRead CUを節約しない。
//   1000件読んで10件だけ返す場合も1000件分のRCUを消費する

// ✅ GSIで事前にフィルタリングできる設計にする

3. トランザクション範囲の取り違え

TransactWriteは1回のトランザクションで最大100アイテムまで、同一リージョン・同一AWSアカウントのみという制限がある。マイクロサービス間でトランザクションを張ろうとして詰まった経験がある。このあたりはイベント駆動アーキテクチャとSagaパターンで解決するのが現実的だ。イベント駆動アーキテクチャの実装パターンについては別記事で詳しく書いているので参考にしてほしい。

4. 大きなアイテムへの無頓着

DynamoDBのアイテムサイズ上限は400KBだ。画像URLのリストや長いテキストをそのままアイテムに埋め込もうとして上限に引っかかったことがある。大きなデータはS3に保存してDynamoDBにはS3のキーだけ持つのが定石。これも最初は「まあ大丈夫でしょ」と思っていたら普通に引っかかった。

まとめると、遭遇した問題とその対策はこんな感じになる。

問題発生頻度影響対策
ホットパーティション月1〜2回(初期)スロットリング、レイテンシ急増PKを高カーディナリティに
GSI整合性タイムラグ日常的データ不整合に見える設計で考慮、モニタリング追加
FilterExpression乱用設計フェーズRCUコスト増GSI設計を見直す
アイテムサイズ超過月0〜1回PutItem失敗S3との併用
書き込みコスト増継続的コスト超過Sparse Index、GSI削減

2026年現在のDynamoDB——何が変わったか

2026年現在、DynamoDBにはいくつか地味に嬉しいアップデートが来ていて運用が楽になっている。

ゼロETL統合の強化: Aurora PostgreSQLからDynamoDBへのゼロETLは現在GAになっており、既存のRDBデータをDynamoDBに段階的に移行するシナリオで活用している。ただしスキーマ変換が必要なので万能ではない。

DynamoDB Streams + Pipes統合: EventBridge Pipesとの統合が強化されており、Streamsのイベントを直接EventBridgeに送る構成がより簡単になった。うちのStream Processorも以前はLambda経由だったものをPipesで置き換えて、設定がかなりシンプルになった。

グローバルテーブルv2の改善: マルチリージョン運用における競合解決のオプションが増えた。うちはまだシングルリージョンだけど、マルチクラウド・マルチリージョン設計を検討しているチームには選択肢が広がった感じ。

コスト面でいうと、2026年現在のオンデマンドキャパシティの料金体系も見直されており、読み取り主体のワークロードではさらにコスト効率が改善している。うちの構成でのコスト内訳はこんな感じ:

pie title DynamoDB月次コスト内訳(本番環境)
    "オンデマンドRead" : 35
    "オンデマンドWrite" : 28
    "ストレージ" : 12
    "GSIオーバーヘッド" : 18
    "DynamoDB Streams" : 7

GSIオーバーヘッドが18%あるのが地味に痛い。Sparse Indexを徹底することでもう少し削れるはずで、正直まだ最適化の余地がある。ここは引き続き改善中の課題だ。

ちなみにSingle Table Designに関して公開済みの別記事(DynamoDB2年本番運用)も参考にしてほしい。あちらではより運用面の話に踏み込んでいる。

まとめ

2年間STDを本番で運用して見えてきたことを整理すると、だいたい以下の5点に集約される。

  1. アクセスパターンは必ず先に定義する: 後からGSIを追加するのは技術的には可能だが、設計の整合性が崩れていく。実装前に洗い出した一覧表は今でも設計レビューの必須資料になっている。

  2. GSIは少なく、Sparse Indexを活用する: GSIを増やすとWrite CU消費が比例して増加する。全アイテムに適用が必要なGSIか、特定条件のアイテムだけで済むSparse Indexで代替できないかを常に検討する。

  3. FilterExpressionはコスト削減にならない: クエリで絞り込めない条件はGSIで対応する設計にする。FilterExpressionは最後の手段。

  4. エンティティ種別(EntityType)を最初から付けておく: STDでは複数エンティティが混在するので、後からのデータ管理・マイグレーションのために必須。

  5. GSI結果整合性を意識した設計にする: 書き込み直後のGSIクエリは最新データを返すとは限らない。UXに影響するユースケースでは設計で考慮する。

個人的には、STDに対して最初に抱いていた「設計の敗北」という感覚は完全に消えた。むしろRDBの正規化とは別の軸で、アクセスパターン駆動で設計を考えるという思考の転換が面白い。ただしその転換にコストがかかるのも事実で、RDB慣れしたチームがSTDに移行する場合は相応の学習コストを見込んでおくべきだと思う。

次のアクションとしては、まだ最適化できていないGSIのSparse Index化を進めること。あとは最近GAになったDynamoDB Auto Scalingのターゲット追跡ポリシーの調整も引き続き検証中だ。皆さんはSTDでどんな課題を経験してますか?特にマルチテナント設計あたりでまだ悩んでいるので、知見があればぜひ教えてほしい。

U

Untanbaby

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

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

関連記事