Database
 sql >> Base de données >  >> RDS >> Database

Clés étrangères, blocage et conflits de mise à jour

La plupart des bases de données doivent utiliser des clés étrangères pour appliquer l'intégrité référentielle (RI) dans la mesure du possible. Cependant, cette décision va au-delà du simple fait de décider d'utiliser des contraintes FK et de les créer. Il y a un certain nombre de considérations à prendre en compte pour s'assurer que votre base de données fonctionne aussi bien que possible.

Cet article couvre une de ces considérations qui ne reçoit pas beaucoup de publicité :pour minimiser le blocage , vous devez réfléchir attentivement aux index utilisés pour imposer l'unicité du côté parent de ces relations de clé étrangère.

Cela s'applique que vous utilisiez le verrouillage lecture validée ou basée sur la gestion des versions lire l'isolement d'instantané validé (RCSI). Les deux peuvent être bloqués lorsque les relations de clé étrangère sont vérifiées par le moteur SQL Server.

Sous l'isolement d'instantané (SI), il y a une mise en garde supplémentaire. Le même problème essentiel peut entraîner des échecs de transaction inattendus (et sans doute illogiques) en raison de conflits de mise à jour apparents.

Cet article est en deux parties. La première partie examine le blocage de clé étrangère sous le verrouillage de lecture validée et l'isolation d'instantané de lecture validée. La deuxième partie couvre les conflits de mise à jour associés sous l'isolement d'instantané.

1. Blocage des vérifications de clé étrangère

Voyons d'abord comment la conception de l'index peut affecter le blocage dû aux vérifications de clé étrangère.

La démo suivante doit être exécutée sous read commited isolement. Pour SQL Server, la valeur par défaut est le verrouillage de la lecture validée; Azure SQL Database utilise RCSI par défaut. N'hésitez pas à choisir celui que vous préférez ou exécutez les scripts une fois pour chaque paramètre afin de vérifier par vous-même que le comportement est le même.

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

Créez deux tables connectées par une relation de clé étrangère :

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

Ajouter une ligne à la table parent :

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

Sur une seconde connexion , mettez à jour l'attribut de table parent non clé ParentValue à l'intérieur d'une transaction, mais ne vous engagez pas c'est pour l'instant :

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

N'hésitez pas à écrire le prédicat de mise à jour en utilisant la clé naturelle si vous préférez, cela ne fait aucune différence pour nos besoins actuels.

Retour sur la première connexion , essayez d'ajouter un enregistrement enfant :

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

Cette instruction d'insertion va bloquer , que vous ayez choisi le verrouillage ou la gestion des versions lire validé isolement pour ce test.

Explication

Le plan d'exécution pour l'insertion d'enregistrement enfant est :

Après avoir inséré la nouvelle ligne dans la table enfant, le plan d'exécution vérifie la contrainte de clé étrangère. La vérification est ignorée si l'identifiant parent inséré est nul (obtenu via un prédicat "pass through" sur la semi-jointure gauche). Dans le cas présent, l'identifiant parent ajouté n'est pas nul, donc la vérification de la clé étrangère est effectué.

SQL Server vérifie la contrainte de clé étrangère en recherchant une ligne correspondante dans la table parent. Le moteur ne peut pas utiliser la gestion des versions de ligne pour ce faire, il doit s'assurer que les données qu'il vérifie sont les dernières données validées , pas une ancienne version. Le moteur s'en assure en ajoutant un READCOMMITTEDLOCK interne indice de table pour la vérification de la clé étrangère sur la table parent.

Le résultat final est que SQL Server essaie d'acquérir un verrou partagé sur la ligne correspondante dans la table parent, qui bloque car l'autre session détient un verrou en mode exclusif incompatible en raison de la mise à jour non encore validée.

Pour être clair, l'indice de verrouillage interne ne s'applique qu'à la vérification de la clé étrangère. Le reste du plan utilise toujours RCSI, si vous avez choisi cette implémentation du niveau d'isolement de lecture validée.

Éviter le blocage

Validez ou annulez la transaction ouverte dans la deuxième session, puis réinitialisez l'environnement de test :

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

Créez à nouveau les tables de test, mais cette fois au lieu d'accepter les valeurs par défaut, nous choisissons de rendre la clé primaire non clusterisée et la contrainte unique regroupée :

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

Ajoutez une ligne à la table parent comme avant :

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

Dans la deuxième session , exécutez la mise à jour sans la valider à nouveau. J'utilise la clé naturelle cette fois juste pour la variété - ce n'est pas important pour le résultat. Utilisez à nouveau la clé de substitution si vous préférez.

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

Exécutez maintenant l'insertion enfant sur la première session :

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

Cette fois, l'insertion enfant ne bloque pas . Cela est vrai que vous exécutiez une isolation en lecture validée basée sur le verrouillage ou sur la gestion des versions. Ce n'est pas une faute de frappe ou une erreur :RCSI ne fait aucune différence ici.

Explication

Le plan d'exécution pour l'insertion d'enregistrement enfant est légèrement différent cette fois :

Tout est comme avant (y compris l'invisible READCOMMITTEDLOCK indice) sauf la vérification de clé étrangère utilise maintenant le non clusterisé index unique appliquant la clé primaire de la table parent. Dans le premier test, cet index était clusterisé.

Alors pourquoi ne bloque-t-on pas cette fois ?

La mise à jour de la table parent non encore validée dans la deuxième session a un verrou exclusif sur l'index cluster ligne car la table de base est en cours de modification. La modification de la ParentValue la colonne ne le fait pas affecter la clé primaire non clusterisée sur ParentID , afin que la ligne de l'index non clusterisé ne soit pas verrouillée .

La vérification de clé étrangère peut donc acquérir le verrou partagé nécessaire sur l'index de clé primaire non clusterisé sans conflit, et l'insertion de table enfant réussit immédiatement .

Lorsque le primaire était en cluster, la vérification de la clé étrangère nécessitait un verrou partagé sur la même ressource (ligne d'index en cluster) qui était exclusivement verrouillée par l'instruction de mise à jour.

Le comportement peut être surprenant, mais ce n'est pas un bogue . Donner au contrôle de clé étrangère sa propre méthode d'accès optimisée évite les conflits de verrouillage logiquement inutiles. Il n'est pas nécessaire de bloquer la recherche de clé étrangère car le ParentID l'attribut n'est pas affecté par la mise à jour simultanée.

2. Conflits de mise à jour évitables

Si vous exécutez les tests précédents sous le niveau Snapshot Isolation (SI), le résultat sera le même. La ligne enfant insère des blocs lorsque la clé référencée est appliquée par un index cluster , et ne bloque pas lorsque l'application des clés utilise un élément non clusterisé index unique.

Cependant, il existe une différence de potentiel importante lors de l'utilisation de SI. Sous isolation en lecture validée (verrouillage ou RCSI), l'insertion de ligne enfant réussit finalement après la mise à jour dans la deuxième session, valide ou annule. En utilisant SI, il y a un risque d'abandon d'une transaction en raison d'un conflit de mise à jour apparent.

C'est un peu plus délicat à démontrer car une transaction d'instantané ne commence pas par le BEGIN TRANSACTION déclaration - elle commence par le premier accès aux données de l'utilisateur après ce point.

Le script suivant configure la démonstration SI, avec une table factice supplémentaire utilisée uniquement pour s'assurer que la transaction d'instantané a vraiment commencé. Il utilise la variante de test où la clé primaire référencée est appliquée à l'aide d'un unique cluster index (par défaut):

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

Insertion de la ligne parent :

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

Toujours dans la première session , démarrez la transaction d'instantané :

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

Dans la deuxième session (fonctionnant à n'importe quel niveau d'isolement) :

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

Tentative d'insertion de la ligne enfant dans les premiers blocs de session comme prévu :

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

La différence se produit lorsque nous terminons la transaction à la deuxième séance. Si nous annulons , l'insertion de ligne enfant de la première session se termine avec succès .

Si nous nous engageons à la place la transaction ouverte :

-- Session 2
COMMIT TRANSACTION;

La première session signale un conflit de mise à jour et annule :

Explication

Ce conflit de mise à jour se produit malgré le fait que la clé étrangère en cours de validation n'a pas changé par la mise à jour de la deuxième session.

La raison est essentiellement la même que dans la première série de tests. Lorsque l'index cluster est utilisé pour l'application de la clé référencée, la transaction d'instantané rencontre une ligne qui a été modifié depuis son lancement. Ceci n'est pas autorisé sous l'isolement d'instantané.

Lorsque la clé est appliquée à l'aide d'un index non cluster , la transaction d'instantané ne voit que la ligne d'index non cluster non modifiée, il n'y a donc pas de blocage et aucun "conflit de mise à jour" n'est détecté.

Il existe de nombreuses autres circonstances dans lesquelles l'isolement d'instantané peut signaler des conflits de mise à jour inattendus ou d'autres erreurs. Voir mon article précédent pour des exemples.

Conclusions

De nombreuses considérations doivent être prises en compte lors du choix de l'index clusterisé pour une table de magasin de lignes. Les problèmes décrits ici ne sont qu'un autre facteur à évaluer.

Cela est particulièrement vrai si vous comptez utiliser l'isolement d'instantané. Personne ne profite d'une transaction abandonnée , en particulier celui qui est sans doute illogique. Si vous utilisez RCSI, le blocage lors de la lecture valider des clés étrangères peut être inattendu et entraîner des blocages.

La valeur par défaut pour une PRIMARY KEY la contrainte est de créer son index de support en tant que cluster , à moins qu'un autre index ou une autre contrainte dans la définition de la table n'indique explicitement qu'il doit être mis en cluster à la place. C'est une bonne habitude d'être explicite sur votre intention de conception, je vous encourage donc à écrire CLUSTERED ou NONCLUSTERED à chaque fois.

Index en double ?

Il peut arriver que vous envisagiez sérieusement, pour de bonnes raisons, d'avoir un index clusterisé et un index non clusterisé avec la même(s) clé(s) .

L'intention peut être de fournir un accès en lecture optimal pour les requêtes des utilisateurs via le cluster index (évitant les recherches de clés), tout en permettant une validation minimalement bloquante (et conflictuelle avec la mise à jour) pour les clés étrangères via le compact non clusterisé index comme indiqué ici.

C'est réalisable, mais il y a quelques hic à surveiller :

  1. Étant donné plus d'un index cible approprié, SQL Server ne fournit aucun moyen de garantir quel index sera utilisé pour l'application de la clé étrangère.

    Dan Guzman a documenté ses observations dans Secrets of Foreign Key Index Binding, mais celles-ci peuvent être incomplètes, et dans tous les cas ne sont pas documentées, et donc pourraient changer .

    Vous pouvez contourner ce problème en vous assurant qu'il n'y a qu'une cible index au moment où la clé étrangère est créée, mais cela complique les choses et invite à de futurs problèmes si la contrainte de clé étrangère est supprimée et recréée.

  2. Si vous utilisez la syntaxe de clé étrangère abrégée, SQL Server ne fera que lier la contrainte à la clé primaire , qu'il soit non clusterisé ou clusterisé.

L'extrait de code suivant illustre cette dernière différence :

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

Les gens se sont habitués à ignorer largement les conflits de lecture-écriture sous RCSI et SI. J'espère que cet article vous a donné quelque chose de plus à penser lors de la mise en œuvre de la conception physique des tables liées par une clé étrangère.