メインコンテンツへスキップ
友田 陽大
決済・課金
AWS
DynamoDB
Python
サーバーレス
アーキテクチャ設計

サーバーレス決済基盤で「二重課金ゼロ」を設計する — DynamoDBで冪等性・原子性・ゼロダウンタイム移行を実装した話

実際の金銭を扱うサーバーレス決済基盤の信頼性レイヤー設計。冪等性キーと条件付き書き込みで二重課金を防ぎ、DynamoDBトランザクションで残高整合性を担保し、二重書き込みで本番を止めずにスキーマを進化させた実装パターンを、実コードに基づいて解説します。

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

決済システムのバグは、ユーザーの財布から直接お金を奪うか、事業者に損失を出すかのどちらかです。「だいたい動く」では済みません。1円も、1回も、間違えてはいけない。

私は、環境・カーボンクレジット/地域通貨分野のマルチテナント決済プラットフォーム(AWSサーバーレス基盤)で、チーム開発(主要開発者3名)の中核エンジニアとして、顧客・加盟店・管理・店頭端末の4面にわたるフロントエンド/バックエンド(4バックエンド+4フロント)と共有基盤・インフラを横断的に実装しました(リポジトリの約6割のコミットを担当)。本記事ではそのなかでも、私が設計・主導した決済信頼性レイヤーにフォーカスします。実際の金銭・ポイント・Jクレジット(カーボンクレジット)・地域通貨を扱う基盤で、二重課金・残高不整合をゼロにし、かつ本番を1秒も止めずにデータモデルを進化させ続けることが求められた領域です。そして実際に、本番稼働中の二重課金・残高不整合は0件を維持しています。

この記事では、その信頼性レイヤーで採った設計を、実際に運用しているコードのパターンに基づいて解説します。テーマは大きく4つです。

  • 冪等性(Idempotency) — リトライが来ても課金は1回に収束させる
  • 原子性(Atomicity) — 競合下でも残高を壊さない
  • 再試行(Retry) — 「再試行してよい失敗」だけを吸収する
  • ゼロダウンタイム移行 — 走りながらデータモデルを乗せ換える

貫いている思想はひとつです。正しさを「運用の注意深さ」ではなく「コードの構造」で保証する。 レビューや手順書で守る正しさはいつか破れます。型・条件式・トランザクションで守る正しさは破れません。

注: 本文中のコードは、設計意図を伝えるために要点を抜き出して簡略化したものです。テナント名・テーブル名・固有値などは抽象化しています。

前提:なぜ「サーバーレス × 単一テーブル」なのか

基盤は AWS Lambda + DynamoDB のサーバーレス構成です。決済ロジックは顧客アプリ・加盟店・管理・店頭端末という複数のLambdaから共通で呼ばれるため、ビジネスロジックは共有Lambda Layerに集約し、唯一の真実源(SSoT: Single Source of Truth)としました。

DynamoDBを選んだ理由は、リレーショナルなトランザクションの代わりに、決済で本当に必要な2つのプリミティブが手に入るからです。

  1. ADD による原子的な数値増減 — 「読んで・足して・書き戻す」を1命令にできる
  2. ConditionExpression による条件付き書き込み — 「残高が足りるなら引く」をDB側で原子的に判定できる

この2つに TransactWriteItems(最大100アイテムをall-or-nothingでコミット) を組み合わせると、決済の「正しさ」をアプリケーションコードのロックではなく、DBの一貫性保証に委ねられます。これが信頼性レイヤー全体の土台です。

データは単一テーブル設計(PK=uuid, SK=type)で、関心ごとにレコードを分けています。

レコード(SK)役割
BALANCE#ECOPAY主残高
BALANCE#POINT / BALANCE#JCREDITポイント/カーボンクレジット残高
BALANCE#REGIONAL#{key}地域通貨残高
METRICSCO2削減量などの集計値
card_op_idem#<操作>#<key>冪等性マーカー(TTL付き)

関心ごとにSKを分けるこの設計が、後述の「原子性」と「ゼロダウンタイム移行」の両方で効いてきます。

1. 冪等性:二重課金を「設計」で防ぐ

問題

モバイルアプリの決済では、リクエストが1回で届く保証はどこにもありません。3G/4G回線のタイムアウト、API Gatewayの再試行、Lambdaの再実行 — どれもが「同じ決済リクエストの再送」を生みます。

ここで重要なのは、リトライそのものは異常ではなく正常系であるという割り切りです。やるべきは「リトライを止める」ことではなく、「何回届いても課金は1回に収束させる」ことです。

解法:クライアント発行キー + attribute_not_exists + TTL

クライアントは決済リクエストのボディに idempotencyKey(UUID)を含めます。サーバー側はこれを冪等性マーカーのソートキーに連結し、attribute_not_exists 条件付きで挿入します。

# 冪等性マーカーを「データ」として組み立てる純粋関数(簡略版)
# DB I/O は一切せず、TransactItem の dict を返すだけ。
def build_idempotency_item(
    *,
    table: str,
    uuid: str,
    sk: str,                # 例: "card_op_idem#topup#<key>"
    ttl_seconds: int,       # 既定 90 日
    timestamp: int,
    extra: dict | None = None,
) -> dict:
    item = {
        "uuid": {"S": uuid},
        "type": {"S": sk},
        "processed_at": {"N": str(timestamp)},
        "ttl": {"N": str(timestamp + ttl_seconds)},
        **(extra or {}),
    }
    return {
        "Put": {
            "TableName": table,
            "Item": item,
            # 同一 (uuid, type) が既にあれば挿入失敗 = 二重実行を阻止
            "ConditionExpression": "attribute_not_exists(#uuid)",
            "ExpressionAttributeNames": {"#uuid": "uuid"},
        }
    }

設計上のポイントは3つです。

  • 純粋関数である。 この関数はDynamoDBに触れません。「マーカーをどう書くか」というデータだけを返すので、DBを起動せずに単体テストでき、SK形式やTTL計算の破壊的変更をゴールデンベクタテストで検知できます。
  • TTLで自動失効する。 マーカーは既定90日でDynamoDB TTLにより自動削除されます。冪等性のためだけのレコードを永遠に持ち続けると、ストレージコストとテーブルサイズが線形に膨らみます。「正しさ」と「コスト効率」を両立させるための設計です。
  • マーカー挿入を決済本体と同じトランザクションに入れる。 これが核心です。冪等マーカーのPutと、残高のADDを**単一のTransactWriteItems**にまとめます。2回目のリクエストはマーカー挿入がConditionalCheckFailedになり、トランザクション全体が原子的に巻き戻るため、残高は1ミリも動きません。

Webhookの冪等性:失敗したら「マーカーも残さない」

外部決済(Stripe)からのWebhookでも同じ原則を使いますが、ここには罠があります。

よくある実装は「ハンドラ実行のにイベントIDのマーカーを書く」ですが、これだとハンドラが途中で落ちたとき、マーカーだけが残ってしまいます。Stripeが正しく再送しても「処理済み」と誤判定され、決済が静かに失われます

そこで、イベントIDのマーカーをハンドラの副作用と同じトランザクションに含めます

# Stripe イベント ID ベースの冪等マーカーも TransactItem として返す。
# handler 失敗時はマーカーも書かれない → Stripe の再送で正しく再処理される。
idem_item = build_event_idempotency_transact_item(
    table=table,
    event_id=event["id"],
    ttl_seconds=30 * 86400,   # Stripe の再送上限は約3日。30日は監査用の保守的設定。
    timestamp=now,
)

client.transact_write_items(
    TransactItems=[idem_item, deduct_item, history_item],  # all-or-nothing
)

マーカーと副作用が運命を共にするので、「マーカーは残ったが処理は失敗」という宙ぶらりんの状態が構造的に存在しえません。Webhookの冪等期間は30日(Stripeの再送上限3日 + 監査余裕)と短めにして、ここでもコストを最適化しています。

段階的ロールアウトと可観測性

冪等性キーをいきなり必須にすると、既存クライアントが壊れます。そこで、

  1. 任意フェーズ — キーがあれば使う。欠落時は IdempotencyKeyMissing メトリクスを発火
  2. 本番の欠落率をCloudWatchで監視 — 0に収束したことを数字で確認
  3. 必須フェーズ — キー欠落をHTTP 400で弾く

という移行をしました。リプレイ(同じキーの再到達)も IdempotencyReplay メトリクスで観測し、異常な再送パターンを検知できるようにしています。「いつ必須化してよいか」を勘ではなくメトリクスで判断するという考え方です。

2. 原子性:競合下でも残高を壊さない

問題

同一カード・同一顧客に、決済・チャージ・返金が同時に飛んでくることがあります。素朴な実装はこうです。

# アンチパターン:read-modify-write はレースを生む
balance = read_balance(card_id)          # ① 残高を読む
if balance < amount:                     # ② チェック
    raise InsufficientError
write_balance(card_id, balance - amount) # ③ 書き戻す

①と③の間に別のトランザクションが割り込めば、チェックは古い残高で行われ、残高がマイナスに沈みます。アプリ側のロックで防ぐこともできますが、ロックは新たな単一障害点とデッドロックの温床です。

解法:ADD + ConditionExpression でread-modify-writeを消す

DynamoDBでは「読んで・引く」を1つの条件付きUpdateに畳み込めます。

# 残高更新を「データ」として組み立てる純粋関数(簡略版)
def build_deduct(table: str, card_id: str, amount: Decimal, now: int) -> dict:
    return {
        "Update": {
            "TableName": table,
            "Key": {"uuid": {"S": card_id}, "type": {"S": "card"}},
            # ADD は原子的。読み取り後書き込みのレースが存在しない。
            "UpdateExpression": "ADD balance :delta SET updated_at = :ua",
            # 「残高が足りるとき」だけコミットされる。判定は DB 側で原子的。
            "ConditionExpression": "balance >= :amount",
            "ExpressionAttributeValues": {
                ":delta":  {"N": str(-amount)},
                ":amount": {"N": str(amount)},
                ":ua":     {"N": str(now)},
            },
        }
    }

「残高を読む」ステップが消えました。条件判定はDynamoDBがコミット時点で原子的に行うので、どれだけ並行しても残高がマイナスになることはありません。

チャージ上限のような「足す側」の制約も同じ発想で表現できます。例えばカードチャージで balance + amount ≤ 上限 を保証したいとき、ADDとconditionは別々に評価される性質を利用して、こう書きます。

ADD       balance :delta            # :delta = チャージ額 + ボーナス
CONDITION balance <= :max_allowed   # :max_allowed = 上限 − チャージ額

balance <= 上限 − amount が成り立つときだけADDされるので、結果として balance + amount <= 上限 が原子的に保証されます。

失敗を「型」で分類する

条件付き書き込みが失敗したとき、その理由は1つではありません。残高不足なのか、上限超過なのか、ただの競合なのか — これらは呼び出し側が取るべき行動が違うため、曖昧に握りつぶしてはいけません。失敗原因を型付きのenumに分類します。

class BalanceTransactFailure(StrEnum):
    INSUFFICIENT = "insufficient"   # 残高不足   → HTTP 400(再試行しても無駄)
    CAP_EXCEEDED = "cap_exceeded"   # 上限超過   → HTTP 400
    CONFLICT     = "conflict"       # 純粋な競合 → 再試行可
    THROTTLED    = "throttled"      # 容量超過   → 503 / 再試行可

INSUFFICIENTCONFLICT を同じ「失敗」として扱うと、残高不足のリクエストを無駄に再試行してレイテンシとコストを浪費します。原因を分類することで、再試行すべき失敗とすべきでない失敗を分離できます(次章につながります)。

金額はすべて Decimalfloat は受け取らない

金額とCO2換算(換算レートは Decimal('0.01'))は、すべて Decimal で統一しています。float は二進数浮動小数のため、0.1 + 0.2 != 0.3 の世界です。決済でこれを許すと、丸め誤差が取引のたびに累積します。

そこで、値をDynamoDB属性に変換する低レベル関数の段階で float実行時に拒否しています。型注釈(mypy strict)で静的にも、変換関数で動的にも float を弾くことで、「うっかり float が混入する」事故を二重に防いでいます。

3. 再試行:再試行してよい失敗だけを吸収する

TransactWriteItems は、楽観的並行制御によって一時的に TransactionConflict でキャンセルされることがあります。これは「たまたま同時に書こうとした」だけなので、少し待って再試行すれば成功します。

一方、ConditionalCheckFailed(残高不足・冪等衝突・上限超過)は、何度再試行しても結果が変わりません。むしろ再試行は害です。

この2つを峻別する再試行ヘルパーを実装しました。

_RETRY_MAX_ATTEMPTS = 3
_RETRY_BASE_DELAY_MS = 50

def transact_write_with_retry(client, transact_items, *, max_retries=_RETRY_MAX_ATTEMPTS):
    for attempt in range(max_retries + 1):
        try:
            client.transact_write_items(TransactItems=transact_items)
            return
        except client.exceptions.TransactionCanceledException as exc:
            # ConditionalCheckFailed は意味論的失敗 → 即座に伝播(再試行しない)
            if any_condition_failed(exc):
                raise
            # TransactionConflict 以外のキャンセルも再試行しない
            if not is_transaction_conflict(exc):
                raise

            _emit_transaction_conflict_metric()   # CloudWatch に発火
            if attempt == max_retries:
                raise

            # 指数バックオフ(50ms → 100ms → 200ms)+ ジッター(±50%)
            delay_s = (_RETRY_BASE_DELAY_MS * (2 ** attempt)) / 1000
            jitter_s = random.uniform(0, delay_s * 0.5)
            time.sleep(delay_s + jitter_s)

設計上のポイントです。

  • 再試行は TransactionConflict のみ。 ConditionalCheckFailed は冪等衝突・残高条件の意味論に直結するため、即座に呼び出し側へ伝播させます。「再試行で直る失敗」と「直らない失敗」を混ぜないことが、レイテンシとコストの両方を守ります。
  • 指数バックオフ + ジッター。 50ms × 2^attempt で待ち時間を倍々にし、さらに ±50% のジッターを加えます。ジッターがないと、競合した複数のリクエストが同じ間隔で一斉に再試行し、**サンダリングハード(雪崩)**を起こします。
  • reasonコード判定はSSoT化。 "TransactionConflict" / "ConditionalCheckFailed" という文字列判定を各所に散らさず、共有モジュール(any_condition_failed / is_transaction_conflict)に集約しています。1箇所直せば全Lambdaに反映される — DRYの実践です。
  • 競合は可観測。 競合のたびに EcoPay/Payments::TransactionConflict メトリクスを発火し、CloudWatchアラーム(5分間に5回超で発報)が並行競合の急増を検知します。再試行で握りつぶして「見えなくする」のではなく、吸収しつつ計測するのがポイントです。

4. ゼロダウンタイム移行:走りながらエンジンを乗せ換える

問題

初期のデータモデルは、1人の顧客の残高・ポイント・Jクレジット・プロフィールが、すべて1つの巨大レコード(いわゆる God Record)に同居していました。並行更新で書き込み競合が起きやすく、関心の分離もできていません。

これを「関心ごとに分割した新スキーマ」へ移行したい。しかし — 本番の決済は1秒も止められません。 深夜にメンテ画面を出して一括バッチ移行、という選択肢は最初からありませんでした。一括移行は、移行中のデータ不整合と、失敗時の巻き戻しリスクが高すぎます。

解法:二重書き込み(mirror writes)による段階移行

採ったのは、Expand / Migrate / Contract の考え方を決済データに適用した段階移行です。

  1. 二重書き込み(Expand) — 新しい書き込みを、旧スキーマと新スキーマの両方に反映する
  2. 読み替え・重複排除(Migrate) — 読み取りを新スキーマ中心に切り替え、旧/新が混在する期間は重複排除して整合させる。並行して旧データを新スキーマへバックフィル
  3. 旧データ撤去(Contract) — 新スキーマへの一本化を確認し、旧スキーマへの書き込みを停止

これを13フェーズ超に細かく分解し、各フェーズ単独でいつ止めても本番が壊れないように設計しました。例えば「God Recordをプロフィールと残高に分離(12-3)」「集計値METRICSからBALANCE#POINT/BALANCE#JCREDITを分離(12-4)」といった具合です。

移行の書き込みは、すべて純粋なビルダー関数UpdateTransactItemを返す形にしました。

# 残高ミラー書き込みを「データ」として組み立てる純粋関数(簡略版)
def build_balance_mirror_items(table, customer_id, *, point_delta=0, now) -> list[dict]:
    items: list[dict] = []
    if point_delta:
        items.append({
            "Update": {
                "TableName": table,
                "Key": {"uuid": {"S": customer_id}, "type": {"S": "BALANCE#POINT"}},
                # ADD は冪等な増分。バックフィルが再実行されても最終状態は一意に収束。
                "UpdateExpression": "ADD balance :delta SET updated_at = :ua",
                "ExpressionAttributeValues": {
                    ":delta": {"N": str(point_delta)},
                    ":ua": {"N": str(now)},
                },
            }
        })
    return items

このアプローチが安全である理由が、移行の肝です。

  • ADD は冪等な増分操作。 「残高に+100する」は、何回流れても正しく動くわけではない…と思いきや、バックフィルを設計でべき等にすれば話は別です。新規の二重書き込みは1回だけADDされ、過去分のバックフィルは「未処理レコードだけを一度だけ移送する」ように設計します。ADDが原子的なので、二重書き込みとバックフィルが時間的に交錯しても、最終残高は一意に収束します。
  • if_not_exists で既存値を保護。 プロフィールのバックフィルでは created_at = if_not_exists(created_at, :ca) のように書き、既にある値をバックフィルが上書きしないようにします。
  • 削除意図を None と区別する。 「この属性を更新しない(None)」と「この属性を消す」は別物です。後者を表すために専用の CLEAR センチネルを用意し、phone_number=CLEAR のように削除を明示します。None で消すような曖昧さを排除しています。
  • 書き込み不要なら空配列を返す。 変更が何もないときビルダーは [] を返し、無駄な書き込みをしません(no-op)。
  • 読み取り側で重複排除。 移行期間は旧形式と新形式の取引履歴が両方存在しうるため、読み取り時に (PK, SK, timestamp, type) の同一性で重複排除し、利用者からは移行が透過的に見えるようにしました。

結果として、稼働中の決済を1秒も止めずに、God Recordから関心分離されたスキーマへ、13フェーズ超を無停止で移行しきりました。

横断的な土台:テスト容易性・型安全性・可観測性

ここまでのコードに共通する設計が3つあります。これらは「あとで足す」ものではなく、信頼性を成立させる前提条件でした。

テスト容易性(純粋関数)。 冪等マーカー・残高更新・移行ビルダーは、すべてDB I/Oを持たない純粋関数です。入力に対して「どんなTransactItemを返すか」だけを検証すればよいので、DynamoDBを起動せず・モックを組まずに、境界条件まで含めて高速にテストできます。さらにSK・条件式・TTLといったワイヤフォーマットをゴールデンベクタで固定し、「気づかないうちに保存形式が変わる」事故を回帰テストで止めています。

型安全性(mypy strict)。 共有Layerは disallow_untyped_defs のmypy strictモードで、関数の型注釈を強制しています。決済ロジックから Any を排除することで、リファクタリング時の取りこぼしをコンパイル相当の段階で検知できます。型は最良の、そして最も安価なテストです。

可観測性(構造化ログ + メトリクス)。 冪等キー欠落率・リプレイ・トランザクション競合を、構造化ログとCloudWatchメトリクスで継続的に観測しています。重要なのは、ログにPIIを残さないこと。メール・電話番号はマスクし、PIN・トークン・パスワードの類はログに一切出しません。観測性と個人情報保護は両立させる必要があります。

設計を貫く原則

最後に、この信頼性レイヤーを貫いた原則を整理します。

原則この基盤での現れ方
正しさは構造で守る冪等性はattribute_not_exists、整合性はADD+条件式、原子性はTransactWriteItemsに委譲
SSoT / DRY決済ロジックは共有Layerに集約。reasonコード判定も1箇所に
SRP「判断(純粋関数)」と「実行(I/O)」を分離。ビルダーはデータだけを返す
失敗を分類する再試行可(CONFLICT/THROTTLED)と不可(INSUFFICIENT/CAP_EXCEEDED)を型で峻別
コスト効率冪等マーカーはTTLで自動失効。サーバーレスで需要に追従
可観測性吸収するだけでなく計測する。判断はメトリクスで
無停止での進化(ETC)Expand→Migrate→Contractで、止めずに乗せ換える

決済の信頼性は、派手な機能ではありません。「何も起きなかった」ことそのものが成果です。二重課金が起きない、残高が壊れない、移行中も誰も気づかない — それを偶然ではなく設計で保証することが、実際の金銭を預かるシステムの責務だと考えています。事実、本番稼働中の二重課金・残高不整合は0件を維持しています。

同種の「実際の金銭・ポイントを扱う基盤の信頼性設計」「サーバーレスでの冪等・原子的なデータ整合」「本番を止めないスキーマ移行」にお困りであれば、設計段階からお力になれます。

友田

友田 陽大

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

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

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

ケーススタディを見る