sql >> Base de Datos >  >> RDS >> Database

Claves foráneas, bloqueo y conflictos de actualización

La mayoría de las bases de datos deberían hacer uso de claves externas para hacer cumplir la integridad referencial (RI) siempre que sea posible. Sin embargo, hay más en esta decisión que simplemente decidir usar restricciones FK y crearlas. Hay una serie de consideraciones a tener en cuenta para garantizar que su base de datos funcione de la mejor manera posible.

Este artículo cubre una de esas consideraciones que no recibe mucha publicidad:minimizar el bloqueo , debe pensar detenidamente en los índices utilizados para imponer la unicidad en el lado principal de esas relaciones de clave externa.

Esto se aplica tanto si está usando bloqueo lectura confirmada o basada en versiones lectura de aislamiento de instantánea confirmada (RCSI). Ambos pueden experimentar bloqueos cuando el motor de SQL Server verifica las relaciones de clave externa.

Bajo el aislamiento de instantáneas (SI), hay una advertencia adicional. El mismo problema esencial puede conducir a fallos de transacción inesperados (y posiblemente ilógicos). debido a aparentes conflictos de actualización.

Este artículo consta de dos partes. La primera parte analiza el bloqueo de claves foráneas bajo el bloqueo de lectura confirmada y el aislamiento de instantáneas de lectura confirmada. La segunda parte cubre los conflictos de actualización relacionados bajo el aislamiento de instantáneas.

1. Bloqueo de comprobaciones de claves foráneas

Veamos primero cómo puede afectar el diseño del índice cuando se produce un bloqueo debido a comprobaciones de claves externas.

La siguiente demostración debe ejecutarse con lectura confirmada aislamiento. Para SQL Server, el valor predeterminado es bloquear la confirmación de lectura; Azure SQL Database usa RCSI como valor predeterminado. Siéntete libre de elegir lo que quieras, o ejecuta los scripts una vez para cada configuración para verificar por ti mismo que el comportamiento es el mismo.

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

Cree dos tablas conectadas por una relación de clave externa:

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

Agregue una fila a la tabla principal:

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

En una segunda conexión , actualice el atributo de la tabla principal no clave ParentValue dentro de una transacción, pero no confirmar todavía:

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

Siéntase libre de escribir el predicado de actualización usando la clave natural si lo prefiere, no hace ninguna diferencia para nuestros propósitos actuales.

Volviendo a la primera conexión , intente agregar un registro secundario:

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 declaración de inserción bloqueará , ya sea que elija bloqueo o control de versiones lectura confirmada aislamiento para esta prueba.

Explicación

El plan de ejecución para la inserción del registro secundario es:

Después de insertar la nueva fila en la tabla secundaria, el plan de ejecución verifica la restricción de clave externa. La verificación se omite si la identificación principal insertada es nula (logrado a través de un predicado de "paso a través" en la semiunión izquierda). En el presente caso, la identificación principal agregada no es nula, por lo que la verificación de clave externa es realizado.

SQL Server verifica la restricción de clave externa buscando una fila coincidente en la tabla principal. El motor no puede usar el control de versiones de fila para hacer esto, debe estar seguro de que los datos que está comprobando son los últimos datos confirmados , no una versión antigua. El motor asegura esto agregando un READCOMMITTEDLOCK interno sugerencia de tabla para la verificación de clave externa en la tabla principal.

El resultado final es que SQL Server intenta adquirir un bloqueo compartido en la fila correspondiente de la tabla principal, que bloquea porque la otra sesión tiene un bloqueo de modo exclusivo incompatible debido a la actualización aún no confirmada.

Para ser claros, la sugerencia de bloqueo interno solo se aplica a la verificación de clave externa. El resto del plan todavía usa RCSI, si elige esa implementación del nivel de aislamiento de confirmación de lectura.

Evitar el bloqueo

Confirme o revierta la transacción abierta en la segunda sesión, luego reinicie el entorno de prueba:

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

Vuelva a crear las tablas de prueba, pero esta vez, en lugar de aceptar los valores predeterminados, optamos por hacer que la clave principal sea no agrupada. y la restricción ú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)
);

Agregue una fila a la tabla principal 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
);

En la segunda sesión , ejecute la actualización sin confirmarla nuevamente. Estoy usando la clave natural esta vez solo por variedad, no es importante para el resultado. Utilice la clave sustituta de nuevo si lo prefiere.

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

Ahora ejecute la inserción secundaria en la primera sesión :

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 vez la inserción infantil no bloquea . Esto es cierto ya sea que esté ejecutando un aislamiento de confirmación de lectura basado en bloqueo o basado en versiones. Eso no es un error tipográfico o un error:RCSI no hace ninguna diferencia aquí.

Explicación

El plan de ejecución para la inserción del registro secundario es ligeramente diferente esta vez:

Todo es igual que antes (incluido el invisible READCOMMITTEDLOCK pista) excepto la verificación de clave externa ahora usa el no agrupado índice único que impone la clave principal de la tabla principal. En la primera prueba, este índice estaba agrupado.

Entonces, ¿por qué no bloqueamos esta vez?

La actualización de la tabla principal aún no confirmada en la segunda sesión tiene un bloqueo exclusivo en el índice agrupado fila porque se está modificando la tabla base. El cambio en ParentValue la columna no afecta la clave principal no agrupada en ParentID , para que esa fila del índice no agrupado no esté bloqueada .

Por lo tanto, la verificación de clave externa puede adquirir el bloqueo compartido necesario en el índice de clave principal no agrupado sin contención, y la inserción de la tabla secundaria tiene éxito de inmediato .

Cuando el principal estaba agrupado, la verificación de clave externa necesitaba un bloqueo compartido en el mismo recurso (fila de índice agrupado) que estaba bloqueado exclusivamente por la declaración de actualización.

El comportamiento puede ser sorprendente, pero no es un error . Dar a la verificación de clave externa su propio método de acceso optimizado evita la contención de bloqueo lógicamente innecesaria. No hay necesidad de bloquear la búsqueda de clave externa porque el ParentID el atributo no se ve afectado por la actualización simultánea.

2. Conflictos de actualización evitables

Si ejecuta las pruebas anteriores en el nivel de aislamiento de instantáneas (SI), el resultado será el mismo. La fila secundaria inserta bloques cuando la clave a la que se hace referencia se aplica mediante un índice agrupado y no bloquea cuando la aplicación de claves utiliza un no agrupado índice único.

Sin embargo, hay una diferencia potencial importante cuando se usa SI. Bajo el aislamiento de lectura confirmada (bloqueo o RCSI), la inserción de la fila secundaria finalmente tiene éxito después de que la actualización en la segunda sesión se confirme o revierta. Al usar SI, existe el riesgo de una transacción abortar debido a un aparente conflicto de actualización.

Esto es un poco más complicado de demostrar porque una transacción instantánea no comienza con BEGIN TRANSACTION declaración:comienza con el primer acceso a los datos del usuario después de ese punto.

La siguiente secuencia de comandos configura la demostración SI, con una tabla ficticia adicional que se usa solo para garantizar que la transacción de la instantánea realmente haya comenzado. Utiliza la variación de prueba donde la clave principal a la que se hace referencia se aplica mediante un único agrupado índice (el predeterminado):

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

Insertando la fila principal:

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

Todavía en la primera sesión , inicie la transacción de la instantánea:

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

En la segunda sesión (ejecutándose en cualquier nivel de aislamiento):

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

Intentar insertar la fila secundaria en los bloques de la primera sesión como se esperaba:

-- 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 diferencia ocurre cuando finalizamos la transacción en la segunda sesión. Si lo hacemos retroceder , la inserción de la fila secundaria de la primera sesión se completa correctamente .

Si, en cambio, nos comprometemos la transacción abierta:

-- Session 2
COMMIT TRANSACTION;

La primera sesión informa de un conflicto de actualización y retrocede:

Explicación

Este conflicto de actualización ocurre a pesar de que la clave externa siendo validado no fue cambiado por la actualización de la segunda sesión.

La razón es esencialmente la misma que en el primer conjunto de pruebas. Cuando el índice agrupado se utiliza para la aplicación de claves referenciadas, la transacción de instantánea encuentra una fila que ha sido modificado desde que comenzó. Esto no está permitido bajo el aislamiento de instantáneas.

Cuando la clave se aplica mediante un índice no agrupado , la transacción de instantánea solo ve la fila de índice no agrupada sin modificar, por lo que no hay bloqueo y no se detecta ningún "conflicto de actualización".

Hay muchas otras circunstancias en las que el aislamiento de instantáneas puede informar conflictos de actualización inesperados u otros errores. Consulte mi artículo anterior para ver ejemplos.

Conclusiones

Hay muchas consideraciones que se deben tener en cuenta al elegir el índice agrupado para una tabla de almacenamiento de filas. Los problemas descritos aquí son solo otro factor para evaluar.

Esto es especialmente cierto si va a utilizar el aislamiento de instantáneas. Nadie disfruta de una transacción abortada , especialmente uno que podría decirse que es ilógico. Si va a utilizar RCSI, el bloqueo al leer validar claves foráneas puede ser inesperado y puede conducir a interbloqueos.

El predeterminado para una PRIMARY KEY la restricción es crear su índice de soporte como agrupado , a menos que otro índice o restricción en la definición de la tabla sea explícito acerca de agruparse en su lugar. Es un buen hábito ser explícito sobre la intención de su diseño, por lo que le recomiendo que escriba CLUSTERED o NONCLUSTERED cada vez.

¿Índices duplicados?

Puede haber ocasiones en las que considere seriamente, por razones sólidas, tener un índice agrupado y un índice no agrupado con las mismas claves. .

La intención podría ser proporcionar un acceso de lectura óptimo para las consultas de los usuarios a través de agrupados index (evitando búsquedas de claves), al mismo tiempo que permite la validación de claves foráneas con un bloqueo mínimo (y conflictos de actualización) a través del compacto no agrupado índice como se muestra aquí.

Esto es factible, pero hay un par de inconvenientes a tener en cuenta:

  1. Dado más de un índice de destino adecuado, SQL Server no proporciona una forma de garantizar qué índice se usará para la aplicación de la clave externa.

    Dan Guzman documentó sus observaciones en Secrets of Foreign Key Index Binding, pero es posible que estén incompletos y, en cualquier caso, no estén documentados, por lo que podrían cambiar. .

    Puede solucionar esto asegurándose de que solo haya un objetivo index en el momento en que se crea la clave externa, pero complica las cosas e invita a futuros problemas si la restricción de clave externa alguna vez se elimina y se vuelve a crear.

  2. Si usa la sintaxis abreviada de clave externa, SQL Server solo vincular la restricción a la clave principal , ya sea no agrupado o agrupado.

El siguiente fragmento de código demuestra la última diferencia:

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

La gente se ha acostumbrado a ignorar en gran medida los conflictos de lectura y escritura en RCSI y SI. Esperamos que este artículo le haya dado algo más en lo que pensar al implementar el diseño físico para tablas relacionadas por una clave externa.