トランザクション分離レベルの違いと選び方【入門】

概要

トランザクション分離レベル(Isolation Level)は、同時に動いているトランザクション同士が、相手の変更をどこまで見てよいかを決める設定です。

多くのプロジェクトでは DB のデフォルトのまま使われていますが、そのデフォルトは DB ごとに違います。

  • PostgreSQL のデフォルト:READ COMMITTED
  • MySQL(InnoDB)のデフォルト:REPEATABLE READ

「DB を変えたら挙動が変わった」「在庫がたまに合わない」「まれにしか再現しない不整合がある」。こうした問題の原因が分離レベルにあることは少なくありません。

この記事では次の内容を入門向けに解説します。

  1. 分離レベルが防ごうとしている「異常現象」
  2. SQL 標準の4つの分離レベル
  3. PostgreSQL / MySQL での実際の挙動の違い
  4. 2セッションで挙動を確かめる手順
  5. 用途別の選び方と実装パターン

前提

  • PostgreSQL 16 系 / MySQL 8.0 系(InnoDB)の公式ドキュメントの記述をもとにしています
  • SQL の例は、2つのターミナル(セッションA / セッションB)から交互に実行する想定です
  • バージョンや設定で挙動が変わることがあります。本番に適用する前に、使っている環境で必ず確認してください

分離レベルが防ごうとしている問題

ダーティリード

他のトランザクションがまだコミットしていない変更が見えてしまう現象です。相手がロールバックすると、存在しなかった値を読んだことになります。

ノンリピータブルリード

同じトランザクションの中で同じ行を2回読んだとき、間に他者がコミットした更新のせいで値が変わる現象です。

ファントムリード

同じ条件で2回検索したとき、他者の INSERT / DELETE のせいで結果の行の集合が変わる現象です。

ロストアップデート

2つのトランザクションが同じ値を読み、それぞれ計算して書き戻した結果、片方の更新が消える現象です。

A: 残高を読む → 1000
B: 残高を読む → 1000
A: 1000 - 100 = 900 を書き込んでコミット
B: 1000 - 200 = 800 を書き込んでコミット
→ 最終残高は 800(A の出金 100 が消えた。正しくは 700)

書き込みスキュー

2つのトランザクションがそれぞれ別の行を更新するため直接はぶつからないものの、両方がコミットされた結果、複数行にまたがるルールが壊れる現象です。

ルール: 当番中の担当者は常に1人以上いること(現在2人が当番中)
A: 当番中は2人 → 自分を外してよいと判断し、自分を off にする
B: 当番中は2人 → 自分を外してよいと判断し、自分を off にする
→ 両方コミットされ、当番が0人になる

SQL 標準の分離レベルは、最初の3つの現象をもとに定義されています。一方、実務で問題になりやすいのはロストアップデートと書き込みスキューです。

SQL 標準の4つの分離レベル

分離レベル ダーティリード ノンリピータブルリード ファントムリード
READ UNCOMMITTED 起こりうる 起こりうる 起こりうる
READ COMMITTED 起こらない 起こりうる 起こりうる
REPEATABLE READ 起こらない 起こらない 起こりうる
SERIALIZABLE 起こらない 起こらない 起こらない

下に行くほど分離は強くなります。その代わり、待ち(ロック)やエラー(直列化失敗)が増え、同時実行性能は下がりやすくなります。

実際の DB での振る舞い

標準が定めているのは「最低限防ぐべき現象」だけです。どう実現するかは DB ごとに違い、標準より強く防ぐ DB もあります。

DB デフォルト 実現方式の概要
PostgreSQL READ COMMITTED MVCC。REPEATABLE READ はスナップショット分離、SERIALIZABLE は SSI
MySQL(InnoDB) REPEATABLE READ MVCC + ネクストキーロック / ギャップロック
Oracle Database READ COMMITTED MVCC。SERIALIZABLE もスナップショットベース
SQL Server READ COMMITTED デフォルトはロックベース。READ_COMMITTED_SNAPSHOT / SNAPSHOT で行バージョニング

PostgreSQL の特徴

  • READ UNCOMMITTED を指定しても、READ COMMITTED と同じ動きになります
  • READ COMMITTED は文ごと、REPEATABLE READ はトランザクションの最初の文の時点のスナップショットを使います
  • REPEATABLE READ では、標準では許されているファントムも起きません
  • REPEATABLE READ で、他者が先に更新してコミットした行を更新しようとすると、could not serialize access due to concurrent update(SQLSTATE 40001)になります
  • SERIALIZABLE(SSI)は書き込みスキューも検出して 40001 で失敗させます。リトライが前提です

MySQL(InnoDB)の特徴

  • REPEATABLE READ では、普通の SELECT は最初に読んだ時点のスナップショットを見ます
  • UPDATE / DELETE / SELECT ... FOR UPDATE などは最新のコミット済みデータに対して動きます。そのため「SELECT では見えなかった行が UPDATE の対象になる」ことがありえます
  • ロックを取る読み取りでは、ネクストキーロックが範囲への INSERT を防ぎます。そのぶん、待ちやデッドロックは増えやすくなります
  • ロストアップデートを自動ではエラーにしません。後から更新した側は、待ったあとそのまま上書きします
  • SERIALIZABLE では、autocommit が無効なとき、普通の SELECT が暗黙に SELECT ... FOR SHARE として扱われます

同じ「REPEATABLE READ」でも、PostgreSQL と MySQL では防いでくれる現象が違う点に注意してください。

設定の確認と指定

-- PostgreSQL
SHOW default_transaction_isolation;
SHOW transaction_isolation;
BEGIN ISOLATION LEVEL REPEATABLE READ;

-- MySQL 8.0
SELECT @@global.transaction_isolation, @@session.transaction_isolation;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;          -- 次のトランザクションのみ
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- セッション全体

2セッションで挙動を確かめる

CREATE TABLE accounts (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO accounts VALUES (1, 1000), (2, 1000);

ノンリピータブルリード(PostgreSQL)

手順 セッションA セッションB
1 BEGIN ISOLATION LEVEL READ COMMITTED;
2 SELECT balance FROM accounts WHERE id = 1; → 1000
3 UPDATE accounts SET balance = 500 WHERE id = 1;
4 SELECT balance FROM accounts WHERE id = 1;
5 COMMIT;

READ COMMITTED では、手順4で 500 が見えます。REPEATABLE READ にすると 1000 のままです。

ロストアップデートの検出(PostgreSQL の REPEATABLE READ)

手順 セッションA セッションB
1 BEGIN ISOLATION LEVEL REPEATABLE READ; 同左
2 SELECT balance FROM accounts WHERE id = 1; 同左
3 UPDATE accounts SET balance = 900 WHERE id = 1;
4 UPDATE accounts SET balance = 800 WHERE id = 1;(待ちになる)
5 COMMIT;
6 could not serialize access due to concurrent update

READ COMMITTED や MySQL のデフォルトでは、B の UPDATE は待ったあと成功し、最終値は 800 になります(ロストアップデート)。

書き込みスキュー

CREATE TABLE on_call (staff_id int PRIMARY KEY, on_duty boolean NOT NULL);
INSERT INTO on_call VALUES (1, true), (2, true);
手順 セッションA セッションB
1 BEGIN ISOLATION LEVEL REPEATABLE READ; 同左
2 SELECT count(*) FROM on_call WHERE on_duty; → 2 同左 → 2
3 UPDATE on_call SET on_duty = false WHERE staff_id = 1; ... WHERE staff_id = 2;
4 COMMIT; COMMIT;

PostgreSQL の REPEATABLE READ では、両方コミットされて当番が0人になります。SERIALIZABLE にすると、どちらか一方が 40001 で失敗します。

※ 手順の結果は、各 DB の公式ドキュメントに書かれている挙動に基づく想定です。手元の環境で実際に確かめてから判断してください。

選び方

「どのレベルが最強か」ではなく、守りたい整合性の種類に合わせて道具を選ぶのがポイントです。

  1. まずデフォルトの挙動を知る:使っている DB のデフォルトと挙動を把握し、ORM やフレームワークの設定も確認します
  2. 単一行の競合は分離レベル以外で解決する:アトミック UPDATE、SELECT ... FOR UPDATE、楽観ロックを使います
  3. 一貫した読み取りには REPEATABLE READ:集計やエクスポートなど、複数のクエリで同じ時点のデータを見たい場合に使います
  4. 複数行にまたがるルールは SERIALIZABLE + リトライ:または明示ロックや制約で守ります。制約で表現できるなら、それが最も確実です
  5. READ UNCOMMITTED は基本的に使わない

MySQL でギャップロックによる待ちやデッドロックに悩んでいる場合は、READ COMMITTED も選択肢になります。ただし行ベースのバイナリログ(MySQL 8.0 のデフォルト)が前提で、既存の処理への影響の検証も必要です。

やりたいこと おすすめ
単一行の加減算・在庫引き当て アトミック UPDATE / FOR UPDATE / 楽観ロック
複数クエリで数字を揃えたい集計 REPEATABLE READ(READ ONLY)
複数行にまたがるルールの維持 SERIALIZABLE + リトライ、または明示ロック・制約
一般的な Web アプリの CRUD DB のデフォルト + 上記パターンの使い分け
コミットされていないデータを読みたい ほぼ不要
-- 集計用(PostgreSQL)
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- 集計用(MySQL)
START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;

実装例

アトミックな UPDATE

UPDATE accounts
SET    balance = balance - 100
WHERE  id = 1
AND    balance >= 100;
-- 更新件数が 0 なら残高不足として扱う

楽観ロック(version 列)

UPDATE items
SET    stock = stock - 1,
       version = version + 1
WHERE  id = 42
AND    version = 7;
-- 更新件数が 0 なら他者が先に更新 → 読み直してやり直す

SERIALIZABLE のリトライ(Python / psycopg 3)

import time

import psycopg
from psycopg import errors

RETRYABLE = (errors.SerializationFailure, errors.DeadlockDetected)


def run_serializable(conn: psycopg.Connection, work, max_retries: int = 5):
    """work(conn) を SERIALIZABLE で実行し、直列化失敗時はトランザクションごとリトライする"""
    for attempt in range(max_retries):
        try:
            with conn.transaction():
                conn.execute("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
                return work(conn)
        except RETRYABLE:
            time.sleep(0.05 * (2 ** attempt))  # 指数バックオフ
    raise RuntimeError("serialization retry limit exceeded")


def take_off_duty(conn: psycopg.Connection, staff_id: int) -> None:
    on_duty = conn.execute(
        "SELECT count(*) FROM on_call WHERE on_duty"
    ).fetchone()[0]
    if on_duty <= 1:
        raise ValueError("当番が0人になるため外せません")
    conn.execute(
        "UPDATE on_call SET on_duty = false WHERE staff_id = %s", (staff_id,)
    )


with psycopg.connect("dbname=example", autocommit=True) as conn:
    run_serializable(conn, lambda c: take_off_duty(c, 1))
  • リトライはトランザクション全体をやり直します
  • work の中で、外部 API 呼び出しやメール送信のようなやり直せない副作用を起こさないようにします
  • リトライの回数には上限を設け、間隔を少しずつ広げます

注意点

  • 長いトランザクションを避ける:PostgreSQL では VACUUM を妨げ、MySQL では undo ログを膨らませます
  • コネクションプール:SET SESSION で変えた設定は、使い回される接続に残ります。トランザクション単位で指定するほうが安全です
  • ORM の設定:アプリ側の設定で、DB のデフォルトが上書きされていることがあります
  • テスト:分離レベルが原因のバグは、並行実行しないと再現しません
  • DB の移行:同じ名前のレベルでも、意味が違うことがあります

まとめ

  • 分離レベルは「同時実行中の他者の変更がどこまで見えるか」の取り決め
  • デフォルトは PostgreSQL が READ COMMITTED、MySQL が REPEATABLE READ。同じ名前のレベルでも挙動は DB ごとに違う
  • 実務で効くのはロストアップデートと書き込みスキュー
  • 単一行の競合はアトミック UPDATE・ロック・楽観ロックで、一貫した読み取りは REPEATABLE READ で、複数行のルールは SERIALIZABLE + リトライ(または制約・明示ロック)で守る
  • 「上げれば安全」ではなく、守りたい整合性に合わせて選ぶ

参考

  • PostgreSQL Documentation: Transaction Isolation
  • MySQL 8.0 Reference Manual: Transaction Isolation Levels / Consistent Nonlocking Reads / Locking Reads
  • Berenson et al., "A Critique of ANSI SQL Isolation Levels" (1995)

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。