概要
DBMSのトランザクションについて久々に勉強したのでそのメモ。
基本的には記事などの引用と、それに対して調査したことをセットにして書いていきます。
参考記事
- DBアタマアカデミー:第2回 トランザクションを知ればデータベースがわかる―「データ復旧」「同時実行制御」を行う“不完全な”しくみ(1) #gihyojp http://bit.ly/bVAIE7
学んだこと
トランザクションとは?
トランザクションとは,複数の検索や更新を含む処理からなる意味的な単位です。「意味的な」というのは,要するにどこからどこまでが1つのトランザクションかDBMSには自動的にわからないということです。したがって,「ここからここまでが1つのトランザクションである」ということは,ユーザから指定する必要があります。
トランザクションは、原理的にはユーザーによってDBMSに対して指定するもの。
複数のクエリをまとめた単位(になる場合があるもの)なので、意味合いを指定する必要がある。
開発者にとってはどこからどこまでを1トランザクションとするかが重要である。
なんのためにあるのか?
ACID特性を満たすようなデータ操作を実現するためにある。
ACID特性についてはこの記事で順に説明していく。
ざっくりいうと、複数のクエリを発行するんだけど、それぞれ連続して行われないと意味がないため、クエリ実行中に他のクライアントからのクエリによって更新したいデータを更新されたくない。という画面で使う。
中途半端な状態で終わってほしくないし、変更したいデータを邪魔されたくないとき。
トランザクションの開始/終了コマンド
開始コマンドがDBMSによってバラバラなのは,標準SQLで明確に決まっていないためです。中にはOracleやDB2のように,データベースへ接続したら自動的にトランザクションが始まることになっているため,開始コマンドのないDBMSもあります。
トランザクションの終了コマンドはCOMMITおよびTRANSACTIONで統一されているけど、開始コマンドはDBMSによって異なる。
MySQLやPostgresはある。Oracleには無い(→SET TRANSACTIONコマンドを使えば明示的に開始できるが、基本的にはプログラムの最初のSQL文により開始される)。
例えばPHPのMySQLモジュールでは、begin_transaction
メソッドが用意されている。
参考:http://php.net/manual/ja/mysqli.begin-transaction.php
また、こちらの記事では、実際にPHPのPDOでトランザクションを開始してクエリを発行している事例が書かれています。
//プリペアドステートメント
$stmt = $dbh->prepare("UPDATE USERS SET COUNT = COUNT + 1 WHERE ID = ?");
//トランザクション処理を開始
$dbh->beginTransaction();
try {
$stmt->bindParam(1, $id, PDO::PARAM_INT);
$stmt->execute();
//コミット
$dbh->commit();
}catch(PDOException $e){
//ロールバック
$dbh->rollback();
throw $e;
}
近々GolangでMySQLを触ってみたいと思っているが、Golangでもトランザクションを管理する方法は用意されているようだ。
しかし、そもそもソースコード上で全部コミットやロールバックを直書きすることは危なっかしい気がするため、下記のような手段を使うのが良さそうだ。
deferを上手く使ったラッパー関数で、直書きの手間を省いている。
- golangでトランザクション管理を少し楽にするラッパー関数 http://tsujitaku50.hatenablog.com/entry/2017/11/01/232707
また、具体的なパッケージとしてはdatabase/sqlというものがある。
- [Go言語] database/sqlパッケージを使ってみた https://qiita.com/tenntenn/items/dddb13c15643454a7c3b
どうやらコネクションプールについてもよしなにやってくれているとのこと。
トランザクションの満たす耐久性
障害発生時,DBMSに求められる最優先事項は,データベースに格納されているデータをきちんと復旧(リカバリ)できることです。リレーショナルデータベースは,この「きちんと」を次のように定義しています。
「障害発生前に終了しているトランザクションの結果は保証する」
これが,トランザクションが満たすべき四特性(ACID)の一つ,D(Durability:耐久性)です。
ここで、終了している、というのは発行者から見た場合COMMITコマンドを発行したことを指すのだが、実際にはCOMMITコマンドを発行してから物理ディスクに書き込みが終わるまでの間、厳密には時間がかかってしまうため、その書き込み中に障害が起こった場合、COMMITを発行したにも関わらずデータの変更がなされていない状態が起こりうる。
そのため、DBMSにはCOMMITが発行されるとデータファイルへの書き込み前にログを吐き出す仕組みが備わっている。ログはテキストファイルなので、データの書き込みよりもずっと速くできる。
その書き込みのあとに、ゆっくりとデータを反映する。すると、仮に書き込み中に障害が起こっても、ログからデータの変更を把握できるので、復旧作業が可能になる。
データファイルへの書き込みのことをチェックポイントと呼ぶ。
従って、
- COMMIT
- ログ書き込み
- データファイルへの書き込み(チェックポイント)
COMMIT前に起こった障害の場合、データファイルへの書き込みは無かったことになる(ロールバック)。
COMMIT後かつデータファイルへの反映前に起こった障害の場合、DBMSの再起動時などにログから復旧作業が行われデータファイルへ書き込みがされる(ロールフォワード)。
以上より、ACIDのD(耐久性)が実現されている。
また、この仕組によってトランザクションの結果は0か完璧かしかなく、ACID特性のA(Atomic、原子性)が満たされているという。
ちなみに、例えばPostgreSQLのドキュメントにも、ログ書き込みについて書かれている。
システムがクラッシュしたとしても、最後のチェックポイント以降に作成されたログ項目を"やり直し"することで、データベースを整合性を維持した状態にリストアすることができます。
トランザクションの満たす分離性(Isolation)
分離性とロック
私たちがデータベースを利用するとき,1人で占有しているという贅沢なことはまずありません。自分以外にも多くの人が,検索,更新,削除といった多様な処理を同時並行で実行しています。しかし,私たちはそのことを意識しません。
例えば銀行口座Aから銀行口座Bに送金する時に、Aから10万円を引いて、Bに10万円を足すわけだが、Aから10万円引いたタイミングで銀行口座Bが別の口座Cに移行されてしまったとする。
この場合、Bに10万円足すタイミングでエラーが起きてしまい、送金がなかったことになってしまうが(ロールバック)、時系列的にはA→Bの送金のあとにB→Cの移行なので、システムとしてはCに10万円入ることが要求されるだろう。
この場合は、A→Bの送金完了までをトランザクションとして、かつ他のトランザクション(今回だと銀行口座の移行)が入り込まないようにする必要があるといえる。
この状態を解消する考え方が「ロック」である。
ロックによって、ACID特性のI(Isolation)、分離性を実現しようとしている。
排他ロックと共有ロック
UPDATED文の実行時には「排他ロック」が掛けられる。
排他ロックは、更新対象の行に対して他のトランザクションからのアクセスを一切禁止する。
禁止されている場合は、他のトランザクションのアクセスは全て待機となる。
ロックとはいえども、実際は変更中の行に対しては待ち行列を生成して無理やり並列のプロセスを直列にするということである。
ちなみに、「共有ロック」というロックがあり、他のトランザクションがSELECT文を実行することしかできなくするロックである。SELECT文の発行時に使われる。
全てのトランザクションがSELECTしかしないのであれば、排他ロックする必要性が無いので、共有ロックで良い。
したがって行の更新や削除時に排他ロックをかけるのが自然な発想であるが、SELECT時にも排他ロックを掛けることができる。
MySQLドキュメントによると、MySQLには自動コミットという機能があるのだが、START TRANSACTION
を実行して自動コミットを無効にした上、SELECT文の末尾にFOR UPDATE
を付けることで、トランザクションがコミットまたはロールバックするまで、SELECTされるリソースに排他ロックを掛けることができる。
例えばSELECTした値を元にして処理を行うような場合、ロックがかかっている前提でないと複数回のクエリでSELECT先のカラムの値が変わってしまい、想定した動きにならないリスクが有る。
そういった場合にはSELECT文であっても排他ロックをかける必要がある。
冒頭にも述べたが、開発者にとってはどこからどこまでを1トランザクションとするかが重要である。
分離性のレベル
完璧な分離性を実現しようとすると、ある程度以上のトランザクションが一気に貼られるプロダクトに成長したとき、ロックが一度に行われて、複数の排他ロックが同時に起こって永遠に待ち状態になる「デッドロック」や、性能が著しく低下する「スラッシング」が起こる。
そこで、現状のDBMSはほぼ、ある程度の妥協をしている。ここでは完璧な分離性とそうでない分離性の説明をしていく。
非コミット読み取り(Read Uncommitted)
もっともレベルの低い分離性。ダーティリードと呼ばれる現象を引き起こす。
これはもはや全く分離できていないので論外である。
ダーティリード
Taが列の値を変更しているが,まだコミットしていない場合でも,Tbが変更後の値を読み出す。たとえば,あるテーブルの列値が「10」であるレコードをTaが「20」に変更した場合,コミット前でもTbがSELECTした結果が「20」になる。
コミット済み読み取り(Read Committed)
コミットされたデータであれば別トランザクションでも読み取ってしまう状態。下記現象を引き起こす。
最初に,Taがある列値「10」を読み出したとする。そのあと,Tbが列値を「20」に変更し,コミットも行った。そのあと,Taが再度SELECTを実行すると,(「10」ではなく)変更後の「20」が読み出される。
再読み込み可能読み取り(Repeatable Read)
他のトランザクションの書き込みが行われると、SELECTの件数が増えてしまうパターンが生じる状態。
下記現象を引き起こす。
ファントム
最初に,Taが範囲検索を行い,3行のレコードを読み出したとする。そのあと,Tbがちょうどその範囲に収まるデータを1行INSERTし,コミットも行った。そのあと,Taが再度同じSELECT文を実行すると,選択されるレコード数が4行になる(もし直列化可能であれば,最初と同じ3行が選択されなければならない)。
これがどうして起こるかというと、範囲検索というのがミソで、例えばIDが1と3のデータがあった場合、トランザクションAでIDが5以下の範囲検索をすると2件だけど、次にトランザクションBがID2のデータを新規生成したら、トランザクションAのIDが5以下の範囲検索は3件になってしまう。
SELECTした行自体はロックしても、その範囲に収まる行を作ってしまうことは、他のトランザクションからでもできてしまう。
直列化可能(Serializable)
本当に直列でトランザクションを実行するので、原理的に分離性が担保される。
これはデッドロック問題が起こっているかどうかの切り分けなどに用いられ、現実的に導入されることはまずないと考えていい。
それほどまでにスラッシングなどの問題が深刻ということである。
実際のDBにおける分離性
MySQLでは、全ての分離性をサポートしている。
ちなみに、Repeatable Readであってもファントムリードは生じないようになっている。
ネクストキーロックという仕組みを使っているので、ドキュメントを参照されたい。
ちなみにMySQLではRepeatable Readがデフォルトの分離レベルである。
また、PostgreSQLでは、非コミット読み取りは指定できない。一方で、Repeatable ReadではMySQLと同じくファントムリードは起きないという。
PostgreSQLではRead Committedがデフォルトの分離レベルである。
内部的には、リードコミッティド、リピータブルリードおよびシリアライザブルに対応する3つの独立した分離レベルのみがあります。
リピータブルリードのPostgreSQLでの実装ではファントムリードの可能性はありません。 このように実際の分離レベルは選択したレベルより厳密になることがあります。 これは標準SQLでも許されています。 この4つの分離レベルについては、発生してはならない事象のみが定義され、発生しなければならない事象は定義されていません。
余談
NoSQLの事例として、
Firebase Realtime Databaseでもトランザクションを発行できるという情報を載せる。
https://firebase.google.com/docs/database/web/save-data?hl=ja#save_data_as_transactions
https://firebase.google.com/docs/reference/js/firebase.database.Reference?hl=ja#transaction
Firebaseでは別トランザクションからの書き込みがあったら再試行するらしい。
新しい値が正常に書き込まれる前に別のクライアントがその場所に書き込んだ場合、update 関数が現在の新しい値で再度呼び出されて、書き込みが再試行されます。
所感
トランザクションの分離性に関しては、特にアプリケーション要件と、速度などのパフォーマンスのトレードオフに関わってくるため、エンジニアとして抑えておきたい。
またDBMSによって設定できる内容や、同じ分離レベルでも実態が異なっている場合があるため、注意したい。
今回は触れなかったが、ドキュメントを読み込むと、スナップショットの保持の仕方などが詳しく書かれていて、この辺まで知るとDBMSを主体的に選択できるようになりそうと感じた。