トランザクション分離レベル

同時実行されるトランザクション間のデータの見え方を制御するデータベースの設定

データベーストランザクション

トランザクション分離レベルとは

トランザクション分離レベルは、同時に走るトランザクションが互いの途中の状態をどこまで見るかを決める設定である。上げれば読み取りの筋は通るようになり、下げれば同時実行の余地が広がる。ただし「上げた分の代償」は実装方式によって現れる場所が違う。ロックで実現している経路では待ち時間とデッドロックとして出るが、多版方式 (同じ行の複数の版を保持して読み手を待たせない方式) の経路では待ちではなくトランザクションの巻き戻しと再実行として出る。どちらの代償を受け取るのか知らずにレベルだけ上げると、想定していない側で詰まる。

4 つの分離レベルと 4 つの異常

レベルダーティリードノンリピータブルリードファントムリード直列化異常
Read Uncommitted発生する発生する発生する発生する
Read Committed防止される発生する発生する発生する
Repeatable Read防止される防止される発生する発生する
Serializable防止される防止される防止される防止される

3 種類の異常で覚えられていることが多いが、この表では PostgreSQL 公式ドキュメントの現象列挙と同じ整理で 4 つ目 (直列化異常) を並べている (標準自体が現象として列挙するのは下位 3 つ)。直列化異常は「コミットまで通った一群のトランザクションの結果が、それらを 1 件ずつ順に実行したどの順序の結果とも一致しない」状態を指す。標準は下位 3 レベルを「起きてはいけない現象」の列挙で定義し、Serializable だけは「同時実行の結果がいずれかの直列実行と一致する」という別の言い方で定義している。この 4 列目を落とすと、Repeatable Read に残る危険はファントムだけだと読めてしまい、Serializable の一歩手前と誤解する。実際に残るのは、読んだ値を条件に別の行を書く形の破れである。

表の「発生する」は「標準がそのレベルで禁止していない」という意味であって、「必ず起きる」ではない。実装がより強い保証を出しても標準違反にはならないので、手元の DB の挙動は次の実装差で確認する必要がある。

実装差を先に押さえる

PostgreSQL は 4 レベルすべてを指定できるが、内部で区別しているのは 3 つで、Read Uncommitted は Read Committed と同じ挙動になる。さらに Repeatable Read はスナップショットを固定する方式で実装されているため、標準が許しているファントムも起きない。残るのは直列化異常だけである。既定は Read Committed で、この場合は文ごとに新しいスナップショットを取る。

MySQL の InnoDB は Repeatable Read が既定で、素の SELECT は同一トランザクション内で最初の読み取り時点のスナップショットを読み続ける。ロック読み取り (FOR UPDATE / FOR SHARE) と UPDATE / DELETE は、一意索引の一意検索なら見つけた索引レコードだけを、範囲条件なら走査した索引範囲をギャップロックやネクストキーロックで押さえ、隙間への挿入を他セッションから止める。Read Committed に落とすとギャップロックが無効になるので隙間への挿入が通り、ファントムが起こり得る。既定を下げる判断は、この 1 点を承知の上で行う。索引の張り方で押さえられる範囲が変わる点は データベースインデックス と併せて見る。

Serializable の作り方も同じではない。InnoDB の Serializable は Repeatable Read に、autocommit を無効にしているときは素の SELECT を共有ロック読み取りへ読み替える動作を足したものである。PostgreSQL の Serializable は直列化可能スナップショット分離と呼ばれる方式で、読んだ条件を述語ロックとして記録し、直列順序が作れないと判明した側を SQLSTATE 40001 の直列化失敗で落とす。この述語ロックは他を待たせないためデッドロックの材料にはならず、代わりに「落ちたら丸ごとやり直す」経路がアプリ側の前提条件になる。40001 をまとめて捕まえて再実行する仕組みが無い状態で Serializable を選ぶと、平常時は正しく動き、混雑したときだけ利用者にエラーが出る。

異常が起きる形

ダーティリード:
  TX1: UPDATE balance SET amount = 0  (未コミット)
  TX2: SELECT amount → 0 を読む
  TX1: ROLLBACK
  → TX2 は最終的に存在しなかった値で判断した

ノンリピータブルリード:
  TX1: SELECT amount → 1000
  TX2: UPDATE amount = 500; COMMIT
  TX1: SELECT amount → 500 (同じ TX 内で同じ行の値が変わった)

ファントムリード:
  TX1: SELECT COUNT(*) WHERE age > 20 → 10件
  TX2: INSERT (age=25); COMMIT
  TX1: SELECT COUNT(*) WHERE age > 20 → 11件 (条件に合う行が増えた)

直列化異常 (書き込みスキュー):
  前提: 当番が 2 人。1 人以上残るなら降りてよい
  TX1: SELECT COUNT(*) WHERE on_call → 2 なので自分を降ろす
  TX2: SELECT COUNT(*) WHERE on_call → 2 なので自分を降ろす
  両者 COMMIT → 当番 0 人。どちらを先に実行しても起き得ない結果になった

上の 3 つは同じ行や同じ条件を読み直したときのずれなので、スナップショットを固定すれば消える。4 つ目は読み直していないのに破れるため、スナップショットの固定では防げない。この差が、Repeatable Read と Serializable を分ける実質的な線である。

既定値と読み取り専用インスタンスの例外

DB既定の分離レベル
PostgreSQLRead Committed
MySQL (InnoDB)Repeatable Read
Aurora PostgreSQLRead Committed
Aurora MySQL (ライター)Repeatable Read
Aurora MySQL (リーダー)Repeatable Read で固定

Aurora MySQL のライターインスタンスでは 4 レベルすべてを指定できるが、読み取り専用のレプリカは既定で Repeatable Read を使い続け、分離レベルを変える文を無視する (2026 年 8 月時点)。ライター側の更新が多くレプリカ側に長時間クエリが居る構成では、古い版の回収が長時間クエリに阻まれて溜まっていく。この症状に対しては、レプリカ側を Read Committed で動かす設定が用意されている。

既定値の食い違いは移行のときに効く。MySQL から PostgreSQL へ寄せる場合、送り側の既定は Repeatable Read で受け側の既定は Read Committed なので、アプリを変えないまま移すと同一トランザクション内で同じ行を読み直した結果が変わる。同じ行を 2 回読む処理があるかどうかを先に洗い出す。

DynamoDB のトランザクション

DynamoDB は分離レベルを選ぶ形ではなく、API の組み合わせごとに保証が決まっている。トランザクション操作同士、およびトランザクション操作と通常の書き込み (PutItem / UpdateItem / DeleteItem) の間は直列化可能である。一方、トランザクション書き込みと GetItem / Query / Scan の間はリードコミッティッドで、コミット済みの値しか返らないことは保証されるが、読んだ直後に他が書き換えるのは止まらない。テーブル設計側の事情は DynamoDB を参照。

1 回の TransactWriteItems は、最大 100 アクション、宛先は最大 100 個の別々の項目、同一アカウント同一リージョン内の 1 つ以上のテーブル、合計サイズ 4 MB までである。同じ項目を 1 つのトランザクションで 2 回対象にすることはできず、インデックス経由でも実行できない。

try {
  await db.transactWrite({
    TransactItems: [
      { Update: { TableName: 'accounts', Key: { id: 'A' },
        UpdateExpression: 'SET balance = balance - :amount',
        // 条件が無いと残高が負に振れる。全体が取り消される点が要
        ConditionExpression: 'balance >= :amount',
        ExpressionAttributeValues: { ':amount': 100 } } },
      { Update: { TableName: 'accounts', Key: { id: 'B' },
        UpdateExpression: 'SET balance = balance + :amount',
        ExpressionAttributeValues: { ':amount': 100 } } },
    ],
    ClientRequestToken: transferId, // 同じ鍵での再送は二重に適用されない
  });
} catch (e) {
  if (e.name === 'TransactionCanceledException') {
    // どのアクションがなぜ止まったかは取り消し理由の配列に入る
    console.error(e.CancellationReasons);
    return;
  }
  throw e;
}

容量は、内部で準備と確定の 2 回ずつ読み書きするため通常操作の 2 倍消費される。取り消された呼び出しでも消費されるので、条件不成立を通常運転に織り込む設計はそのままコストになる。再送で二重適用を防ぐ鍵の扱いは 冪等性 と併せて設計する。

選び方

状況選択
更新が 1 文で完結する一般的な Web アプリRead Committed
複数の文で一貫した集計を取るRepeatable Read (文をまたいでスナップショットが固定される)
複数行にまたがる不変条件を守る (残高 / 二重予約 / 在庫)Serializable と再実行、または 楽観的ロック
短い更新を高スループットで流す (MySQL)Read Committed (ギャップロックが外れる代わりに隙間への挿入を許す)
長時間の分析クエリレベルより配置で解く。更新系と同じインスタンスで長くスナップショットを握らない

分析クエリを Read Committed にすれば軽くなるという整理は、多版方式の DB では成り立たない。素の読み取りは行ロックを取らないので、下げても待ちは減らない。むしろ Read Committed は文ごとにスナップショットを取り直すため、複数の文で集計を組む処理では文と文の間に他の更新が挟まり、内訳と合計が合わない結果が出る。一貫した断面が欲しいなら Repeatable Read の側が要る。長時間クエリの本当の代償は待ちではなく、古い版を回収できずに溜めることである。

Serializable と楽観的ロックの使い分けは、衝突をどこで検出するかで決める。Serializable は DB が読み書きの依存を追って落とすため、「その条件に合う行が無いこと」のように書いていない前提にも効くが、どれだけ再実行が起きるかは事前に読みにくい。楽観的ロックは行のバージョン列を更新条件に入れて、更新件数 0 をアプリが衝突と判断する形なので、対象行が特定できている更新に強く挙動も読みやすい。守りたい不変条件が特定の行に閉じているかどうかが分かれ目になる。

つまずきやすい点

Repeatable Read で同じ行を 2 つのトランザクションが更新しようとすると、PostgreSQL では先にコミットした側が残り、後から来た側は同時更新のため直列化できない旨のエラーで巻き戻る。この巻き戻しは読み取りだけのトランザクションには起きない。アプリから見れば「更新系だけが時々失敗する」形になるので、更新処理には最初からやり直す経路を用意しておく。やり直しの 2 回目は、コミット済みの変更を最初から含んだ視点で始まる。

分離レベルは 1 つのトランザクションの中の話であって、ACID の他の性質を代替しない。そしてトランザクションを開けている時間が長いほど、待たせる側でもスナップショットを握る側でも代償が増える。外部 API 呼び出しをトランザクションの中に入れないという原則は、どの分離レベルを選んでも変わらない。

共有:

この記事は役に立ちましたか?

関連用語

関連する記事