Database
 sql >> Datenbank >  >> RDS >> Database

Fremdschlüssel, Blockierung und Aktualisierungskonflikte

Die meisten Datenbanken sollten nach Möglichkeit Fremdschlüssel verwenden, um die referenzielle Integrität (RI) zu erzwingen. Diese Entscheidung beinhaltet jedoch mehr als nur die Entscheidung, FK-Einschränkungen zu verwenden und diese zu erstellen. Es gibt eine Reihe von Überlegungen, die Sie berücksichtigen sollten, um sicherzustellen, dass Ihre Datenbank so reibungslos wie möglich funktioniert.

Dieser Artikel behandelt eine solche Überlegung, die nicht viel Aufmerksamkeit erfährt:Das Minimieren von Blockierungen , sollten Sie sorgfältig über die Indizes nachdenken, die verwendet werden, um die Eindeutigkeit auf der übergeordneten Seite dieser Fremdschlüsselbeziehungen zu erzwingen.

Dies gilt unabhängig davon, ob Sie Sperren verwenden Read Committed oder versioning-based Read Committed Snapshot Isolation (RCSI). Beide können blockiert werden, wenn Fremdschlüsselbeziehungen von der SQL Server-Engine überprüft werden.

Bei der Snapshot-Isolation (SI) gibt es eine zusätzliche Einschränkung. Das gleiche wesentliche Problem kann zu unerwarteten (und wohl unlogischen) Transaktionsfehlern führen aufgrund offensichtlicher Update-Konflikte.

Dieser Artikel besteht aus zwei Teilen. Der erste Teil befasst sich mit der Blockierung von Fremdschlüsseln unter Locking Read Committed und Read Committed Snapshot Isolation. Der zweite Teil behandelt verwandte Update-Konflikte unter Snapshot-Isolation.

1. Sperren von Fremdschlüsselprüfungen

Sehen wir uns zunächst an, wie sich das Indexdesign auswirken kann, wenn es aufgrund von Fremdschlüsselprüfungen zu Blockierungen kommt.

Die folgende Demo sollte unter read commit ausgeführt werden Isolation. Für SQL Server ist die Standardeinstellung das Sperren von festgeschriebenen Lesevorgängen; Azure SQL-Datenbank verwendet standardmäßig RCSI. Fühlen Sie sich frei, zu wählen, was Ihnen gefällt, oder führen Sie die Skripte einmal für jede Einstellung aus, um selbst zu überprüfen, ob das Verhalten gleich ist.

-- 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;

Erstellen Sie zwei Tabellen, die durch eine Fremdschlüsselbeziehung verbunden sind:

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)
);

Fügen Sie der übergeordneten Tabelle eine Zeile hinzu:

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
);

Bei einer zweiten Verbindung , aktualisieren Sie das Nicht-Schlüssel-Attribut der übergeordneten Tabelle ParentValue innerhalb einer Transaktion, aber nicht festschreiben es nur noch:

DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION;
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentID = @ParentID;

Fühlen Sie sich frei, das Update-Prädikat mit dem natürlichen Schlüssel zu schreiben, wenn Sie es vorziehen, es macht für unsere gegenwärtigen Zwecke keinen Unterschied.

Zurück zur ersten Verbindung , versuchen Sie, einen untergeordneten Datensatz hinzuzufügen:

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    
);

Diese Einfügeanweisung wird blockieren , unabhängig davon, ob Sie sich für Sperren oder Versionierung entschieden haben bestätigt lesen Isolierung für diesen Test.

Erklärung

Der Ausführungsplan für die untergeordnete Datensatzeinfügung lautet:

Nach dem Einfügen der neuen Zeile in die untergeordnete Tabelle überprüft der Ausführungsplan die Fremdschlüsseleinschränkung. Die Prüfung wird übersprungen, wenn die eingefügte Eltern-ID null ist (erreicht durch ein „Pass-Through“-Prädikat auf dem linken Semi-Join). Im vorliegenden Fall ist die hinzugefügte Eltern-ID nicht null, also ist die Fremdschlüsselprüfung ist durchgeführt.

SQL Server überprüft die Fremdschlüsseleinschränkung, indem es nach einer übereinstimmenden Zeile in der übergeordneten Tabelle sucht. Die Engine kann keine Zeilenversionierung verwenden Dazu muss es sicher sein, dass es sich bei den geprüften Daten um die neuesten festgeschriebenen Daten handelt , nicht irgendeine alte Version. Die Engine stellt dies sicher, indem sie ein internes READCOMMITTEDLOCK hinzufügt Tabellenhinweis auf die Fremdschlüsselprüfung der übergeordneten Tabelle.

Das Endergebnis ist, dass SQL Server versucht, eine gemeinsame Sperre für die entsprechende Zeile in der übergeordneten Tabelle zu erwerben, die blockiert wird da die andere Sitzung aufgrund des noch nicht festgeschriebenen Updates eine inkompatible Sperre im exklusiven Modus enthält.

Um es klarzustellen, der interne Verriegelungshinweis gilt nur für die Fremdschlüsselprüfung. Der Rest des Plans verwendet immer noch RCSI, wenn Sie diese Implementierung der Lesefestschreibungs-Isolationsstufe gewählt haben.

Umgehung der Blockierung

Committen Sie die offene Transaktion in der zweiten Sitzung oder machen Sie sie rückgängig und setzen Sie dann die Testumgebung zurück:

DROP TABLE IF EXISTS
    dbo.Child, dbo.Parent;

Erstellen Sie die Testtabellen erneut, aber anstatt die Standardwerte zu akzeptieren, entscheiden wir uns diesmal dafür, den Primärschlüssel nicht gruppiert zu machen und die eindeutige Einschränkung gruppiert:

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)
);

Fügen Sie wie zuvor eine Zeile zur übergeordneten Tabelle hinzu:

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
);

In der zweiten Sitzung , führen Sie das Update aus, ohne es erneut festzuschreiben. Ich verwende diesmal den natürlichen Schlüssel nur zur Abwechslung – er ist für das Ergebnis nicht wichtig. Verwenden Sie den Ersatzschlüssel erneut, wenn Sie möchten.

DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION 
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentNaturalKey = @ParentNaturalKey;

Führen Sie nun die untergeordnete Einfügung in der ersten Sitzung zurück :

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    
);

Dieses Mal wird die untergeordnete Einfügung nicht blockiert . Dies gilt unabhängig davon, ob Sie unter sperrungs- oder versionierungsbasierter Read Committed-Isolation ausgeführt werden. Das ist kein Tippfehler oder Fehler:RCSI macht hier keinen Unterschied.

Erklärung

Der Ausführungsplan für die untergeordnete Datensatzeinfügung ist diesmal etwas anders:

Alles ist wie vorher (einschließlich des unsichtbaren READCOMMITTEDLOCK Hinweis) außer die Fremdschlüsselprüfung verwendet jetzt den nonclustered Eindeutiger Index, der den Primärschlüssel der übergeordneten Tabelle erzwingt. Im ersten Test wurde dieser Index geclustert.

Warum werden wir dieses Mal nicht blockiert?

Die noch nicht festgeschriebene Aktualisierung der übergeordneten Tabelle in der zweiten Sitzung hat eine exklusive Sperre auf dem clustered index Zeile, da die Basistabelle geändert wird. Die Änderung des ParentValue Spalte nicht wirken sich auf den nicht geclusterten Primärschlüssel auf ParentID aus , sodass diese Zeile des nonclustered Index nicht gesperrt ist .

Die Fremdschlüsselprüfung kann daher die erforderliche gemeinsame Sperre für den nicht geclusterten Primärschlüsselindex ohne Konflikte erwerben, und das Einfügen der untergeordneten Tabelle ist sofort erfolgreich .

Wenn der Primärschlüssel geclustert war, benötigte die Fremdschlüsselprüfung eine gemeinsame Sperre für dieselbe Ressource (Zeile des geclusterten Index), die ausschließlich durch die Update-Anweisung gesperrt wurde.

Das Verhalten mag überraschend sein, aber es ist kein Fehler . Indem der Fremdschlüsselprüfung eine eigene optimierte Zugriffsmethode gegeben wird, werden logisch unnötige Sperrkonflikte vermieden. Die Fremdschlüsselsuche muss nicht blockiert werden, da die ParentID -Attribut ist von der gleichzeitigen Aktualisierung nicht betroffen.

2. Vermeidbare Update-Konflikte

Wenn Sie die vorherigen Tests unter der Ebene Snapshot Isolation (SI) ausführen, ist das Ergebnis dasselbe. Die untergeordnete Zeile fügt Blöcke ein wenn der referenzierte Schlüssel durch einen Clustered Index erzwungen wird , und wird nicht blockiert wenn die Schlüsselerzwingung ein nonclustered verwendet eindeutiger Index.

Es gibt jedoch einen wichtigen potenziellen Unterschied bei der Verwendung von SI. Unter Read Committed (Locking oder RCSI)-Isolation ist die Einfügung der untergeordneten Zeile letztendlich erfolgreich nachdem das Update in der zweiten Sitzung festgeschrieben oder zurückgesetzt wurde. Bei Verwendung von SI besteht die Gefahr eines Abbruchs der Transaktion aufgrund eines offensichtlichen Aktualisierungskonflikts.

Dies ist etwas schwieriger zu demonstrieren, da eine Snapshot-Transaktion nicht mit BEGIN TRANSACTION beginnt -Anweisung – sie beginnt mit dem ersten Benutzerdatenzugriff nach diesem Punkt.

Das folgende Skript richtet die SI-Demonstration ein, wobei eine zusätzliche Dummy-Tabelle nur verwendet wird, um sicherzustellen, dass die Snapshot-Transaktion wirklich begonnen hat. Es verwendet die Testvariante, bei der der referenzierte Primärschlüssel mithilfe eines eindeutigen clustered erzwungen wird Index (Standard):

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)
);

Einfügen der übergeordneten Zeile:

DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 100;
 
INSERT dbo.Parent 
(
    ParentID, 
    ParentNaturalKey, 
    ParentValue
) 
VALUES 
(
    @ParentID, 
    @ParentNaturalKey, 
    @ParentValue
);

Noch in der ersten Sitzung , starten Sie die Snapshot-Transaktion:

-- Session 1
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
 
-- Ensure snapshot transaction is started
SELECT COUNT_BIG(*) FROM dbo.Dummy AS D;

In der zweiten Sitzung (läuft auf jeder Isolationsstufe):

-- Session 2
DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION;
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentID = @ParentID;

Der Versuch, die untergeordnete Zeile in die ersten Sitzungs-Blöcke einzufügen wie erwartet:

-- 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    
);

Der Unterschied tritt auf, wenn wir die Transaktion beenden in der zweiten Sitzung. Wenn wir es zurücksetzen , wird die Einfügung der untergeordneten Zeile der ersten Sitzung erfolgreich abgeschlossen .

Wenn wir uns stattdessen verpflichten die offene Transaktion:

-- Session 2
COMMIT TRANSACTION;

Die erste Sitzung meldet einen Aktualisierungskonflikt und rollt zurück:

Erklärung

Dieser Aktualisierungskonflikt tritt trotz des Fremdschlüssels auf validiert wurde nicht geändert durch die Aktualisierung der zweiten Sitzung.

Der Grund ist im Wesentlichen derselbe wie in der ersten Testreihe. Wenn der Clustered Index für die Durchsetzung von referenzierten Schlüsseln verwendet wird, trifft die Snapshot-Transaktion auf eine Zeile die seit ihrem Start geändert wurde. Dies ist unter Snapshot-Isolation nicht zulässig.

Wenn der Schlüssel mithilfe eines nicht gruppierten Indexes erzwungen wird , sieht die Snapshot-Transaktion nur die unveränderte Nonclustered-Indexzeile, sodass es keine Blockierung gibt und kein „Aktualisierungskonflikt“ erkannt wird.

Es gibt viele andere Umstände, unter denen die Snapshot-Isolation unerwartete Aktualisierungskonflikte oder andere Fehler melden kann. Beispiele finden Sie in meinem vorherigen Artikel.

Schlussfolgerungen

Bei der Auswahl des gruppierten Indexes für eine Zeilenspeichertabelle sind viele Überlegungen zu berücksichtigen. Die hier beschriebenen Probleme sind nur ein weiterer Faktor auszuwerten.

Dies gilt insbesondere, wenn Sie die Snapshot-Isolation verwenden. Niemand freut sich über eine abgebrochene Transaktion , besonders eine, die wohl unlogisch ist. Wenn Sie RCSI verwenden, die Blockierung beim Lesen Fremdschlüssel zu validieren, kann unerwartet sein und zu Deadlocks führen.

Die Standardeinstellung für einen PRIMARY KEY Die Einschränkung besteht darin, den unterstützenden Index als clustered zu erstellen , es sei denn, ein anderer Index oder eine Einschränkung in der Tabellendefinition ist explizit darauf ausgerichtet, stattdessen geclustert zu werden. Es ist eine gute Angewohnheit, explizit zu sein über Ihre Designabsicht, daher würde ich Sie ermutigen, CLUSTERED zu schreiben oder NONCLUSTERED jedes Mal.

Doppelte Indizes?

Es kann Zeiten geben, in denen Sie aus guten Gründen ernsthaft in Betracht ziehen, einen gruppierten Index und einen nicht gruppierten Index mit denselben Schlüsseln zu verwenden .

Die Absicht könnte darin bestehen, optimalen Lesezugriff für Benutzeranfragen über das geclusterte bereitzustellen Index (Vermeidung von Schlüsselsuchen) und gleichzeitig eine minimal blockierende (und Aktualisierungskonflikte verursachende) Validierung für Fremdschlüssel über das kompakte nonclustered ermöglichen Index wie hier gezeigt.

Dies ist machbar, aber es gibt ein paar Probleme zu beachten:

  1. Bei mehr als einem geeigneten Zielindex bietet SQL Server keine Möglichkeit zur Garantie welcher Index für die Durchsetzung von Fremdschlüsseln verwendet wird.

    Dan Guzman hat seine Beobachtungen in Secrets of Foreign Key Index Binding dokumentiert, aber diese sind möglicherweise unvollständig und in jedem Fall nicht dokumentiert und können sich daher ändern .

    Sie können dies umgehen, indem Sie sicherstellen, dass es nur ein Ziel gibt Index zum Zeitpunkt der Erstellung des Fremdschlüssels, aber es verkompliziert die Dinge und führt zu zukünftigen Problemen, wenn die Fremdschlüsselbeschränkung jemals gelöscht und neu erstellt wird.

  2. Wenn Sie die abgekürzte Fremdschlüsselsyntax verwenden, wird SQL Server nur Binden Sie die Einschränkung an den Primärschlüssel , egal ob es sich um Nonclustered oder Clustered handelt.

Das folgende Code-Snippet demonstriert den letzteren Unterschied:

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)
);

Man hat sich daran gewöhnt, Lese-Schreib-Konflikte unter RCSI und SI weitgehend zu ignorieren. Hoffentlich hat Ihnen dieser Artikel etwas zusätzliches zum Nachdenken gegeben, wenn Sie das physische Design für Tabellen implementieren, die durch einen Fremdschlüssel verbunden sind.