公開日 2026-05-08

PostgreSQLのトランザクション入門(BEGIN / COMMIT / ROLLBACK と同時更新)

PostgreSQL の `BEGIN` / `COMMIT` / `ROLLBACK` を送金デモで確認し、同時更新で `BEGIN` だけでは足りない場面まで `psql` で再現する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 最小デモ環境を作る
  4. 3. スキーマと seed を用意し、トランザクションの形を先に図で見る
  5. コードのポイント
  6. 4. 明示トランザクションなしだと片側だけ更新される
  7. 5. BEGIN と COMMIT で 1 つの作業として確定する
  8. コードのポイント
  9. 6. ROLLBACK で途中変更を戻す
  10. 7. 同時更新では BEGIN だけでは足りないことがある
  11. 8. 何を使い分ければよいかを整理する
  12. コードのポイント
  13. 9. まとめ

PostgreSQL の SELECT / UPDATE は読めるが、BEGIN / COMMIT / ROLLBACK をどこで使い、同時更新では何に気を付けるかまだ整理できていない人を対象にしています。 題材は口座残高の送金です。Docker + PostgreSQL + psql だけで、片側だけ更新される失敗、COMMIT でまとめて確定する流れ、ROLLBACK で戻る流れ、2 セッションの同時更新で起きる上書きまで順に確認します。特定のプログラミング言語や ORM は対象外です。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop
  • PostgreSQL 17
  • psql

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。

1. ゴールと非対象

到達する状態:

  • BEGIN / COMMIT / ROLLBACK を、「複数文を 1 つの作業として扱う」道具として理解できる
  • 送金の途中で失敗したとき、トランザクションなしだと何が壊れるかを再現できる
  • 同時更新では BEGIN だけで十分とは限らず、別の工夫が必要な場面があると分かる

扱わない内容:

  • SERIALIZABLE / REPEATABLE READ の詳細比較
  • deadlock の再現
  • SAVEPOINT
  • Laravel / Symfony / Doctrine の書き方
  • 分散トランザクション

今回は「途中失敗をまとめて戻せること」と、「同時更新で何が起こるか」の入口に集中します。

2. 最小デモ環境を作る

記事用の空ディレクトリを作ります。

mkdir -p ~/projects/postgresql-transactions-intro-demo
cd ~/projects/postgresql-transactions-intro-demo
mkdir -p sql
code .

最終的な構成は次の 4 ファイルです。

postgresql-transactions-intro-demo/
├─ compose.yml
├─ .env.example
└─ sql/
   ├─ 01-schema.sql
   └─ 02-seed.sql

compose.yml を作成し、次の内容を保存します。

services:
  db:
    image: postgres:17
    working_dir: /workspace
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./:/workspace
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 20

volumes:
  db-data:

.env.example を作成し、次の 3 行を保存します。

POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app

起動は次のコマンドです。

cp .env.example .env
docker compose up -d
docker compose exec db pg_isready -U app -d app
docker compose exec db psql -U app -d app -c "SELECT version();"
docker compose exec db psql -U app -d app -c "SHOW transaction_isolation;"
Docker起動とバージョン確認の実行結果

確認ポイント:

  • pg_isreadyaccepting connections を返す
  • SELECT version(); が 1 行返る
  • SHOW transaction_isolation;read committed を返す

詰まったとき:

docker compose logs db
docker compose down -v

3. スキーマと seed を用意し、トランザクションの形を先に図で見る

今回は accountstransfer_log の 2 テーブルを使います。 先に流れだけ図で置くと、COMMITROLLBACK がどの範囲に効くのか追いやすくなります。

flowchart LR
    A["BEGIN"] --> B["Alice の残高を減らす"]
    B --> C["Bob の残高を増やす"]
    C --> D["transfer_log に履歴を入れる"]
    D --> E{"終わり方"}
    E -->|COMMIT| F["3つとも確定する"]
    E -->|ROLLBACK| G["3つとも取り消す"]

sql/01-schema.sql を作成し、次の内容を保存します。

CREATE TABLE accounts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  account_name TEXT NOT NULL UNIQUE,
  balance INTEGER NOT NULL CHECK (balance >= 0)
);

CREATE TABLE transfer_log (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  from_account_id BIGINT NOT NULL REFERENCES accounts(id),
  to_account_id BIGINT NOT NULL REFERENCES accounts(id),
  amount INTEGER NOT NULL CHECK (amount > 0),
  note TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

sql/02-seed.sql を作成し、次の内容を保存します。

TRUNCATE TABLE transfer_log, accounts RESTART IDENTITY CASCADE;

INSERT INTO accounts (account_name, balance)
VALUES
  ('Alice', 10000),
  ('Bob', 5000);

スキーマと seed を適用します。

docker compose exec db psql -U app -d app -f sql/01-schema.sql
docker compose exec db psql -U app -d app -f sql/02-seed.sql
docker compose exec db psql -U app -d app -c "\\d accounts"
docker compose exec db psql -U app -d app -c "SELECT account_name, balance FROM accounts ORDER BY id;"

初期状態は次のとおりです。

account_namebalance
Alice10000
Bob5000

以降の検証は、必要に応じて sql/02-seed.sql を流し直し、初期状態へ戻しながら進めます。

スキーマ適用と初期データの確認結果

コードのポイント

CREATE TABLE accounts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  account_name TEXT NOT NULL UNIQUE,
  balance INTEGER NOT NULL CHECK (balance >= 0)
);

CREATE TABLE transfer_log (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  from_account_id BIGINT NOT NULL REFERENCES accounts(id),
  to_account_id BIGINT NOT NULL REFERENCES accounts(id),
  amount INTEGER NOT NULL CHECK (amount > 0),
  note TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
  • 4行目 CHECK (balance >= 0) — 残高が負にならないことを DB レベルで保証する。片側の UPDATE だけ通った場合でも、残高が 0 未満になる書き込みはここで弾かれる。
  • 9〜10行目 from_account_idto_account_id — 同じ accounts テーブルを 2 本の外部キーで参照する。送金元と送金先を 1 行で表現する際の最小構成。
  • 11行目 CHECK (amount > 0) — 送金額ゼロや負の金額を DB レベルで排除する。

4. 明示トランザクションなしだと片側だけ更新される

最初は失敗例です。 ここでは「Alice から 3000 円引いた直後に、アプリが落ちた」場面を仮定します。

まず初期状態へ戻します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql

次の 1 文だけを実行します。

docker compose exec db psql -U app -d app -c "UPDATE accounts SET balance = balance - 3000 WHERE account_name = 'Alice';"

この時点でアプリが止まった想定にします。Bob 側の加算も、履歴 INSERT もまだ実行していません。

状態を確認します。

docker compose exec db psql -U app -d app -c "SELECT account_name, balance FROM accounts ORDER BY id;"
docker compose exec db psql -U app -d app -c "TABLE transfer_log;"

期待する結果:

account_namebalance
Alice7000
Bob5000

transfer_log は 0 行のままです。

これが「複数文をまとめて扱っていない」状態です。 PostgreSQL は BEGIN を書かなくても各 SQL 文を個別トランザクションとして実行します。そのため、最初の UPDATE だけ先に確定し、そのあとの処理が失敗すると中途半端な状態が残ります。

トランザクションなしで片側だけ更新された状態

5. BEGINCOMMIT で 1 つの作業として確定する

次は、同じ送金を明示トランザクションで 1 単位にまとめる手順です。

まず seed を流し直します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql
docker compose exec db psql -U app -d app

psql が開いたら、次の SQL をそのまま貼り付けます。

BEGIN;

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

UPDATE accounts
SET balance = balance + 3000
WHERE account_name = 'Bob';

INSERT INTO transfer_log (from_account_id, to_account_id, amount, note)
VALUES (
  (SELECT id FROM accounts WHERE account_name = 'Alice'),
  (SELECT id FROM accounts WHERE account_name = 'Bob'),
  3000,
  'Alice -> Bob manual transfer'
);

COMMIT;

COMMIT; まで終えたら \qpsql を閉じます。

結果を確認します。

docker compose exec db psql -U app -d app -c "SELECT account_name, balance FROM accounts ORDER BY id;"
docker compose exec db psql -U app -d app -c "SELECT amount, note FROM transfer_log ORDER BY id;"

期待する結果:

account_namebalance
Alice7000
Bob8000
amountnote
3000Alice -> Bob manual transfer

COMMIT まで到達したので、残高 2 行の更新と履歴 1 行の追加がまとめて確定しました。 ここで守りたいのは「1 本ずつ成功すること」ではありません。「送金 1 件として全部そろったときだけ保存すること」です。

BEGIN / COMMITで送金が確定した状態

コードのポイント

BEGIN;

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

UPDATE accounts
SET balance = balance + 3000
WHERE account_name = 'Bob';

INSERT INTO transfer_log (from_account_id, to_account_id, amount, note)
VALUES (
  (SELECT id FROM accounts WHERE account_name = 'Alice'),
  (SELECT id FROM accounts WHERE account_name = 'Bob'),
  3000,
  'Alice -> Bob manual transfer'
);

COMMIT;
  • 1行目 BEGIN — ここから COMMIT までの 3 文(UPDATE 2 本と INSERT 1 本)が 1 つの作業単位になる。途中でエラーが起きれば、どれも確定せずに済む。
  • 19行目 COMMIT — 3 文の変更をまとめて確定する。COMMIT 前の変更は自分のセッション内では見えているが、他のセッションへはまだ見えていない。

6. ROLLBACK で途中変更を戻す

今度は同じ処理を途中で取り消します。

また seed を流し直します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql
docker compose exec db psql -U app -d app

psql で次の SQL を実行します。

BEGIN;

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

UPDATE accounts
SET balance = balance + 3000
WHERE account_name = 'Bob';

INSERT INTO transfer_log (from_account_id, to_account_id, amount, note)
VALUES (
  (SELECT id FROM accounts WHERE account_name = 'Alice'),
  (SELECT id FROM accounts WHERE account_name = 'Bob'),
  3000,
  'rollback demo'
);

SELECT account_name, balance
FROM accounts
ORDER BY id;

ROLLBACK;

トランザクションの途中で SELECT すると、次のように一時的な変更が見えます。

account_namebalance
Alice7000
Bob8000

そのまま ROLLBACK; を実行し、psql\q で閉じます。 そのあと、トランザクション外で状態を確認します。

docker compose exec db psql -U app -d app -c "SELECT account_name, balance FROM accounts ORDER BY id;"
docker compose exec db psql -U app -d app -c "TABLE transfer_log;"

期待する結果:

account_namebalance
Alice10000
Bob5000

transfer_log は 0 行です。

確認したい点は 2 つです。

  • 自分のトランザクション内では、未コミットの変更を自分で確認できる
  • ROLLBACK すると、そのトランザクションで行った UPDATEINSERT がまとめて消える

例外発生時に ROLLBACK を入れるのは、「最後の SQL だけ戻す」ためではありません。その作業単位全体をなかったことにするためです。

ROLLBACKで初期状態に戻った結果

7. 同時更新では BEGIN だけでは足りないことがある

ここからは、途中失敗ではなく同時更新です。 先に流れだけ図で見ると、2 つのセッションが同じ残高 10000 を読んでから別々の値を書いていることが分かります。

sequenceDiagram
    participant A as Session A
    participant DB as PostgreSQL
    participant B as Session B

    A->>DB: BEGIN
    A->>DB: SELECT balance
    DB-->>A: 10000
    A->>DB: SELECT pg_sleep(5)

    B->>DB: BEGIN
    B->>DB: SELECT balance
    DB-->>B: 10000
    B->>DB: UPDATE balance = 8000
    B->>DB: COMMIT

    A->>DB: UPDATE balance = 7000
    A->>DB: COMMIT

本来の最終残高は 10000 - 3000 - 2000 = 5000 です。しかし、古い読み取り結果から計算した固定値を書き戻すと、先にコミットした側の変更が後から来た更新で上書きされ、正しい残高になりません。

まず seed を流し直します。

docker compose exec db psql -U app -d app -f sql/02-seed.sql

次に、ターミナルを 2 つ開いてそれぞれ psql に入ります。

ターミナルA:

docker compose exec db psql -U app -d app

ターミナルB:

docker compose exec db psql -U app -d app

ターミナルAでは次を実行します。

BEGIN;
SELECT balance FROM accounts WHERE account_name = 'Alice';
SELECT pg_sleep(5);
UPDATE accounts
SET balance = 7000
WHERE account_name = 'Alice';
COMMIT;

ターミナルAが pg_sleep(5) の待機に入ったのを確認してから、ターミナルBで次を実行します。

BEGIN;
SELECT balance FROM accounts WHERE account_name = 'Alice';
UPDATE accounts
SET balance = 8000
WHERE account_name = 'Alice';
COMMIT;

両方終わったら、どちらかのターミナルで次を実行します。

SELECT account_name, balance
FROM accounts
WHERE account_name = 'Alice';

期待する結果:

account_namebalance
Alice7000

5000 にはなりません。ターミナルBの 8000 が、あとから来たターミナルAの 7000 で上書きされたからです。

PostgreSQL が壊れているわけではありません。 この再現で問題なのは、UPDATE 同士が競合してデータが消えたわけではなく、両セッションが SELECT で読んだ 10000 を前提に 7000 / 8000 という固定値を作って書き戻していることです。 BEGIN は all-or-nothing を守りますが、「古い SELECT 結果から自分で計算した値がもう古い」という問題までは自動で直しません。

同時更新でAliceの残高が7000に上書きされた結果

8. 何を使い分ければよいかを整理する

ここまで来ると、役割を次の 3 つに切り分けると分かりやすくなります。

やりたいことまず使うもの理由次の一歩
複数文をまとめて成功 / 失敗させたいBEGIN / COMMIT / ROLLBACK1 つの作業単位として確定・取消できる本記事
同じ 1 行の数値を単純に増減したいUPDATE ... SET col = col +/- n現在の行の値を DB 側で使って更新できる下の例
読んで条件判定してから更新したいSELECT ... FOR UPDATE読んだあと更新するまでの順番を作れる後続記事

たとえば、単純な加減算を 1 文で完結できるなら次の形を先に試します。

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

この形なら、更新式の中で「その時点の行の値」を使えます。 2 つのセッションが同じ行を更新しても、固定値 7000 / 8000 を書くより事故を起こしにくくなります。 ただし、これで送金全体の業務要件まで片付くわけではありません。残高不足判定、相手口座への加算、履歴 INSERT まで含めて扱うなら、別の設計が必要です。

一方で、「残高を読んで、足りるか判定してから減らす」のように read-then-write が必要なら、次の形へ進みます。

BEGIN;

SELECT balance
FROM accounts
WHERE account_name = 'Alice'
FOR UPDATE;

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

COMMIT;

FOR UPDATE を付けると、その行を読んだセッションが COMMIT または ROLLBACK するまで他のセッションは同じ行を更新できなくなります。これが「読んでから更新するまでの順番」を作る仕組みです。 実行順まで掘り下げるなら、後続の PHP + PostgreSQLで在庫引当を安全に実装する(SELECT … FOR UPDATE 最小構成) がそのまま続きになります。

複数ワーカーが「待つ」のではなく「別の仕事へ進む」形まで広げるなら、PHP + PostgreSQLでジョブキューを作る(FOR UPDATE SKIP LOCKED 最小構成) が次の一歩です。

コードのポイント

BEGIN;

SELECT balance
FROM accounts
WHERE account_name = 'Alice'
FOR UPDATE;

UPDATE accounts
SET balance = balance - 3000
WHERE account_name = 'Alice';

COMMIT;
  • 1行目 BEGINFOR UPDATE のロックはトランザクション内でのみ保持される。BEGIN なしで実行すると SELECT 終了と同時にロックが解放され、意味をなさない。
  • 6行目 FOR UPDATE — この行に排他ロックを取る。他のセッションが同じ行を FOR UPDATE で読もうとすると、このトランザクションが COMMITROLLBACK するまで待機する。
  • 12行目 COMMIT — ロックを解放する。待機していた後続セッションはここで最新の残高を読んで処理に進める。

9. まとめ

今回確認したことは次の 3 つです。

  • BEGIN / COMMIT は、複数文を 1 つの作業として確定するために使う
  • ROLLBACK は、その作業単位全体をまとめて取り消す
  • 同時更新では BEGIN だけで十分とは限らず、固定値の上書きや read-then-write に別の対策が必要になる

トランザクションの最初の役割は、「複数文をまとめて成功か失敗かのどちらかにすること」です。 そこに同時更新が加わると、「何をいつ読み、どの順番で更新するか」の設計が別に必要になります。

次の一歩は、SELECT ... FOR UPDATE で順番を作る記事です。アプリケーションコードから試したいなら、WSL2 + Docker + PHP + PostgreSQLで最小CRUDを作る から始めると自然につながります。

シリーズ 4/7

このシリーズ

PostgreSQL基礎

  1. 1. PostgreSQLでER図からテーブル設計する(Mermaid + CREATE TABLE最小構成)
  2. 2. PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY)
  3. 3. PostgreSQLの外部キーと削除ルールを整理する(CASCADE / RESTRICT / SET NULL)
  4. 4. PostgreSQLのトランザクション入門(BEGIN / COMMIT / ROLLBACK と同時更新) 現在の記事
  5. 5. PostgreSQLのインデックス入門(B-tree と EXPLAIN ANALYZE の最小例)
  6. 6. 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む)
  7. 7. EXPLAIN ANALYZEの結果から改善策を選ぶ(Sort / Join / Aggregate ごとの打ち手)