4.

DB トランザクション完全ガイド(SQL BEGIN/COMMIT / Laravel / Spring / 分離レベル / デッドロック)

編集
この記事の要点
  • 原子性を保証する仕組み: BEGIN; ... ; COMMIT;(途中で失敗したら ROLLBACK
  • Laravel: DB::transaction(function() { ... }) がクロージャ版(自動 commit/rollback)
  • Spring: @Transactional アノテーション(メソッド単位で開始 / 終了)
  • 分離レベル: READ COMMITTED / REPEATABLE READ / SERIALIZABLE で「他のトランザクションがどこまで見えるか」が変わる
  • ネスト: ネスト中の commit / rollback はセーブポイントとして扱われる(MySQL/PostgreSQL)

トランザクションとは

「複数の DB 操作を 1 つの不可分な単位として扱う」ための仕組みです。全部成功 or 全部失敗(ロールバック)の二択しかなく、中途半端な状態を残しません。古典的な銀行送金の例:

-- 送金: A から B へ 1000 円
BEGIN;
  UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
  -- ← ここでサーバが落ちたら?
  UPDATE accounts SET balance = balance + 1000 WHERE id = 'B';
COMMIT;

トランザクション無しだと、A から引かれたが B に振り込まれない状態で固まります。トランザクションで囲めば、サーバ落ち時に DB が自動で ROLLBACK し、A の残高は元に戻ります。

ACID 特性

特性意味
Atomicity(原子性)全成功 or 全失敗
Consistency(一貫性)制約を満たした状態を維持
Isolation(分離性)並行トランザクションの干渉を防ぐ
Durability(永続性)COMMIT 後はクラッシュしても残る

素の SQL でのトランザクション

-- 標準 SQL
BEGIN;
  INSERT INTO orders (user_id, total) VALUES (1, 5000);
  -- SELECT LAST_INSERT_ID() を使って order_id を取得
  INSERT INTO order_items (order_id, product_id, qty) VALUES (LAST_INSERT_ID(), 10, 2);
COMMIT;

-- 失敗時
BEGIN;
  UPDATE accounts SET balance = balance - 1000 WHERE id = 'A';
  -- 残高不足を検知
  ROLLBACK;

-- MySQL は START TRANSACTION でも同じ
START TRANSACTION;
  -- ...
COMMIT;

-- PostgreSQL も BEGIN / COMMIT / ROLLBACK
-- SQL Server は BEGIN TRANSACTION / COMMIT TRANSACTION

Laravel でのトランザクション

Laravel は 2 種類の書き方があります。クロージャ版が推奨です:

use Illuminate\Support\Facades\DB;

// ✅ 推奨: クロージャ版(例外時に自動ロールバック)
DB::transaction(function () use ($request) {
    $order = Order::create($request->validated());
    foreach ($request->items as $item) {
        $order->items()->create($item);
    }
    $order->user->notify(new OrderPlaced($order));
}, 3);     // ← 2 引数目はデッドロック時のリトライ回数

// 手動制御版(細かく制御したいとき)
DB::beginTransaction();
try {
    $order = Order::create($request->validated());
    foreach ($request->items as $item) {
        $order->items()->create($item);
    }
    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

Spring Boot でのトランザクション

import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderService {

    @Transactional
    public Order placeOrder(OrderRequest req) {
        Order order = orderRepo.save(new Order(req));
        for (Item item : req.getItems()) {
            itemRepo.save(new OrderItem(order, item));
        }
        return order;
    }

    // 読み取り専用
    @Transactional(readOnly = true)
    public List listOrders() { return orderRepo.findAll(); }

    // 分離レベルとロールバック対象指定
    @Transactional(
        isolation = Isolation.REPEATABLE_READ,
        rollbackFor = { Exception.class },
        timeout = 30
    )
    public void complex() { /* ... */ }
}

Spring のデフォルトでは RuntimeException でのみロールバック、検査例外(Checked Exception)はロールバックしません。それを変えるには rollbackFor = Exception.class を指定します。

分離レベル(Isolation Level)

分離レベルダーティリード非再現リードファントムリード性能
READ UNCOMMITTEDありありあり最速
READ COMMITTED無しありあり
REPEATABLE READ(MySQL 既定)無し無しあり*普通
SERIALIZABLE無し無し無し

* MySQL InnoDB の REPEATABLE READ は MVCC + ギャップロックで実質ファントム無し

-- MySQL で分離レベル設定
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
  -- ...
COMMIT;

-- セッション単位
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- グローバル
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 現在のレベル確認
SELECT @@transaction_isolation;

デッドロック

2 つ以上のトランザクションが、お互いがロックしているリソースを待つ状態。DB は片方を強制 ABORT します:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found
when trying to get lock; try restarting transaction

対策:

  • ロック順を統一: 必ず ID 昇順で UPDATE する等の規約
  • リトライ: Laravel の DB::transaction($fn, 3) で 3 回まで自動リトライ
  • トランザクションを短くする: ユーザ入力待ちを挟まない
  • 適切なインデックス: 不要なテーブル全行ロックを防ぐ
// Laravel でのリトライ
DB::transaction(function () {
    $user = User::lockForUpdate()->find(1);     // SELECT ... FOR UPDATE
    $user->balance -= 1000;
    $user->save();
}, $attempts = 3);

ネスト(セーブポイント)

多くの DB(MySQL InnoDB / PostgreSQL)はトランザクションのネストをセーブポイントで実現します:

BEGIN;
  INSERT INTO orders ...;
  SAVEPOINT sp1;
    UPDATE inventory SET qty = qty - 1 WHERE id = 10;
    -- 在庫不足を検知
  ROLLBACK TO SAVEPOINT sp1;       -- sp1 までロールバック
  -- orders の INSERT は残る
COMMIT;

Laravel もネストすると内部的にセーブポイントを使います:

DB::transaction(function () {
    User::create([...]);            // 外側

    try {
        DB::transaction(function () {
            Order::create([...]);    // 内側(SAVEPOINT)
            throw new \Exception();
        });
    } catch (\Throwable $e) {
        // 内側だけ ROLLBACK TO SAVEPOINT
        // User の create は残る
    }
});     // 外側 COMMIT

FOR UPDATE / 共有ロック

-- 排他ロック(UPDATE / DELETE 想定)
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- 共有ロック(読み取りだけ整合性が欲しい)
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE;     -- MySQL
SELECT * FROM accounts WHERE id = 1 FOR SHARE;              -- PostgreSQL

-- NOWAIT / SKIP LOCKED(待たない)
SELECT * FROM jobs WHERE status = 'pending' FOR UPDATE SKIP LOCKED LIMIT 1;

FAQ

Q: SELECT 文だけのときもトランザクションは要る?
A: 複数 SELECT で整合性のあるスナップショットが欲しい場合は必要です(REPEATABLE READ)。単発 SELECT なら不要。

Q: 自動コミットモードとは
A: MySQL / PostgreSQL は既定で1 文 = 1 トランザクションで自動 COMMIT します。BEGIN を打つとそれが解除され、明示的に COMMIT/ROLLBACK が必要になります。

Q: トランザクション中の例外で何もしないとどうなる?
A: コネクションが切れるとサーバ側で自動 ROLLBACK されます。ただし明示的にロールバックするのが事故防止上の鉄則です。

Q: 長時間のトランザクションは何が問題?
A: ロック保持時間が長くなるためデッドロック・待ち時間が増え、レプリケーション遅延も発生します。トランザクションは秒オーダーで終わらせる設計が原則です。

編集
Post Share
子ページ

子ページはありません

同階層のページ
  1. config/database.phpファイル
  2. .env
  3. 複数のデータベースに接続する方法
  4. DBトランザクション
  5. DBの悲観ロック