PDOのトランザクションはネストできない – SAVEPOINTで安全にやる
PDOのトランザクションとは、複数のSQLをまとめて「全部成功」か「全部なかったこと」にする仕組みです。ただしPDOの beginTransaction() はネストできず、内側で呼ぶと例外になります。
これ、実務で一番踏むのは「トランザクションの中で呼ばれるとは思っていなかったメソッド」が自分でトランザクションを開始したときですね。単体では動くのに、他から呼ばれた瞬間に壊れる。厄介な部類のバグだと思います。
なぜネストできないのか
そもそもRDBMS側にネストしたトランザクションという概念がありません。MySQLもPostgreSQLも、トランザクションはフラットです。PDOはそれをそのまま反映しているだけで、内側で beginTransaction() を呼ぶと「There is already an active transaction」で落ちます。
$pdo->beginTransaction();
// どこか深い階層のサービスクラスで…
$pdo->beginTransaction(); // PDOException: There is already an active transaction
もっと怖いのは、例外にならずに黙って壊れるケースです。内側の処理が commit() を呼んだ瞬間、外側のトランザクションごと確定してしまう。外側がその後 rollBack() しても、もう戻せません。「ロールバックしたはずのデータが残っている」という報告は、だいたいこれが原因です。
inTransaction()で逃げるのは半分正解
とりあえずの回避策としてよく見るのがこれです。
$own = !$pdo->inTransaction();
if ($own) {
$pdo->beginTransaction();
}
try {
// 処理
if ($own) {
$pdo->commit();
}
} catch (Throwable $e) {
if ($own) {
$pdo->rollBack();
}
throw $e;
}
「自分が始めたトランザクションだけを自分で閉じる」という考え方で、壊れはしません。ただ、内側の処理だけを失敗させて外側は続行する、ということができない。内側でこけたら全部巻き戻すしかありません。それで困らないなら、これで十分だと思います。
部分的に巻き戻すにはSAVEPOINT
内側だけを取り消したいなら、SQLのSAVEPOINTを使います。トランザクションの途中に栞を挟んで、そこまで戻せる機能ですね。MySQLもPostgreSQLもSQLiteも対応しています。
SAVEPOINT sp1;
-- 何か実行
ROLLBACK TO SAVEPOINT sp1; -- sp1 以降だけ取り消し
RELEASE SAVEPOINT sp1; -- 栞を捨てる(確定ではない)
PDOには専用APIがないので、exec() で直接投げます。これを薄いラッパーにまとめておくと、呼び出し側は入れ子を意識しなくてよくなります。
final class TransactionManager
{
private int $level = 0;
public function __construct(private PDO $pdo) {}
public function transactional(callable $fn): mixed
{
$this->begin();
try {
$result = $fn();
$this->commit();
return $result;
} catch (Throwable $e) {
$this->rollBack();
throw $e;
}
}
private function begin(): void
{
if ($this->level === 0) {
$this->pdo->beginTransaction();
} else {
$this->pdo->exec('SAVEPOINT sp' . $this->level);
}
$this->level++;
}
private function commit(): void
{
$this->level--;
if ($this->level === 0) {
$this->pdo->commit();
} else {
$this->pdo->exec('RELEASE SAVEPOINT sp' . $this->level);
}
}
private function rollBack(): void
{
$this->level--;
if ($this->level === 0) {
$this->pdo->rollBack();
} else {
$this->pdo->exec('ROLLBACK TO SAVEPOINT sp' . $this->level);
}
}
}
使う側はこうなります。ネストしても、内側の失敗は内側だけで止められる。
$tm->transactional(function () use ($tm, $repo) {
$repo->createOrder($order);
try {
$tm->transactional(fn() => $repo->sendPointBonus($order));
} catch (BonusException $e) {
// ポイント付与だけ巻き戻して、注文自体は生かす
$logger->warning('ボーナス付与に失敗', ['order' => $order->id]);
}
});
クロージャで囲む形にしておくと、commit() の書き忘れが構造的に起きなくなります。DoctrineやLaravelのDBファサードが同じ形をしているのは、たぶんこれが理由ですね。
暗黙のコミットという地雷
MySQLで一番やられるのがこれです。トランザクション中に CREATE TABLE、ALTER TABLE、TRUNCATE、DROP などのDDLを実行すると、その時点で暗黙的にコミットが走ります。SAVEPOINTもろとも消えます。
$pdo->beginTransaction();
$pdo->exec('INSERT INTO orders ...');
$pdo->exec('TRUNCATE TABLE temp_import'); // ここで暗黙コミット
$pdo->rollBack(); // 何も戻らない
バッチ処理で一時テーブルを使うコードでよく見かけます。一時テーブルを消したいなら TRUNCATE ではなく DELETE FROM にする。地味ですが、これだけでトランザクションの中に収まります。
エラーモードを例外にしていないと全部無意味
ここまでの話は、SQLの失敗が例外として飛んでくる前提です。PDOの ERRMODE_SILENT のままだと、INSERTが失敗しても false が返るだけで、catch には入りません。ロールバックされないままコミットが走ります。
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
PHP 8.0からは ERRMODE_EXCEPTION がデフォルトになったので、新しく書くぶんには意識しなくてよくなりました。ただ古いコードを引き継いだときは、接続部分を最初に確認したほうがいいと思います。
接続が切れたあとのrollBackも失敗する
長時間走るバッチだと、MySQLの wait_timeout で接続が切られていることがあります。この状態で rollBack() を呼ぶと、そこでまた例外が飛んで、本来伝えたかった元の例外が握りつぶされる。
} catch (Throwable $e) {
try {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
} catch (Throwable $ignored) {
// ロールバック自体の失敗は握って、元の例外を優先する
}
throw $e;
}
catch ブロックの中で例外を出しうる処理を書くときは、元の例外を守る、と覚えておくと事故が減ります。
まとめ
PDOのトランザクションはネストできないので、素直に書くと「内側が勝手にコミットして外側が壊れる」という形で事故ります。全体を巻き戻してよければ inTransaction() で自分の担当かどうかを判定するだけで足りますし、部分的に戻したいならSAVEPOINTを深さカウンタで管理するラッパーを一つ作っておくのが早いです。あとはMySQLのDDLによる暗黙コミットと、エラーモードの設定。この二つを外していると、どんなに丁寧にラッパーを書いても意味がなくなります。トランザクションまわりは、うまく動いているときは何も起きないぶん、壊れていることに気づくのが遅れるのが一番怖いところかもしれません。
よくある質問
Q. beginTransaction()を二重に呼ぶとどうなりますか?
A. PHP 8.0以降ではPDOExceptionが投げられます。それ以前のバージョンでも動作は保証されないので、inTransaction() で状態を確認してから呼ぶか、深さを管理するラッパーを経由してください。
Q. SAVEPOINTはどのデータベースで使えますか?
A. MySQL(InnoDB)、PostgreSQL、SQLite、SQL Serverなど主要なRDBMSで使えます。ただしMyISAMのようにトランザクション自体に非対応のストレージエンジンでは意味がありません。
Q. トランザクション中にCREATE TABLEを書いてしまいました。ロールバックできますか?
A. MySQLではDDLが暗黙コミットを起こすため戻せません。PostgreSQLはDDLもトランザクションに含められるので戻せます。移植性を考えるなら、トランザクション内でDDLを書かない方針にしておくのが無難だと思います。