データベースのトランザクション分離レベルを意識していなくて少し痛い目を見た話
はじめに
みなさんはデータベースのトランザクション分離レベルをちゃんと理解してますか?
自分は理解が浅くあまり意識せずに開発してたところ、少し前にチームでの開発時に不具合を踏んだので備忘録的に記事にします。
トランザクション分離レベルをざっくりおさらい
トランザクション分離レベルについては、以下の記事などで詳しく説明されているので、ここでは詳しく記載しません。
ざっくり整理するとポイントは以下です。(上に記載のものから順に制約が強い)
分離レベル | コミット前の値を他のトランザクションから参照できるか | 他のトランザクションでできること |
---|---|---|
SERIALIZABLE | 🙅♂️ | なし |
REPETABLE READ | 🙅♂️ | 追加・削除 |
READ COMMITTED | 🙅♂️ | 追加・更新・削除 |
READ UNCOMMITED | 🙆♂️ | 追加・更新・削除 |
そして、自分が触っていたアプリケーションでは「REPETABLE READ」となっていました(MySQLのデフォルトはこれ)。
つまり、あるテーブルに対してトランザクションAで参照していても、別のトランザクションBでそのテーブルへのレコードの挿入はできてしまいます。
実際に起きた問題
実際に起きた問題と書きつつ、あまり具体的なシステムの話にならないように、ここでは仮に「ユーザの情報を元にをプロフィールを作成する処理」とします。
-
前提
- ユーザにはプロフィールが 0 or 1 個紐づく
- プロフィールを作る処理は以下の流れ
- トランザクション開始
- 対象のユーザにプロフィールが紐づいていないことをチェック
- プロフィールを新規作成
- トランザクション終了
-
発生した問題
- ほぼ同時に同じユーザに対してプロフィール作成処理が走った時、同じユーザに複数のプロフィールが紐づく現象が発生
トランザクション分離レベルをREPEATABLE READ にしていると、明示的にロックをかけない限り、このような現象が発生します。前述した通り、トランザクションAで対象のユーザに紐づくプロフィールが存在しないことを確認できていても、同じ処理をするトランザクションBも同時に走った場合、トランザクションBでも紐づいていないことのチェックを通過し、2つのトランザクションで同時にレコードを作れてしまいます。
対応方針
いくつか対応方針を考えました。
- DB側で制約を貼る
記事を読んでるみなさんは、DB側の制約を追加しろよ、と思いながら読んでたかもしれません。
たしかにその通りなのですが、以下の理由などでDB側でユニーク制約を貼って解決するのは少し大変な事情がありました(今回は記事にするために簡略化して記載したものの、実際はもう少し複雑)。- プロフィールの削除は論理削除としているため、プロフィールテーブルのユーザidでのユニーク制約は貼れない(これ自体は論理削除のカラムをNULL or 日時にすることで避けられるかもだけど、全体の方針とずれてしまう)。
- データを履歴管理していて、ユーザとプロフィールの履歴同士の紐付きはあるものの、履歴でない親同士の紐付きは保持してなかった。そのため、この時点で紐付きを持たせるとなると過去データのマイグレーションなどが大変。
- 分離レベルを変える
該当の処理だけ分離レベルをSERIALIZABLE
にすれば解決すると思われますが、処理が遅くなるのと、アプリケーション全体で分離レベルが混在すると混乱を招く恐れがあり、却下しました。 - 後処理で検知する
以下の処理の追加を検討しました。- 一旦はそのままトランザクションを流す
- トランザクション終了後に「ユーザに複数のプロフィールが紐づいていないか」をチェック
- もし異常があれば開発者に通知する
そもそも今回の現象の発生頻度は高くないと見込まれたので、3の形に落ち着きました。
完璧な解決策ではないですが、「問題を検知できる」状態を作ることを優先した形です。
おわりに
これまでトランザクション分離レベルを意識した開発をやってこなかったので、個人的にはとても勉強になりました。今回の件は「通知で気づける」形に落とし込みましたが、根本的にはDBの制約で解決できるよう、設計で防ぐべき問題な気はしてます。
これに感化されて、データベーススペシャリストの勉強も始めたので、そちらの勉強も引き続きやっていき、学びがあればまた記事にしたいなと思います。
同じようなシステムを作る際に参考になれば幸いです。
Discussion