データが突然…「おかしな値」になった時
1分間に約500件の注文が発生するECサイトの運用を始めたばかりの頃、チーム全員が頭を抱えるような「不可解な」バグに遭遇したことがあります。ローカル環境でのテストは非常にスムーズでした。しかし、本番環境にデプロイし、ordersテーブルが1,000万行を超えたあたりから、おかしなケースが発生し始めました。顧客は支払いを済ませているのに注文ステータスが「支払い待ち」のままだったり、同じ在庫レポートを見ているのにページを更新(F5)するたびに数字が変わったりしたのです。
私は、単に BEGIN と COMMIT を使うだけでは不十分であることに気づきました。何百ものトランザクションが同時に一つのテーブルに流れ込むとき、分離レベル(Isolation Level)の制御が欠けていると、データベースは混乱状態に陥ります。データの競合(並行性の問題)を避けるために、MySQLがセッションをどのように管理しているかを正確に理解する必要があります。
データ不整合を引き起こす3つの典型的な現象
設定を行う前に、すべてのバックエンド開発者が直面する3つの問題を明確に区別しておく必要があります。
1. Dirty Read(ダーティリード:未コミットデータの読み取り)
この現象は、トランザクションAが、トランザクションBが修正中だがまだコミット(COMMIT)していない行を読み取ったときに発生します。例えば、顧客Aが50万円を入金し、システムがDBに一時的に書き込んだとします。もしトランザクションBがその50万円という数字を読み取った後に、トランザクションAでエラーが発生してROLLBACKされた場合、実際には顧客Aの残高は0円のままです。トランザクションBは「ゴミデータ」を読み取ってしまったことになります。
2. Non-repeatable Read(反復不能読み取り:不整合な読み取り)
1回目のSELECTで残高が10万円だったとします。その直後、別のプロセスがこの残高を5万円にUPDATEし、すぐにCOMMITしました。あなたが(まだ古いトランザクションの中にいる状態で)2回目のSELECTを行うと、残高が5万円に変わっています。一つのトランザクションの途中でデータが変わってしまうことは、集計計算などのロジックを破壊する原因になります。
3. Phantom Read(ファントムリード:幻の読み取り)
既存の行の修正とは異なり、ファントムリードはINSERTやDELETEに関連します。例えば、注文数をカウントするためにSELECT COUNT(*)を実行して10件だったとします。1秒後、誰かが新しい注文を1件挿入しました。再度SELECTすると11件になっています。これらの「幻(ファントム)」のデータ行は、期末の統計レポートなどで数値が合わない主な要因となります。
MySQLにおける4つの分離レベルとその仕組み
MySQL(InnoDBエンジン)は4つのカスタマイズ可能なレベルを提供しています。レベルが高くなるほどデータの安全性は高まりますが、ロック(Locking)メカニズムによりパフォーマンスが大幅に低下するという代償があります。
レベル1:READ UNCOMMITTED
最も緩いレベルです。トランザクションは互いの未コミットデータまで見ることができます。実際、ダーティリードのリスクが大きすぎるため、このレベルを使うことはほとんどありません。
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
-- 他ユーザーによるコミット前の、一時的な変更も見ることができます
SELECT balance FROM accounts WHERE id = 1;
レベル2:READ COMMITTED
SQL ServerやPostgreSQLのデフォルト設定です。コミットされたデータのみを読み取ることが保証されます。このレベルではDirty Readは防げますが、Non-repeatable ReadとPhantom Readは依然として発生する可能性があります。
レベル3:REPEATABLE READ(MySQL의デフォルト)
InnoDBはMVCC(多版型並行性制御)という非常にスマートな仕組みを使用しています。一つのトランザクション内であれば、何度SELECTしてもデータは最初の1回目と全く同じままです。さらにMySQLはNext-Key Lockingという技術を使い、Phantom Readの大部分も防ぎます。これはMySQLが速度と安全性のバランスを保つ上での大きな利点です。
レベル4:SERIALIZABLE
最も厳格なレベルです。トランザクションは順番に実行される必要があります。トランザクションAがデータ領域Xを読み取っている間、そこに挿入したいトランザクションBは待機しなければなりません。リクエスト量が多いシステムでは、処理速度が急激に低下します。
実践:分離レベルの確認と変更
現在のモードを確認するには、次のコマンドを使用します。
SELECT @@transaction_isolation;
機密性の高い金融取引などを処理する場合、そのセッションを最高レベルで実行するように強制できます。
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
START TRANSACTION;
-- 支払いロジックを実行
UPDATE accounts SET balance = balance - 100 WHERE user_id = 10;
COMMIT;
実務経験:最適化のためにどのレベルを選ぶべきか?
大規模なデータベースを長年扱ってきた中で導き出した、いくつかのルールを紹介します。
- REPEATABLE READを優先する: Webアプリケーションの90%に適しています。MySQLはMVCCによって非常によく最適化されているため、速度について過度に心配する必要はありません。
- READ COMMITTEDを使うタイミング: 巨大なデータテーブルがあり、ログの書き込み速度を上げたり、デッドロック(Deadlock)を最小限に抑えたりするために、わずかなデータの変化を許容できる場合に使用します。
- 可能な限りSERIALIZABLEを避ける: 極めて厳格な銀行のコアシステムなどにのみ使用します。これをフラッシュセール時のECサイトに適用すると、トランザクションが互いにロックを待機し合い、システムが確実にハングアップします。
重要な注意点として、テーブルが1,000万行を超えると、インデックス(Index)のないSELECT文がGap Lockingを引き起こします。データベースが広範囲のデータ領域をロックするため、他のプロセスがINSERTできなくなります。WHERE句で使用するカラムには必ずインデックスが設定されているか確認してください。
まとめ
分離レベルの選択は単なる設定変更ではなく、正確性とパフォーマンスのトレードオフです。デフォルト設定に任せきりにせず、データの特性に基づいて最適なレベルを選択してください。もしシステムでデッドロックが発生したり、マルチスレッド実行時にデータが食い違ったりする場合は、まず分離レベルを最初に見直してみてください。

