本記事では、MVCCの基本概念とPostgreSQLでの具体的な動作、そして関連するトランザクションの問題について調べたことをまとめます。
この記事は自分の理解なので間違っているところがるかもしれません。
詳しくは以下を読んでみてください。
MVCCとは
PostgreSQLの公式ドキュメントを参照すると
MVCC(MultiVersion Concurrency Control:多版型同時実行制御)とは、マルチユーザ環境におけるデータベースの性能を向上させる高度な技術です
とあります。簡単に言うと、データの取得の際や書き込みの際にそのデータを基本的にロックしないので、競合による待機(ブロック)が起こりずらく、マルチユーザーが使用するときでもスムーズにデータの処理を行えますよ、と言う話です。
各トランザクションがそれぞれのスナップショット(実際のDBのコピーみたいなもの)を持っているので、他のトランザクションが本体のデータを変更しても影響がなく、複数のトランザクションの際に並列して実行するパフォーマンスが向上します
では、そもそも同時にトランザクションを実行するとどのような問題が起こるのでしょうか?MVCCではトランザクションが同時に起こることによる問題をスナップショットを利用することで解決しているようです。
と、その前に簡単にトランザクションとは何かについて見てみましょう。
トランザクションとは
トランザクションとは一連の操作を一つの不可分な塊として考える、ということです。これがトランザクション自体の意味(必須のもの)であり、実際のデータベースでの運用の際に目指すべき理想の姿としてACID特性というものがあります。(必ずしも全てを満たしているとは限らない)
- Atomicity(原子性):全部の操作が成功するor失敗する
- 実際のDB:保証される。(失敗した場合にロールバックが可能)
- Consistency(一貫性):データの整合性を維持する
- 実際のDB:保証されるように設計する。(アプリケーション側で担保することもある)
- Isolation(隔離性):他のトランザクションの影響を受けない
- 実際のDB:隔離レベルによって異なる。(隔離レベルによってはダーディーリードなどが起こる場合もある。この後に詳しく解説)
- Durability(永続性):コミット(確定)されたデータは失われない
- 実際のDB:DBのストレージ機能に依存する。(クラッシュ時にデータを復元できるかどうか)
原子性とは
トランザクションに必ず保証されている(トランザクションそのもの)
データの更新などがあった場合に、データが保存されているディスクに対してすぐに変更を書き込むのではなく、まずはデータベースのメモリ領域、バッファキャッシュに変更が反映されます。この状態ではまだ実際のディスクの方には変更はされていない状態なのですが、適切な隔離レベルがされていないと、他のトランザクションがこのデータを読めてしまいます(ダーティーリード)。
その後にトランザクションログというロールバックする際やクラッシュ時に参照するために使用されるログファイルにデータが保存されます。
そして最終的に問題がなければデータをコミット、確定してディスクに書き込みトランザクションが終了する、という流れです。
では、トランザクションが何かについて、データベース運用の際に守りたい特性について分かったので、トランザクションが複数同時に実行されたときに起こる問題、防ぐべき現象について考えてみましょう
防ぐべき3つの問題
トランザクションが複数同時に実行されたときに防ぐべき現象について考えてみましょう
- Dirty Read (ダーティーリード)
- 同時に実行されている他のトランザクションが書き込んだ、未コミットのデータを読んでしまう現象
- 例:
- トランザクションAが
残高を1000円から500円
に更新しようとする(バッファキャッシュやトランザクションログには書き込んだが未コミット) - トランザクションBが残高を見ようとするとキャッシュを参照して500円になっている
- しかし、トランザクションAが何か失敗し、ロールバックするとデータは1000円に戻る
- トランザクションBは存在しない500円というデータを参照してしまった
- トランザクションAが
- Non-repeatble Read(反復不能読み取り)
- トランザクションが以前読み込んだデータを再度読み込む際に、そのデータを別のトランザクションが更新しコミットしたことによって、以前のデータではなく新しいデータを得てしまう現象
- 例:
- トランザクションAが
残高=1000円
を見る - トランザクションBが
残高=500円
に更新して、コミットする - トランザクションAが同じ処理の中で再度残高を確認すると500円になっている
- トランザクションAは同じ処理の中で同じデータを確認したはずなのに、以前のデータではなく新しいデータが表示されてしまった
- トランザクションAが
- Phantom Read(ファントムリード)
- トランザクションが、ある行の集合を返す検索条件で問い合わせを再実行したとき、別のトランザクションがその問い合わせ条件を満たす行を変更し、コミットしてしまったために、同じ検索条件で問い合わせを実行しても異なる結果を得てしまう現象
- 例:
- トランザクションAが
価格>1000円の商品一覧
を取得する(3件) - トランザクションBが
価格=1500円の商品を追加
してコミットする - トランザクションAが処理の中で再び
価格>1000円の商品一覧の取得
をすると4件になってしまっている - 同じトランザクションの処理中に、同じクエリを投げたのに結果が異なってしまっている
- トランザクションAが
Non-repeatable Read
とPhantom Read
の違いは、対象が行か集合かの違いです。簡単に言えば発生原因が違いますね。
Non-repeatble Read
は既存の行の更新や、削除が原因なのに対して、
Phantom Read
は新しい行の挿入や、既存の行の変更による条件一致が原因です。
あまり変わらないかと思うかもしれませんが、対策方法が異なるので隔離レベルによって防げるか防げないかが変わってきます。
ではこれらの問題に対してどのような隔離レベルがあって、それらがどのように問題に対処できているのか見てみましょう
隔離レベル
ACID特性のIsolation(独立性)にあたるものです。この隔離レベルを変えることによって先ほどの問題に対処できるようになります。その一方で、並行処理のパフォーマンスが低下する可能性もあるので、システムの要件に合わせて選択していきます
まずは隔離レベルと、問題の起こる可能性を表にまとめてみます
トランザクション隔離レベル
隔離レベル | ダーティーリード | 反復不能読み取り | ファントムリード |
---|---|---|---|
Read uncommitted | 可能性あり | 可能性あり | 可能性あり |
Read commited | 安全 | 可能性あり | 可能性あり |
Repeatble read | 安全 | 安全 | 可能性あり |
Serializable | 安全 | 安全 | 安全 |
ではそれぞれの隔離レベルが何をしていているのかについてみていきます
Read uncommited (確定していないデータまで読み取る)
これは他のトランザクションの未コミットのデータまで読んでしまうというものです。計算途中のデータや不完全なデータなどを読み取る恐れがあり、トランザクションの並列動作によってデータを破壊する可能性は高いですが、性能自体はいいです
ちなみに、PostgreSQLではこのレベルを指定したとしてもRead Commited
として内部的に処理されます
Read commited (確定した最新データを常に読み取る)
これは、常にコミット済みのデータを参照するということです。なのでSLECT文ではこの問い合わせが開始される直前までにコミットされたデータのみを参照します。一方で単一のトランザクション内で2回のSELECT文を使用しており、一つ目のSELECT文が終わった際に別のトランザクションが更新をコミットすると2回目のSELECT文では異なるデータ(結果)を参照することに注意が必要です。
PostgreSQLなどではRead commitedがデフォルトの隔離レベルに設定されています。
このレベルで提供されるトランザクション隔離は多くのアプリケーションでは十分なものであり、高速で動作します。しかし、複雑な問い合わせや更新を行うアプリケーションではより整合性を保証するために何かしらの処理を行った方が良いかもしれません。
Repeatable read (読み取り対象のデータを常に読み取る)
これは一つのトランザクションが実行中の間に読み取り対象のデータが他のトランザクションによって変更される心配がなくなる、というものです。変更する行に対してロックを行なって(排他的ロックであり、更新は出来ないが読み取りは可能)、他のトランザクションが操作をできなくするので、同じトランザクション内では何度データを読み取っても同じ値を読み取れることを保証しています。
しかし、ファントムリードは起こってしまいます。例えば、他のトランザクションが追加したり削除したデータを参照すること自体は出来てしまうので(UPDATEはされない)、集合を求めるときには整合性が一致しない場合があります
Serializable (直列化可能)
これはトランザクションの隔離としては最も厳密なもので、トランザクションが同時にではなく、次から次へと、あたかも順に実行されているように逐次的なトランザクションの実行を模倣します。(詳しくは後の章で)
トランザクションがこのレベルにあるときはトランザクション自体が開始されたタイミングのデータを取得することを保証します。つまり、Read commitedレベルのように、SELECT文が実行されたタイミングではなく、SELECT文を含むトランザクションが始まった時点のテーブル(正確に言うと、テーブル全体ではなく、そのトランザクションの条件が参照する行の変更を防ぐように、その部分のみ)をロックして、それ以降の変更がないデータを保証します。
MVCCと隔離レベルの関係
色々書いてみましたが、話が行ったり来たりしてしまい大枠を忘れかけていると思うので、再度これらの関係性についてまとめてみます。
まず、MVCCとは??
データのスナップショットを利用することで、ロックなしで並行処理を実現する仕組み
この際にトランザクションごとに適切なバージョンのデータを参照することで、他のトランザクションの影響を受けずに処理を行うことができる、というものです
MVCCはデータの一貫性と並行処理の両立を目的とした仕組みですが、トランザクションの隔離レベルによってどのスナップショットを参照するかが変わります。
ポイント
- スナップショットを利用するので、他のトランザクションの影響を受けない
- 書き込みの処理(更新、削除など)は新しいバージョン(行)を作成し、古いものには削除予定フラグをつけるだけにする。こうすることでロックせずに読み取りが可能になる。(ここのauto vacuumなどについてもいつか記事を書きたいです)
- 競合が少なくなるので、高い並行性が実現可能になり、マルチユーザーのアクセスでも高パフォーマンスが維持できる
ここで先ほどの隔離レベルの話が出てきます。
隔離レベル | 影響 |
---|---|
Read commited | デフォルトで設定されている。 クエリの実行時点での最新コミット済みデータを読み込む(スナップショットがクエリごとに更新される) |
Repeatble read | トランザクション開始時点のスナップショットを利用し続けるため、その間に他のトランザクションが変更を加えても影響を受けず、不整合が防げる |
Serializable | 他のトランザクションの影響を完全に排除し、直列実行を保証。 内部的には Repatable read + 検出された競合をロールバック している |
これらは先ほども述べたとおり、隔離レベルごとによって防げる問題と処理速度の一長一短なものなので、作るものの要件に従って適切に設定するべきものです。
MVCC=ロックを使わないの??
上記を見てわかる通り、完全にロックを使用しないわけではないですが、MVCCの仕組み(スナップショットの利用)によって読み取り時(SELECT)の際にロックが不要になります。そのため、複数ユーザーのアクセスなどでも高いパフォーマンスを出せます。
また、書き込みの際にも、元あったデータを更新するのではなく、新しく行を追加(既存のものには削除フラグのようなものを立てて、後に自動削除(auto vacuum))しているので基本的にはロックが不要になり、高速な書き込みなどを補助しています。
書き込みに関しては隔離レベルや、同じデータを複数のトランザクションが更新しようとした場合に、ロックが必要になることがあります
Serializableについて少し踏み込んで
先ほどの説明でSerializableでは
他のトランザクションの影響を完全に排除し、直列実行を保証。
内部的にはRepatable read + 検出された競合をロールバックしている
と書いたのですが、PostgreSQLのSerializableにおいてはちょっと異なっています(他のDBMSは知りません、、、)
PostgreSQLのSerializableではロックを使わずに直列化を保証しているという点で他のDBMSとは異なります。
PostgreSQLではSerializavle Snapshot Isolation(SSI)
という仕組みを使用しています。
これはスナップショットに基づいて直列実行可能性をチェックし、競合が発生したらロールバックを行うというものです。
簡単にいうと、二つのトランザクションにおける依存関係を裏で確認し、もし問題がなければ今まで通り並列に処理しています(新しいバージョンの行が作成されるので基本的にはロックなしで処理が進む)。もし依存関係がある書き込み、つまり競合があると判断された場合は、直列化出来ないと判断され、一方のトランザクションをロールバックします。これにより不整合は起らずに済みます。
失敗した方のトランザクションの処理については、再試行するかどうかをアプリケーション側で決めることができ、PostgreSQL側では自動で再試行などを行わないため、リトライ処理などを実装することが推奨されています。
とにかく、他のDBMSのようなロックベースでの直列化とは違い、Serializableのレベルでもロックを基本的には使用していない(依存関係の確認のみを裏で行なっている)ので、マルチユーザーでの処理が高速に行えます
まとめ
MVCCとは何か、特にPostgreSQLにおけるMVCCについて焦点を当てて書いていました。少し話が行ったり来たりしているので全体像がわかりづらいかもしれませんが、とにかく、スナップショットを利用しているので、ロックが基本的には不要で、マルチユーザーのアクセスでも高パフォーマンスで処理できますよ、というものです。
ロックが基本的には不要
- 読み込みの際(SELECT)ではどの隔離レベルにおいても一切他のトランザクションのロックをしない
- 異なる行に対する変更はロックされない(UNIQUEなどの制約がある場合はロックされる場合がある)
- 同じ行に対する変更は行ロックが発生する。
Read commited
,Repeatable read
:待機(ブロック)、Serializable
:ロックはされないが、競合があれば失敗(片方のみロールバック)する
PostgreSQLでは、データの更新の際に常に新しく行を追加していることや、増えすぎた不必要なデータを自動吸引して空きメモリとして使用する実装がされているので、そこらへんのことについて調べてみたいですね。