メインコンテンツへスキップ
友田 陽大
データベース・RLS
AWS
DynamoDB
アーキテクチャ設計
冪等性
サーバーレス
TypeScript
可観測性

DynamoDB シングルテーブル設計&本番信頼性パターン完全ガイド(2026年版):冪等性・条件付き書き込み・トランザクションを実コードで

DynamoDBのシングルテーブル設計を、アクセスパターン起点のキー設計(PK/SK・GSIオーバーロード)から冪等性・条件付き書き込み・原子的な残高更新・TransactWriteItems・整合性まで、AWS公式仕様に忠実なAWS SDK v3 TypeScript実コードで解説します。

公開日
読了時間
20分
著者
友田 陽大
シェア

DynamoDBは「速くて落ちないKVS」ではありません。正しさをコードの構造で保証するための、強力なプリミティブ群です。条件付き書き込み・原子的増減・トランザクションを正しく組み合わせれば、アプリケーション側のロックやリトライ手順書に頼らずに、二重実行・残高の壊れ・データ不整合を「設計で」封じられます。

本記事は、私が実際にサーバーレスマルチテナント決済プラットフォームの信頼性レイヤー(4つのPythonサーバーレスバックエンド+DynamoDB、リポジトリの約6割=403/694コミットを担当)を設計・主導し、本番稼働中の二重課金0件を維持してきた経験をベースにした、リファレンス/パターン集です。具体的な決済基盤の物語は別記事「サーバーレス決済基盤で「二重課金ゼロ」を設計する」にあります。本記事はそれと相補的に、再利用できる設計パターンとAWS SDK v3 TypeScriptの実コードに絞って体系化します。

すべての仕様・上限値はAWS公式ドキュメント(2026年6月時点)に照合しています。

注: コードは設計意図を伝えるための要点抜粋です。テーブル名・属性名は抽象化しています。

1. なぜシングルテーブル設計か(RDB思考からの脱却)

出発点はテーブルではなくアクセスパターン

リレーショナルDBでは、まず正規化したデータモデルを作り、後からクエリを足していけます。DynamoDBでは順序が逆です。AWS公式は明確にこう述べています。

あなたのDynamoDBのスキーマは、それが答えるべき質問を知るまで設計を始めるべきではない。ビジネス上の問題とアプリケーションのユースケースを事前に理解することが不可欠である。

DynamoDBは「限られた方法では極めて効率的に、それ以外の方法では高コストかつ低速に」クエリできるエンジンです。だから設計の第一歩は、テーブルを切ることではなく、システムが満たすべきクエリパターンを列挙することになります。

設計時に把握すべき3つの性質(公式):

性質意味
Data size一度に保存・取得するデータ量 → パーティション分割の効き方を決める
Data shapeクエリ時に整形するのではなく、クエリされる形でそのまま保存する
Data velocityピーク時のクエリ負荷 → I/O容量を使い切るための分散設計

「テーブルは少なく」が原則

公式の一般原則は「DynamoDBアプリケーションではテーブルをできるだけ少なく保つべき」です。理由は明快で、テーブルが少ないほどスケールしやすく、権限管理が単純になり、オーバーヘッドとバックアップコストが下がるからです。「関連データを一箇所にまとめる(locality of reference)」ことが、NoSQLの応答性能を決める鍵だとされています。

つまり、user・order・payment を別テーブルに分けるのではなく、1つのテーブルに異なる種類のアイテムを同居させるのがシングルテーブル設計です。例:

PKSK役割(エンティティ)
USER#u_123PROFILEユーザープロフィール
USER#u_123ORDER#o_900そのユーザーの注文
ORDER#o_900PAYMENT#p_555その注文に紐づく決済
ORDER#o_900IDEM#charge#k_abc冪等性マーカー(TTL付き)

同じPKを持つアイテム群(item collection)は物理的に近接して保存されるため、Query(PK指定)一発で「あるユーザーのプロフィール+全注文」をまとめて取得できます。これがRDBのJOINに相当する操作を、結合なしで実現します。

いつシングルテーブルを使うべきでないか(YAGNI)

シングルテーブル設計は万能ではありません。避けた方がよいケース:

  • アクセスパターンが固まっていない / 探索的にアドホックなクエリを多用する段階のアプリ。DynamoDBは事前に決めたパターン以外が苦手で、新しい問い合わせのたびにGSI追加やデータ再設計が要る。要件が流動的なうちはRDB(PostgreSQLなど)の方が圧倒的に速く回せる。
  • 小規模アプリで、複数テーブルでも運用負荷が問題にならない場合。シングルテーブルのキー設計コストに見合わない。
  • 複雑な集計・分析クエリ(GROUP BY、ウィンドウ関数等)が中心の場合。公式も「高ボリュームの時系列データや、まったく異なるアクセスパターンを持つデータセット」は例外だと述べている。

私自身、決済「コア」の整合性が要る部分はDynamoDBに寄せつつ、探索的なクエリが必要な領域は別の選択肢を使い分けています。シングルテーブルは目的ではなく、確定したアクセスパターンを最小コスト・最大整合性で満たすための手段です。


2. キー設計:PK/SK・GSIオーバーロード・スパースインデックス

複合主キー(PK + SK)

PK(パーティションキー)がデータの物理配置を、SK(ソートキー)が同一パーティション内の並びを決めます。SKに begins_with や範囲条件を使えるため、ORDER# プレフィックスで「注文だけ」を絞り込む、といったクエリが安価にできます。

公式の設計原則:

  • Keep related data together — 関連アイテムを同じPKに集める。
  • Use sort order — キー設計でソート順を作り、範囲クエリを効率化する。
  • Distribute queries — 1つのパーティションにアクセスが集中(ホットパーティション)しないよう、キーを設計して負荷を均等に分散する。

GSIオーバーロード

DynamoDBのテーブルは「行ごとに型が違ってよい」ため、1つのGSIで複数のアクセスパターンを兼用できます。これがGSIオーバーロードです。GSIのキーに GSI1PK / GSI1SK のような汎用名の属性を割り当て、アイテムの種類ごとに意味を変えて値を入れます。

例(注文の検索を1本のGSIで多用途化):

エンティティGSI1PKGSI1SK
注文(ステータス検索用)STATUS#paid2026-06-24T10:00:00Z
決済(顧客別検索用)CUSTOMER#u_123PAYMENT#2026-06-24

公式によれば、テーブルあたりGSIはデフォルトクォータ20個ですが、オーバーロードを使えば「実質的に20をはるかに超えるデータフィールドを索引できる」とされています。GSIの個数を増やすほどコスト(書き込み時に各GSIへ伝播)とクォータを消費するため、汎用キーで兼用するのが定石です。

スパースインデックス

GSIは「そのGSIのキー属性を持つアイテムだけ」が索引対象になります。これを利用したのがスパースインデックスです。例えば「未処理の決済だけ」を引きたいなら、未処理アイテムにのみ GSI_UNPROCESSED_PK 属性を付与し、処理完了時にその属性を削除します。すると、GSIには未処理のものだけが残り、全件スキャンせずに対象を即取得できます。

// 「未処理」マーカーを持つ間だけ GSI に載る(スパースインデックス)
// 処理完了時に REMOVE すると GSI から自動的に消える
const markProcessed = new UpdateCommand({
  TableName: TABLE,
  Key: { PK: `ORDER#${orderId}`, SK: `PAYMENT#${paymentId}` },
  UpdateExpression: "REMOVE GSI_UNPROCESSED_PK SET #st = :done",
  ExpressionAttributeNames: { "#st": "status" },
  ExpressionAttributeValues: { ":done": "processed" },
});

3. 冪等性(Exactly-once):attribute_not_exists で「一度だけ作る」

なぜ問題になるか

モバイル回線のタイムアウト、API Gatewayのリトライ、Lambdaの再実行 — 分散システムでは「同じリクエストの再送」が必ず起きます。PutItem はデフォルトで同じ主キーのアイテムを無条件に上書きするため、素朴に書くと2回目の決済作成が通ってしまいます。

解:条件付き PutItem

公式の attribute_not_exists() を使い、「まだ存在しない時だけ作る」を保証します。複合キーの場合、公式の注記どおり attribute_not_exists(PK) は「PKとSKの両方が存在しない(=そのアイテム自体が無い)」を1つの条件として評価します。

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient,
  PutCommand,
} from "@aws-sdk/lib-dynamodb";
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}), {
  marshallOptions: { removeUndefinedValues: true },
});
const TABLE = process.env.TABLE_NAME!;

/**
 * 冪等な「決済の作成」。同じ idempotencyKey での2回目以降は
 * ConditionalCheckFailedException となり、課金は1回に収束する。
 */
export async function createChargeOnce(input: {
  orderId: string;
  paymentId: string;
  idempotencyKey: string;
  amount: number;
}): Promise<{ created: boolean }> {
  try {
    await ddb.send(
      new PutCommand({
        TableName: TABLE,
        Item: {
          PK: `ORDER#${input.orderId}`,
          SK: `PAYMENT#${input.paymentId}`,
          idempotencyKey: input.idempotencyKey,
          amount: input.amount,
          status: "created",
          createdAt: new Date().toISOString(),
          // TTL: マーカーを永久に残さない(運用コスト・容量の抑制)
          expiresAt: Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30,
        },
        // PK が存在しない=このアイテムがまだ無い時だけ作成
        ConditionExpression: "attribute_not_exists(PK)",
      }),
    );
    return { created: true };
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      // 既に作成済み。再送なので「成功」として扱う(exactly-once の収束)
      return { created: false };
    }
    throw err;
  }
}

ポイントは、冪等性をアプリのロックや「重複チェックSELECT」で守らないことです。SELECTしてからPUTする間に並行リクエストが割り込めば破れます。条件式は書き込みと同一の原子操作として評価されるため、競合下でも破れません。

専用の冪等性キーアイテムSK = IDEM#<操作>#<key>)を別レコードとして先に立てるパターンも有効です。操作結果(レスポンス)をそのアイテムに保存しておけば、再送時に「保存済みレスポンスを返すだけ」で済み、副作用の再実行を完全に避けられます。TTLで一定期間後に自動削除します。


4. 原子性(Atomicity):残高を壊さない更新

read-modify-write は競合で壊れる

「残高を読む → アプリで引き算する → 書き戻す」は、並行実行でロストアップデートを起こします。公式のAlice/Bobの例がまさにこれで、後勝ちで片方の更新が消えます。残高や在庫のような金銭的価値で、これは許されません。

原子カウンタ(ADD)の罠

UpdateItemADD(あるいは SET x = x + :v)による原子カウンタは、読み書きを1命令にできますが、公式が明記するとおり冪等ではありません

原子カウンタでは、更新は冪等ではない。UpdateItem を呼ぶたびに数値が増減する。

つまりリトライが来ると二重に加算/減算されます。公式も「銀行アプリのように過剰/過少カウントが許されない場面では、原子カウンタではなく条件付き更新を使う方が安全」と述べています。アクセス数のカウンタなら原子カウンタでよいですが、残高には使ってはいけない、というのが境界線です。

解:条件付き UpdateItem(残高がマイナスにならない引き落とし)

残高が足りるなら引く」をDB側に原子的に判定させます。ConditionExpressionbalance >= :amount を付け、足りなければ書き込み自体を失敗させます。

import {
  UpdateCommand,
} from "@aws-sdk/lib-dynamodb";
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";

/**
 * 残高からの安全な引き落とし。
 * - balance >= amount を満たす時だけ、原子的に balance を減らす
 * - 競合下でもマイナス残高にならない(read-then-write を排除)
 */
export async function debitBalance(input: {
  userId: string;
  amount: number; // > 0
}): Promise<{ ok: true; newBalance: number } | { ok: false; reason: "insufficient" }> {
  try {
    const res = await ddb.send(
      new UpdateCommand({
        TableName: TABLE,
        Key: { PK: `USER#${input.userId}`, SK: "BALANCE" },
        UpdateExpression: "SET balance = balance - :amt",
        // 「足りるなら引く」をDBが原子的に判定
        ConditionExpression: "balance >= :amt",
        ExpressionAttributeValues: { ":amt": input.amount },
        ReturnValues: "UPDATED_NEW",
      }),
    );
    return { ok: true, newBalance: res.Attributes!.balance as number };
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      // 残高不足。書き込みは一切起きていない
      return { ok: false, reason: "insufficient" };
    }
    throw err;
  }
}

この更新は、条件と更新対象が同じ balance 属性なので、公式のいう冪等な条件付き書き込みにはなりません(リトライすると再度引かれます)。そのため、冪等性キー(セクション3)と組み合わせ、「同じ操作の再送かどうか」を冪等性マーカーで判定したうえでこの引き落としを1回だけ適用するのが本番パターンです。私の決済基盤では、この「冪等性マーカー+条件付き原子更新」の二段構えで、二重課金・残高不整合を0件に保っています。


5. トランザクション:複数アイテムをall-or-nothingで

TransactWriteItems の仕様(公式・要確認の上限)

項目
最大アクション数100(最大100の異なるアイテム)
対象範囲同一AWSアカウント・同一リージョンの1つ以上のテーブル
合計サイズ上限4 MB
単一アイテムサイズ上限400 KB
冪等トークンClientRequestToken完了後10分間は同一トークンの再送が無変更で成功
使えるアクションPut / Update / Delete / ConditionCheck
索引トランザクションはインデックスに対しては実行できない
同一アイテム1トランザクション内で同じアイテムを複数アクションで対象にできない

公式の冪等性に関する重要点:

  • ClientRequestToken はリクエスト完了後10分間有効。10分を過ぎると、同じトークンでも新しいリクエストとして扱われる。
  • 10分の冪等ウィンドウ内に同じトークンで他のパラメータを変えて再送すると IdempotentParameterMismatch 例外になる。

コスト上の注意

トランザクションは追加料金こそないものの、公式によれば各アイテムにつき内部的に2回の読み書き(準備+コミット)が走ります。例えば1秒あたり1トランザクションで3アイテム(各500バイト)を書くなら、各アイテム2WCU×3=6WCUが必要、という見積もりになります。これはCloudWatchメトリクスにも反映されます。

実コード:注文確定(残高引き落とし+注文ステータス更新を原子的に)

import { randomUUID } from "node:crypto";
import {
  TransactWriteCommand,
} from "@aws-sdk/lib-dynamodb";
import { TransactionCanceledException } from "@aws-sdk/client-dynamodb";

/**
 * 「残高を引く」と「注文を paid にする」を all-or-nothing で確定する。
 * どちらか一方だけが反映される状態を作らない。
 */
export async function settleOrder(input: {
  userId: string;
  orderId: string;
  amount: number;
  clientRequestToken?: string; // リトライ時は同じ値を渡すと10分間冪等
}): Promise<void> {
  try {
    await ddb.send(
      new TransactWriteCommand({
        // トークンを呼び出し側が固定できると、再送が安全になる
        ClientRequestToken: input.clientRequestToken ?? randomUUID(),
        TransactItems: [
          {
            Update: {
              TableName: TABLE,
              Key: { PK: `USER#${input.userId}`, SK: "BALANCE" },
              UpdateExpression: "SET balance = balance - :amt",
              ConditionExpression: "balance >= :amt",
              ExpressionAttributeValues: { ":amt": input.amount },
            },
          },
          {
            Update: {
              TableName: TABLE,
              Key: { PK: `ORDER#${input.orderId}`, SK: "META" },
              UpdateExpression: "SET #st = :paid",
              // 二重確定を防ぐ:created の時だけ paid にできる
              ConditionExpression: "#st = :created",
              ExpressionAttributeNames: { "#st": "status" },
              ExpressionAttributeValues: { ":paid": "paid", ":created": "created" },
            },
          },
        ],
      }),
    );
  } catch (err) {
    if (err instanceof TransactionCanceledException) {
      // 残高不足 or 既に paid 済み。CancellationReasons で原因を切り分ける
      throw new Error(`settle canceled: ${err.message}`);
    }
    throw err;
  }
}

いつトランザクションを使うか(vs 単一条件付き書き込み)

公式のベストプラクティスは「不要ならまとめるな」です。

  • 単一アイテムで完結するなら、条件付き書き込み1発にする。トランザクションは2倍のキャパシティを消費し、競合時にキャンセルされやすい。
  • 複数アイテムの不可分な更新(残高+注文+台帳など)が必要な時だけ TransactWriteItems を使う。
  • 競合の多いアイテムを同一トランザクションに複数含めると TransactionCanceledException が増えるため、頻繁に同時更新される属性は1アイテムにまとめてトランザクションの範囲を狭める。

6. 整合性:強整合 vs 結果整合

公式の整理:

  • 結果整合読み取り(eventually consistent)が既定。直前の書き込みが反映されていないことがある。
  • 強整合読み取り(strongly consistent)GetItem / Query / ScanConsistentRead: true で要求でき、直前の成功した書き込みをすべて反映した最新値を返す。
  • GSIとStreamは常に結果整合。GSI・Streamに対する強整合読み取りはサポートされない
  • 強整合読み取りはテーブルとLSIでのみサポート。
  • 結果整合読み取りは強整合読み取りの半分のコスト

実務上の落とし穴は、「書き込み直後にGSIで読んで最新値を期待する」ことです。GSIへの伝播は非同期なので、直後に最新が必要ならテーブル本体への強整合読み取りを使います。トランザクション完了直後も、結果整合読み取りは一時的に古い値を返しうるため、確定値が要る箇所は ConsistentRead: true にします。

import { GetCommand } from "@aws-sdk/lib-dynamodb";

// 引き落とし直後に「確定した最新残高」を見せたい → 強整合読み取り
const res = await ddb.send(
  new GetCommand({
    TableName: TABLE,
    Key: { PK: `USER#${userId}`, SK: "BALANCE" },
    ConsistentRead: true, // テーブル本体に対してのみ有効(GSI不可)
  }),
);

整合性はコストとのトレードオフです。一覧表示など多少の遅延が許される読み取りは結果整合(=半額)で十分。「お金が動いた直後の確認」など、古い値が事故になる箇所だけ強整合にする、という線引きが費用対効果に優れます。


7. 運用:コスト・可観測性・DR・無停止移行

キャパシティモード(オンデマンド vs プロビジョンド)

観点オンデマンドプロビジョンド
課金実行したRCU/WCUに従量課金確保した容量に対して課金(Auto Scaling可)
向くケーストラフィックが読みにくい / スパイキー / 立ち上げ初期安定して予測可能な負荷、コスト最適化したい本番
ホットパーティションどちらでも、特定キーに集中すると性能劣化・スロットリング

迷ったらまずオンデマンドで運用しメトリクスを集め、定常負荷が見えてからプロビジョンド+Auto Scalingに寄せる、という順序がコスト効率的です。いずれの場合も、ホットパーティションを避けるキー設計(公式の Distribute queries)が大前提です。

可観測性(CloudWatch)

最低限ウォッチすべきメトリクス: スロットリング(ReadThrottleEvents / WriteThrottleEvents)、TransactionConflict(トランザクション競合の発生数)、消費キャパシティ。私の決済基盤では、これらにCloudWatchアラームを設定し、閾値超過をSlackへ通知する構成にしています。条件付き書き込みの失敗(残高不足など正常系)と、スロットリング(容量不足の異常系)を区別して監視するのが重要です。

バックアップ・PITR・DR

  • **PITR(ポイントインタイムリカバリ)**で直近35日間の任意時点に復元可能にする。
  • AWS Backup + Vault Lockで、誤削除・ランサムウェアに対して改ざん不能なバックアップを保持する。
  • 注意点(公式): トランザクションの変更はGSI・Stream・バックアップへ段階的に伝播するため、伝播中にバックアップ/エクスポートすると最近のトランザクションが部分的にしか含まれないことがある。DR設計ではこの非同期性を前提にする。

私の担当領域でも、AWS Backup・Vault Lock・PITRでDRを構築し、CI(GitHub Actions、mypy strict)で型・テストの検証ゲートを通してから本番反映する運用にしています。

シングルテーブルの無停止移行

スキーマ(キー設計)を本番を止めずに進化させる定石は**ミラーライト(二重書き込み)**です。

  1. 書き込みを旧形式と新形式の両方へ同時に行う(mirror-write)。読み取りは旧形式のまま。
  2. バックフィルで既存データを新形式へ変換する。
  3. 読み取りを新形式へ切り替える。整合性を検証する。
  4. 旧形式への書き込みと旧データを撤去する。

各ステップはいつでもロールバック可能で、ダウンタイムゼロで進められます。私はこのミラーライト方式で、本番を1秒も止めずに決済基盤のデータモデルを更新しました。


FAQ

Q1. シングルテーブル設計は常に正解ですか? いいえ。アクセスパターンが固まっていない初期段階、探索的なアドホッククエリや複雑な集計が中心の領域では、RDB(PostgreSQL等)の方が開発速度・柔軟性で勝ります。公式も時系列データや「まったく異なるアクセスパターン」を例外として認めています。確定したパターンを最小コスト・最大整合性で満たす手段として選ぶべきです。

Q2. 冪等性は原子カウンタ(ADD)で実現できますか? できません。公式が明記するとおり原子カウンタは冪等ではなく、リトライで二重加算されます。冪等性は attribute_not_exists による条件付き作成、または冪等性キーアイテム+保存済みレスポンスで実現します。

Q3. 残高更新に SET balance = balance - :amt だけで十分ですか? 不十分です。ConditionExpression: "balance >= :amt" を必ず付け、残高不足を書き込み前にDB側で弾きます。これがないと並行実行でマイナス残高になりえます。さらにリトライ二重引き落としを防ぐため、冪等性マーカーと組み合わせます。

Q4. TransactWriteItems の上限は? 最大100アクション(最大100アイテム)、合計4MB、単一アイテム400KBまでです。ClientRequestToken による冪等性は完了後10分間有効で、同一トークンで他パラメータを変えると IdempotentParameterMismatch になります。

Q5. 書き込み直後にGSIで読んだら古い値でした。バグですか? 仕様です。GSI(およびStream)は常に結果整合で、伝播は非同期です。直後に最新が必要なら、テーブル本体に対する強整合読み取り(ConsistentRead: true)を使ってください。GSIへの強整合読み取りはサポートされません。

Q6. オンデマンドとプロビジョンド、どちらを選ぶべき? 負荷が読めない/スパイキーな段階や立ち上げ初期はオンデマンド、安定して予測可能な本番負荷ではプロビジョンド+Auto Scalingがコスト効率的です。いずれもホットパーティションを避けるキー設計が前提で、まずオンデマンドで計測してから移行する順序を推奨します。


おわりに:正しさを構造で保証する

DynamoDBの信頼性は、attribute_not_exists・条件付き原子更新・TransactWriteItems という3つのプリミティブを、アクセスパターン起点のキー設計の上に正しく積むことで生まれます。レビューや手順書で守る正しさは破れますが、条件式とトランザクションで守る正しさは破れません。

私は、一人 × 生成AI(Claude Code)という体制で、設計判断は人間の検証ゲートを通す進め方を徹底し、本番二重課金0件のサーバーレス決済基盤を実装・運用してきました。DynamoDBを軸にしたサーバーレスの信頼性設計(冪等性・原子的な残高更新・無停止移行・DR・可観測性)について、設計レビューから実装まで伴走できます。

サーバーレス/DynamoDBの信頼性設計でお困りの方は、お問い合わせからご相談ください。 まずは現状のアクセスパターンとリスク箇所を整理するところからご一緒します。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

環境分野のサーバーレス決済プラットフォーム(フルスタック開発・決済信頼性レイヤーを主導)

ケーススタディを見る