Firebirdにおけるトランザクション
Firebirdのトランザクション:ACID、分離レベル、デッドロック、更新競合の解決
Alexey Kovyazin、Vlad KhorsunとDmitry Kuzmenkoの協力による、2019年4月8日
目次:
トランザクションの仕組みを知る必要があるのか?
おそらく、トランザクションの概念は単純であるため、多くの開発者はFirebirdでトランザクションを正しく使用することの重要性を過小評価しています。しかし、トランザクションの仕組みを徹底的に理解して初めて、パフォーマンスに関連する多くの不可解な現象、例えば突然のデータベースの遅延(不適切なトランザクション管理によって生じる過剰なレコードバージョンの掃除に関連する)を理解できるようになります。
一般的に、トランザクションの概念は、ある状態から別の状態へ移行するあらゆる動的システムに適用されます。例えば、古典的なトランザクションの例は、ある口座から別の口座への送金です。通常、それは次のようなものです:
Begin --- 口座1から口座2への送金
--口座1を減らす
--口座2を増やす
End - トランザクションのコミット
この例は、お金が口座1から消え、口座2に同時に現れなければならないという事実に帰着します。そうでなければ、システム内で一時的に余分なお金や説明のつかない不足が生じることになります。
データベースの観点から見ると、トランザクションは通常、他のトランザクションから独立していると見なされるデータベース上で実行される操作のグループとして定義されます。私の見解では、この定義は他の定義より優れているわけでも劣っているわけでもありませんが、どの定義もDBMSの実際の内部動作とロジックを知らなければほとんど意味を成しません。
データベースのトランザクションは、いわゆるACID要件を満たさなければならないと考えられています。
A - 原子性(Atomicity)
С - 一貫性(Consistency)
I - 分離性(Isolation)
D - 永続性(Durability)
データベースアプリケーションの開発者の多くはこの頭字語に大いに触発され、異なるDBMSを比較する際に「あなたにはACIDのDがない」といった議論をよく使います(その直後にはたいてい「あなたの意見はどうでもいい」と続きます)。
実際には、すべては非常に単純です - ACIDは特定のDBMSにおけるトランザクションの実装に関する要件のセットであり、その一部は非常に厳格です(例えば、D - もちろん、永続性は重要です!)が、一部はそれほど厳格ではありません - トランザクション分離レベルを調べると、分離性は変化し得ることがわかります。
そのため、この頭字語の文字通りの意味をすぐに理解しようとする価値はありません。代わりに、DBMS(特にトランザクション)がどのように機能するかのロジックを調べ、「それが何を意味するか」ではなく「それがどのように作られているか」という観点からACIDを見ていきます。
トランザクションの側面は複雑であるため、トランザクションの動作と相互作用を示すためのグラフィカルな表現 - 一種の図 - が必要です。これらの図を使用して、論理的な説明を構築し、トランザクションの仕組みの詳細を調べることができます。
まず第一に、トランザクションは時間の中で展開するため、タイムラインを導入します。タイムラインは必要な方法でマークされます - 秒や分は必要なく、トランザクション間の相互作用の主要なステップが必要です:

次に、このタイムラインにトランザクションを追加します - それを長方形の形で描き、その辺はトランザクションの開始と終了に対応します。Firebirdのすべてのトランザクションには番号が付けられているため、トランザクション番号も指定します。

したがって、この図は、時刻t3に開始し、時刻t10に終了したトランザクション番号11を示しています。トランザクションが終了する方法は2つあります - COMMIT、つまりトランザクション内で行われたすべての変更を適用すること、そしてROLLBACK、つまりトランザクション内で行われたすべての変更をキャンセルすることです。トランザクションの終了方法を次のように示します:

先に進むために、これらの図上でトランザクションのさまざまなパラメータを指定する必要があります。それらは、対応するトランザクションを表す長方形の左下隅に指定します - この例では、トランザクション#11がスナップショット分離レベルを持つことを示しています。

「トランザクションXがデータを挿入する」または「トランザクションYがこれこれのデータを読み取る」と言うとき、それは形式的には正しくありません。なぜなら、「変更はトランザクションX内で行われた」と言うべきだからです。データを読み取ったり挿入したりできるのはSQLステートメントだけなので、説明にとって重要であれば、これらのステートメントをトランザクションの長方形の内側に示します:

この例では、テーブルT1、フィールドi1、値100に対するINSERT操作があります - この操作はトランザクション#11内で実行され、コミットされています。
また、操作の結果を示す必要がある場合もあります。例えば、次の例のように:

この例は次のことを示しています:
- 分離レベルパラメータがsnapshotに設定されたトランザクション#11(分離レベルについては後で説明します。ここでは全体像を描くために示しているだけです)が時刻t3に開始されます
- 値100をテーブルt1のフィールドi1に挿入するINSERT INTO T1(i1) values (100)操作が時刻t5に開始され、時刻t7に終了します
- 値i1が100に等しいことを返すSELECT i1 from T1操作が時刻t8に開始されます
- トランザクション#11はCOMMITステートメントで終了します。つまり、トランザクション#11によって行われた変更がデータベースにコミットされます
このように、トランザクション図の助けを借りて、データベースで何が起こっているかを詳細に説明し、トランザクションがどのように機能するかを知ることができます。
トランザクション図ができたので、ACIDという頭字語が実際に何を意味するのかを見てみましょう。
原子性(Atomicity)
原子性とは、トランザクションを構成するすべての操作が実行されるか、またはそのいずれも実行されないことを意味します:「すべてか無か」。それは簡単なことのように思えますが、その後詳細が明らかになります。
まず、DBMS(FirebirdだけでなくほぼすべてのDBMS)には2種類の原子性があります:ステートメントレベルの原子性と、トランザクション内のステートメントグループレベルの原子性です。
ステートメントレベルの原子性とは、UPDATET1 SETX=1 WHEREY=2ステートメントが常に正常に実行されるか、または実行されないかのどちらかであることを意味します。
ステートメントグループレベルの原子性は異なる方法で機能します(ここではトランザクションがいつ開始され、いつコミットされるかを示すために擬似コードを使用します):
Start transaction 11
INSERT ..100
INSERT ..200
INSERT ..300
Commit 11
これは図ではおおよそ次のようになります:

これは、3つのINSERTステートメントすべてが正常に実行され、それらによって行われた変更がトランザクション#11がコミットされた時点でコミットされることを意味します。
ワークショップでトランザクションについて話すときによく尋ねる質問は、INSERT INTO..300が例外を発生させた場合、トランザクション11のCOMMITステートメントは正常に実行されるかどうかです:

聴衆のかなりの部分が常にCOMMITステートメントは正常に実行されないと答えます!(興味深いことに、他のいくつかのDBMSでは、それがトランザクションのロールバックを引き起こすでしょう!)
しかし、それは真実ではありません - isqlを実行して任意のデータベースで実験を行ってください(isqlはユーザーのために「推測」することなく、単純で率直な操作の実装を持っています)。
重要なのは、ステートメントグループレベルでの原子性がトランザクションのコミットによって保証されることは、ビジネスロジックの問題であるということです。アプリケーションの開発者は、3番目のINSERTステートメントで例外が発生した場合にトランザクションをコミットすべきかどうかを決定する必要があります。ビジネスロジックが結果のコミットを可能にするなら、COMMITステートメントは簡単に実行できます。
したがって、ACIDにおける原子性の要件とは、DBMSが1つのトランザクション内で実行された一連のステートメントの結果をコミットまたはロールバックできるという要件です。コミットするかロールバックするかの決定は、実装する必要のあるビジネスロジックに依存します。
そして、もう一度強調しておきましょう - 一連のステートメントに対するトランザクションの原子性が、結果に関係なくグループ全体をコミットまたはロールバックできる可能性を意味する一方で(その選択はビジネスロジックに依存します)、1つのステートメントの原子性はDBMSの実装によって保証されます。つまり、1つのステートメント(たとえばUPDATE)を「不完全に」(非原子的に)実行することは不可能です。
整合性(Consistency)
整合性とは、データベース内のデータに矛盾が存在しないことを意味します。もちろん、ここには推測の余地が大いにあるため、「『矛盾が存在しない』とはそもそも何を意味するのか」という疑問が生じます。
通常、2つの整合性レベルが区別されます:
- データベースレベル - 整合性は、データが主キー、一意キー、外部キー、チェックなどのデータベース制約に対応していることを意味します。この整合性レベルは、データベース制約が制約に対応しないデータの挿入を不可能にすることで保証されます。例:CHECK(x>0)は、対応するフィールドに負の数が挿入されることを許可しません。
- ビジネスロジックレベル - 整合性は、トランザクションなどのDBMSが提供するツールを使用して、アプリケーションの開発者によって保証されます。
トランザクションは、ビジネスロジックレベルでの整合性の確保にどのように役立つのでしょうか?非常に簡単です - 送金の例を考えれば、開発者は例外が発生した場合にすべての変更がロールバックされることを確認する必要があり、トランザクションを使用することでそれが可能になります。
トランザクション開始
口座1の金額を減らす…. 成功
口座2の金額を増やす… 失敗
ロールバック ---- 例外が発生した場合!
言い換えれば、開発者は例外が発生した場合にデータがロールバックされるようにコードを記述し、それによってビジネスロジックの観点からデータの整合性が保たれるようにする必要があります。
このように、ACIDの頭字語における整合性の要件は、DBMSがトランザクションメカニズムを通じてデータの整合性を維持する可能性を持つことが必要であることを意味します。
分離性(Isolation)
トランザクション分離の要件は、操作が実行される順序に関係なく、一連の操作の結果を保証する必要性から生じます。
簡単に言えば、各トランザクションは、同時にアクティブなトランザクションが存在するかどうかに関係なく、同じ結果で実行される必要があります。
トランザクションメカニズムは、ビジネスロジックレベルでの整合性を保証することになっていますが、同時実行トランザクションの実行プロセス中に現れる可能性のある一時的な未確定データからトランザクションを保護することも想定されています。
実際には次のようになります:

時刻t2にトランザクション#11が開始され、その中で時刻t3-t5にテーブルへの挿入が行われます。トランザクション#11は挿入直後にコミットされず、時刻t8までアクティブなままです。
同時に、トランザクション#12が開始され、トランザクション#11が挿入を行ったテーブルのレコードに対してSELECTステートメントを実行します。最初のSELECTステートメントは時刻t6に実行されます。この時点で挿入操作はすでに完了していますが、このステートメントは空の結果を返します。なぜなら、トランザクション#12は他のトランザクションからの未コミットデータを参照できないためです。
トランザクション#11は時刻t8にコミットされ、トランザクション#12内のSELECTステートメントは時刻t9に実行されます。これは100に等しい結果を返します。なぜなら、トランザクション#11内で作成されたデータが現在コミットされているためです(また、トランザクション#12の分離レベルがRead Committedであるためですが、これについては後で説明します)。
この例は、分離性の要件を説明するのに十分です - 原子性や整合性とは異なり、分離性は分離レベルと呼ばれる厳格なルールとして実装され、各トランザクションには、それが動作する分離レベルを設定するパラメータが必要です。
永続性(Durability)
永続性の概念により、開発者は、コミットされたトランザクション内で作成されたデータが即座にデータベースに現れ、(明示的な削除や変更ステートメントがない限り)その後何が起こってもデータベースから消えないという事実に完全に依存できます。
ご覧のとおり、永続性の要件は単なる常識です - データが突然消える可能性のあるシステムを誰も使用したいとは思わないでしょう。
ACID:まとめ
ACIDは、トランザクションがどのように動作する必要があるかという要件を意味します:
- 原子性
- ステートメントは常に原子的です
- ステートメントのグループは、トランザクションを使用して原子的にできます
- 整合性
- 2つの整合性レベル:データベース制約とビジネスロジック
- 分離性
- トランザクションに設定された分離レベルを使用して、トランザクションメカニズムによって保証されます
- 永続性
- コミットされたすべてのデータは永続的になります
ご覧のとおり、すべてが非常に論理的です。実際には、主な難しさは分離レベルによってもたらされるため、それらがどのように機能するかを詳しく見てみましょう。
トランザクションの分離レベルは、このトランザクションが参照できるコミット済みデータを定義します。
標準と呼ばれる分離レベルがあります。これらはANSI SQL標準(さまざまな改訂版)で説明されています。私の知る限り、標準で説明されているとおりに正確に実装しているDBMSは1つもありませんが、特定のDBMSにおけるトランザクションの実際のメカニズムにはビジネスロジックを実装するための必要なオプションがすべてあるため、誰もそれを気にしていません。
分離レベルの古典的な定義は「A Critique of ANSI SQL Isolation Levels」にあります。
この記事を読んだ方のために、古典的な分離レベルとFirebirdの類似の分離レベルを比較した表を示します。もちろん、対応関係は単純ではありません。Firebirdの分離レベルは、他のDBMSと同様に、ANSI SQLの定義に100%準拠しているわけではありませんが、それらに非常に似ています。
| ANSI分離レベル | Firebirdの分離レベル |
| Read Uncommitted | 該当なし |
| Read Committed | Read Committed |
| Repeatable Read | Snapshot |
| Serializable | Snapshot table stability |
他のDBMSと同様に、Firebirdには分離の実装に独自の特徴があります。ここでは、標準への準拠度ではなく、Firebirdで分離レベルがどのように機能するかに焦点を当てます。
Snapshot分離レベル
Snapshot分離レベルは、InterBaseの元のコードで最初に実装され、FirebirdコアAPIおよびユーティリティ(たとえばisql.exe)のデフォルトのままです。これが、最も理解しやすい理由かもしれません。
Snapshotは、トランザクションをその開始時点から行われたすべての変更から分離します。
以下のトランザクション図を見てみましょう:Snapshot分離レベルで開始されたトランザクション#10が示されています。このトランザクション内で、この例ではレコードがないテーブルT1に対して複数のSELECTステートメントが実行されます。

トランザクション#10の開始後に開始された同時トランザクション#15は、テーブルT1にデータを挿入し、このトランザクションは時刻t9にCOMMITステートメントで終了します。つまり、データはこの時点でデータベースにコミットされ、他のトランザクションのステートメントから利用可能になります。
ただし、時刻t10(つまりトランザクション#15がコミットされた後)にトランザクション#10内で実行されたステートメントは、挿入されたデータを参照しません。なぜなら、Snapshot分離レベルでは、トランザクション#10の開始前に挿入または変更されたコミット済みデータのみを参照できるためです。
このように、Snapshot分離レベルを使用すると、トランザクション開始時点でデータベースが凍結されているかのようにデータベースを操作できます。これは、急速に変化するデータに基づいて複雑なレポートを作成するために通常必要です:レポートの最初の部分があるデータに基づき、最後の部分が別のデータに基づくという状況を避けるためにSnapshotが使用されます。
しかし、この優れた機能には代償があります。後でFirebirdにおける分離の実装方法を調べるときに、スナップショット分離レベルで非常に長いトランザクションを開始すると、過剰なレコードバージョンが発生し、パフォーマンスが低下することがわかるでしょう。
Read Committed分離レベル
read committed分離レベルのトランザクションは、アクティブな間にコミットされた他のトランザクションのコミット済みデータを参照できます(スナップショットレベルの場合とは異なり、トランザクション開始時点より前にコミットされたデータのみを参照できます)。
次の図を使用して、read committed分離レベルがどのように機能するかを示しましょう:

これは前の例とほぼ同じ例を示しています:2つの同時トランザクションがあり、1つは定期的にテーブルT1からデータを読み取り、もう1つはデータを挿入してコミットします。
スナップショット分離レベルの場合とは異なり、この例のトランザクション#10は、トランザクション#15によって挿入およびコミットされたデータを参照します。
この例は、read committed分離レベルの影響についてのアイデアを与えてくれます:この分離レベルを持つトランザクション内のステートメントは、対応するステートメントが実行される時点より前にコミットされたデータを参照できます。
次の図は、2つの同時トランザクション#11と#18がデータを変更する例を示しています。
トランザクション#11は、データを読み取るトランザクション#14の開始前に開始され、トランザクション#18はその後に開始されますが、結果には影響しません:データがコミットされている場合、read committed分離レベルの同時トランザクションから参照できます。

この可能性により、read committed分離レベルは、最新のデータベース状態を表示するために定期的に実行されるSQLステートメント(たとえば、最新の注文を表示するため)にとって自然な選択肢となります。
ガベージコレクションに費やされる部分では、Firebirdバージョン4までのread-only修飾子を持つread committedトランザクションが、「無限」読み取りトランザクションに最適な選択肢であることが示されます。なぜなら、それらは事前コミット済みとして開始されるからです。
Snapshot table stability分離レベル
スナップショットテーブル安定性分離モード(標準のSerializable分離モードの対応物)についての話は、非常に短くすることも、かなり長く詳細にすることも可能です。
話の短いバージョンは次のとおりです:このレベルはスナップショットレベルと完全に類似していますが、さらにテーブル(トランザクションパラメータで明示的に指定する必要があります)を書き込みと読み取り用にロックします。つまり、指定されたテーブルを完全に占有するトランザクションを開始でき、他のトランザクションはアクセスエラーを受け取ることになります。
言い換えると、スナップショットテーブル安定性分離レベルのトランザクションは、指定されたテーブルへのすべてのクエリを実質的にキューに入れます。実際には、通常のトランザクションでの読み取りのみが(通常どおり)順番を飛ばして行われ、他のすべてのモードはキューを形成します(もちろん、相互作用に依存します)。
注意せずに実装すると、ロックが発生し、データベースを操作できなくなる可能性があるため、Firebirdデータベースアプリケーション開発者はこの分離レベルを使用することを恐れるかもしれません。
ただし、正しく実装された場合、Serializable分離レベルは、キューを簡単に形成し、データベースレコードの順次変更を行うことを可能にし、カウンター、連続文書番号、およびそのようなオブジェクトの実装に非常に役立ちます。
スナップショットテーブル安定性分離レベルのトランザクションを使用してキューを形成する方法を正しく説明するには、もう1つのトランザクションパラメータであるwait/nowaitを調べる必要があり、その後キューの例に戻ります。
以前、トランザクション間の相互作用の方法として、1つのトランザクション内でデータが変更され、別のトランザクション内で読み取られる方法を調べました。
しかし、実際には、異なるトランザクションが同じデータを変更しようとする場合がよくあります。データベースには1つの結果のみが保存されるため、同時トランザクションは競合メッセージを受け取ります。実際には、すでに変更されたデータを変更しようとする特定のステートメントの実行を中断(およびキャンセル)する例外です。
waitオプションは、トランザクションが更新競合にどのように反応するかを定義します。このオプションを構成する方法は3つあります:
- Wait(パラメータなし)= 同時トランザクションが終了するまで待機
- Wait Timeout N sec = 同時トランザクションが終了するまで待機しますが、N秒を超えない
- Nowait - 同時トランザクションが終了するまで待機しない
waitオプションはここでは擬似コードで指定されており、APIや特定のコンポーネントでは名前が異なる場合がありますが、意味は同じままです。
waitオプションのさまざまなバリアントで更新競合が発生した場合に何が起こるかを詳しく見てみましょう。
Wait
では、2つの同時にアクティブなトランザクション(#11と#14)があり、その中で同じテーブルT1の同じレコードを変更する必要があるUPDATEステートメントが実行されると想像してみましょう。
トランザクション#14はwaitオプションで実行されます(isqlを使用して例を再現する場合、waitがデフォルトで設定されています)。
トランザクション#11のUPDATEステートメントは時刻t3で開始され、時刻t5で終了しますが、トランザクションはまだコミットされていません。つまり、COMMITステートメントは時刻t6まで存在しません。
次の図はこの状況を示しています:

UPDATEステートメントはトランザクション#14でも実行され、同じテーブルの同じレコードを更新しようとしますが、開始は遅くなります - およそ時刻t4です。
トランザクション#11の更新との更新競合があり、トランザクション#14でwaitが指定されているため、UPDATEステートメントは競合するトランザクション#11が終了するまで待機します。
トランザクション#11が十分に長く続く場合、トランザクション#14のUPDATEステートメントは、このステートメントの実行を監視しているユーザーの観点からはフリーズしているように見えます。
2つのisql.exeを使用してこの状況を再現する場合、次の図は、2番目のトランザクション(正確には、同時UPDATEステートメントが後で開始されるトランザクション - この例ではトランザクション#14)が最初のトランザクション(この例ではトランザクション#11)が終了するまで待機している瞬間を示しています。

トランザクション#11でCOMMITステートメントが実行された後、待機していたトランザクション#14はすぐに通知され、競合する更新は例外で終了します。
以下に、そのようなエラーメッセージの例を示します(同時トランザクションの番号は、トランザクション番号が各データベースで最初から始まり、バックアップ/リストア後にのみリセットされるため、この例とは一致しません):
SQL> update T1 set i1 = 2 where i1=1;
Statement failed, SQLSTATE = 40001
deadlock
-update conflicts with concurrent update
-concurrent transaction number is 19
SQL>
エラーメッセージの「deadlock」という単語に注意してください - 現在、実際には古典的な定義によるデッドロックはありません。代わりに更新競合がありますが、Firebird開発者は35年以上使用されているため、エラーメッセージを変更しません。真の「古典的な」デッドロックについては後で扱います。
これで、同時UPDATEステートメントを持つトランザクションがCOMMITステートメントで終了する状況を調べました。次に、同様の状況ですが、ロールバックされる場合を見てみましょう - 次の図で確認できます:

状況は前のものと完全に似ています - 2つのUPDATEステートメントが同じレコードを更新しようとしますが、今回は同時トランザクション#20がロールバックされ、結果としてトランザクション#15内の変更はエラーなしでデータベースに保存されます。
したがって、waitオプションを使用すると、競合する更新がキューで無限に待機し、競合するトランザクションがROLLBACKステートメントで終了することを最後の瞬間まで期待するように、更新のビジネスロジックを編成できます。
この戦略は常に意味をなすのでしょうか?もちろん、ビジネスロジックの実装によりますが、Firebird は更新競合を解決するための他のオプションも提供しています。その一つが wait オプションです。
Wait with timeout
まず第一に、待機時間を制限するのは良い考えかもしれません。競合が発生した場合に無限に待つ代わりに、wait オプションにタイムアウトを指定して待機時間を制限できます。
isql.exe では、このようなパラメータは次のステートメントを使用して指定します:
SET TRANSACTION WAIT LOCK TIMEOUT N;
ここで、N は並行トランザクションが競合の解決を待つ時間(秒)です。
トランザクション制御ステートメントの詳細については、Firebird Language Reference を参照してください。特定のドライバーやアクセスコンポーネントでは、タイムアウトの指定方法が異なる場合があることに注意してください(通常は API パラメータを使用します)。
isql での例は、下の図で確認できます:

トランザクションチャートを使用して、wait オプションにタイムアウトを指定した場合にトランザクションがどのように相互作用するかを調べてみましょう。
状況は同じです。2つの並行トランザクション #11 と #14 があり、その中でテーブル T1 の同じレコードを更新しようとする UPDATE ステートメントが実行されます。

ただし、この場合、トランザクション #14 内のステートメントは、トランザクション #11 が終了するか、指定されたタイムアウト(3秒)が経過するまで待機します。どちらか早い方が優先されます。
この例ではタイムアウトが先に経過し、ステートメントは例外で終了します:
SQL> update T1 set i1=6 where i1=1;
Statement failed, SQLSTATE = 40001
lock time-out on wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 40
エラーメッセージに再び「deadlock」が含まれていますが、これは依然として「真の」デッドロックではありません。
対応して、状況は wait オプションの場合と似ていますが、タイムアウトによって制限されています。指定されたタイムアウトが並行トランザクションの終了より先に経過した場合です。
wait オプションにタイムアウトを指定することは、すべての書き込みトランザクションが非常に短い(たとえば、1〜2秒以内)と確信している場合、ビジネスロジックを実装するための良い解決策になる可能性があります。
Nowait
形式的な観点から Nowait が何であるかを説明するのは非常に簡単です。それはタイムアウトがゼロの wait です。トランザクションで nowait を指定すると、競合する更新は即座に例外を発生させます。

この場合も、並行トランザクション #11 と #14(nowait)があり、並行する UPDATE ステートメントが実行されます。nowait オプションを持つトランザクション内のステートメントは、並行更新を検出したときに待機せず、更新の瞬間に次の例外を即座に発生させます(トランザクション番号だけが異なります):
SQL> update T1 set i1=5 where i1=1;
Statement failed, SQLSTATE = 40001
lock conflict on no wait transaction
-deadlock
-update conflicts with concurrent update
-concurrent transaction number is 50
SQL>
2つの isql ツールを使用した例では、次のようになります:

nowait トランザクションは、並行する UPDATE ステートメントを持つトランザクションがいつ、どのように終了するか(COMMIT か ROLLBACK か)を気にしないことに注意してください。例外は常に発生します。
ビジネスロジックの観点から、nowait トランザクションは、並行更新が現在のステートメントのアクションを確実にキャンセルする必要があると確信している場合に便利です。
多くの Firebird ドライバーは nowait オプションをデフォルト値として使用しており、多くの開発者が更新競合を解決するためのより緩いレベルを設定できることを知らない限り(たとえば、wait lock timeout 1)、そのアプリケーション(場合によってはユーザーも)は競合による不要なエラーに悩まされます。
「deadlock」というキーワードが更新競合に関連する各例外に存在するため、多くのアプリケーション開発者はこれが真のデッドロックであると確信しています(中には、このエラーに非常に Dead が関与していると考える人もいます)。
同時に、設定ファイル firebird.conf を見ると、DeadlockTimeout パラメータ(デフォルトでは10秒)が表示され、fb_lock_print ユーティリティの出力ヘッダーを見ると、「Deadlock scans」パラメータも表示されます。
問題は、「真のデッドロック」が Firebird で発生する可能性があり、更新競合に関連するすべての例外に現れる「deadlock」というキーワードは、それと直接的な関係がないということです。幸いなことに、真のデッドロックはかなりまれにしか発生しません。
この「真のデッドロック」が何であるかを見てみましょう。これを行うには、次のトランザクション相互作用のチャートを見てください:

wait オプションを持つ2つの並行トランザクションがあり、その中で UPDATE ステートメントが実行されます。単純な更新競合の場合とは異なり、ここでは相互依存する更新競合が見られます:
- トランザクション #11 はキー = 20 のレコードを更新し、トランザクション #12 はキー = 10 のレコードを更新します;
- その後、トランザクション #11 はキー = 10 のレコードを更新し、トランザクション #12 はキー = 20 のレコードを更新します;
結果として、各トランザクションが他方の終了を待たなければならず、両方とも wait オプションが指定されているため、両方が無限に待つ可能性がある状況が発生します。もちろん、サーバーはそれを許可することはできないため、デフォルトで10秒に設定されている DeadlockTimeout パラメータで指定されたタイムアウトの後、一方のトランザクションは強制的にロールバックされます。
この状況は、2つの isql を使用して再現できます:

2番目のトランザクションが開始された後、真のデッドロックの状況が発生します。それを確実に検出するために、サーバーは Deadlock scan と呼ばれるプロシージャを開始します。これは、デフォルトで10秒に等しい DeadlockTimeout に等しい間隔で開始されます。
クライアント(この場合は isql)は通常の更新競合メッセージを受け取りますが、トランザクションが wait オプションで開始された場合でも、10秒後に開始されることに注意してください。
サーバーが2つのトランザクションの相互依存ロックを検出すると、内部デッドロックカウンタもインクリメントします(fb_lock_print 出力で確認できます)。
Snapshot Table Stability の実用的な使用法
競合する UPDATE ステートメントでトランザクションがどのように機能するかがわかったので、Snapshot Table Stability 分離レベルに戻って、その実用的な使用法を見つけることができます。
この分離レベルが指定されると、テーブルは書き込みおよび読み取りのためにロックされます。
トランザクションパラメータでテーブルが明示的に指定されていない場合、このトランザクション内でアクセスされるすべてのテーブルがロックされ、それはテーブルへの最初のアクセス時に発生することに注意してください。明らかに、この分離レベルを注意せずに使用すると、多数の更新競合が簡単に発生します。
Reserving TableNN 句を使用すると、トランザクションの開始時にロックする特定のテーブル(または複数のテーブル)を指定できます(予約モードを指定することも可能です)。
この優れた機能は wait オプションと組み合わせることで、特定のテーブルを変更するための非常に効果的なシーケンシャルキューを実装できます。
実際には、次のようになります。特定のテーブルへのキューを作成する必要があるクライアントは、このテーブルを指定して SNAPSHOT TABLE STABILITY トランザクションを開始し、このトランザクション内で操作を実行してすぐに終了します。
たとえば、CREATE TABLE Table1(i1 integer not null) 型の唯一のレコードを持つテーブルに順次インクリメントするカウンタを作成したいが、何らかの理由でジェネレータを使用できないとします。
擬似コードはおおよそ次のようになります:
set transaction snapshot table stability reserving TABLE1 for protected write
UPDATE Table1 Set i1 = i1+1;
SELECT i1 from Table1;
COMMIT;
如果我們不以快照表穩定性(Table1)隔離等級執行此程式碼,而是使用較低的隔離等級,則並行的 UPDATE 語句有可能在交易開始之後、其 UPDATE 語句之前介入。結果,我們要麼立即收到更新異常(nowait),要麼語句會凍結直到並行交易結束(wait),要麼發生逾時(wait interval)–換句話說,衝突會在語句層級以某種方式解決。
使用快照表穩定性隔離等級時,我們可以免受此影響,因為表在交易開始時就被保留–它要麼完全屬於我們,要麼完全不屬於我們。如果我們指定 wait 選項來解決衝突,並行連線將自動形成佇列,而無需處理任何錯誤。

當然,這種方法只能應用於短交易(就像我們範例中的情況)。
在實務上,快照表穩定性隔離等級用於在獨佔模式下形成佇列和重新計算複雜邏輯(在相對較小的表中,或當沒有其他使用者時)。
在引擎內部,Firebird 使用快照表穩定性隔離等級來建立索引–也就是說,當您執行 ALTER INDEX indexname ACTIVE; 語句時,Firebird 將完全佔用正在建立索引的表。
接下來是什麼?
本文僅介紹 Firebird 交易概念的入門知識。要完全理解 Firebird 中交易的工作方式,有必要考慮多世代架構(記錄版本和垃圾回收概念)、交易標記(Oldest Interesting、Oldest Active、Oldest Snapshot 等)以及其他內容。
本文基於「All About Transactions」研討會/工作坊的材料,該研討會於 2013 年在 Firebird Tour 研討會期間首次推出,並基於 IBSurgeon 的培訓「Firebird Transaction in details」。
聯絡方式
[email protected] 如有任何問題或建議,請隨時與我們聯絡:[email protected]