Database
 sql >> Base de Dados >  >> RDS >> Database

Chaves estrangeiras, bloqueio e conflitos de atualização


A maioria dos bancos de dados deve usar chaves estrangeiras para impor a integridade referencial (RI) sempre que possível. No entanto, há mais nessa decisão do que simplesmente decidir usar restrições FK e criá-las. Há uma série de considerações a serem feitas para garantir que seu banco de dados funcione da melhor maneira possível.

Este artigo aborda uma dessas considerações que não recebe muita publicidade:minimizar o bloqueio , você deve pensar cuidadosamente sobre os índices usados ​​para impor exclusividade no lado pai desses relacionamentos de chave estrangeira.

Isso se aplica se você estiver usando bloqueio leia o commit ou o baseado em versão leia o isolamento de instantâneo confirmado (RCSI). Ambos podem sofrer bloqueio quando os relacionamentos de chave estrangeira são verificados pelo mecanismo do SQL Server.

No isolamento de instantâneo (SI), há uma ressalva extra. O mesmo problema essencial pode levar a falhas de transação inesperadas (e possivelmente ilógicas) devido a aparentes conflitos de atualização.

Este artigo está dividido em duas partes. A primeira parte examina o bloqueio de chave estrangeira sob bloqueio de leitura confirmada e isolamento de instantâneo de leitura confirmada. A segunda parte abrange os conflitos de atualização relacionados no isolamento de instantâneo.


1. Bloqueando verificações de chave estrangeira


Vejamos primeiro como o design do índice pode afetar quando o bloqueio ocorre devido a verificações de chave estrangeira.

A demonstração a seguir deve ser executada em leitura confirmada isolamento. Para o SQL Server, o padrão é o bloqueio de leitura confirmada; O Banco de Dados SQL do Azure usa RCSI como padrão. Sinta-se à vontade para escolher o que quiser ou execute os scripts uma vez para cada configuração para verificar por si mesmo se o comportamento é o mesmo.
-- 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;

Crie duas tabelas conectadas por um relacionamento de chave estrangeira:
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)
);

Adicione uma linha à tabela pai:
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
);

Em uma segunda conexão , atualize o atributo de tabela pai não chave ParentValue dentro de uma transação, mas não confirme isso ainda:
DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION;
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentID = @ParentID;

Sinta-se à vontade para escrever o predicado de atualização usando a chave natural, se preferir, isso não faz diferença para nossos propósitos atuais.

De volta à primeira conexão , tente adicionar um registro filho:
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    
);

Esta instrução de inserção irá bloquear , se você escolheu bloqueio ou controle de versão leitura confirmada isolamento para este teste.

Explicação


O plano de execução para a inserção de registro filho é:



Após inserir a nova linha na tabela filha, o plano de execução verifica a restrição de chave estrangeira. A verificação é ignorada se o id pai inserido for nulo (obtido por meio de um predicado 'pass through' na semijunção esquerda). No presente caso, o id pai adicionado não é nulo, então a verificação de chave estrangeira é realizado.

O SQL Server verifica a restrição de chave estrangeira procurando uma linha correspondente na tabela pai. O mecanismo não pode usar o controle de versão de linha para fazer isso — deve ter certeza de que os dados que estão verificando são os últimos dados confirmados , não uma versão antiga. O mecanismo garante isso adicionando um READCOMMITTEDLOCK interno dica de tabela para a verificação de chave estrangeira na tabela pai.

O resultado final é que o SQL Server tenta adquirir um bloqueio compartilhado na linha correspondente na tabela pai, que bloqueia porque a outra sessão mantém um bloqueio de modo exclusivo incompatível devido à atualização ainda não confirmada.

Para ser claro, a dica de bloqueio interno só se aplica à verificação de chave estrangeira. O restante do plano ainda usa RCSI, se você escolher essa implementação do nível de isolamento de leitura confirmada.

Evitando o bloqueio


Confirme ou reverta a transação aberta na segunda sessão e redefina o ambiente de teste:
DROP TABLE IF EXISTS
    dbo.Child, dbo.Parent;

Crie as tabelas de teste novamente, mas desta vez em vez de aceitar os padrões, optamos por tornar a chave primária não clusterizada e a restrição única agrupada:
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)
);

Adicione uma linha à tabela pai como antes:
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
);

Na segunda sessão , execute a atualização sem confirmá-la novamente. Estou usando a chave natural desta vez apenas para variar – não é importante para o resultado. Use a chave substituta novamente, se preferir.
DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION 
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentNaturalKey = @ParentNaturalKey;

Agora execute a inserção filho de volta na primeira sessão :
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    
);

Desta vez, a inserção secundária não bloqueia . Isso é verdade se você estiver executando em isolamento de leitura confirmada com base em bloqueio ou versão. Isso não é um erro de digitação ou erro:RCSI não faz diferença aqui.

Explicação


O plano de execução para a inserção do registro filho é um pouco diferente desta vez:



Tudo é o mesmo de antes (incluindo o invisível READCOMMITTEDLOCK dica) exceto a verificação de chave estrangeira agora usa o não clusterizado índice exclusivo que impõe a chave primária da tabela pai. No primeiro teste, esse índice foi agrupado.

Então, por que não bloqueamos desta vez?

A atualização da tabela pai ainda não confirmada na segunda sessão tem um bloqueio exclusivo no índice clusterizado linha porque a tabela base está sendo modificada. A mudança no ParentValue coluna não afetar a chave primária não clusterizada em ParentID , para que a linha do índice não clusterizado não seja bloqueada .

A verificação de chave estrangeira pode, portanto, adquirir o bloqueio compartilhado necessário no índice de chave primária não clusterizada sem contenção, e a inserção da tabela filha tem êxito imediatamente .

Quando o primário era agrupado, a verificação de chave estrangeira precisava de um bloqueio compartilhado no mesmo recurso (linha de índice agrupado) que foi bloqueado exclusivamente pela instrução de atualização.

O comportamento pode ser surpreendente, mas não é um bug . Dar à verificação de chave estrangeira seu próprio método de acesso otimizado evita a contenção de bloqueio logicamente desnecessária. Não há necessidade de bloquear a pesquisa de chave estrangeira porque o ParentID atributo não é afetado pela atualização simultânea.

2. Conflitos de atualização evitáveis


Se você executar os testes anteriores no nível Snapshot Isolation (SI), o resultado será o mesmo. A linha filha insere blocos quando a chave referenciada é aplicada por um índice clusterizado , e não bloqueia quando a aplicação de chave usa um não clusterizado índice único.

Há uma diferença de potencial importante ao usar o SI. No isolamento de leitura confirmada (bloqueio ou RCSI), a inserção da linha filha finalmente é bem-sucedida após a atualização na segunda sessão, confirma ou reverte. Usando SI, existe o risco de uma transação abortar devido a um aparente conflito de atualização.

Isso é um pouco mais complicado de demonstrar porque uma transação de instantâneo não começa com BEGIN TRANSACTION instrução — começa com o primeiro acesso aos dados do usuário após esse ponto.

O script a seguir configura a demonstração do SI, com uma tabela fictícia extra usada apenas para garantir que a transação do instantâneo tenha realmente começado. Ele usa a variação de teste em que a chave primária referenciada é aplicada usando um único clustered índice (o padrão):
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)
);

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

Ainda na primeira sessão , inicie a transação de instantâneo:
-- Session 1
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
 
-- Ensure snapshot transaction is started
SELECT COUNT_BIG(*) FROM dbo.Dummy AS D;

Na segunda sessão (executando em qualquer nível de isolamento):
-- Session 2
DECLARE
    @ParentID integer = 1,
    @ParentNaturalKey varchar(10) = 'PNK1',
    @ParentValue integer = 200;
 
BEGIN TRANSACTION;
    UPDATE dbo.Parent 
    SET ParentValue = @ParentValue 
    WHERE ParentID = @ParentID;

Tentando inserir a linha filha na primeira sessão blocos como esperado:
-- 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    
);

A diferença ocorre quando encerramos a transação na segunda sessão. Se revertermos , a inserção da linha filha da primeira sessão é concluída com êxito .

Se, em vez disso, nos comprometermos a transação aberta:
-- Session 2
COMMIT TRANSACTION;

A primeira sessão relata um conflito de atualização e reverte:


Explicação


Este conflito de atualização ocorre apesar de a chave estrangeira sendo validado não foi alterado pela atualização da segunda sessão.

A razão é essencialmente a mesma do primeiro conjunto de testes. Quando o índice clusterizado é usado para aplicação de chave referenciada, a transação de instantâneo encontra uma linha que foi modificado desde que começou. Isso não é permitido no isolamento de instantâneo.

Quando a chave é aplicada usando um índice não clusterizado , a transação de instantâneo vê apenas a linha de índice não clusterizada não modificada, portanto, não há bloqueio e nenhum 'conflito de atualização' é detectado.

Há muitas outras circunstâncias em que o isolamento de instantâneo pode relatar conflitos de atualização inesperados ou outros erros. Veja meu artigo anterior para exemplos.

Conclusões


Há muitas considerações a serem consideradas ao escolher o índice clusterizado para uma tabela de armazenamento de linhas. Os problemas descritos aqui são apenas outro fator avaliar.

Isso é especialmente verdadeiro se você estiver usando o isolamento de instantâneo. Ninguém gosta de uma transação abortada , especialmente um que é sem dúvida ilógico. Se você estiver usando RCSI, o bloqueio ao ler validar chaves estrangeiras pode ser inesperado e pode levar a deadlocks.

O padrão para uma PRIMARY KEY restrição é criar seu índice de suporte como agrupado , a menos que outro índice ou restrição na definição da tabela seja explícito sobre ser agrupado. É um bom hábito ser explícito sobre sua intenção de design, então recomendo que você escreva CLUSTERED ou NONCLUSTERED toda vez.

Índices duplicados?


Pode haver momentos em que você considere seriamente, por boas razões, ter um índice clusterizado e um índice não clusterizado com as mesmas chaves .

A intenção pode ser fornecer acesso de leitura ideal para consultas de usuários por meio do clustered index (evitando pesquisas de chave), além de habilitar a validação de bloqueio mínimo (e conflitante de atualização) para chaves estrangeiras por meio do compacto não clusterizado índice como mostrado aqui.

Isso é possível, mas há alguns problemas estar atento a:

  1. Dado mais de um índice de destino adequado, o SQL Server não fornece uma maneira de garantir qual índice será usado para imposição de chave estrangeira.

    Dan Guzman documentou suas observações em Secrets of Foreign Key Index Binding, mas estas podem estar incompletas e, em qualquer caso, não documentadas, e portanto podem mudar .

    Você pode contornar isso garantindo que haja apenas um destino index no momento em que a chave estrangeira é criada, mas complica as coisas e convida a problemas futuros se a restrição de chave estrangeira for descartada e recriada.

  2. Se você usar a sintaxe de chave estrangeira abreviada, o SQL Server somente vincular a restrição à chave primária , seja não clusterizado ou clusterizado.

O trecho de código a seguir demonstra a última diferença:
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)
);

As pessoas se acostumaram a ignorar amplamente os conflitos de leitura e gravação em RCSI e SI. Espero que este artigo tenha lhe dado algo extra para pensar ao implementar o design físico para tabelas relacionadas por uma chave estrangeira.