Postgres のトランザクションを使ったマルチステップワークフロー:複数の更新を安全にまとめ、部分書き込みを防ぎ、リトライを扱い、データ整合性を保つ方法を学ぶ。

多くの実際の機能は一つのデータベース更新だけでは終わりません。挿入→残高更新→ステータス変更→監査記録を書き込む→ジョブをキューに入れる、というような短い連鎖になっていることが多いです。部分的な書き込みは、その中の一部だけがデータベースに反映されるときに起きます。
これはチェーンを中断する何かが起きたときに現れます:サーバーエラー、アプリと Postgres 間のタイムアウト、ステップ2 の後のクラッシュ、あるいはステップ1 を再実行するリトライなど。各 SQL 文は単独では問題なく見えるかもしれません。ワークフローが中途半端に止まると破綻します。
たいてい次のような形で見つかります:
具体例:プランのアップグレードで顧客のプランを更新し、支払いレコードを追加し、利用可能クレジットを増やすとします。支払いは保存されたがクレジット追加の前にアプリがクラッシュすると、サポートはあるテーブルに「paid」を見て別のテーブルに「クレジット無し」を見ることになります。クライアントがリトライすると支払いが二重に記録されることさえあります。
目標は簡単です:ワークフローを単一のスイッチのように扱うこと。すべてのステップが成功するか、全く実行されないかにして、半端な作業を保存しないことです。
トランザクションは、データベースが「これらのステップを一つの作業単位として扱う」と言う方法です。すべての変更が起きるか、全く起きないか。これは、行を作る、残高を更新する、監査記録を書く、といった複数の更新が必要なワークフローで重要になります。
例を考えると、口座 A から差し引いて口座 B に足すときです。A から引いた後にアプリがクラッシュすると、差し引きだけが残るのは望ましくありません。
コミットすると、Postgres に対してそのトランザクションで行ったすべてを保持するよう指示します。変更は恒久化され、他のセッションから見えるようになります。
ロールバックすると、そのトランザクションで行った変更をすべてなかったことにするよう指示します。Postgres はトランザクションがなかったかのように変更を取り消します。
トランザクション内では、コミット前に他のセッションに半端な結果を見せないことは保証されます。何かが失敗してロールバックすれば、そのトランザクションの書き込みはクリーンに取り消されます。
ただしトランザクションはワークフロー設計の誤りを自動的に修正しません。誤った金額を差し引いたり、間違ったユーザー ID を使ったり、必要なチェックを飛ばしたりすると、Postgres はその誤った結果を忠実にコミットします。また、トランザクションだけでは過剰販売のようなビジネスレベルの競合をすべて防げないため、適切な制約やロック、分離レベルと組み合わせる必要があります。
ひとつの実世界のアクションを完了するために複数のテーブルや複数の行を更新する場合は、トランザクションの候補です。要点は同じ:すべてが行われるか、何も行われないか。
注文のフローは典型例です。注文行を作り、在庫を確保し、支払いを取り、注文を paid にする、といった一連の流れがあります。支払いは成功したがステータス更新が失敗すると、注文は未払いのままお金だけが取られていることになります。注文行が作られたが在庫が確保されなければ、実際に持っていない商品を販売してしまいます。
ユーザーのオンボーディングも同様に壊れます。ユーザーを作成し、プロファイルを挿入し、ロールを割り当て、ウェルカムメール送信の記録を作る——これらは一つの論理的なアクションです。グループ化しないと、ログインはできるが権限がないユーザーや、ユーザーのないプロファイルが作られることがあります。
バックオフィスの操作も「証跡 + 状態変更」が一緒に成功する必要があることが多いです。申請を承認して、監査エントリを書き、残高を更新する処理は一緒に成功すべきです。残高は変わったのに監査ログがなければ、誰が何を変えたのかが分からなくなります。
バックグラウンドジョブも恩恵を受けます。作業アイテムを処理するときに複数のステップがある場合:アイテムを確保して二重処理を防ぎ、ビジネス更新を適用し、レポート用の結果を記録して、アイテムを完了(または理由付きで失敗)にする。これらがずれるとリトライや同時実行で混乱を招きます。
マルチステップ機能は独立した更新の寄せ集めのように扱うと壊れます。DB クライアントを開く前に、ワークフローを短い物語にして一つの明確な終着点を定義しましょう:ユーザーにとって「完了」とは何か。
まずステップを平易な言葉で列挙し、次に単一の成功条件を定義します。例えば「注文が作られ、在庫が確保され、ユーザーに注文確認番号が表示される」。その条件に満たないものは成功ではありません。
次に、データベース作業と外部作業を明確に分けます。データベース内のステップはトランザクションで保護できますが、カード決済やメール送信、サードパーティ API は遅延や失敗があり、通常はロールバックできません。
簡単な設計手法:ステップを (1) 全か無かである必要があるもの、(2) コミット後に行ってよいもの に分ける。
トランザクション内には、一緒に整合している必要があるステップだけを入れます:
副作用は外に出します。たとえば、注文をコミットしてからアウトボックスのレコードに基づいて確認メールを送るようにします。
各ステップについて、次のステップが失敗したら何が起きるべきかを書いておきます。「ロールバック」はデータベースのロールバックである場合も、補償アクションを意味する場合もあります。
例:支払いは成功したが在庫確保が失敗した場合、即座に返金するのか、それとも「支払い済みだが在庫待ち」として非同期に処理するのかを事前に決めておきます。
トランザクションは Postgres に「これらのステップを一つの単位として扱え」と伝えます。全て実行されるか、何も実行されないか。これは部分的な書き込みを防ぐ最も単純な方法です。
開始から終了まで一つのデータベース接続(一つのセッション)を使います。接続をまたいでステップを広げると、Postgres は全か無かを保証できません。
シーケンスはシンプルです:BEGIN、必要な読み取りと書き込みを実行し、すべて成功したら COMMIT、そうでなければ ROLLBACK して明確なエラーを返します。
最小限の SQL 例:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
トランザクションは実行中ロックを保持します。長く開いていると他の作業をブロックしやすく、タイムアウトやデッドロックに遭遇しやすくなります。トランザクション内では必要最小限を行い、メール送信や決済プロバイダ呼び出し、PDF 生成など時間のかかる処理は外に出しましょう。
何かが失敗したときには、再現に十分なコンテキストをログに残しますが、機密データは漏らさないようにします:ワークフロー名、order_id や user_id、重要なパラメータ(amount、currency)、Postgres エラーコードなど。全ペイロードやカードデータ、個人情報のログは避けます。
同時実行とは同時に二つのことが起きることです。最後のコンサートチケットを二人が同時に買おうとする場面を想像してください。どちらの画面も「残り1」と表示され、両方が支払いをクリックしたとき、誰がそれを手に入れるかを決める必要があります。
保護がないと、両方のリクエストが同じ古い値を読み、両方が更新を書き込みます。これがマイナス在庫や重複予約、注文のない支払いの原因になります。
行ロックは最も単純なガードレールです。変更する行をロックしてチェックを行い、更新します。同期して同じ行に触れようとする他のトランザクションはあなたがコミットまたはロールバックするまで待ちます。これにより二重更新を防げます。
よくあるパターン:トランザクションを開始し、FOR UPDATE で在庫行を選んで在庫があるか確認し、減らしてから注文を挿入する。これがクリティカルなステップ中に「ドアを押さえる」役割をします。
分離レベルは、同時トランザクションからどれだけの「変な重なり」を許すかを制御します。トレードオフは安全性と速度です:
ロックは短く保ちましょう。トランザクションが外部 API 呼び出しやユーザーのアクションを待っていると長時間座席を占有してしまい、待機やタイムアウトが発生します。明確な失敗パスを優先してください:ロックタイムアウトを設定し、エラーをキャッチして「再試行してください」と返す方がリクエストをぶら下げるより良いです。
外部作業(カード請求など)をする必要があるならワークフローを分割してください:素早く予約してコミットし、その後遅い処理を行い、別の短いトランザクションで最終化します。
リトライは Postgres を使うアプリでは普通に起きます。正しいコードでも死活、デッドロック、ステートメントタイムアウト、ネットワークの一時的な切断、あるいは高い分離レベルでのシリアライゼーションエラーで失敗することがあります。同じハンドラをただ再実行すると、二つの注文ができたり二重課金になったり、重複したイベント行を挿入したりする危険があります。
対策は冪等性(idempotency)です:同じ入力で二回実行しても安全であること。データベースが「これは同じリクエストだ」と認識でき、一貫した応答を返す仕組みが必要です。
実用的なパターンは idempotency key(多くはクライアント生成の request_id)をマルチステップワークフローに付与し、それをメインのレコードに保存してユニーク制約を付けることです。
たとえばチェックアウトで、ユーザーが Pay をクリックしたときに request_id を生成し、その request_id を付けて注文を挿入します。リトライが起きると二回目はユニーク制約に引っかかり、既存の注文を返して新しい注文を作りません。
重要な点:
リトライループはトランザクションの外に置きましょう。各試行は新しいトランザクションを開始して単位を最初から再実行するべきです。失敗したトランザクション内でリトライしても、Postgres はトランザクションを aborted とマークするため意味がありません。
小さな例:アプリが注文を作って在庫を確保しようとしたがコミット直後にタイムアウトしたとします。クライアントがリトライすると、idempotency key があれば二回目は既に作られた注文を返し、二重の確保を行わずに済みます。
トランザクションでワークフローをまとめても、データが正しいことを自動的に保証するわけではありません。アプリのバグがあってもデータベース上で「間違った」状態を起こしにくくすることは、部分的な書き込みの影響を減らす強力な方法です。
まず基本的な安全策を入れましょう。外部キーは参照の正当性を確保します(注文行がない注文を指せない)。NOT NULL は半端な行を防ぎます。CHECK 制約は意味のない値を捕まえます(例えば quantity > 0、total_cents >= 0)。これらのルールはどのサービスやスクリプトがデータベースに触れても書き込みごとに働きます。
長いワークフローでは状態遷移を明示的にモデル化しましょう。多くのブールフラグを使う代わりに、一つの status カラム(pending、paid、shipped、canceled)を使い、有効な遷移だけを許可します。制約やトリガーで不正な遷移(shipped -> pending など)を拒否させることができます。
重複を許さないことも正しさの一形態です。重複がワークフローを壊す箇所にユニーク制約を追加します:order_number、invoice_number、リトライ用の idempotency_key など。そうすればアプリが同じリクエストを再送しても Postgres が二回目の挿入をブロックし、「既に処理済み」を安全に返せます。
トレーサビリティが必要なら明示的に保存してください。誰がいつ何を変えたかを記録する監査テーブル(または履歴テーブル)は、障害時に「謎の更新」を事実に変えます。
ほとんどの部分書き込みは「悪い SQL」ではなく、ワークフロー設計の決定が原因です。半端な状態でコミットしやすくしてしまう設計ミスが多いです。
accounts → orders の順に更新し、別のリクエストが orders → accounts の順に更新すると、負荷下でデッドロックが起きやすくなります。具体例:チェックアウトで在庫を確保し、注文を作り、カードを課金するとします。課金をトランザクション内で行うと、ネットワークを待つ間に在庫ロックを保持してしまうかもしれません。課金が成功してもその後でトランザクションがロールバックすると、顧客に請求だけが残って注文はない状態になります。
より安全なパターンは次の通りです:トランザクションはデータベースの状態(在庫確保、注文作成、支払い試行を記録)に集中させてコミットし、外部 API 呼び出しはコミット後に行い、その結果を別の短いトランザクションで書き戻す。多くのチームは pending ステータスとバックグラウンドジョブでこれを実装します。
ワークフローに複数のステップ(挿入、更新、課金、送信)があるとき、目標は簡単:すべてが記録されるか、何も記録されないか。
必要なデータベース書き込みはすべて一つのトランザクション内に収めましょう。どれか一つが失敗したらロールバックしてデータを元に戻します。
成功条件を明確にします。例:「注文が作成され、在庫が確保され、支払いステータスが記録される」。これに満たないものは失敗です。
BEGIN ... COMMIT ブロック内で行う。ROLLBACK し、呼び出し元には明確な失敗を返す。同じリクエストがリトライされることを前提に設計しましょう。データベースが一度だけ行うべき処理を助けてくれます。
トランザクション内では最小限を行い、ネットワーク待ちでロックを保持しない。
どこで壊れているか見えないと推測で対応し続けることになります。
チェックアウトは複数のステップが一緒に進む必要があります:注文作成、在庫確保、支払い試行の記録、注文ステータスの更新。
ユーザーが 1 個のアイテムを購入するとします。
1 つのトランザクション内でデータベースの変更だけを行います:
orders 行を pending_payment ステータスで挿入。inventory.available を減らすか reservations 行を作る)。idempotency_key を持つ payment_intents 行を挿入(ユニーク)。order_created のような outbox 行を挿入。どれか一つが失敗すれば(在庫不足、制約違反、クラッシュ)、Postgres がトランザクションをロールバックします。注文があるのに予約がない、予約があるのに注文がない、といった状態は生じません。
支払いプロバイダはデータベースの外にあるので別のステップとして扱います。
プロバイダ呼び出しがコミット前に失敗したらトランザクションを中止し、何も書かれません。プロバイダ呼び出しがコミット後に失敗したら、新しいトランザクションで支払い試行を失敗にし、予約を解除し、注文ステータスをキャンセルにする処理を行います。
クライアントはチェックアウトごとに idempotency_key を送ります。payment_intents(idempotency_key) にユニークインデックスを付けて強制します。リトライ時は既存の行を参照して続行し、新しい注文を作りません。
トランザクション内でメールを送らないでください。同じトランザクションでアウトボックスレコードを書き、コミット後にバックグラウンドワーカーがメールを送るようにします。これにより、ロールバックされた注文について誤ってメールを送ることがなくなります。
複数のテーブルに触れるワークフロー(サインアップ+ウェルカムメール、チェックアウト+在庫、請求書+台帳、プロジェクト作成+デフォルト設定など)を一つ選んでください。
まずステップを書き、その後常に成り立つべきルール(不変条件)を定義します。例:「注文は完全に支払われ予約されているか、支払われておらず予約もないか。半端に予約された状態は作らない。」これらのルールを全か無かの単位に変えます。
簡単な計画:
その後、わざと厄介なケースをテストします。ステップ2 の後のクラッシュ、コミット直前のタイムアウト、UI からの二重送信をシミュレートしてください。目標は退屈な結果:孤立した行なし、二重課金なし、永遠に pending が残らないこと。
プロトタイプが早ければ、ハンドラやスキーマを生成する前に設計を描くと役に立ちます。例えば Koder.ai (koder.ai) は Planning Mode を持ち、スナップショットとロールバックをサポートしているのでトランザクション境界や制約を反復設計する際に便利です。
今週ひとつのワークフローに取り組めば、二つ目はずっと早く進みます。