ERROR: current transaction is aborted, commands ignored until end of transaction block
トランザクションの途中で SQL が1文失敗すると、そのあとは何を投げてもこれが返ってくる。SELECT 1 すら通らない。失敗したのは1文だけで、後続はまとめて同じ文言で弾かれる。
この文言は出どころによって見え方が変わる。手がかりになる表記を先に並べておく。
ERROR: current transaction is aborted, commands ignored until end of transaction block- SQLSTATE は
25P02。psqlで\set VERBOSITY verboseを設定するとERROR: 25P02: current transaction is aborted, ...の形で出る psqlのプロンプトにapp=!#の!が付く(!が abort 状態の印)pg_stat_activityのstate列ではidle in transaction (aborted)と表示される- この文言の前には、原因になった別のエラーが出ている(例:
23505のduplicate key value violates unique constraint)
言語や ORM のトランザクション管理は扱わない。再現は Docker + PostgreSQL 17 + psql だけで足りる。最小再現から始め、後続が弾かれる仕組みを pg_stat_activity で確かめ、抜け方と、放置したセッションの行方まで扱う。BEGIN / COMMIT / ROLLBACK そのものの説明は PostgreSQLのトランザクション入門(BEGIN / COMMIT / ROLLBACK と同時更新) にある。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
- PostgreSQL 17
psql
以降のコマンド実行場所は、特記がない限り WSL 側ターミナルだ。Windows 側から始める場合は、先に WSL へ入る。
wsl
1. デモ環境を作る
作業用のディレクトリを作り、VS Code で開く。
mkdir -p ~/projects/postgresql-current-transaction-is-aborted-demo/sql
cd ~/projects/postgresql-current-transaction-is-aborted-demo
code .
compose.yml を作成する。
services:
db:
image: postgres:17
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db-data:
.env.example を作成する。
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=app_password
.env を用意してコンテナを起動する。
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();"
/var/run/postgresql:5432 - accepting connections
version
--------------------------------------------------------------------------------------------------------------------
PostgreSQL 17.9 (Debian 17.9-1.pgdg13+1) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)
2. 題材のテーブルを用意する
email に UNIQUE を付けた口座テーブルを使う。狙って失敗する SQL を書けて、失敗の理由も一目で分かるからだ。
sql/01-schema.sql を作成する。
CREATE TABLE accounts (
id bigserial PRIMARY KEY,
email text NOT NULL UNIQUE,
balance integer NOT NULL DEFAULT 0
);
INSERT INTO accounts (email, balance) VALUES
('alice@example.com', 1000),
('bob@example.com', 500);
流し込む。
docker compose exec -T db psql -U app -d app -f /dev/stdin < sql/01-schema.sql
CREATE TABLE
INSERT 0 2
3. 最小再現
トランザクションの状態はセッションが持つ。-c で1文ずつ投げると毎回別のセッションになるので、対話セッションを1つ開いたまま操作する。
docker compose exec db psql -U app -d app
BEGIN してから、既にいる alice@example.com をもう一度入れる。UNIQUE に当たって失敗する。そのあと、失敗とは何の関係もない SELECT を2つ投げる。
app=# BEGIN;
BEGIN
app=*# INSERT INTO accounts (email, balance) VALUES ('alice@example.com', 300);
ERROR: duplicate key value violates unique constraint "accounts_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.
app=!# SELECT count(*) FROM accounts;
ERROR: current transaction is aborted, commands ignored until end of transaction block
app=!# SELECT 1;
ERROR: current transaction is aborted, commands ignored until end of transaction block
app=!# ROLLBACK;
ROLLBACK
app=#
SELECT 1 は文法も正しく、テーブルにも触らない。それでも弾かれた。
プロンプトが状態を映している。BEGIN で app=*#(トランザクション中)になり、INSERT の失敗で app=!#(abort 状態)へ移る。ROLLBACK すると app=# に戻る。
SQLSTATE を見るなら VERBOSITY を上げる。
app=# \set VERBOSITY verbose
app=# BEGIN;
BEGIN
app=*# INSERT INTO accounts (email, balance) VALUES ('alice@example.com', 300);
ERROR: 23505: duplicate key value violates unique constraint "accounts_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.
SCHEMA NAME: public
TABLE NAME: accounts
CONSTRAINT NAME: accounts_email_key
LOCATION: _bt_check_unique, nbtinsert.c:666
app=!# SELECT count(*) FROM accounts;
ERROR: 25P02: current transaction is aborted, commands ignored until end of transaction block
LOCATION: exec_simple_query, postgres.c:1140
app=!# ROLLBACK;
ROLLBACK
出ている2つのエラーは役割が違う。23505 が本来の失敗で、25P02 はその後始末を求める通知だ。直す対象は前者にあり、後者は前者を放置した結果でしかない。
4. なぜ後続まで弾かれるのか
SAVEPOINT を張っていないトランザクションは、失敗を抱えたまま生き残って後続を拒んでいるのではない。エラーの時点で既に終わっている。セッションだけがその中に取り残されている。
pg_stat_activity で確かめられる。abort 状態のまま放置したセッションを、別のセッションから覗く。
docker compose exec db psql -U app -d app -c \
"SELECT pid, state, backend_xid, backend_xmin FROM pg_stat_activity WHERE datname = 'app' AND state LIKE 'idle in transaction%';"
pid | state | backend_xid | backend_xmin
-----+-------------------------------+-------------+--------------
708 | idle in transaction (aborted) | |
(1 row)
backend_xid が空だ。このセッションはもうトランザクション ID を持っていない。エラーの時点でトランザクションは中断済みになり、それまでの書き込みは無かったことになり、獲得していたロックもスナップショットも解放されている。残るのは「セッションが BEGIN ブロックの中にいる」という位置情報だけで、SQL を積む先のほうが無い。後続が 25P02 で返るのはこのためだ。
SAVEPOINT の内側で失敗した場合は違う
ここまでは SAVEPOINT を1つも張っていない場合の話だ。失敗した文が SAVEPOINT の内側にあると、中断されるのは savepoint 以降の部分だけで、トップレベルのトランザクションは生きたまま残る。同じ観測を、UPDATE → SAVEPOINT sp1 → 失敗 INSERT で止めたセッションに対して撃つと、値が変わる。
pid | state | backend_xid | backend_xmin
-----+-------------------------------+-------------+--------------
112 | idle in transaction (aborted) | 742 |
(1 row)
state は先ほどと同じ idle in transaction (aborted) だ。プロンプトも同じ app=!# になる。それでいて backend_xid には 742 が入っている。トランザクション ID を持ったまま abort 状態にいる。この2つの状態は state とプロンプトでは区別できない。見分けるには backend_xid を読む。
stateDiagram-v2
[*] --> 通常: 接続
通常 --> トランザクション中: BEGIN
トランザクション中 --> トランザクション中: SQL が成功
トランザクション中 --> abort状態: SQL がエラー(SAVEPOINT なし)
トランザクション中 --> savepoint内のabort: SQL がエラー(SAVEPOINT の内側)
abort状態 --> abort状態: 後続の SQL は 25P02
savepoint内のabort --> savepoint内のabort: 後続の SQL は 25P02
savepoint内のabort --> トランザクション中: ROLLBACK TO SAVEPOINT
abort状態 --> 通常: ROLLBACK / COMMIT
savepoint内のabort --> 通常: ROLLBACK / COMMIT
トランザクション中 --> 通常: COMMIT / ROLLBACK
5章で ROLLBACK TO SAVEPOINT が効くのは savepoint内のabort のほうだ。トップレベルが生きているから、戻る先が残っている。
弾かれる段階には順序がある
abort 状態でも、構文の誤りは 25P02 より先に出る。
app=!# THIS IS NOT SQL AT ALL;
ERROR: syntax error at or near "THIS"
LINE 1: THIS IS NOT SQL AT ALL;
^
文法の照合は通常どおり動き、そこを抜けた文が abort 状態の検査に当たる。25P02 ではなく構文エラーが出ても、状態が変わったわけではない。
検査を通り抜けるのは文法の照合までで、テーブル名や列名の解決はその後ろにある。存在しない表を叩いても、返るのは 25P02 だ。
app=!# SELECT * FROM no_such_table;
ERROR: current transaction is aborted, commands ignored until end of transaction block
25P02 が返ったことは、その SQL が文法として通ったこと以上を意味しない。表が無いことは abort 状態を抜けるまで分からない。
\d のようにカタログを読むメタコマンドは、内部で SQL を発行するので同じく弾かれる。
app=!# \d accounts
ERROR: current transaction is aborted, commands ignored until end of transaction block
サーバーへ問い合わせないものは通る。\set VERBOSITY verbose や \echo、\timing は abort 状態でもそのまま効くので、状態を調べる側の手は残っている。
5. abort 状態から抜ける
出口は2系統ある。トランザクションごと捨てるか、SAVEPOINT まで戻って続けるかだ。
ROLLBACK でトランザクションを捨てる
3節の最小再現で使ったとおり、ROLLBACK を投げるとプロンプトは app=# に戻り、次の SQL が通るようになる。
COMMIT を投げても ROLLBACK になる
abort 状態で COMMIT を送っても確定はしない。応答自体が ROLLBACK で返る。
app=# BEGIN;
BEGIN
app=*# UPDATE accounts SET balance = 999999 WHERE email = 'bob@example.com';
UPDATE 1
app=*# INSERT INTO accounts (email, balance) VALUES ('alice@example.com', 300);
ERROR: duplicate key value violates unique constraint "accounts_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.
app=!# COMMIT;
ROLLBACK
app=# SELECT email, balance FROM accounts ORDER BY id;
email | balance
-------------------+---------
alice@example.com | 1000
bob@example.com | 500
(2 rows)
UPDATE のほうは UPDATE 1 と成功していた。それでも bob@example.com の残高は 500 のままで、999999 は残らない。失敗した INSERT より前の成功分も一緒に捨てられる。
SAVEPOINT まで戻ってトランザクションを続ける
前の2つはトランザクションごと捨てる。捨てたくない場合は、失敗しうる文の前に SAVEPOINT を張っておく。4節で見た「savepoint 内の abort」がこれで、トップレベルは生きているので戻る先が残っている。ROLLBACK TO SAVEPOINT で戻れば、トランザクションはそのまま続く。
app=# BEGIN;
BEGIN
app=*# UPDATE accounts SET balance = balance + 1 WHERE email = 'bob@example.com';
UPDATE 1
app=*# SAVEPOINT sp1;
SAVEPOINT
app=*# INSERT INTO accounts (email, balance) VALUES ('alice@example.com', 300);
ERROR: duplicate key value violates unique constraint "accounts_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.
app=!# ROLLBACK TO SAVEPOINT sp1;
ROLLBACK
app=*# INSERT INTO accounts (email, balance) VALUES ('carol@example.com', 700);
INSERT 0 1
app=*# COMMIT;
COMMIT
app=# SELECT email, balance FROM accounts ORDER BY id;
email | balance
-------------------+---------
alice@example.com | 1000
bob@example.com | 501
carol@example.com | 700
(3 rows)
ROLLBACK TO SAVEPOINT sp1 の直後、プロンプトが app=!# から app=*# へ戻っている。捨てられたのは savepoint 以降、つまり失敗した INSERT だけだ。その前の UPDATE は生き残り、bob@example.com は 501 で確定した。続けて投げた carol@example.com の INSERT も通り、最後の COMMIT は ROLLBACK ではなく COMMIT を返している。
SAVEPOINT は失敗より前に張っておく必要がある。abort 状態に入ってから張ろうとすると、その SAVEPOINT 自体が弾かれる。
app=!# SAVEPOINT sp_late;
ERROR: current transaction is aborted, commands ignored until end of transaction block
戻り先を用意できるのは、失敗する前のセッションだけだ。
別名と AND CHAIN
ABORT は ROLLBACK の、END は COMMIT の別名だ。abort 状態ではどちらも ROLLBACK を返して抜ける。
AND CHAIN を付けると、抜けたうえで新しいトランザクションが始まる。ROLLBACK AND CHAIN も COMMIT AND CHAIN も、abort 状態では応答が ROLLBACK になる。
app=!# ROLLBACK AND CHAIN;
ROLLBACK
app=*# SELECT 'after ROLLBACK AND CHAIN' AS probe;
probe
--------------------------
after ROLLBACK AND CHAIN
(1 row)
戻った先のプロンプトが app=# ではなく app=*# になっている。abort 状態は抜けたが、セッションはトランザクションの中にいる。COMMIT か ROLLBACK はもう一度要る。
接続を切って入り直しても 25P02 は消える。ただし出口が増えたわけではない。トランザクションはサーバー側で捨てられ、結果は ROLLBACK と同じになる。
6. 放置されたセッションはどうなるか
abort 状態のセッションは、放っておいても自然には消えない。クライアントが5章の出口を通るか、接続が切れるまで idle in transaction (aborted) のまま座り続ける。
何を握ったまま座るかは、backend_xid を持っているかどうかで決まる。4節の2つの状態がここで効いてくる。
他のセッションを待たせるのは backend_xid を持っている側だ。次の2つが該当する。
- まだ abort していないトランザクション(
idle in transaction) SAVEPOINTの内側で失敗して abort したセッション(idle in transaction (aborted))
どちらも同じ行を狙った UPDATE は transactionid のロック待ちに入る。ターミナルを2つ使うと差が見える。A で BEGIN して UPDATE し、COMMIT も ROLLBACK も送らずに開いたままにする。
app=# BEGIN;
BEGIN
app=*# UPDATE accounts SET balance = balance + 1 WHERE email = 'bob@example.com';
UPDATE 1
app=*#
B から同じ行を更新すると待たされる。
app=# SET lock_timeout = '3s';
SET
app=# UPDATE accounts SET balance = 2 WHERE email = 'bob@example.com';
ERROR: canceling statement due to lock timeout
CONTEXT: while updating tuple (0,22) in relation "accounts"
A を UPDATE → SAVEPOINT sp1 → 失敗する INSERT の順に進めて app=!# で止めた場合も、B は同じように待たされる。A は abort 状態だが、backend_xid を持っているからだ。
backend_xid が空になるのは SAVEPOINT を張らずに abort した場合で、このときだけ B の UPDATE は即座に UPDATE 1 を返す。
VACUUM も同じ線で分かれる。SAVEPOINT の内側で失敗したセッションを REPEATABLE READ で走らせると、backend_xmin も残る。
pid | state | backend_xid | backend_xmin
-----+-------------------------------+-------------+--------------
190 | idle in transaction (aborted) | 745 | 745
(1 row)
backend_xmin を握ったセッションがいる間、それより新しい不要行は回収できない。SAVEPOINT を張らずに abort した側は backend_xid も backend_xmin も空なので、この経路が無い。
SAVEPOINT を張らずに abort したセッションが占め続けるのは接続スロットで、max_connections を削る。放置を時間で断ち切るなら idle_in_transaction_session_timeout を設定する。
app=# SET idle_in_transaction_session_timeout = '3s';
SET
app=# BEGIN;
BEGIN
app=*# INSERT INTO accounts (email, balance) VALUES ('alice@example.com', 300);
ERROR: duplicate key value violates unique constraint "accounts_email_key"
DETAIL: Key (email)=(alice@example.com) already exists.
FATAL: terminating connection due to idle-in-transaction timeout
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost
3秒待つとサーバー側から接続が切られた。この設定が数えるのは、トランザクションを開いたままアイドルでいる時間だ。abort 状態にも、abort していない idle in transaction にも効く。実行中の SQL を打ち切る設定ではないので、長い UPDATE や集計はこれでは止まらない。上の例はセッション単位の SET だが、運用ではサーバー設定かロール単位で入れておくと、接続を握ったまま止まったクライアントを回収できる。
7. 切り分け
25P02 しか見当たらず、原因のエラーが分からない: 原因は時間的に前にある。23505 のような最初のエラーを握り潰したまま後続を投げ続けると、ログには 25P02 だけが並ぶ。最初の1件まで遡る。アプリ経由なら、例外を捨てずに最初のエラーを記録する。
明示的に BEGIN していないのに出る: BEGIN を書いていなくても、クライアントやフレームワークが自動でトランザクションを開くことがある。別セッションから pg_stat_activity を見て idle in transaction か idle in transaction (aborted) が観測できれば、そのセッションは開いたトランザクションを抱えている。active の場合は state だけでは判定できない(明示トランザクションの途中か、単発の SQL かを区別しないため)。
ROLLBACK したのにまだ出る: 接続プールでは、ROLLBACK を送った接続と次の SQL を投げた接続が別物になりうる。pg_stat_activity の pid で同じセッションを追う。
構文エラーが返ってくる: abort 状態でも文法の照合は先に走る(4節)。構文エラーが出たことは、abort 状態を抜けた証拠にならない。プロンプトの ! か pg_stat_activity の state で確かめる。逆に、テーブル名や列名の誤りは 25P02 に化けるので、25P02 が返っても SQL が正しいとは限らない。
ロック待ちの相手が idle in transaction (aborted) になっている: abort 状態でもロックは残りうる。SAVEPOINT の内側で失敗した場合、トップレベルのトランザクションは backend_xid を持ったまま生きている(4節)。state だけで無害と判断せず、backend_xid を見る。ORM のネストしたトランザクションは savepoint を自動で張るので、この形になりやすい。
idle in transaction (aborted) が積み上がっている: ROLLBACK を送らずに接続を返しているクライアントがいる。idle_in_transaction_session_timeout は溜まったセッションを切れるが、送られていない ROLLBACK の代わりにはならない。
失敗した文そのものの読み方は制約の側の話で、23505 を含む制約違反の全体像は PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY) にまとめてある。行ロックを取ってトランザクションを組み立てる例は PHP + PostgreSQLで在庫引当を安全に実装する(SELECT … FOR UPDATE 最小構成) にある。