Database
 sql >> база данни >  >> RDS >> Database

Конфликти на външни ключове, блокиране и актуализиране

Повечето бази данни трябва да използват външни ключове, за да наложат референтната цялост (RI), когато е възможно. Това решение обаче е нещо повече от това просто да решите да използвате FK ограничения и да ги създадете. Има редица съображения, които трябва да обърнете внимание, за да гарантирате, че вашата база данни работи възможно най-гладко.

Тази статия обхваща едно такова съображение, което не получава много публичност:Да се ​​сведе до минимум блокирането , трябва да помислите внимателно за индексите, използвани за налагане на уникалност на родителската страна на тези връзки с външни ключове.

Това важи, независимо дали използвате заключване read committed или базирани на версии четене на изолация на извършени моментни снимки (RCSI). И двете могат да изпитат блокиране, когато връзките с външни ключове се проверяват от механизма на SQL Server.

При изолация на моментни снимки (SI) има допълнително предупреждение. Същият основен проблем може да доведе до неочаквани (и може би нелогични) неуспешни транзакции поради очевидни конфликти в актуализациите.

Тази статия е в две части. Първата част разглежда блокирането на външни ключове при заключване, четене на ангажименти и изолиране на записани моментни снимки. Втората част обхваща свързани конфликти на актуализации при изолиране на моментни снимки.

1. Блокиране на проверки на чужд ключ

Нека първо да разгледаме как дизайнът на индекса може да повлияе, когато възникне блокиране поради проверки на външни ключове.

Следната демонстрация трябва да се изпълни под read commited изолация. За SQL Server по подразбиране е заключено четене, ангажирано; Azure SQL база данни използва RCSI по подразбиране. Чувствайте се свободни да изберете каквото желаете или стартирайте скриптовете веднъж за всяка настройка, за да се уверите сами, че поведението е същото.

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

Създайте две таблици, свързани чрез връзка с външен ключ:

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

Привтора връзка , актуализиране на неключовия атрибут на родителска таблица 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    
);

Това изявление за вмъкване ще блокира , независимо дали сте избрали заключване или версия прочетено е ангажирано изолация за този тест.

Обяснение

Планът за изпълнение на вмъкването на дъщерен запис е:

След вмъкване на новия ред в дъщерната таблица, планът за изпълнение проверява ограничението на външния ключ. Проверката се пропуска, ако вмъкнатият родителски идентификатор е нулев (постига се чрез предикат ‘pass through’ в лявото полусъединяване). В настоящия случай добавеният родителски идентификатор не е нулев, така че проверката на външния ключ е изпълнено.

SQL Server проверява ограничението на външния ключ, като търси съвпадащ ред в родителската таблица. Двигателятне може да използва версия на реда за да направите това — трябва да е сигурен, че данните, които проверява, са последните заети данни , а не някаква стара версия. Двигателят гарантира това, като добавя вътрешен READCOMMITTEDLOCK таблица намек за проверка на външния ключ на родителската таблица.

Крайният резултат е SQL Server се опитва да получи споделено заключване на съответния ред в родителската таблица, което блокира защото другата сесия държи несъвместимо заключване на изключителен режим поради все още незаети актуализация.

За да бъде ясно, подсказката за вътрешно заключване се отнася само за проверката на външния ключ. Останалата част от плана все още използва RCSI, ако сте избрали тази реализация на нивото на изолация за четене.

Избягване на блокирането

Извършете или отменете отворената транзакция във втората сесия, след което нулирайте тестовата среда:

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

Ввтора сесия , стартирайте актуализацията, без да я извършвате отново. Този път използвам естествения ключ само за разнообразие — той не е важен за резултата. Използвайте сурогатния ключ отново, ако предпочитате.

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 намек) освен проверката на външния ключ вече използва неклъстерирания уникален индекс, налагащ първичния ключ на родителската таблица. В първия тест този индекс беше групиран.

Така че защо не получим блокиране този път?

Все още неангажираната актуализация на родителската таблица във втората сесия има изключително заключване вклъстерирания индекс ред, защото основната таблица се променя. Промяната на ParentValue колонане засяга неклъстерирания първичен ключ на ParentID , така че редът на неклъстерирания индекс да не е заключен .

Следователно проверката на външния ключ може да придобие необходимото споделено заключване на индекса на неклъстерирания първичен ключ без конкуренция и вмъкването на дъщерната таблица успява незабавно .

Когато основният беше групиран, проверката на външния ключ се нуждаеше от споделено заключване на същия ресурс (клъстериран индексен ред), който беше изключително заключен от оператора за актуализиране.

Поведението може да е изненадващо, но тоне е грешка . Предоставянето на проверка на външния ключ на собствен оптимизиран метод за достъп избягва логически ненужни спорове за заключване. Няма нужда да блокирате търсенето на външен ключ, тъй като ParentID атрибутът не се влияе от едновременната актуализация.

2. Неизбежни конфликти при актуализации

Ако изпълните предишните тестове под нивото на Snapshot Isolation (SI), резултатът ще бъде същият. Детският ред вмъква блокове когато референтният ключ е наложен от клъстериран индекс , и не блокира когато налагането на ключове използва неклъстериран уникален индекс.

Въпреки това има една важна потенциална разлика при използване на SI. При изолиране на извършено четене (заключване или RCSI) вмъкването на дъщерен ред в крайна сметка успява след като актуализацията във втората сесия се ангажира или се връща назад. При използване на 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;

Ввтора сесия (работи на всяко ниво на изолация):

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

Разликата настъпва, когато прекратим транзакцията във втората сесия. Ако говърнем обратно , вмъкването на дъщерен ред на първата сесия завършва успешно .

Ако вместо това се ангажираме отворената транзакция:

-- Session 2
COMMIT TRANSACTION;

Първата сесия съобщава за конфликт на актуализацията и се връща назад:

Обяснение

Този конфликт на актуализация възниква въпреки факта, че външният ключ е валидиран не е променен до актуализацията на втората сесия.

Причината по същество е същата като в първия набор от тестове. Когато клъстерираният индекс се използва за прилагане на референтен ключ, транзакцията на моментната снимка среща ред която е променена от началото. Това не е разрешено при изолиране на моментни снимки.

Когато ключът е наложен чрез неклъстериран индекс , транзакцията за моментна снимка вижда само немодифицирания неклъстериран индексен ред, така че няма блокиране и не се открива „конфликт при актуализацията“.

Има много други обстоятелства, при които изолирането на моментни снимки може да съобщи за неочаквани конфликти при актуализация или други грешки. Вижте предишната ми статия за примери.

Заключения

Има много съображения, които трябва да се вземат предвид при избора на клъстериран индекс за таблица с редове. Проблемите, описани тук, са самодруг фактор за оценка.

Това е особено вярно, ако ще използвате изолация на моментни снимки. Никой не се радва напрекъсната транзакция , особено този, който може би е нелогичен. Ако ще използвате RCSI, блокирането при четене за валидиране на външни ключове може да е неочаквано и да доведе до блокиране.

По подразбиране за PRIMARY KEY ограничението е да създаде своя поддържащ индекс като клъстериран , освен ако друг индекс или ограничение в дефиницията на таблицата не е изрично за групиране. Добър навик е да бъдете изрични относно вашето намерение за проектиране, така че бих ви насърчил да напишете CLUSTERED или NONCLUSTERED всеки път.

Дублиращи се индекси?

Може да има моменти, когато сериозно обмисляте, по основателни причини, да имате клъстериран индекс и неклъстериран индекс с същите ключове .

Целта може да е да се осигури оптимален достъп за четене за потребителски заявки чрез клъстерирания индекс (избягвайки търсене на ключове), като същевременно позволява минимално блокиращо (и конфликтно с актуализации) валидиране за външни ключове чрез компактния неклъстериран индекс, както е показано тук.

Това е постижимо, но има няколко качки да внимавате за:

  1. Като се има предвид повече от един подходящ целеви индекс, SQL Server не предоставя начин за гаранция кой индекс ще се използва за прилагане на външния ключ.

    Дан Гузман документира своите наблюдения в Secrets of Foreign Key Index Binding, но те може да са непълни и във всеки случай да са недокументирани и така могат да се променят .

    Можете да заобиколите това, като се уверите, че има само една цел индексира в момента на създаване на външния ключ, но това усложнява нещата и приканва бъдещи проблеми, ако ограничението на външния ключ някога бъде премахнато и създадено отново.

  2. Ако използвате съкратения синтаксис на външния ключ, SQL Server ще само свържете ограничението към първичния ключ , независимо дали е неклъстериран или клъстериран.

Следният кодов фрагмент демонстрира последната разлика:

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. Надяваме се, че тази статия ви е дала нещо допълнително, за което да помислите, когато внедрявате физическия дизайн за таблици, свързани с външен ключ.


  1. Database
  2.   
  3. Mysql
  4.   
  5. Oracle
  6.   
  7. Sqlserver
  8.   
  9. PostgreSQL
  10.   
  11. Access
  12.   
  13. SQLite
  14.   
  15. MariaDB
  1. SQL заявки

  2. SQL Sentry вече е SentryOne

  3. Модел на партийни взаимоотношения. Как да моделирам взаимоотношения

  4. Как да преименувате колона в SQL

  5. Как да изпълните необработен SQL в SQLAlchemy