Skip to content

필사 모드: DBトランザクションと分離レベルを徹底解説

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — トランザクションは何を約束するのか

データベースでお金が動くと、怖い問いが生まれます。「送金の途中でサーバが落ちたらどうなる?」口座Aから100を引き、口座Bに100を足す二つの文の間でプロセスが落ちれば、お金は消えることも二重になることもあります。トランザクションは、まさにこの恐怖を消すための仕組みです。

トランザクションの約束は単純です。「これら複数の文を一つのまとまりとして扱え。すべて反映されるか、一つも反映されないかのどちらかだ」。このまとまりはコミット(commit)されれば確定し、ロールバック(rollback)されればまるごと無かったことになります。ところがこの単純な約束を、複数のトランザクションが同時に走る環境で守ろうとすると、驚くほど微妙な問題が噴き出します。本稿はその微妙さに正面から向き合います。

概念を実際のSQLで試したければ、このサイトのSQLプレイグラウンドPostgreSQLプレイグラウンドDuckDBプレイグラウンドでクエリを実行できます。

ACID — 四文字が意味するもの

トランザクションの保証は、よくACIDという略語で要約されます。一文字ずつ正確に見ましょう。

  • Atomicity(原子性): トランザクションは分割できない一つの単位です。すべて成功するか、すべて失敗します。送金の「引き」だけが反映されて「足し」が抜ける、ということは起きません。
  • Consistency(一貫性): トランザクションはデータベースをある有効な状態から別の有効な状態へ移します。制約(外部キー、ユニーク、チェック)がトランザクション境界で守られます。ここでの一貫性はアプリケーションが定義した不変条件を指し、後述する分散システムの「一貫性(consistency)」とは別の概念です。
  • Isolation(分離性): 同時に走るトランザクションが互いの中間状態を見ないようにします。本稿の半分はこの分離性の話です。完全な分離は高くつくため、実務では複数段階の分離レベルから選びます。
  • Durability(永続性): コミットされたトランザクションは、その後システムが落ちても生き残ります。通常はWAL(Write-Ahead Log、先行書き込みログ)にまず記録して保証します。

このうち原子性と永続性はおおむね直感的です。本当の難しさは分離性にあります。「他のトランザクションの中間状態をどこまで見せるか」という問いには唯一の正解がなく、性能と正確性のあいだのスペクトルしかないからです。

なぜ分離が難しいのか — 並行性という根本問題

トランザクションが一つずつ順に実行されるなら、分離は問題になりません。難しさは複数のトランザクションが時間的に重なって走るときに生じます。理論上もっとも安全な状態は「直列化可能(serializable)」です。つまり、トランザクションが実際には重なって動いても、その結果があたかも何らかの順序で一つずつ実行したのと同じ状態になることです。

問題は、この完璧な直列性を強制すると費用が大きいことです。トランザクションが互いに待ち合い、並行性が下がり、スループットが落ちます。そこでデータベースは妥協を提供します。「この程度の異常までは許すから、その代わり速く動く」という段階が、まさに**分離レベル(isolation level)**です。

各分離レベルを理解する鍵は「このレベルはどの異常を防ぎ、どれを許すか」です。ですからまず異常そのものを正確に定義しましょう。

四つの異常 — 何が壊れうるか

分離が弱いときに現れる代表的な読み取り異常は次の通りです。それぞれを具体的なシナリオで見ましょう。

1. ダーティリード(dirty read)— 未コミットの値を読む。 トランザクションBが、トランザクションAがまだコミットしていない値を読みます。もしAが後でロールバックすれば、Bは存在したことのない幽霊の値を読んだことになります。

  A: UPDATE balance = 200 (まだコミットしていない)
  B:                          SELECT balance -> 200  (ダーティ!)
  A: ROLLBACK               (200は無かった値になる)
  B: 存在しなかった200を根拠に判断してしまう

2. 反復不能読み取り(non-repeatable read)— 同じ行を二度読むと値が違う。 トランザクションBが同じ行を二度読むあいだに、別のトランザクションAがその行を更新してコミットし、二つの読み取りの値が変わります。

  B: SELECT balance -> 100
  A: UPDATE balance = 200; COMMIT
  B: SELECT balance -> 200   (同じ行なのに値が変わった)

3. ファントムリード(phantom read)— 同じ条件で二度問い合わせると行の集合が変わる。 Bがある条件(例:「残高 > 50」)に合う行を二度問い合わせるあいだに、Aがその条件に合う新しい行を挿入(または削除)し、結果集合の行数が変わります。反復不能読み取りが「既にある行の値の変化」なら、ファントムは「行そのものの出現・消滅」です。

  B: SELECT count(*) WHERE balance > 50 -> 3件
  A: INSERT (balance = 500); COMMIT
  B: SELECT count(*) WHERE balance > 50 -> 4件  (幽霊の行が出現)

4. 書き込みスキュー(write skew)— 各自が見た値は正しいが、合わせると規則違反。 これはさらに微妙です。二つのトランザクションが互いに重ならない行を読み、各自更新するのですが、個別にはそれぞれ規則を守ったのに、合わさった結果が不変条件を破ります。古典的な例が「当直医」の規則です。常に最低一人は当直でなければならないのに、二人の医師が同時に「もう一人が当直だから自分は抜けてよい」と判断して各自抜ければ、結果として当直が0人になります。

  規則: 当直医は常に >= 1人
  現在: アリス(当直)、ボブ(当直) — 2人

  A(アリス): SELECT count(当直) -> 2、「ボブがいるので自分は抜けてよい」-> アリスoff
  B(ボブ):   SELECT count(当直) -> 2、「アリスがいるので自分は抜けてよい」-> ボブoff
  両方コミット -> 当直0人。規則違反!

書き込みスキューが特に恐ろしいのは、各トランザクションを単独で見ると完璧に正当に見えるからです。この現象はスナップショット分離(後述)でも起こりうるので、「直列化可能」がなぜ必要かを示す代表例です。

四つの標準分離レベル

SQL標準は、上の異常を基準に四つの分離レベルを定義します。低いレベルほど多くの異常を許して速く、高いレベルほど安全で高くつきます。

  • READ UNCOMMITTED: もっとも弱い。ダーティリードまで許します。未コミットの値も読まれえます。実務ではほとんど使いません。
  • READ COMMITTED: ダーティリードは防ぎます。コミット済みの値だけを読みます。しかし反復不能読み取りとファントムは依然として許します。多くのデータベースの実質的なデフォルトです。
  • REPEATABLE READ: 反復不能読み取りまで防ぎます。トランザクション開始時に見た行は、トランザクション中ずっと同じ値に見えます。標準上はファントムを許しますが、実装によってはファントムまで防ぎます。
  • SERIALIZABLE: もっとも強い。すべての異常を防ぎます。結果がトランザクションを何らかの順序で一つずつ実行したのと同一であることを保証します。

標準が定義する「レベル対異常」の関係を表にすると次の通りです。

分離レベルダーティリード反復不能読み取りファントムリード
READ UNCOMMITTED許す許す許す
READ COMMITTED防ぐ許す許す
REPEATABLE READ防ぐ防ぐ許す(標準)
SERIALIZABLE防ぐ防ぐ防ぐ

重要な注意が一つ。この表はSQL「標準」の定義にすぎず、実際のデータベースの動作は表より強かったり微妙に違ったりします。たとえば後で見るように、PostgreSQLのREPEATABLE READは標準が許すファントムまで実際には防ぎます。ですから「自分のDBでこのレベルが実際に何を保証するか」は、常にそのDBの文書で確認すべきです。

MVCC — ロックせずに読ませる魔法

分離を実装する方式には大きく二系統あります。一つはロック(locking)、もう一つは**多版並行制御(MVCC, Multi-Version Concurrency Control)**です。今日の主要データベース — PostgreSQL、MySQL(InnoDB)、Oracle — はすべてMVCCを使います。

MVCCの核心はこうです。行を上書きせず、新しい版を作る。ある行が更新されると古い版がそのまま残り、新しい版が追加されます。各版には「どのトランザクションが作ったか」を示す情報が付きます。トランザクションがデータを読むときは、自分の時点を基準に「見えるべき版」だけを選んで見ます。

  行xの版たち (時間順):
    v1 (トランザクション10が生成)
    v2 (トランザクション25が生成)   <- 最新

  トランザクション20がxを読むと:
    -> 20は25の結果を見られない (まだ無かった未来)
    -> v1を見る  (自分のスナップショットに合う版)

この方式の決定的な利点は、読みが書きを妨げず、書きが読みを妨げないことです。読む側は自分の時点に合う古い版を見ればよく、書く側は新しい版を作ればよい。互いに待つ必要がなく、並行性が大きく上がります。トランザクションが開始時点の一貫したスナップショットを見るこの分離を、**スナップショット分離(snapshot isolation)**と呼びます。

代償もあります。古い版が溜まるので、定期的に掃除しなければなりません。PostgreSQLではこの掃除をVACUUMが担い、掃除が滞ると死んだタプルが溜まって性能が悪化します。そして先に見た書き込みスキューは、純粋なスナップショット分離だけでは防げません。スナップショットはそれぞれ一貫していますが、二つのスナップショットの決定が合わさるときの衝突は見えないからです。

ロック対楽観的並行制御

同時更新の衝突を扱う戦略は、大きく悲観的(pessimistic)と楽観的(optimistic)の二つに分かれます。

悲観的ロック(pessimistic locking)。「衝突が起きる」と仮定し、データに触れるときにまずロックします。他のトランザクションはロックが解けるまで待ちます。衝突が多い状況で安全ですが、待ちとデッドロック(deadlock)の危険があります。後述のSELECT ... FOR UPDATEがこの方式の代表的な道具です。

楽観的並行制御(optimistic concurrency control)。「衝突は稀だ」と仮定し、まずロックせずに進めます。代わりにコミット直前に「自分が読んだ後にこのデータが変わったか」を確認し、変わっていればトランザクションを失敗させて再試行させます。よくある実装はバージョン列です。行にバージョン番号を置き、更新時に「自分が読んだバージョンと現在のバージョンが同じときだけ更新」する条件を付けます。

-- 楽観的ロック: バージョンが変わっていないときだけ更新
UPDATE accounts
SET balance = 200, version = version + 1
WHERE id = 42 AND version = 7;
-- 影響を受けた行が0なら -> その間に誰かが変えたということ -> 再試行

選択の基準は衝突の頻度です。衝突が多ければ悲観的ロックが再試行の無駄を減らして有利で、衝突が稀なら楽観的方式が待ちを無くしてスループットを上げます。ウェブアプリケーションの多くの更新は衝突が稀なので、楽観的方式がよく合います。

SELECT FOR UPDATE — 明示的な行ロック

MVCCのおかげで通常の読みはロックしませんが、ときに「自分が読んだこの行を誰も触れないようロックしておきたい」が必要になります。代表的な場面が**読んで・検査して・書く(read-modify-write)**パターンです。残高を読み、十分か検査し、引き落とす流れがそれです。

このとき単なるSELECTで読むと、読んだ直後に別のトランザクションが同じ行を変えてしまい、更新の喪失(lost update)が起こりえます。SELECT ... FOR UPDATEは読むその瞬間に該当行へ書き込みロックをかけ、トランザクションが終わるまで他のトランザクションがその行を更新できないようにします。

BEGIN;
-- この行をロックしたまま読む。他のトランザクションはこの行の更新時に待つ。
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- アプリケーションで残高が十分か検査した後
UPDATE accounts SET balance = balance - 100 WHERE id = 42;
COMMIT;  -- コミット時点でロック解除

FOR UPDATEの変種も知っておくとよいでしょう。FOR SHAREは共有ロックで、他の読みは許すが書きは防ぎます。FOR UPDATE SKIP LOCKEDは既にロックされた行を飛ばし、複数のワーカーがキューから互いに別の仕事を取っていく作業キューパターンに有用です。FOR UPDATE NOWAITはロックを得られないと待たずに即座にエラーを出します。

一つ注意すべきはデッドロックです。二つのトランザクションが互いのロックした行を逆順にロックしようとすると、永遠に待ち合います。データベースがこれを検知して一方を強制的に失敗させるので、アプリケーションはこのエラーを捕まえて再試行する準備が要ります。デッドロックを減らす実践的なコツは「常に同じ順序でロックする」ことです。

PostgreSQL対MySQL — デフォルトが違う

ここで実務に直接影響する違いを押さえます。二つのデータベースはデフォルトの分離レベルが違います。

  • PostgreSQLのデフォルトはREAD COMMITTEDです。各文が始まるときの最新のコミット済みスナップショットを見ます。ですから一つのトランザクション内でも文ごとに違うスナップショットを見えてしまい、反復不能読み取りがデフォルトで起こりえます。
  • MySQL(InnoDB)のデフォルトはREPEATABLE READです。トランザクションの最初の読みの時点でスナップショットを取り、トランザクション中ずっと維持します。

さらに興味深いのは、各エンジンの上位レベルが実際にどう動くかです。

  • PostgreSQLのREPEATABLE READは真のスナップショット分離を実装し、標準が許すファントムリードまで実際に防ぎます。ただし書き込みスキューは依然として起こりえます。
  • PostgreSQLのSERIALIZABLEはSSI(Serializable Snapshot Isolation)という技法で、スナップショット分離の上に危険な依存を検知し、衝突するトランザクションを失敗させます。こうして書き込みスキューまで防ぎますが、その分だけ直列化失敗(40001系のエラー)が出ることがあり、アプリケーションが再試行しなければなりません。
  • MySQL/InnoDBのREPEATABLE READは通常の読みには一貫したスナップショットを与えますが、ロック読み(FOR UPDATEなど)にはギャップロック(gap lock)を使ってファントム挿入を防ぎます。純粋なスナップショット分離とロックが混ざった微妙な挙動で、同じREPEATABLE READでもPostgreSQLとは細部の振る舞いが違います。

この違いはなぜ実務で重要なのでしょうか。同じアプリケーションコードでも、どのDBに繋ぐかによって並行性バグの様相が変わるからです。PostgreSQLでは問題なかったコードがMySQLで違う動きをしたり、その逆もありえます。ですから並行性の絡むロジックは、必ず対象DBのデフォルト分離レベルを知り、必要なら明示的に分離レベルを上げて検証すべきです。

実践的な指針

ここまでの内容を実践の観点で圧縮します。

まず、デフォルトの分離レベルを正確に知って始めましょう。 PostgreSQLはREAD COMMITTED、MySQLはREPEATABLE READがデフォルトです。これを知らないと「なぜこの値がトランザクションの途中で変わる?」あるいはその逆の混乱に陥ります。

次に、読んで・検査して・書くパターンに注意しましょう。 残高の引き落とし、在庫の減少、座席の予約のように「読んだ値を根拠に書く」ロジックは更新喪失の温床です。楽観的なバージョン列やSELECT ... FOR UPDATEで明示的に守りましょう。

書き込みスキューを覚えておきましょう。 各トランザクションが個別には正当に見えても、合わさると不変条件を破りえます。純粋なスナップショット分離では防げないので、本当に必要な不変条件ならSERIALIZABLEに上げるか、明示的なロックで強制しましょう。

直列化失敗とデッドロックに備えた再試行を入れましょう。 SERIALIZABLEや楽観的方式、ロックはすべて「失敗したら再試行」を前提にします。アプリケーションに再試行ロジックが無いと、これらの安全装置がかえって障害になります。

最後に、トランザクションを短く保ちましょう。 長いトランザクションはロックを長く握り、MVCCの古い版の掃除を妨げ(PostgreSQLのVACUUM遅延)、衝突の確率を上げます。

おわりに

トランザクションは「複数の作業を一つに束ね、全か無かで処理せよ」という単純な約束から出発しますが、複数のトランザクションが同時に走った瞬間、分離という深い問題へつながります。ダーティリード、反復不能読み取り、ファントム、書き込みスキューという異常があり、それをどこまで防ぐかが四つの分離レベルに分かれます。

現代のデータベースはたいていMVCCで「読みが書きを妨げない」スナップショット分離を提供し、その上で必要なときにロックや楽観的バージョンで衝突を扱います。そしてPostgreSQLとMySQLはデフォルトの分離レベルから上位レベルの細部の挙動まで違うので、実際の対象DBの動作を知ることが何より重要です。

核心はこうです。完璧な分離は高くつき、分離を完全に放棄すれば危険です。ですから私たちは「このデータにどの異常まで許せるか」を決め、それに合う分離レベルと並行性戦略を選びます。その選択を意識的に行うとき、トランザクションは恐怖の対象ではなく、信頼できる土台になります。

参考資料

현재 단락 (1/97)

データベースでお金が動くと、怖い問いが生まれます。「送金の途中でサーバが落ちたらどうなる?」口座Aから100を引き、口座Bに100を足す二つの文の間でプロセスが落ちれば、お金は消えることも二重になるこ...

작성 글자: 0원문 글자: 8,554작성 단락: 0/97