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;"
確認ポイント:
pg_isreadyがaccepting connectionsを返すSELECT version();が 1 行返るSHOW transaction_isolation;がread committedを返す
詰まったとき:
docker compose logs db
docker compose down -v
3. スキーマと seed を用意し、トランザクションの形を先に図で見る
今回は accounts と transfer_log の 2 テーブルを使います。
先に流れだけ図で置くと、COMMIT と ROLLBACK がどの範囲に効くのか追いやすくなります。
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_name | balance |
|---|---|
| Alice | 10000 |
| Bob | 5000 |
以降の検証は、必要に応じて 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_idとto_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_name | balance |
|---|---|
| Alice | 7000 |
| Bob | 5000 |
transfer_log は 0 行のままです。
これが「複数文をまとめて扱っていない」状態です。
PostgreSQL は BEGIN を書かなくても各 SQL 文を個別トランザクションとして実行します。そのため、最初の UPDATE だけ先に確定し、そのあとの処理が失敗すると中途半端な状態が残ります。
5. BEGIN と COMMIT で 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; まで終えたら \q で psql を閉じます。
結果を確認します。
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_name | balance |
|---|---|
| Alice | 7000 |
| Bob | 8000 |
| amount | note |
|---|---|
| 3000 | Alice -> Bob manual transfer |
COMMIT まで到達したので、残高 2 行の更新と履歴 1 行の追加がまとめて確定しました。
ここで守りたいのは「1 本ずつ成功すること」ではありません。「送金 1 件として全部そろったときだけ保存すること」です。
コードのポイント
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 文(UPDATE2 本とINSERT1 本)が 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_name | balance |
|---|---|
| Alice | 7000 |
| Bob | 8000 |
そのまま 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_name | balance |
|---|---|
| Alice | 10000 |
| Bob | 5000 |
transfer_log は 0 行です。
確認したい点は 2 つです。
- 自分のトランザクション内では、未コミットの変更を自分で確認できる
ROLLBACKすると、そのトランザクションで行ったUPDATEとINSERTがまとめて消える
例外発生時に ROLLBACK を入れるのは、「最後の SQL だけ戻す」ためではありません。その作業単位全体をなかったことにするためです。
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_name | balance |
|---|---|
| Alice | 7000 |
5000 にはなりません。ターミナルBの 8000 が、あとから来たターミナルAの 7000 で上書きされたからです。
PostgreSQL が壊れているわけではありません。
この再現で問題なのは、UPDATE 同士が競合してデータが消えたわけではなく、両セッションが SELECT で読んだ 10000 を前提に 7000 / 8000 という固定値を作って書き戻していることです。
BEGIN は all-or-nothing を守りますが、「古い SELECT 結果から自分で計算した値がもう古い」という問題までは自動で直しません。
8. 何を使い分ければよいかを整理する
ここまで来ると、役割を次の 3 つに切り分けると分かりやすくなります。
| やりたいこと | まず使うもの | 理由 | 次の一歩 |
|---|---|---|---|
| 複数文をまとめて成功 / 失敗させたい | BEGIN / COMMIT / ROLLBACK | 1 つの作業単位として確定・取消できる | 本記事 |
| 同じ 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行目
BEGIN—FOR UPDATEのロックはトランザクション内でのみ保持される。BEGINなしで実行するとSELECT終了と同時にロックが解放され、意味をなさない。 - 6行目
FOR UPDATE— この行に排他ロックを取る。他のセッションが同じ行をFOR UPDATEで読もうとすると、このトランザクションがCOMMITかROLLBACKするまで待機する。 - 12行目
COMMIT— ロックを解放する。待機していた後続セッションはここで最新の残高を読んで処理に進める。
9. まとめ
今回確認したことは次の 3 つです。
BEGIN/COMMITは、複数文を 1 つの作業として確定するために使うROLLBACKは、その作業単位全体をまとめて取り消す- 同時更新では
BEGINだけで十分とは限らず、固定値の上書きや read-then-write に別の対策が必要になる
トランザクションの最初の役割は、「複数文をまとめて成功か失敗かのどちらかにすること」です。 そこに同時更新が加わると、「何をいつ読み、どの順番で更新するか」の設計が別に必要になります。
次の一歩は、SELECT ... FOR UPDATE で順番を作る記事です。アプリケーションコードから試したいなら、WSL2 + Docker + PHP + PostgreSQLで最小CRUDを作る から始めると自然につながります。