はじめに:「整合性を守りたい」という善意が招く本番死

決済処理、メールやSlackへの通知、外部SaaSのWebhook送信、S3へのファイルアップロード——Webアプリケーションを開発していれば、外部のAPIと連携する処理は無数に存在する。

そして、初中級のエンジニアがほぼ例外なく一度は書いてしまうのが、次のようなコードだ。

// ❌ 最凶の即死アンチパターン:トランザクションの中で外部HTTP APIを叩く
func ProcessOrder(ctx context.Context, db *sql.DB, orderReq OrderRequest) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    // 1. 注文レコードを「処理中」でINSERT
    if err := createOrder(ctx, tx, orderReq); err != nil {
        return err
    }

    // 2. 外部決済ゲートウェイ(Stripe等)にHTTPリクエストを送信
    // ⚠️ ここでネットワークI/O(200ms〜数秒)が発生!その間DBコネクションを握り続ける
    paymentRes, err := paymentGateway.Charge(ctx, orderReq.Amount)
    if err != nil {
        return fmt.Errorf("決済失敗: %w", err) // ロールバックされるから安心…?
    }

    // 3. 決済完了フラグをUPDATE
    if err := updateOrderStatus(ctx, tx, paymentRes.ID, "COMPLETED"); err != nil {
        return err
    }

    return tx.Commit()
}

このコードを書いた本人は、至極真っ当な動機を持っている。「外部決済がエラーになったら、作成した注文レコードを綺麗にロールバックしたい」「DBと外部決済の結果をアトミックに一致させたい」。

開発マシンのローカル環境や、数人のQAがポチポチ叩くステージング環境では、このコードは何の破綻も見せずに完璧に動作する。テストも通り、プルリクエストも「決済失敗時のロールバックが考慮されていて良いですね」とApproveされて本番へデプロイされる。

だが、セール開始やテレビ放映、あるいは外部決済SaaSのわずかな瞬断が起きた瞬間、このたった1行のネットワーク呼び出しが、Webシステム全体のデータベース接続を窒息死させ、トップページの参照クエリすら巻き込んで全サービスを504エラーで全滅させる。

物理的現実:リトルの法則とコネクションプールの瞬間蒸発

なぜ、外部APIをトランザクション内で叩くだけでシステムが全滅するのか。その理由は「DBコネクションの占有時間」の桁違いな乖離にある。

クエリ処理時間と外部通信時間の「1000倍のギャップ」

適切にインデックスが貼られたリレーショナルデータベース(PostgreSQLやMySQL)において、単一の INSERT や UPDATE クエリの実行にかかる時間は通常 0.5ms 〜 2ms 程度である。

一方で、外部の決済APIやWebhookへのHTTPS通信はどうだろうか。DNS解決、TLSハンドシェイク、相手先サーバーの内部処理、往復のインターネット遅延(RTT)を含めれば、どれほど良好な状態でも 150ms 〜 500ms はかかる。相手側の負荷が高まっていれば 1秒〜3秒、もしネットワーク障害でパケットロスが起きれば、クライアントのタイムアウト設定(一般的に10秒〜30秒)まで処理は戻ってこない。

トランザクション(BEGIN)を開始した瞬間、アプリケーションはデータベースのコネクションプールから接続(コネクション)を1本排他的に確保する。そしてそのコネクションは、COMMIT または ROLLBACK が完了するまで、他のいかなるリクエストにも明け渡すことができない。

トランザクション内で外部APIを叩くということは、「数ミリ秒で終わるはずのDB接続の占有」を「数百倍から数千倍の時間にわたって人質に取る」行為と同義である。

リトルの法則(Little's Law)が宣告する必然の死

待ち行列理論における基本公式「リトルの法則」を用いれば、この設計がいかに無謀であるかが数学的に証明できる。

L = λ × W
(平均滞在数 L = 到着率 λ × 平均処理時間 W)

データベースのコネクションプールサイズを堅牢な推奨値である 20本、注文APIへのリクエスト到着率を秒間50リクエスト(λ = 50 req/sec)と仮定しよう。

プールの20本がすべて「外部APIの返事待ち」で埋め尽くされた瞬間、後続のリクエストはすべてコネクション取得待ちキューにスタックする。

そして最悪なことに、コネクションプールはアプリケーション全体で共有されている。決済とは何の関係もない「ユーザー名を取得するだけの SELECT」「お知らせ一覧を表示するだけの軽量クエリ」までもがコネクションを1本も確保できなくなり、サーバー全体のリクエストハンドラがタイムアウト死する(カスケード障害)。これが「たった1箇所の外部呼び出しによる全滅」の物理的メカニズムだ。

さらに致命的:「タイムアウト時」の分散不整合という地獄

「プールを大きくすれば耐えられるのでは?」と考えたとしたら、それはさらなる地獄の入り口だ(プールの拡大はDB自体のコンテキストスイッチ爆発を招くだけである)。だが、問題はリソース枯渇だけにとどまらない。

トランザクション内で外部APIを呼ぶ最大の動機であったはずの「整合性の維持」すら、分散システムにおいては完全に破綻しているという事実を直視しなければならない。

外部HTTP呼び出しにおける「3つの状態」

ローカルDBのトランザクションは「成功(Commit)」か「失敗(Rollback)」の2値の世界だ。しかし、ネットワークを挟んだ外部通信には本質的に「成功」「失敗」「不明(In-doubt)」の3値が存在する。

外部APIの呼び出しが5秒でタイムアウトして error が返ってきたとき、現場で起きている現実は次のいずれかである。

  1. 到達前切断: リクエストが相手のサーバーに届く前に遮断された(未処理)
  2. 処理中切断: 相手サーバーが決済を正常に完了させたが、そのレスポンス(HTTP 200)がこちらに戻る途中でタイムアウトした(決済成立済み!)

ここでアプリケーションが「エラーになったから」と無邪気に tx.Rollback() を実行したらどうなるか。

自社DB上は「注文レコードがロールバックされ、存在しない(あるいは失敗)」。しかし、外部決済サービス上は「ユーザーのクレジットカードから1万円が正常に引き落とされている」。

ユーザーには「システムエラーが発生しました。注文は完了していません」と表示されるのに、クレカの利用速報メールには課金通知が届く。激怒したユーザーからの問い合わせ、手動での決済キャンセル、DBとの突き合わせ作業……「整合性を保つためのトランザクション」が、皮肉にも最悪の金銭不整合を生み出すのだ。

現場で生き残るための「外部API追放」実践アーキテクチャ

この泥沼から脱出するための鉄則は極めて明快である。「外部ネットワーク通信を、DBトランザクション境界から1ミリ秒の例外もなく永久追放すること」だ。

原則1: トランザクションは「1ミリ秒未満」で即座に閉じる

トランザクションの中で許されるのは、純粋なインメモリ計算と、同一DBインスタンス内の高速なSQL実行のみである。外部HTTP通信はもちろん、Redisアクセス、暗号鍵の生成、重い画像のデコードなど、レイテンシが不確定な処理をトランザクション内に持ち込んではならない。

原則2: ステートマシンによる「PENDING → 外部呼出 → 確定」分離

処理を「DBコミット」と「外部通信」で直列に分断し、状態遷移(ステートマシン)として設計する。これが実務における王道パターンだ。

// ⭕ 生き残る設計:トランザクションの外で外部APIを呼び出す
func ProcessOrderRobust(ctx context.Context, db *sql.DB, orderReq OrderRequest) error {
    orderID := generateUUIDv7()

    // ----------------------------------------------------
    // Phase 1: 初期レコードを PENDING 状態で即座に確定(所要時間: 1ms)
    // ----------------------------------------------------
    err := runInTx(ctx, db, func(tx *sql.Tx) error {
        return createOrderWithStatus(ctx, tx, orderID, orderReq, "PENDING")
    })
    if err != nil {
        return fmt.Errorf("注文初期化失敗: %w", err)
    }
    // 💡 ここでDBコネクションは完全に解放されている!

    // ----------------------------------------------------
    // Phase 2: トランザクションの外で外部決済を実行(所要時間: 300ms〜数秒)
    // ----------------------------------------------------
    // 万が一ここで外部APIが遅延・タイムアウトしても、DBコネクションは1本も消費しない
    paymentRes, err := paymentGateway.Charge(ctx, ChargeRequest{
        IdempotencyKey: orderID, // 冪等性キーとして注文IDを渡す
        Amount:         orderReq.Amount,
    })

    // ----------------------------------------------------
    // Phase 3: 結果を受けてステータスを更新(所要時間: 1ms)
    // ----------------------------------------------------
    if err != nil {
        // タイムアウトなどの不確定エラー時は「FAILED」ではなく「PAYMENT_UNKNOWN」へ
        newStatus := "FAILED"
        if isTimeout(err) {
            newStatus = "PAYMENT_UNKNOWN" // リコンサイルバッチの対象にする
        }
        _ = updateOrderStatus(ctx, db, orderID, newStatus)
        return fmt.Errorf("決済処理エラー: %w", err)
    }

    // 決済成功時:COMPLETED に更新
    return updateOrderStatus(ctx, db, orderID, "COMPLETED")
}

この設計の圧倒的な利点は、外部決済が何秒かかろうと、外部サービスがDDoSを受けて停止していようと、自社データベースのコネクションを保持している時間はPhase 1とPhase 3のわずか1ミリ秒ずつしかないという点だ。

外部APIが詰まっても、自社DBのコネクションプールは常に空いており、トップページの閲覧や他のAPIは一切影響を受けずに高速に動き続ける。

原則3: タイムアウトは「Webhook」と「照会(Reconcile)バッチ」で解決する

「Phase 2でタイムアウトした場合、決済が通ったかどうかはどう判断するのか?」という問いに対する答えこそが、現場のアーキテクチャの真骨頂である。

おわりに:トランザクション境界は「ドメインの防壁」である

「ひとつの関数、ひとつのトランザクションの中に処理を全部詰め込みたい」という誘惑は強い。コードの見た目はすっきりとし、成功と失敗の分岐も単純に見えるからだ。

しかし、それは「自社DBのリソースの有限性」と「ネットワークの不確実性」から目を背けた見せかけのシンプルさに過ぎない。外部サービスの都合で自社の最重要インフラであるデータベースが道連れにされるようなシステムは、アーキテクチャとして脆弱極まりない。

データベースのトランザクションは、内部データの一貫性を極限の短時間で保証するための神聖な境界だ。そこに泥臭く不確実な外部ネットワークを持ち込んではいけない。

トランザクションをミリ秒単位で閉じ、非同期とステートマシンで外部通信を受け止める。この「境界を引く冷徹な規律」こそが、アクセス急増にも外部SaaSの障害にもビクともしない、本当に強い本番システムを作り上げるのだ。