公開日 2026-09-01

PostgreSQLで「current transaction is aborted」が出たときに何が起きているか

PostgreSQL 17で、明示的なトランザクション中にSQLが1文失敗すると、後続のSELECTまでSQLSTATE 25P02で拒否される。その仕組みを pg_stat_activity で確かめ、ROLLBACK / COMMIT / SAVEPOINT での抜け方と、放置したセッションがロックを握る条件までpsqlの実測で追う

目次

  1. 前提環境
  2. 1. デモ環境を作る
  3. 2. 題材のテーブルを用意する
  4. 3. 最小再現
  5. 4. なぜ後続まで弾かれるのか
  6. SAVEPOINT の内側で失敗した場合は違う
  7. 弾かれる段階には順序がある
  8. 5. abort 状態から抜ける
  9. ROLLBACK でトランザクションを捨てる
  10. COMMIT を投げても ROLLBACK になる
  11. SAVEPOINT まで戻ってトランザクションを続ける
  12. 別名と AND CHAIN
  13. 6. 放置されたセッションはどうなるか
  14. 7. 切り分け
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 最小構成) にある。