最近、仕事でトランザクション分離レベル関連のバグに遭遇した。
普段自分が実装する際は、 MySQL のトランザクション分離レベルやギャップロックなどを意識して書いているが、既存のコードを読む際、または発生した事象からトランザクション分離レベルに問題があると気づくのには慣れてないと気付かされたので、整理と反省のつもりで事象をまとめてみた。
トランザクション分離レベル
一般的にトランザクション分離レベルと発生する事象は以下の通り。
トランザクション分離レベル | ダーティーリード | ファジーリード | ファントムリード |
---|---|---|---|
READ UNCOMMITTED | ○ | ○ | ○ |
READ COMMITTED | ○ | ○ | |
REPEATABLE READ | ○ | ||
SERIALIZABLE |
名前 | 説明 |
---|---|
ダーティーリード | 他トランザクションのコミット前のデータが見える |
ファジーリード | 複数回参照する際、他トランザクションの更新によって結果が変わる |
ファントムリード | 複数回参照する際、他トランザクションで追加されたデータが見えることで結果が変わる |
※ ○が発生する事象
主要な RDB で、 Oracle や PostgreSQL はデフォルトで READ COMMITTED
、MySQL は REPEATABLE READ
である。
また、MySQL(InnoDB)の場合は、ファントムリードも発生しない。
そのため、MySQLの場合は何もしなくてもデータ不整合が発生しないか、というとそんなことはない。
問題となるコード
問題となるケースは以下のようなコードである(Javaで記載)。
@Transactional // Masterにアクセス・サブトランザクションなども未使用
void hoge(final long id, final long newValue) {
final var oldEntity = sampleRepository.findById(id);// 'select * from sample where id = ?'のようなクエリを実行
if (oldEntity == null) {
throw new IllegalArgumentException();
}
sampleRepository.update(newValue, id); // 'update sample set v = ? where id = ?'のようなクエリを実行
final var newEntity = sampleRepository.findById(id);
sample2Repository.update(Sample2Entity.from(newEntity)); // 'update sample2 set v1 = ?, v2 = ? where sample_id = ?'のようなクエリを実行
}
Webアプリのサーバーコードでデータ不整合に関係するトラブルの原因には以下のような例がある。
- ロジックがそもそも再入可能でない
- スレッドが途中で分かれて、トランザクションが別になっている
- キャッシュに問題がある
- 意図しない効き方をしている
- キャッシュのキー指定やシリアライズ/デシリアライズなどに不備がある
- RDBが非同期レプリの構成で、レプリ遅延中にSlaveから参照している
- アプリのバージョンごとに互換性がない。APIやデータ形式などに問題がある
前述のコードは、ぱっと見た時には特に問題ないと思ったのだが、ちゃんと読むと再入可能なコードではなかった。
( ちなみに調査を難しくした1番の要因は、ログに更新内容などがあまり残っておらず、調査依頼が来た時には既にデータが正常な状態だったためであるw )
解説
前述のコードで問題となるのは以下のような処理順の場合である。
MySQL ではダーティーリードが発生しないため、 07 では期待通り、 Thread 1
の更新がコミットされる前の値を取得できる。
次に、 09 で値が newValue
になることを期待したくなるのだが、実際には 03 で既に同じ値で更新されているため、更新できない。
そして、 更新できなかったために、 10 のタイミングで 07 と同じ結果を取得する。
というのも MySQL は REPEATABLE READ のため、トランザクション内の整合性が保たれているためだ。
尚、 Thread 1
がコミット前の場合であっても、 03
の UPDATE文が実行されると行ロックが掛かるため、同じ事象が発生する。
反対に 07 の取得と 08 の順番が逆の場合は、 Thread 2
のトランザクションは意図した通りに動く。
前述のフローでポイントとなるのは 07
の処理が、 03
〜08
の間で実行された場合である。
それらのタイミングで実行された場合、データ不整合が生じる。
mysqlコマンドでの再現
アプリの Thread 2
を再現した例が以下の通りである。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from sample where id = 1;
+----+------+
| id | name |
+----+------+
| 1 | hoge |
+----+------+
1 row in set (0.00 sec)
mysql> update sample set name = 'piyo' where id = 1;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 1 Changed: 0 Warnings: 0
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
mysql>
見ての通り、UPDATE文の実行結果で、 Rows matched: 1 Changed: 0
となっている。
以上のように MySQLがファジーリードが発生しないが、逆にそれだからこそデータ不整合につながるケースが存在する。
当然ながら SERIALIZABLE であれば今回の事象は発生しない。
ちなみに、今回のような原因となった操作は、フロントアプリのモーダルで、閉じる前にボタンを再度押すことで2回APIを叩けたからだった。
( 恐らくボタンを押す際にダブルクリックをしていたのだろう )
対策例
本件の対策例は、定番的なものである。
- レコード取得時にロックする → 最も標準的な対応方法
- 更新結果や更新カラム数などを確認する → ロックを避けたい場合は良さそう
- 更新後のカラムではなく、アプリ内のオンメモリデータを更新し、それを使って更新する → MySQL側で自動更新するカラムも使いたい場合はこれでは回避不能
- フロントアプリで二重実行されないように抑止する → UX的にはこれで十分だろうが、別タブやAPIの直接実行でデータ不整合になるので、根本対策にはならない
- テーブルを正規化する → 今回のケースは回避できたが、カラムの依存関係によっては解決できないし、そもそも2回目の更新で意図しないレコードを取得するケースは防げないので根本対策にはならない
普通にテーブル設計がされていれば、同じ値を複数のテーブルで持つことがない(正規化されている)ため、今回のようなケースは基本的に発生しない。
それゆえについ失念していたのだが、 データの移行期間中などで同じデータを複数のテーブルで持つようなケースは、どうしてもこのようなケースが起こりやすくなってしまう。
ちなみによくある構造として、データ取得(1)してデータチェック→更新→データ取得(2)して結果を返すような API でも、1でロックしないと、2の結果がファジーリードによって変わることがある。
このケースではデータの不整合は起きないが、画面描画が狂う可能性がある。
まとめ
今回は「MySQL であればデータ不整合はあまり起こらないだろう」・「テーブルは基本的に正規化されている」などの先入観ゆえに、データの不整合の原因としてトランザクション分離レベルに最初に思い至らなかったという話である。
トランザクション分離レベルが高くてファントムリードやファジーリードが発生せずとも、SERIALIZABLE でない以上、データの不整合は生じる可能性がある。
そのため、行ロックや更新後のチェックを行っていないコードは、トランザクション分離レベルの考慮不備の可能性を疑って然るべきだ。