表1●トランザクション処理の分離レベルと,それぞれで発生する可能性がある現象。分離レベルは下に行くほど高くなります
表1●トランザクション処理の分離レベルと,それぞれで発生する可能性がある現象。分離レベルは下に行くほど高くなります
[画像のクリックで拡大表示]
図2●Oracleでシリアライザブル分離レベルのトランザクションを実現するための仕組み
図2●Oracleでシリアライザブル分離レベルのトランザクションを実現するための仕組み
[画像のクリックで拡大表示]

マルチユーザー環境では新たな問題が発生

 ここまでの説明は基本的に,一人のユーザーがデータベースにアクセスしていることを前提にしていました。しかし実際には,複数のユーザーが同時にデータベースにアクセスすることはよくあります。むしろ,受発注システムや座席予約システムなど,実用データベース・アプリケーションのほとんどは,複数のユーザーが同時に使うことを前提にしていると言ってよいでしょう。こうしたマルチユーザー環境では,ユーザーが一人のときには無かったさまざまな問題が起こります。

 例えば,ユーザー1がトランザクションの途中で特定のレコードの内容を変更したあとで,別のユーザー2がそのレコードを読み込んだとしましょう。そのあとでユーザー1がトランザクションをロールバックしたら,ユーザー2は,誤ったデータを読み込んだことになります。

 あるトランザクションをしているのと別のユーザーが,そのトランザクションでまだコミットしていないデータを読み込んでしまうことを「ダーティ・リード」と呼びます。ダーティ・リードを回避するためには,コミットしていないデータを別のユーザーが読め込めないようにしなければなりません。

 コミットしていないデータを外部から読めないようにしても,まだ問題は起こり得ます。先の例でユーザー2が同じレコードを2度読み込むような場合,1度目と2度目の読み込みの間にユーザー1がトランザクションをコミットすると,1度目に読み込んだ内容と2度目に読み込んだ内容が異なる可能性があります。このように,複数回の読み込みの結果が,ほかのトランザクションのコミットのタイミングによって変わってくることを「反復不可能読み込み(non-repeatable read)」と呼びます。

 加えて,2回の読み込みの間にユーザー1のトランザクションがレコードを追加したり削除したら,2回目の読み込みでは1回目には無かったレコードが現れたり,それまであったレコードが無くなったりすることになります。この現象を「ファントム」と呼びます

分離性と同時実行性はトレードオフ

 こうした問題を解決するためにもっとも簡単な方法は,それぞれのユーザーが一つひとつ順番に(シリアルに)実行されるようにすることです。そうすれば,トランザクションが互いに影響を及ぼし合うことはなくなります。しかし,それでは単位時間内に実行できるトランザクションの数が少なくなり,パフォーマンスが低下してしまいます。

 このように,トランザクション間の分離性と同時実行性には,トレードオフの関係があります*2。そのため現実には,アプリケーションの性格に合わせてトランザクションの分離性と同時実行性のバランスをとらなくてはいけません。

 ANSI/ISO*3は,トランザクションの分離の度合い(各トランザクションがそれぞれどの程度互いに影響を及ぼし合うか)を表す指標として,(表1[拡大表示])の四つの「分離レベル」を定義しています。これらは,それぞれ先にあげた「ダーティ・リード」「反復不可能読み込み」「ファントム」の三つの現象が発生するかどうかで区別されます。下にいくほど分離の度合いが高くなり,表の一番上の「未コミット読み込み」ではすべての現象が起こり得ますが,一番下の「シリアライザブル」ではいずれも発生しません。

 多くのRDBMSは複数の分離レベルをサポートしており,トランザクションの開始時などに設定が可能です。例えばSQL Serverは表1の四つの分離レベルをすべてサポートしています。一方,Oracleは,コミット済み読み込みとシリアライザブル以外に,更新を行わないトランザクションに使用できる「読取専用」分離レベルを用意しています。いずれのRDBMSも,デフォルトのレベルはコミット済み読み込みです。

ロックがトランザクション分離の基本

 トランザクション分離するための基本的な方法は,トランザクションが終了(コミットもしくはロールバック)するまでロックをかけることです。ロックは,あるトランザクションがレコードにアクセスしているときに,別のトランザクションからそのレコードにアクセスできないようにする仕組みです(囲み記事「ロックには2種類ある」を参照)。

 SQL Serverの場合で説明しましょう。「コミット済み読み込み」分離レベルでは,トランザクションの途中でデータの更新処理を行うと,その時点で取得したロック(排他ロック)をトランザクションの終わりまで保持します。排他ロックでは,そのレコードに対するほかのトランザクションからの読み書きを,ロックがかかっている間は一切禁止します。これによって,ダーティ・リードを防げます。

 「反復可能読み込み(repeatable read)」分離レベルでは,あるトランザクションがデータを読み込むと,その時点でロック(共有ロック)を取得し,トランザクションが終わるまで保持します。共有ロックでは,ほかのトランザクションはデータを読み込むことはできますが,データを変更することはできません。したがって,最初のトランザクションが終わるまで,データがほかから書き換えられないことが保証され,反復不可能読み込みは発生しません。

 「シリアライザブル」分離レベルを実現するのは少し面倒です。例えば

SELECT * FROM emp
WHERE empno BETWEEN 10 AND 100
といったSQL文に対してファントムが起こらないようにするためには,10から100の間のempnoフィールドを持つレコードを追加したり,削除させないようにしなければなりません。SQL Serverでは,empnoが10~100に対してキー範囲ロック*4をかけて,ほかのトランザクションがこの間のempnoを持つレコードを追加したり削除できないようにします。

 ここまでの説明でおわかりのように,分離レベルが高くなるほど,保持されるロックが多くなり,同時実行性は低くなります。アプリケーションの分野によりますが,反復不可能読み込みやファントムを禁止する必要があることはそれほどないので,一般にはデフォルトの「コミット済み読み込み」分離レベルをそのまま使うのがお勧めです。反復不可能読み込みを禁止する必要があるなら,該当する部分だけ明示的にロックかけるようにするのが良いでしょう*5

 Oracleではどうなっているでしょうか。「コミット済み読み込み」で排他ロックをトランザクションの終わりまで保持する点はSQL Serverと同じですが*6,「シリアライザブル」を実現する方法は異なります。Oracleでシリアライザブル分離レベルのトランザクションを行う場合には,まずデータを読み込む際にそのデータのSCNを調べます。そして,データの内容がトランザクション開始以降に更新されている場合には,ロールバック・セグメントから更新前のデータを取り出して読み込みます(図2[拡大表示])。こうすることで,反復不可能読み込みやファントムの発生を回避しているわけです。

 Oracleは,シリアライザブル分離レベルに限らず,常にこのような仕組みで読み込みを行います*7。そのため,読み込みではロックの必要がありません。つまり,読み込みによってほかのトランザクションの書き込み処理が待機することもなければ,読み込みがほかのトランザクションの書き込みを待つこともないのです。これは同時実行性の点では非常に有利です。半面,ロールバック・セグメントがいっぱいになると,古いデータから順に捨てられてしまうので,短期間に更新を繰り返すような場合に,更新前のデータを読み込めなくなることがあります。そうした場合にOracleは「ORA-1555: スナップショットが古すぎます」というエラーを発生します。このエラーが頻繁に出る場合は,ロールバック・セグメントのサイズを大きくするなどして対処する必要があります。


ロックには2種類ある

 ロックは,複数のプロセスがテーブルやレコードなどの各種リソースを利用しようとした際の同時アクセスを制限することで,データの不整合性などが生じないようにするための仕組みです。例えば,あるプロセスがデータ書き出しのためレコードに対してロックを取得すると,ほかのプロセスがそのレコードにデータを書き出そうとしても,ロックが解除されるまで待機することになります。テーブルにアクセスする場合,Oracle,SQL Serverのいずれも,デフォルトでレコード単位のロックを行います。

 ロックには,大きく分けて「排他ロック」と「共有ロック」の2種類があります。排他ロックは,レコードのデータを変更するために取得するロックで,一つのレコードに対して一つのプロセスだけが取得できます。OracleやSQL Serverでは,あるプロセスがデータを更新する際には自動的に排他ロックを取得します。これによって,データを更新している途中で,ほかのプロセスがデータを読み込んだり,変更できないようになります。

 共有ロックは,リソースに対して同時に複数のプロセスが取得できるロックで,基本的に読み込みの際に利用します。あるプロセスが特定のリソースに対して共有ロックを保持していると,ほかのプロセスはそのリソースに対して共有ロックを取得できますが,排他ロックを取得することはできません。共有ロックでは,それぞれのプロセスはロックされているレコードのデータを変更できませんが,読み込むことは可能です。SQL Serverは,データを読み込む際に共有ロックを自動的に取得します。なお,あるプロセスが排他ロックを保持している間,ほかのプロセスは排他ロックも共有ロックも取得できません。

 SQL Serverは「更新ロック」と呼ばれるロックも用意しています。更新ロックは働き自体は共有ロックと同じですが,取得できるのは一つのレコードに対して一つのプロセスに限られます。あるプロセスが更新ロックを取得すると,別のプロセスは共有ロックを取得できますが,排他ロックや更新ロックを取得することはできません。主に,あるレコードのデータを読み込んだ後に,排他ロックに切り替えてデータを書き込む,といった用途で使われます。