ほとんどのデータベースは、可能な限り外部キーを使用して参照整合性(RI)を適用する必要があります。ただし、この決定には、単にFK制約を使用することを決定して作成するだけではありません。データベースが可能な限りスムーズに機能するようにするには、対処すべき考慮事項がいくつかあります。
この記事では、あまり宣伝されていないそのような考慮事項の1つを取り上げます。ブロッキングを最小限に抑えるためです。 、これらの外部キー関係の親側に一意性を適用するために使用されるインデックスについて慎重に検討する必要があります。
これは、ロックを使用しているかどうかに関係なく適用されます コミット済みまたはバージョン管理ベースを読む コミットされたスナップショットアイソレーション(RCSI)を読み取ります。 SQL Serverエンジンによって外部キーの関係がチェックされると、どちらもブロッキングが発生する可能性があります。
スナップショットアイソレーション(SI)では、追加の注意事項があります。同じ本質的な問題は、予期しない(そしておそらく非論理的な)トランザクションの失敗につながる可能性があります 明らかな更新の競合が原因です。
この記事は2部構成になっています。最初の部分では、読み取りコミットおよび読み取りコミットスナップショットアイソレーションのロックの下での外部キーブロッキングについて説明します。 2番目の部分では、スナップショットアイソレーションでの関連する更新の競合について説明します。
1。外部キーチェックのブロック
まず、外部キーチェックが原因でブロッキングが発生した場合にインデックスの設計がどのように影響するかを見てみましょう。
次のデモは、読み取りコミットの下で実行する必要があります 隔離。 SQL Serverの場合、デフォルトはロック読み取りコミットです。 Azure SQL Databaseは、デフォルトとしてRCSIを使用します。自由に選択するか、設定ごとにスクリプトを1回実行して、動作が同じであることを確認してください。
-- Use locking read committed ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT OFF; -- Or use row-versioning read committed ALTER DATABASE CURRENT SET READ_COMMITTED_SNAPSHOT ON;
外部キー関係で接続された2つのテーブルを作成します:
CREATE TABLE dbo.Parent ( ParentID integer NOT NULL, ParentNaturalKey varchar(10) NOT NULL, ParentValue integer NOT NULL, CONSTRAINT [PK dbo.Parent ParentID] PRIMARY KEY (ParentID), CONSTRAINT [AK dbo.Parent ParentNaturalKey] UNIQUE (ParentNaturalKey) ); CREATE TABLE dbo.Child ( ChildID integer NOT NULL, ChildNaturalKey varchar(10) NOT NULL, ChildValue integer NOT NULL, ParentID integer NULL, CONSTRAINT [PK dbo.Child ChildID] PRIMARY KEY (ChildID), CONSTRAINT [AK dbo.Child ChildNaturalKey] UNIQUE (ChildNaturalKey), CONSTRAINT [FK dbo.Child to dbo.Parent] FOREIGN KEY (ParentID) REFERENCES dbo.Parent (ParentID) );
親テーブルに行を追加します:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 100; INSERT dbo.Parent ( ParentID, ParentNaturalKey, ParentValue ) VALUES ( @ParentID, @ParentNaturalKey, @ParentValue );
2番目の接続 、非キーの親テーブル属性ParentValue
を更新します トランザクション内ですが、コミットしないでください まだです:
DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 200; BEGIN TRANSACTION; UPDATE dbo.Parent SET ParentValue = @ParentValue WHERE ParentID = @ParentID;
必要に応じて、自然キーを使用して更新述語を自由に記述してください。現在の目的には何の違いもありません。
最初の接続に戻ります 、子レコードの追加を試みます:
DECLARE @ChildID integer = 101, @ChildNaturalKey varchar(10) = 'CNK1', @ChildValue integer = 999, @ParentID integer = 1; INSERT dbo.Child ( ChildID, ChildNaturalKey, ChildValue, ParentID ) VALUES ( @ChildID, @ChildNaturalKey, @ChildValue, @ParentID );
この挿入ステートメントはブロックします 、ロックまたはバージョン管理を選択したかどうか コミット済みを読む このテストの分離。
子レコード挿入の実行プランは次のとおりです。
新しい行を子テーブルに挿入した後、実行プランは外部キー制約をチェックします。挿入された親IDがnullの場合、チェックはスキップされます(左側の半結合の「パススルー」述部を介して達成されます)。この場合、追加された親IDはnullではないため、外部キーチェックはです。 実行されました。
SQL Serverは、親テーブルで一致する行を探すことにより、外部キー制約を検証します。エンジンは行バージョン管理を使用できません これを行うには—チェックしているデータが最新のコミット済みデータであることを確認する必要があります 、古いバージョンではありません。エンジンは、内部のREADCOMMITTEDLOCK
を追加することにより、これを保証します 親テーブルの外部キーチェックへのテーブルヒント。
最終結果は、SQLServerが親テーブルの対応する行の共有ロックを取得しようとすることです。これはブロックです。 他のセッションは、まだコミットされていない更新のために、互換性のない排他モードロックを保持しているためです。
明確にするために、内部ロックのヒントは外部キーチェックにのみ適用されます。読み取りコミット分離レベルの実装を選択した場合、計画の残りの部分では引き続きRCSIが使用されます。
2番目のセッションで開いているトランザクションをコミットまたはロールバックしてから、テスト環境をリセットします。
DROP TABLE IF EXISTS dbo.Child, dbo.Parent;
テストテーブルを再度作成しますが、今回はデフォルトを受け入れる代わりに、主キーを非クラスター化にすることを選択します。 クラスター化された一意性制約:
CREATE TABLE dbo.Parent ( ParentID integer NOT NULL, ParentNaturalKey varchar(10) NOT NULL, ParentValue integer NOT NULL, CONSTRAINT [PK dbo.Parent ParentID] PRIMARY KEY NONCLUSTERED (ParentID), CONSTRAINT [AK dbo.Parent ParentNaturalKey] UNIQUE CLUSTERED (ParentNaturalKey) ); CREATE TABLE dbo.Child ( ChildID integer NOT NULL, ChildNaturalKey varchar(10) NOT NULL, ChildValue integer NOT NULL, ParentID integer NULL, CONSTRAINT [PK dbo.Child ChildID] PRIMARY KEY NONCLUSTERED (ChildID), CONSTRAINT [AK dbo.Child ChildNaturalKey] UNIQUE CLUSTERED (ChildNaturalKey), CONSTRAINT [FK dbo.Child to dbo.Parent] FOREIGN KEY (ParentID) REFERENCES dbo.Parent (ParentID) );
前と同じように親テーブルに行を追加します:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 100; INSERT dbo.Parent ( ParentID, ParentNaturalKey, ParentValue ) VALUES ( @ParentID, @ParentNaturalKey, @ParentValue );
2番目のセッション 、再度コミットせずに更新を実行します。今回は多様性のためだけに自然キーを使用しています—結果にとって重要ではありません。必要に応じて、代理キーをもう一度使用してください。
DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 200; BEGIN TRANSACTION UPDATE dbo.Parent SET ParentValue = @ParentValue WHERE ParentNaturalKey = @ParentNaturalKey;
次に、最初のセッションで子インサートを実行します。 :
DECLARE @ChildID integer = 101, @ChildNaturalKey varchar(10) = 'CNK1', @ChildValue integer = 999, @ParentID integer = 1; INSERT dbo.Child ( ChildID, ChildNaturalKey, ChildValue, ParentID ) VALUES ( @ChildID, @ChildNaturalKey, @ChildValue, @ParentID );
今回、子挿入はブロックしません 。これは、ロックベースまたはバージョン管理ベースの読み取りコミット分離で実行しているかどうかに関係なく当てはまります。これはタイプミスやエラーではありません。RCSIはここでは違いはありません。
今回は、子レコード挿入の実行プランが少し異なります。
すべてが以前と同じです(非表示のREADCOMMITTEDLOCK
を含む) ヒント)例外 外部キーチェックで非クラスター化が使用されるようになりました 親テーブルの主キーを適用する一意のインデックス。最初のテストでは、このインデックスはクラスター化されました。
では、なぜ今回はブロックされないのですか?
2番目のセッションでのまだコミットされていない親テーブルの更新には排他的ロックがあります クラスター化されたインデックス ベーステーブルが変更されているため、行。 ParentValue
への変更 列はありません ParentID
の非クラスター化主キーに影響を与える 、非クラスター化インデックスの行がロックされないようにする 。
したがって、外部キーチェックは、競合することなく、非クラスター化主キーインデックスで必要な共有ロックを取得でき、子テーブルの挿入はすぐに成功します 。
プライマリがクラスター化された場合、外部キーチェックには、更新ステートメントによって排他的にロックされた同じリソース(クラスター化インデックス行)に対する共有ロックが必要でした。
動作は驚くべきものかもしれませんが、バグではありません 。外部キーチェックに独自の最適化されたアクセス方法を与えることで、論理的に不要なロックの競合を回避できます。 ParentID
であるため、外部キールックアップをブロックする必要はありません。 属性は同時更新の影響を受けません。
2。回避可能な更新の競合
以前のテストをスナップショットアイソレーション(SI)レベルで実行した場合、結果は同じになります。子行はブロックを挿入します 参照されたキーがクラスター化されたインデックスによって強制された場合 、ブロックしない キーエンフォースメントが非クラスター化を使用する場合 一意のインデックス。
ただし、SIを使用する場合の重要な潜在的な違いが1つあります。読み取りコミット(ロックまたはRCSI)分離では、子行の挿入は最終的に成功します 2番目のセッションでの更新がコミットまたはロールバックした後。 SIを使用すると、トランザクションが中止されるリスクがあります。 明らかな更新の競合が原因です。
スナップショットトランザクションはBEGIN TRANSACTION
で始まらないため、これを示すのは少し難しいです。 ステートメント—その時点以降の最初のユーザーデータアクセスから始まります。
次のスクリプトは、スナップショットトランザクションが実際に開始されたことを確認するためにのみ使用される追加のダミーテーブルを使用して、SIデモンストレーションを設定します。参照される主キーが一意のクラスター化を使用して適用されるテストバリエーションを使用します インデックス(デフォルト):
ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON; GO DROP TABLE IF EXISTS dbo.Dummy, dbo.Child, dbo.Parent; GO CREATE TABLE dbo.Dummy ( x integer NULL ); CREATE TABLE dbo.Parent ( ParentID integer NOT NULL, ParentNaturalKey varchar(10) NOT NULL, ParentValue integer NOT NULL, CONSTRAINT [PK dbo.Parent ParentID] PRIMARY KEY (ParentID), CONSTRAINT [AK dbo.Parent ParentNaturalKey] UNIQUE (ParentNaturalKey) ); CREATE TABLE dbo.Child ( ChildID integer NOT NULL, ChildNaturalKey varchar(10) NOT NULL, ChildValue integer NOT NULL, ParentID integer NULL, CONSTRAINT [PK dbo.Child ChildID] PRIMARY KEY (ChildID), CONSTRAINT [AK dbo.Child ChildNaturalKey] UNIQUE (ChildNaturalKey), CONSTRAINT [FK dbo.Child to dbo.Parent] FOREIGN KEY (ParentID) REFERENCES dbo.Parent (ParentID) );
親行の挿入:
DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 100; INSERT dbo.Parent ( ParentID, ParentNaturalKey, ParentValue ) VALUES ( @ParentID, @ParentNaturalKey, @ParentValue );
まだ最初のセッション 、スナップショットトランザクションを開始します:
-- Session 1 SET TRANSACTION ISOLATION LEVEL SNAPSHOT; BEGIN TRANSACTION; -- Ensure snapshot transaction is started SELECT COUNT_BIG(*) FROM dbo.Dummy AS D;
2番目のセッション (任意の分離レベルで実行):
-- Session 2 DECLARE @ParentID integer = 1, @ParentNaturalKey varchar(10) = 'PNK1', @ParentValue integer = 200; BEGIN TRANSACTION; UPDATE dbo.Parent SET ParentValue = @ParentValue WHERE ParentID = @ParentID;
最初のセッションで子行を挿入しようとしていますブロック 予想通り:
-- Session 1 DECLARE @ChildID integer = 101, @ChildNaturalKey varchar(10) = 'CNK1', @ChildValue integer = 999, @ParentID integer = 1; INSERT dbo.Child ( ChildID, ChildNaturalKey, ChildValue, ParentID ) VALUES ( @ChildID, @ChildNaturalKey, @ChildValue, @ParentID );
違いは、トランザクションを終了したときに発生します 2番目のセッションで。 ロールバックした場合 、最初のセッションの子行の挿入は正常に完了します 。
代わりにコミットする場合 オープントランザクション:
-- Session 2 COMMIT TRANSACTION;
最初のセッションで、更新の競合が報告されます そしてロールバックします:
この更新の競合は、外部キーという事実にもかかわらず発生します 検証中変更されていません 2番目のセッションの更新までに。
その理由は、基本的に最初の一連のテストと同じです。 クラスター化されたインデックスの場合 参照キーの適用に使用され、スナップショットトランザクションで行が検出されます それは開始以来変更されています。これはスナップショットアイソレーションでは許可されていません。
非クラスター化インデックスを使用してキーが適用された場合 、スナップショットトランザクションは、変更されていない非クラスター化インデックス行のみを参照するため、ブロッキングは発生せず、「更新の競合」は検出されません。
スナップショットアイソレーションが予期しない更新の競合やその他のエラーを報告する可能性がある状況は他にもたくさんあります。例については、以前の記事を参照してください。
行ストアテーブルのクラスタ化インデックスを選択する際に考慮すべき多くの考慮事項があります。ここで説明する問題は、別の要因にすぎません。 評価する。
これは、スナップショットアイソレーションを使用する場合に特に当てはまります。 中止されたトランザクションを楽しむ人は誰もいません 、特に間違いなく非論理的なもの。 RCSIを使用する場合は、読み取り時のブロック 外部キーを検証することは予期しないことであり、デッドロックにつながる可能性があります。
デフォルト PRIMARY KEY
の場合 制約は、サポートするインデックスを clusteredとして作成することです。 、テーブル定義内の別のインデックスまたは制約が代わりにクラスター化されることについて明示的でない限り。 明示的であるのは良い習慣です あなたのデザインの意図についてですので、CLUSTERED
を書くことをお勧めします またはNONCLUSTERED
毎回。
インデックスが重複していますか?
健全な理由から、同じキーを持つクラスター化されたインデックスとクラスター化されていないインデックスを真剣に検討する場合があります。 。
目的は、クラスター化を介してユーザークエリに最適な読み取りアクセスを提供することである可能性があります。 インデックス(キールックアップを回避)と同時に、コンパクトな非クラスター化を介して外部キーのブロック(および更新の競合)を最小限に抑えることができます。 ここに示すようにインデックスを作成します。
これは達成可能ですが、いくつかのスナッグがあります 注意すべき点:
-
複数の適切なターゲットインデックスが与えられた場合、SQLServerは保証する方法を提供しません どのインデックスが外部キーの実施に使用されるか。
Dan Guzmanは、Secrets of Foreign Key Index Bindingで彼の観察結果を文書化しましたが、これらは不完全である可能性があり、いずれの場合も文書化されていないため、変更される可能性があります 。
これを回避するには、ターゲットが1つだけになるようにします。 外部キーが作成された時点でインデックスを作成しますが、それは事態を複雑にし、外部キー制約が削除されて再作成された場合に将来の問題を招きます。
-
省略形の外部キー構文を使用する場合、SQLServerはのみになります 制約を主キーにバインドします 、非クラスター化かクラスター化か。
次のコードスニペットは、後者の違いを示しています。
CREATE TABLE dbo.Parent ( ParentID integer NOT NULL UNIQUE CLUSTERED ); -- Shorthand (implicit) syntax -- Fails with error 1773 CREATE TABLE dbo.Child ( ChildID integer NOT NULL PRIMARY KEY NONCLUSTERED, ParentID integer NOT NULL REFERENCES dbo.Parent ); -- Explicit syntax succeeds CREATE TABLE dbo.Child ( ChildID integer NOT NULL PRIMARY KEY NONCLUSTERED, ParentID integer NOT NULL REFERENCES dbo.Parent (ParentID) );
人々は、RCSIとSIの下での読み取りと書き込みの競合をほとんど無視することに慣れてきました。この記事が、外部キーによって関連付けられたテーブルの物理設計を実装するときに考慮すべき追加のことを提供してくれることを願っています。