sql >> Databasteknik >  >> RDS >> Database

Utländska nycklar, blockering och uppdateringskonflikter

De flesta databaser bör använda främmande nycklar för att upprätthålla referensintegritet (RI) där så är möjligt. Det finns dock mer i detta beslut än att bara bestämma sig för att använda FK-begränsningar och skapa dem. Det finns ett antal överväganden att ta itu med för att säkerställa att din databas fungerar så smidigt som möjligt.

Den här artikeln tar upp ett sådant övervägande som inte får mycket publicitet:Att minimera blockering , bör du tänka noga på indexen som används för att framtvinga unikhet på föräldersidan av dessa främmande nyckelrelationer.

Detta gäller oavsett om du använder låsning läs committed eller den versionsbaserade read committed snapshot isolation (RCSI). Båda kan uppleva blockering när främmande nyckelrelationer kontrolleras av SQL Server-motorn.

Under snapshot isolation (SI) finns det en extra varning. Samma väsentliga problem kan leda till oväntade (och förmodligen ologiska) transaktionsmisslyckanden på grund av uppenbara uppdateringskonflikter.

Den här artikeln är i två delar. Den första delen tittar på blockering av främmande nyckel under låsning läs committed och read committed snapshot isolation. Den andra delen täcker relaterade uppdateringskonflikter under ögonblicksbildsisolering.

1. Blockera utländska nyckelkontroller

Låt oss först titta på hur indexdesign kan påverka när blockering sker på grund av kontroller av främmande nyckel.

Följande demo bör köras under läs committed isolering. För SQL Server är standardinställningen låsning läs committed; Azure SQL Database använder RCSI som standard. Välj gärna vad du vill, eller kör skripten en gång för varje inställning för att själv verifiera att beteendet är detsamma.

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

Skapa två tabeller kopplade av en främmande nyckelrelation:

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

Lägg till en rad i den överordnade tabellen:

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

På en andra anslutning , uppdatera attributet för överordnad tabell som inte är nyckeln ParentValue i en transaktion, men förbind dig inte det ännu:

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

Skriv gärna uppdateringspredikatet med den naturliga nyckeln om du föredrar det, det gör ingen skillnad för våra nuvarande syften.

Åter till första anslutningen , försök att lägga till en underordnad post:

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

Denna infogningssats kommer att blockeras , oavsett om du väljer låsning eller versionering läs engagerad isolering för detta test.

Förklaring

Utförandeplanen för den underordnade posten är:

Efter att ha infogat den nya raden i den underordnade tabellen kontrollerar exekveringsplanen den främmande nyckelbegränsningen. Kontrollen hoppas över om det infogade överordnade id:t är null (uppnås via ett "pass through"-predikat på den vänstra halvanslutningen). I det aktuella fallet är det tillagda överordnade id:t inte null, så den främmande nyckelkontrollen är utförs.

SQL Server verifierar den främmande nyckelbegränsningen genom att leta efter en matchande rad i den överordnade tabellen. Motorn kan inte använda radversionering för att göra detta — den måste vara säker på att den information den kontrollerar är den senaste bekräftade informationen , inte någon gammal version. Motorn säkerställer detta genom att lägga till en intern READCOMMITTEDLOCK tabelltips till den främmande nyckelkontrollen på den överordnade tabellen.

Slutresultatet är att SQL Server försöker få ett delat lås på motsvarande rad i den överordnade tabellen, vilket blockerar eftersom den andra sessionen har ett inkompatibelt exklusivt lägeslås på grund av den ännu ej inloggade uppdateringen.

För att vara tydlig, den interna låsningen gäller endast för kontroll av främmande nyckel. Resten av planen använder fortfarande RCSI, om du valde den implementeringen av den läsbestämda isoleringsnivån.

Undvika blockeringen

Bekräfta eller återställa den öppna transaktionen i den andra sessionen och återställ sedan testmiljön:

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

Skapa testtabellerna igen, men den här gången istället för att acceptera standardinställningarna väljer vi att göra primärnyckeln icke-klustrad och den unika begränsningen klustrade:

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

Lägg till en rad i den överordnade tabellen som tidigare:

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

I den andra sessionen , kör uppdateringen utan att utföra den igen. Jag använder den naturliga nyckeln den här gången bara för variation - det är inte viktigt för resultatet. Använd surrogatnyckeln igen om du föredrar det.

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

Kör nu barninlägget tillbaka på den första sessionen :

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

Den här gången blockerar inte den underordnade infogningen . Detta gäller oavsett om du kör under låsnings- eller versionsbaserad läskommitterad isolering. Det är inte ett stavfel eller fel:RCSI gör ingen skillnad här.

Förklaring

Utförandeplanen för den underordnade posten är något annorlunda den här gången:

Allt är detsamma som tidigare (inklusive den osynliga READCOMMITTEDLOCK ledtråd) utom kontrollen av främmande nyckel använder nu icke-klustrade unikt index som upprätthåller den överordnade tabellens primärnyckel. I det första testet klustrades detta index.

Så varför blockerar vi inte denna gång?

Den ännu oengagerade överordnade tabelluppdateringen i den andra sessionen har ett exklusivt lås i det klustrade indexet rad eftersom bastabellen ändras. Ändringen av ParentValue kolumnen inte påverka den icke-klustrade primärnyckeln på ParentID , så att raden i det icke-klustrade indexet inte är låst .

Kontrollen av främmande nyckel kan därför erhålla det nödvändiga delade låset på det icke-klustrade primärnyckelindexet utan konflikt, och den underordnade tabellens infogning lyckas omedelbart .

När den primära klustrades behövde den främmande nyckelkontrollen ett delat lås på samma resurs (klustrad indexrad) som exklusivt låstes av uppdateringssatsen.

Beteendet kan vara förvånande, men det är inte ett fel . Genom att ge den främmande nyckelkontrollen sin egen optimerade åtkomstmetod undviks logiskt onödiga låskonflikter. Det finns inget behov av att blockera uppslagningen av främmande nyckel eftersom ParentID attributet påverkas inte av den samtidiga uppdateringen.

2. Undvikbara uppdateringskonflikter

Om du kör de tidigare testerna under nivån Snapshot Isolation (SI) blir resultatet detsamma. Den underordnade raden infogar block när den refererade nyckeln upprätthålls av ett klustrat index , och blockerar inte när nyckeltillämpning använder en icke-klustrad unikt index.

Det finns dock en viktig potentialskillnad när du använder SI. Under läs committed (låsning eller RCSI) isolering, lyckas den underordnade radens infogning slutligen efter uppdateringen i den andra sessionen begår eller rullar tillbaka. Med SI finns det risk för att en transaktion avbryts på grund av en uppenbar uppdateringskonflikt.

Detta är lite svårare att visa eftersom en ögonblicksbildstransaktion inte börjar med BEGIN TRANSACTION uttalande — det börjar med den första användardataåtkomsten efter den punkten.

Följande skript ställer in SI-demonstrationen, med en extra dummy-tabell som endast används för att säkerställa att ögonblicksbildstransaktionen verkligen har börjat. Den använder testvarianten där den refererade primärnyckeln upprätthålls med en unik klustrad 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)
);

Infogar den överordnade raden:

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

Fortfarande i den första sessionen , starta ögonblicksbildstransaktionen:

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

I den andra sessionen (kör på valfri isoleringsnivå):

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

Försök att infoga den underordnade raden i den första sessionen blockerar som förväntat:

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

Skillnaden uppstår när vi avslutar transaktionen i den andra sessionen. Om vi ​​rullar tillbaka , den första sessionens underordnade infogning slutförs framgångsrikt .

Om vi ​​istället binder oss den öppna transaktionen:

-- Session 2
COMMIT TRANSACTION;

Den första sessionen rapporterar en uppdateringskonflikt och rullar tillbaka:

Förklaring

Den här uppdateringskonflikten uppstår trots främmande nyckel valideras har inte ändrats av den andra sessionens uppdatering.

Anledningen är i huvudsak densamma som i den första uppsättningen av tester. När det klustrade indexet används för refererad nyckeltillämpning, möter ögonblicksbildtransaktionen en rad som har ändrats sedan det började. Detta är inte tillåtet under ögonblicksbildsisolering.

När nyckeln framtvingas med ett icke-klustrat index , ögonblicksbildstransaktionen ser bara den omodifierade icke-klustrade indexraden, så det finns ingen blockering och ingen "uppdateringskonflikt" upptäcks.

Det finns många andra omständigheter där ögonblicksbildsisolering kan rapportera oväntade uppdateringskonflikter eller andra fel. Se min tidigare artikel för exempel.

Slutsatser

Det finns många överväganden att ta hänsyn till när du väljer det klustrade indexet för en rad-butikstabell. Problemen som beskrivs här är bara en annan faktor att utvärdera.

Detta gäller särskilt om du kommer att använda ögonblicksbildsisolering. Ingen njuter av en avbruten transaktion , särskilt en som utan tvekan är ologisk. Om du kommer att använda RCSI, blockering vid läsning att validera främmande nycklar kan vara oväntat och kan leda till dödläge.

standard för en PRIMARY KEY begränsning är att skapa dess stödjande index som klustrade , såvida inte ett annat index eller begränsning i tabelldefinitionen är explicit om att klustras istället. Det är en god vana att vara uttrycklig om din designavsikt, så jag skulle uppmuntra dig att skriva CLUSTERED eller NONCLUSTERED varje gång.

Duplicera index?

Det kan finnas tillfällen då du allvarligt överväger, av goda skäl, att ha ett klustrat index och ett icke-klustrat index med samma nyckel(r) .

Avsikten kan vara att ge optimal läsåtkomst för användarfrågor via den klustrade index (undviker nyckeluppslag), samtidigt som det möjliggör minimalt blockerande (och uppdateringskonflikt) validering för främmande nycklar via den kompakta icke-klustrade index som visas här.

Detta är möjligt, men det finns ett par snacks att se upp för:

  1. Givet mer än ett lämpligt målindex ger SQL Server inget sätt att garantera vilket index som kommer att användas för upprätthållande av främmande nyckel.

    Dan Guzman dokumenterade sina observationer i Secrets of Foreign Key Index Binding, men dessa kan vara ofullständiga och i alla fall odokumenterade, och så kan ändras .

    Du kan kringgå detta genom att se till att det bara finns ett mål index vid den tidpunkt då den främmande nyckeln skapas, men det komplicerar saker och ting och bjuder in till framtida problem om den främmande nyckelbegränsningen någonsin släpps och återskapas.

  2. Om du använder syntaxen för förkortad främmande nyckel kommer SQL Server bara att göra det binda begränsningen till primärnyckeln , oavsett om den är icke-klustrad eller klustrad.

Följande kodavsnitt visar den senare skillnaden:

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

Människor har blivit vana vid att till stor del ignorera läs-skrivkonflikter under RCSI och SI. Förhoppningsvis har den här artikeln gett dig något extra att tänka på när du implementerar den fysiska designen för tabeller relaterade till en främmande nyckel.


  1. Hur SQLite Min() fungerar

  2. Hur man exporterar frågeresultat till en CSV-fil i SQLcl (Oracle)

  3. 4 funktioner som returnerar minuterna från ett tidsvärde i MariaDB

  4. MySQL-uppdateringstabell baserat på ett annat tabellvärde