1 分

Postgres トランザクションでマルチステップワークフローを扱う:実践パターン

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

Postgres トランザクションでマルチステップワークフローを扱う:実践パターン

なぜマルチステップ更新は不整合になりがちか

多くの実際の機能は一つのデータベース更新だけでは終わりません。挿入→残高更新→ステータス変更→監査記録を書き込む→ジョブをキューに入れる、というような短い連鎖になっていることが多いです。部分的な書き込みは、その中の一部だけがデータベースに反映されるときに起きます。

これはチェーンを中断する何かが起きたときに現れます:サーバーエラー、アプリと Postgres 間のタイムアウト、ステップ2 の後のクラッシュ、あるいはステップ1 を再実行するリトライなど。各 SQL 文は単独では問題なく見えるかもしれません。ワークフローが中途半端に止まると破綻します。

たいてい次のような形で見つかります:

  • 行は存在するが関連する行がない(注文は作成されたが明細がない)
  • お金は移動したがステータスが変わっていない(支払い済みだが未払いのまま)
  • ひとつであるべきレコードが二つある(リトライで重複したサブスクリプション)
  • フラグが食い違う(ユーザーが「active」だがプランがない)
  • 負荷や障害時だけに現れる状態

具体例:プランのアップグレードで顧客のプランを更新し、支払いレコードを追加し、利用可能クレジットを増やすとします。支払いは保存されたがクレジット追加の前にアプリがクラッシュすると、サポートはあるテーブルに「paid」を見て別のテーブルに「クレジット無し」を見ることになります。クライアントがリトライすると支払いが二重に記録されることさえあります。

目標は簡単です:ワークフローを単一のスイッチのように扱うこと。すべてのステップが成功するか、全く実行されないかにして、半端な作業を保存しないことです。

トランザクションを簡単に説明すると

トランザクションは、データベースが「これらのステップを一つの作業単位として扱う」と言う方法です。すべての変更が起きるか、全く起きないか。これは、行を作る、残高を更新する、監査記録を書く、といった複数の更新が必要なワークフローで重要になります。

例を考えると、口座 A から差し引いて口座 B に足すときです。A から引いた後にアプリがクラッシュすると、差し引きだけが残るのは望ましくありません。

コミットとロールバック

コミットすると、Postgres に対してそのトランザクションで行ったすべてを保持するよう指示します。変更は恒久化され、他のセッションから見えるようになります。

ロールバックすると、そのトランザクションで行った変更をすべてなかったことにするよう指示します。Postgres はトランザクションがなかったかのように変更を取り消します。

Postgres が保証すること(しないこと)

トランザクション内では、コミット前に他のセッションに半端な結果を見せないことは保証されます。何かが失敗してロールバックすれば、そのトランザクションの書き込みはクリーンに取り消されます。

ただしトランザクションはワークフロー設計の誤りを自動的に修正しません。誤った金額を差し引いたり、間違ったユーザー ID を使ったり、必要なチェックを飛ばしたりすると、Postgres はその誤った結果を忠実にコミットします。また、トランザクションだけでは過剰販売のようなビジネスレベルの競合をすべて防げないため、適切な制約やロック、分離レベルと組み合わせる必要があります。

グループ化すべきワークフロー

ひとつの実世界のアクションを完了するために複数のテーブルや複数の行を更新する場合は、トランザクションの候補です。要点は同じ:すべてが行われるか、何も行われないか。

注文のフローは典型例です。注文行を作り、在庫を確保し、支払いを取り、注文を paid にする、といった一連の流れがあります。支払いは成功したがステータス更新が失敗すると、注文は未払いのままお金だけが取られていることになります。注文行が作られたが在庫が確保されなければ、実際に持っていない商品を販売してしまいます。

ユーザーのオンボーディングも同様に壊れます。ユーザーを作成し、プロファイルを挿入し、ロールを割り当て、ウェルカムメール送信の記録を作る——これらは一つの論理的なアクションです。グループ化しないと、ログインはできるが権限がないユーザーや、ユーザーのないプロファイルが作られることがあります。

バックオフィスの操作も「証跡 + 状態変更」が一緒に成功する必要があることが多いです。申請を承認して、監査エントリを書き、残高を更新する処理は一緒に成功すべきです。残高は変わったのに監査ログがなければ、誰が何を変えたのかが分からなくなります。

バックグラウンドジョブも恩恵を受けます。作業アイテムを処理するときに複数のステップがある場合:アイテムを確保して二重処理を防ぎ、ビジネス更新を適用し、レポート用の結果を記録して、アイテムを完了(または理由付きで失敗)にする。これらがずれるとリトライや同時実行で混乱を招きます。

SQLを書く前にワークフローを設計する

マルチステップ機能は独立した更新の寄せ集めのように扱うと壊れます。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 エラーコードなど。全ペイロードやカードデータ、個人情報のログは避けます。

同時実行の基礎:ロックと分離(専門用語なし)

トランザクションラッパーを実装する
Postgres と短いトランザクションで明確なエラーパスを持つ Go API を立ち上げます。

同時実行とは同時に二つのことが起きることです。最後のコンサートチケットを二人が同時に買おうとする場面を想像してください。どちらの画面も「残り1」と表示され、両方が支払いをクリックしたとき、誰がそれを手に入れるかを決める必要があります。

保護がないと、両方のリクエストが同じ古い値を読み、両方が更新を書き込みます。これがマイナス在庫や重複予約、注文のない支払いの原因になります。

行ロックは最も単純なガードレールです。変更する行をロックしてチェックを行い、更新します。同期して同じ行に触れようとする他のトランザクションはあなたがコミットまたはロールバックするまで待ちます。これにより二重更新を防げます。

よくあるパターン:トランザクションを開始し、FOR UPDATE で在庫行を選んで在庫があるか確認し、減らしてから注文を挿入する。これがクリティカルなステップ中に「ドアを押さえる」役割をします。

分離レベルは、同時トランザクションからどれだけの「変な重なり」を許すかを制御します。トレードオフは安全性と速度です:

  • Read Committed(デフォルト):速いが、ステートメント間で他者のコミットを見られる。
  • Repeatable Read:トランザクション中は安定したスナップショットを見る、整合性のある読み取りに適するがリトライが増える可能性あり。
  • Serializable:最強の安全性。Postgres は結果を逐次実行したかのように保つために一方のトランザクションを中止することがある。

ロックは短く保ちましょう。トランザクションが外部 API 呼び出しやユーザーのアクションを待っていると長時間座席を占有してしまい、待機やタイムアウトが発生します。明確な失敗パスを優先してください:ロックタイムアウトを設定し、エラーをキャッチして「再試行してください」と返す方がリクエストをぶら下げるより良いです。

外部作業(カード請求など)をする必要があるならワークフローを分割してください:素早く予約してコミットし、その後遅い処理を行い、別の短いトランザクションで最終化します。

重複を作らないリトライ

リトライは Postgres を使うアプリでは普通に起きます。正しいコードでも死活、デッドロック、ステートメントタイムアウト、ネットワークの一時的な切断、あるいは高い分離レベルでのシリアライゼーションエラーで失敗することがあります。同じハンドラをただ再実行すると、二つの注文ができたり二重課金になったり、重複したイベント行を挿入したりする危険があります。

対策は冪等性(idempotency)です:同じ入力で二回実行しても安全であること。データベースが「これは同じリクエストだ」と認識でき、一貫した応答を返す仕組みが必要です。

実用的なパターンは idempotency key(多くはクライアント生成の request_id)をマルチステップワークフローに付与し、それをメインのレコードに保存してユニーク制約を付けることです。

たとえばチェックアウトで、ユーザーが Pay をクリックしたときに request_id を生成し、その request_id を付けて注文を挿入します。リトライが起きると二回目はユニーク制約に引っかかり、既存の注文を返して新しい注文を作りません。

重要な点:

  • (request_id) や (user_id, request_id) に対するユニーク制約で重複を防ぐ。
  • 制約違反が起きたら既存行を取得して同じ結果を返す。
  • 副作用にも同じルールを適用する:注文ごとに一つの payment_intent、注文ごとに一つの "order confirmed" イベントなど。
  • サポートが追跡できるように 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」ではなく、ワークフロー設計の決定が原因です。半端な状態でコミットしやすくしてしまう設計ミスが多いです。

実アプリで見られる落とし穴

  • トランザクションを開いたまま遅い外部作業をする。 決済プロバイダ呼び出し、メール送信、ファイルアップロードをトランザクション内で行うとロックを長く保持します。API が遅いと他のユーザーが待たされます。
  • トランザクション外で読み取りを行い、後で書き込みする。 例:ユーザー残高を取得して画面表示し、その後その古い値を元に差し引く。別セッションがその間に残高を変えているかもしれません。
  • エラーを捕まえても何かをコミットしてしまう。 よくあるパターンは「ステップ1 を試し、ステップ2 を試し、エラーをログにして成功を返す」。失敗後に COMMIT に到達すると、その時点で意図的に不整合を作ってしまいます。
  • コードパスでテーブルを異なる順序で更新する。 あるリクエストが accountsorders の順に更新し、別のリクエストが ordersaccounts の順に更新すると、負荷下でデッドロックが起きやすくなります。
  • トランザクションを長時間開いておく。 長いトランザクションは書き込みをブロックし、VACUUM を遅らせ、分かりにくいタイムアウトを生みます。

具体例:チェックアウトで在庫を確保し、注文を作り、カードを課金するとします。課金をトランザクション内で行うと、ネットワークを待つ間に在庫ロックを保持してしまうかもしれません。課金が成功してもその後でトランザクションがロールバックすると、顧客に請求だけが残って注文はない状態になります。

より安全なパターンは次の通りです:トランザクションはデータベースの状態(在庫確保、注文作成、支払い試行を記録)に集中させてコミットし、外部 API 呼び出しはコミット後に行い、その結果を別の短いトランザクションで書き戻す。多くのチームは pending ステータスとバックグラウンドジョブでこれを実装します。

全か無か操作のクイックチェックリスト

ワークフローに複数のステップ(挿入、更新、課金、送信)があるとき、目標は簡単:すべてが記録されるか、何も記録されないか。

トランザクション境界

必要なデータベース書き込みはすべて一つのトランザクション内に収めましょう。どれか一つが失敗したらロールバックしてデータを元に戻します。

成功条件を明確にします。例:「注文が作成され、在庫が確保され、支払いステータスが記録される」。これに満たないものは失敗です。

  • 必要な書き込みはすべて BEGIN ... COMMIT ブロック内で行う。
  • データベース内に一つの明確な完了状態がある(アプリのメモリだけで完了を管理しない)。
  • エラーがあれば ROLLBACK し、呼び出し元には明確な失敗を返す。

冪等性などの安全策(リトライが害にならないように)

同じリクエストがリトライされることを前提に設計しましょう。データベースが一度だけ行うべき処理を助けてくれます。

  • ユニーク制約で一度だけの操作を支える(注文ごとの一つの支払いレコード、アイテムごとの一つの予約など)。
  • リトライは安全かつ再現可能にする(同じ入力が同じ最終状態を作る)。

トランザクションは短く

トランザクション内では最小限を行い、ネットワーク待ちでロックを保持しない。

  • トランザクションは短く保ち、タイムアウトを設定する。
  • 外部プロバイダ呼び出しのような遅い作業はトランザクション外で行い、結果を別の短いトランザクションで記録する。

障害を観測する

どこで壊れているか見えないと推測で対応し続けることになります。

  • 失敗時にワークフローステップとリクエスト ID をログに残す。
  • ロールバック率やロックタイムアウトを監視して部分的書き込みのリスクを早期に検出する。

例:障害が起きても一貫性を保つチェックアウトフロー

安全なワークフローを設計する
まずトランザクション境界を設計してから、React と Go のコードをチャットで生成します。

チェックアウトは複数のステップが一緒に進む必要があります:注文作成、在庫確保、支払い試行の記録、注文ステータスの更新。

ユーザーが 1 個のアイテムを購入するとします。

安全なフロー(DB 作業は一つの単位)

1 つのトランザクション内でデータベースの変更だけを行います:

  • orders 行を pending_payment ステータスで挿入。
  • 在庫を確保(例:inventory.available を減らすか reservations 行を作る)。
  • クライアント提供の idempotency_key を持つ payment_intents 行を挿入(ユニーク)。
  • order_created のような outbox 行を挿入。

どれか一つが失敗すれば(在庫不足、制約違反、クラッシュ)、Postgres がトランザクションをロールバックします。注文があるのに予約がない、予約があるのに注文がない、といった状態は生じません。

支払いが途中で失敗したら?

支払いプロバイダはデータベースの外にあるので別のステップとして扱います。

プロバイダ呼び出しがコミット前に失敗したらトランザクションを中止し、何も書かれません。プロバイダ呼び出しがコミット後に失敗したら、新しいトランザクションで支払い試行を失敗にし、予約を解除し、注文ステータスをキャンセルにする処理を行います。

二重注文を作らずにリトライする

クライアントはチェックアウトごとに idempotency_key を送ります。payment_intents(idempotency_key) にユニークインデックスを付けて強制します。リトライ時は既存の行を参照して続行し、新しい注文を作りません。

メールや通知

トランザクション内でメールを送らないでください。同じトランザクションでアウトボックスレコードを書き、コミット後にバックグラウンドワーカーがメールを送るようにします。これにより、ロールバックされた注文について誤ってメールを送ることがなくなります。

次のステップ:今週ひとつのワークフローに適用する

複数のテーブルに触れるワークフロー(サインアップ+ウェルカムメール、チェックアウト+在庫、請求書+台帳、プロジェクト作成+デフォルト設定など)を一つ選んでください。

まずステップを書き、その後常に成り立つべきルール(不変条件)を定義します。例:「注文は完全に支払われ予約されているか、支払われておらず予約もないか。半端に予約された状態は作らない。」これらのルールを全か無かの単位に変えます。

簡単な計画:

  • 実行する正確な SQL 操作を順にリストする(読み取り、挿入、更新、削除)。
  • まず欠けているデータベース制約を追加する(ユニークキー、外部キー、チェック制約)。
  • リトライで重複を作らないようにリクエストに idempotency key を追加する。
  • ステップを一つのトランザクションにラップし、すべてのチェックが通ったときだけコミットするという成功点を明確にする。
  • 安全なリトライがどのようなものか(同じ idempotency key、同じ結果)を決める。

その後、わざと厄介なケースをテストします。ステップ2 の後のクラッシュ、コミット直前のタイムアウト、UI からの二重送信をシミュレートしてください。目標は退屈な結果:孤立した行なし、二重課金なし、永遠に pending が残らないこと。

プロトタイプが早ければ、ハンドラやスキーマを生成する前に設計を描くと役に立ちます。例えば Koder.ai (koder.ai) は Planning Mode を持ち、スナップショットとロールバックをサポートしているのでトランザクション境界や制約を反復設計する際に便利です。

今週ひとつのワークフローに取り組めば、二つ目はずっと早く進みます。

よくある質問

Postgresのトランザクションはいつ使うべきですか?

1つのユーザー操作で、整合性を保つ必要がある複数の行やテーブルを変更するときは、トランザクションを使います。代表例として、チェックアウト、口座振替、ロール割り当てを伴う登録、在庫の引き当てがあります。

PostgresでCOMMITは何をしますか?

コミットすると、トランザクション内のすべての変更が永続化され、他のデータベースセッションからも見えるようになります。必要な検証と書き込みがすべて成功してからコミットしてください。

ROLLBACKするとどうなりますか?

ロールバックするとトランザクションが取り消され、未コミットの変更が破棄されます。必須の処理が失敗したらコードでロールバックし、呼び出し元が処理できるエラーを返すべきです。

トランザクション内のすべてのクエリで同じ接続が必要ですか?

そのワークフローのデータベース読み取りと書き込みは、BEGINからCOMMITまたはROLLBACKまで同じデータベース接続で行ってください。接続が異なれば、トランザクションも別になります。

トランザクション内で決済APIやメールAPIを呼び出すべきですか?

いいえ。トランザクションはデータベースの変更をまとめますが、カード決済、メール送信、サードパーティAPI呼び出しを取り消すことはできません。まずデータベースの状態をコミットし、outboxイベントまたは保留ステータスを記録してから、外部処理を別途実行してください。

最後の1点を2人のユーザーが購入するのをどう防げますか?

利用可能な在庫数のような共有値を確認・更新する前に、その行をロックしてください。たとえば、FOR UPDATEで在庫行を選択し、在庫数を確認して減らし、コミット前に注文を作成します。

リトライで重複注文を避けるにはどうすればよいですか?

各リクエストに安定した冪等性キーを付与し、一意制約で強制します。リトライ時にその制約に抵触したら、別の注文や支払いレコードを挿入せず、既存の結果を取得して返します。

リトライはなぜ新しいトランザクションで開始する必要があるのですか?

リトライは新しいトランザクションで最初から行ってください。文の実行エラー後、Postgresは現在のトランザクションを中断済みとして扱うため、ロールバックするまで安全に続行できません。

不整合なデータを防ぐのに役立つデータベース制約はどれですか?

有効な参照には外部キー、必須フィールドにはNOT NULL、有効な値にはCHECK制約、1件だけ存在すべきレコードには一意制約を使います。これらのルールは、アプリケーションコードにバグがあってもデータを守ります。

トランザクションを短く保つべき理由は何ですか?

トランザクションは、短時間で終わるデータベース処理に絞ってください。長いトランザクションはロックを保持し、待ち時間を増やします。特にネットワーク呼び出しを待つ場合、デッドロックやタイムアウトを招くことがあります。

Related posts