そのAPI、送ったあと落ちたら誰が覚えてんの?🫨

そのAPI、送ったあと落ちたら誰が覚えてんの?🫨

これ絶対あるじゃん??これ絶対あるじゃん??

  • 外部APIに送信したあとに自社DBにINSERTしてる
  • 「送信した」ってログには書いてるけど、ログを毎回grepしないと「届いたか」がわからない
  • 通信失敗したとき、どこまで進んだか判断できる材料がエラーログしかない
  • お客さんに「もう一回送って」って言われたとき、二重送信になるか不安で一瞬手が止まる

あるじゃん?? マジでこれ絶対あるじゃん??

これ全部、**外部APIに投げたあとの記憶を、誰も持ってないせい。**以降「投げっぱなし送信」って呼ぶね。


てかシンプルに言うとねてかシンプルに言うとね

外部APIに送ったか送ってないか、自社DBに痕跡が残ってないっつーこと。

「それの何がまずいの?」ってなる気持ち、わかる!
ふつうにレスポンス見れば成功か失敗かわかるじゃん、って。でもね、レスポンスを見る前にプロセスが落ちたときに、地獄が始まるんだよね。


ちょっと待って、先に全然関係ない話していい?🏠ちょっと待って、先に全然関係ない話していい?🏠

田中さんって配送センターで働いてる人がいるの。

仕事はシンプルで、注文票が届いたら、商品を運送会社のドライバーに渡して、自社の在庫管理システムに「発送済み」って打ち込むだけ。

ある日、年末セールで注文殺到。

ドライバーがトラックの前で待ってる。田中さん、急いで段ボールを運んで渡した。

「お預かりしました〜」

ドライバーがトラックに荷物を積んで、伝票にサインして、走り去った。

田中さん、デスクに戻って在庫システムを開こうとしたら、PCがフリーズ。Ctrl+Alt+Deleteも効かない。再起動。立ち上がるまで5分。

その間に次の注文票が届いて、別のドライバーが来て、別の段ボールを渡して...って繰り返してたら、さっき渡した段ボールのこと、頭から飛んでた。


3日後、お客さんから電話が来た。

「注文した商品、まだ届かないんですけど」

田中さん、在庫システムを確認した。

「えーっと、お調べします...あ、こちら未発送になってますね。すぐに再発送します!」

(あれ、未発送?まあシステムがそう言ってるしな)

新しい段ボールを用意して、ドライバーに渡した。

翌日、お客さんからまた電話が来た。

「同じ商品が2個届いたんですけど、どうしたらいいですか」

その日、別のお客さんからも電話が来た。

「1個目はもう届いたんですが、なぜか同じ注文番号で『発送しました』ってメールがまた来て...」

電話が10本続いて、月末の在庫棚卸しでは実在庫がシステム在庫より23個少ないことが判明。

段ボール渡したの覚えてないだけなのに...

上司に呼ばれた田中さん、「在庫が合わない原因は?」って聞かれて何も言えなかった。

これが本番で起きてるやつが、投げっぱなし送信ってこと。


じゃあコードで見てみよっか👀じゃあコードで見てみよっか👀

田中さんの段ボール = このコードの sendgrid_client.send_email 呼び出し。

async def send_invoice_email(order_id: int):
    order = db.fetch_one("SELECT * FROM orders WHERE id = ?", order_id)

    # 外部の配信サービスに送信
    response = await sendgrid_client.send_email(
        to=order.customer_email,
        subject=f"注文 #{order_id} の請求書",
        body=order.invoice_text,
    )
    if not response.ok:
        raise Exception("送信失敗")

    # DBに送信履歴を記録
    db.execute(
        "INSERT INTO invoice_emails (order_id, sent_at) VALUES (?, NOW())",
        order_id,
    )

これ、何が起きるかっていうとね。

sendgrid_client.send_emailが成功して、お客さんの受信箱にはメールが届く。届いた直後にプロセスがOOM Killerに殺されると、次のdb.executeが走らない。

結果、お客さんにはメール届いてる。でも自社DBのinvoice_emailsテーブルには1行も残ってない

サポートチームがあとから「請求書のメール、届いてますか?」って聞かれたとき、DBを見て「送ってませんね、再送します」って答えるよね。二重送信。

わたしも昔これやったから言えるんだけど、外部APIで決済処理を呼んだあとにDB INSERTする設計で、決済プロバイダ側には課金が走ってるのに自社DBには何もないって状態を作っちゃって、お客さんに二重課金で謝罪して返金処理を手作業で1件ずつやったことある。マジで二度とやりたくない。


やばいのまだあんだけど、バグに気づけない問題😭やばいのまだあんだけど、バグに気づけない問題😭

投げっぱなし送信のほんとにヤバいとこは、「落ちたら痕跡がない」じゃなくて、「落ちたことに誰も気づかない」のほうがヤバい。

田中さんの配送センターで考えてみて? 段ボール渡したあとにPCがフリーズしたとき、何も起きてないように見える。再起動してデスク戻って、次の注文票が来たら、それを処理する。フリーズで吹っ飛んだ仕事は誰の頭にも残ってない。

「お客さんから電話来るまでバレない」って状態。これがヤバい。


コードでも同じ。ちょっと見てみて?

# サポートチームの調査用クエリ
def has_invoice_been_sent(order_id: int) -> bool:
    row = db.fetch_one(
        "SELECT * FROM invoice_emails WHERE order_id = ?",
        order_id,
    )
    return row is not None

has_invoice_been_sentinvoice_emailsテーブルしか見ない。SendGridのログまで見にいかない。

DBにレコードがなければ「未送信」って答える。SendGrid側にメールが届いてても、DBが空なら「未送信」。これは嘘なんだけど、コードはこれが嘘だって判断する材料を持ってない。

監査担当が「先月の請求書、何件送りました?」って聞いてきたら、SELECT COUNT(*) FROM invoice_emailsで答えるじゃん。実際の送信数より少ない数字を、堂々と答えることになるんだよね。テーブルが嘘のデータを持ってるから、嘘の集計が出る。


メモ取ってた配送センターの話するね〜✨メモ取ってた配送センターの話するね〜✨

田中さんの配送センターとは別に、最初から発送伝票で管理してた隣の配送センターの話するね。

このセンター、注文票が届いたらまず発送伝票を3枚複写で書くところから始まる。1枚目はデスク、2枚目はドライバー、3枚目は完了ボックス用。

伝票には4つのチェック欄がある。

  • 準備中 (注文票を受け取った時点)
  • 渡した (ドライバーに段ボールを渡したらここにスタンプ)
  • 反映済み (在庫システムに入力したらここにスタンプ)
  • 失敗 (何か問題があったらここに理由を書く)

田中さんの仕事はこう変わる。

  1. 注文票が届く → 発送伝票を書く(準備中にチェック)
  2. ドライバーに段ボールを渡す → 1枚目に渡したスタンプ
  3. 在庫システムに入力する → 2枚目に反映済みスタンプ、伝票を完了ボックスへ

PCがフリーズしたら? 伝票はデスクに残ってる。渡したにスタンプが押されてて、反映済みにはスタンプがない状態で。

PCが復活したあと、田中さんが伝票を見て一発で気づく。「あ、これシステム入力だけ残ってるやつだ」。

二重発送、起きない。


コードに戻すとこうなるんだよね。発送伝票 = email_submissionsテーブル。

async def send_invoice_email(order_id: int):
    order = db.fetch_one("SELECT * FROM orders WHERE id = ?", order_id)

    # 1. 送る前にDBに「これから送る」って記録(=伝票を書く)
    submission_id = db.execute("""
        INSERT INTO email_submissions (order_id, recipient, status, created_at)
        VALUES (?, ?, 'requesting', NOW())
        RETURNING id
    """, order_id, order.customer_email)

    # 2. 外部APIに送信(DBトランザクションの外)
    try:
        response = await sendgrid_client.send_email(
            to=order.customer_email,
            subject=f"注文 #{order_id} の請求書",
            body=order.invoice_text,
        )
    except TimeoutError as e:
        # 通信失敗 = 一時エラー、再送できる
        db.execute(
            "UPDATE email_submissions SET status='failed', error=? WHERE id=?",
            str(e), submission_id,
        )
        raise

    if not response.ok:
        # 配信サービスが拒否 = 業務エラー、再送しても無駄
        db.execute(
            "UPDATE email_submissions SET status='rejected', error=? WHERE id=?",
            response.error, submission_id,
        )
        raise Exception("配信サービスが拒否しました")

    # 3. 成功時だけsyncedに進める
    db.execute(
        "UPDATE email_submissions SET status='synced', sent_at=NOW() WHERE id=?",
        submission_id,
    )

statusカラムが伝票のチェック欄。各状態の意味はこんな感じ。

  • 'requesting' → 「送るって決めたとこ。まだ届いたか不明」(=準備中)
  • 'synced' → 「送れた、自社DBにも反映済み」(=完了ボックス入り)
  • 'failed' → 「通信が落ちた。時間置けば成功するかも」(=リトライ妥当)
  • 'rejected' → 「配信サービスに業務的に蹴られた。再送無意味」(=人間判断必要)

途中でプロセスが落ちたら?

  • 'requesting'のまま残ってるレコード = 「これ送ったかどうかわからない、要確認」
  • これを定期ジョブが拾って、SendGridのAPIに「届いてる?」って問い合わせるか、運用に通知する

サポートチームが「請求書送りました?」って聞かれたら、status='synced'を見るだけで答えられる。'requesting'のレコードがあれば「確認中です」って正直に言える。嘘の集計が出ない。


ちゃんとした名前もあるから一応言うね📚ちゃんとした名前もあるから一応言うね📚

さっきの「送る前にDBにメモを残してから送る」設計、ちゃんとした名前がある。

Transactional Outbox(トランザクショナル・アウトボックス)。「外部システムへの送信意思を、送る前に自社DBに永続化する」設計のこと。

Outboxは「未送信トレイ」って意味。送る前にトレイに入れて、送ったらチェックを付けて、送れなかったらトレイに残ったまま。

Chris Richardsonって人がmicroservices.ioでまとめたパターン。元ネタはもっと古くて、Pat Hellandって人が2007年に書いた分散システムの論文に出てくる考え方なんだよね。

マイクロサービスでメッセージブローカーに通知する手法として有名なやつ。でも外部APIを叩く全部の場面で使える考え方なんだよね。決済プロバイダ、メール配信、SMS、外部の在庫管理システム、なんでも。

難しそうに聞こえるけど、要は「送る前にDBにメモ残して、送ったあと更新する」だけっつーこと。


てかこれ、try-exceptでログ出すだけじゃダメなん?ってなった人へ

それ思ったよね? ログでも痕跡は残るじゃん、って。

でもログってサポートチームがgrepしないと見えないんだよね。集計クエリが書けない。WHERE句で条件指定できない。「先月failed状態のレコード何件?」って聞かれて、ログから集計するの想像できる? 無理じゃん。

DBの行として残すと、SQLで一発で取れる。SELECT COUNT(*) FROM email_submissions WHERE status='failed' AND created_at > '2026-04-01'、これだけ。業務イベントとしてDBで管理するのと、デバッグの跡をログに残すのは別物っつーこと。


これだけ覚えて帰って💅これだけ覚えて帰って💅

外部APIを叩くコードを書くとき・レビューするとき、これだけ問いかけてみて。

「このAPIを叩いた直後にプロセスが落ちたら、叩いたこと、誰が覚えてる?」

答えが「ログに書いてあります」なら、それは投げっぱなし送信。
答えが「DBに行が残ってます」なら、ちゃんと管理できてる状態。

もう1個、これも聞いてみて。

「再送が必要かどうか、SQLで判断できる?」

答えが「ログをgrepすれば...」なら、まだ足りない。
答えが「WHERE status IN ('failed', 'requesting')で取れます」なら、ちゃんと観測できる状態。


まとめるとこういうことねまとめるとこういうことね

観点投げっぱなし送信Transactional Outbox
送った痕跡ログだけ。プロセスが落ちると消えるDBに行として残る
プロセスクラッシュ時再起動後にどこまで進んだか不明status='requesting'の行で「途中だった」とわかる
二重送信のリスクDBに記録ないから「未送信」と判断して再送statusを見れば既送信か途中か判断できる
集計クエリログをgrepして手で数えるSELECT COUNT(*) WHERE status=...で一発
エラー分類catch文の中だけ。あとから区別できないfailed(通信エラー) / rejected(業務エラー)でDBに残る

「外部APIにレスポンス返ってきたから成功」じゃなくて、**「送る前にDBにメモを残して、送ったあとにstatusを進める」**っつーこと。


Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第18回


沼りたい人はこっちも読んで📖沼りたい人はこっちも読んで📖

書籍

  • Chris Richardson 著『Microservices Patterns』(2018, Manning) — Transactional Outbox / Saga / Process Manager など、外部システムとの整合性パターンを網羅した本
  • Pat Helland "Life beyond Distributed Transactions: an Apostate's Opinion" — 分散システムで2相コミットを諦めたあとの世界をどう設計するかを論じた古典論文(2007)

一次資料

無料で読める入門記事

  • microservices.io の各パターンページに概要・使いどころ・関連パターンの図解が載ってる
  • Zenn・Qiitaで「Outboxパターン」「結果整合性」で検索すると日本語の実装例が見つかる