¿Cómo devuelve SQL Server un valor nuevo y un valor antiguo durante una ACTUALIZACIÓN?

8

Hemos tenido problemas, durante la alta concurrencia, de consultas que devuelven resultados no sensitivos, resultados que violan la lógica de las consultas que se emiten. Me llevó un tiempo reproducir el problema. Me las arreglé para destilar el problema reproducible a unos pocos puñados de T-SQL.

Nota : La parte del sistema en vivo que tiene el problema se compone de 5 tablas, 4 disparadores, 2 procedimientos almacenados y 2 vistas. He simplificado el sistema real en algo mucho más manejable para una pregunta publicada. Se han reducido las cosas, se han eliminado las columnas, los procedimientos almacenados están en línea, las vistas se han convertido en expresiones de tabla comunes, se han cambiado los valores de las columnas. Todo esto es un largo camino para decir que si bien lo que sigue reproduce un error, puede ser más difícil de entender. Tendrás que abstenerte de preguntarte por qué algo está estructurado de la manera que es. Estoy tratando de averiguar por qué la condición de error ocurre de forma reproducible en este modelo de juguete.

/*
The idea in this system is that people are able to take days off. 
We create a table to hold these *"allocations"*, 
and declare sample data that only **1** production operator 
is allowed to take time off:
*/
IF OBJECT_ID('Allocations') IS NOT NULL DROP TABLE Allocations
CREATE TABLE [dbo].[Allocations](
    JobName varchar(50) PRIMARY KEY NOT NULL,
    Available int NOT NULL
)
--Sample allocation; there is 1 avaialable slot for this job
INSERT INTO Allocations(JobName, Available)
VALUES ('Production Operator', 1);

/*
Then we open up the system to the world, and everyone puts in for time. 
We store these requests for time off as *"transactions"*. 
Two production operators requested time off. 
We create sample data, and note that one of the users 
created their transaction first (by earlier CreatedDate):
*/
IF OBJECT_ID('Transactions') IS NOT NULL DROP TABLE Transactions;
CREATE TABLE [dbo].[Transactions](
    TransactionID int NOT NULL PRIMARY KEY CLUSTERED,
    JobName varchar(50) NOT NULL,
    ApprovalStatus varchar(50) NOT NULL,
    CreatedDate datetime NOT NULL
)
--Two sample transactions
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (52625, 'Production Operator', 'Booked', '20140125 12:00:40.820');
INSERT INTO Transactions (TransactionID, JobName, ApprovalStatus, CreatedDate)
VALUES (60981, 'Production Operator', 'WaitingList', '20150125 12:19:44.717');

/*
The allocation, and two sample transactions are now in the database:
*/
--Show the sample data
SELECT * FROM Allocations
SELECT * FROM Transactions

Las transacciones se insertan como WaitingList. A continuación, tenemos una tarea periódica que se ejecuta, buscando espacios vacíos y pone a cualquiera en la lista de espera en un estado reservado.

En una ventana SSMS separada, tenemos el procedimiento almacenado recurrente simulado:

/*
    Simulate recurring task that looks for empty slots, 
    and bumps someone on the waiting list into that slot.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

--DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 1000000)
BEGIN
    SET @attempts = @attempts+1;

    /*
        The concept is that if someone is already "Booked", then they occupy an available slot.
        We compare the configured amount of allocations (e.g. 1) to how many slots are used.
        If there are any slots leftover, then find the **earliest** created transaction that 
        is currently on the WaitingList, and set them to Booked.
    */

    PRINT '=== Looking for someone to bump ==='
    WITH AvailableAllocations AS (
        SELECT 
            a.JobName,
            a.Available AS Allocations, 
            ISNULL(Booked.BookedCount, 0) AS BookedCount, 
            a.Available-ISNULL(Booked.BookedCount, 0) AS Available
        FROM Allocations a
            FULL OUTER JOIN (
                SELECT t.JobName, COUNT(*) AS BookedCount
                FROM Transactions t
                WHERE t.ApprovalStatus IN ('Booked') 
                GROUP BY t.JobName
            ) Booked
            ON a.JobName = Booked.JobName
        WHERE a.Available > 0
    )
    UPDATE Transactions SET ApprovalStatus = 'Booked'
    WHERE TransactionID = (
        SELECT TOP 1 t.TransactionID
        FROM AvailableAllocations aa
            INNER JOIN Transactions t
            ON aa.JobName = t.JobName
            AND t.ApprovalStatus = 'WaitingList'
        WHERE aa.Available > 0
        ORDER BY t.CreatedDate 
    )


    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

Y finalmente ejecute esto en una tercera ventana de conexión SSMS. Esto simula un problema de concurrencia en el que la transacción anterior pasa de ocupar un espacio, a estar en la lista de espera:

/*
    Toggle the earlier transaction back to "WaitingList".
    This means there are two possibilies:
       a) the transaction is "Booked", meaning no slots are available. 
          Therefore nobody should get bumped into "Booked"
       b) the transaction is "WaitingList", 
          meaning 1 slot is open and both tranasctions are "WaitingList"
          The earliest transaction should then get "Booked" into the slot.

    There is no time when there is an open slot where the 
    first transaction shouldn't be the one to get it - he got there first.
*/
SET NOCOUNT ON;

--Reset the faulty row so we can continue testing
UPDATE Transactions SET ApprovalStatus = 'WaitingList'
WHERE TransactionID = 60981

DECLARE @attempts int
SET @attempts = 0;

WHILE (@attempts < 100000)
BEGIN
    SET @attempts = @attempts+1

    /*Flip the earlier transaction from Booked back to WaitingList
        Because it's now on the waiting list -> there is a free slot.
        Because there is a free slot -> a transaction can be booked.
        Because this is the earlier transaction -> it should always be chosen to be booked
    */
    --DBCC TRACEON(3604,1200,3916,-1) WITH NO_INFOMSGS

    PRINT '=== Putting the earlier created transaction on the waiting list ==='

    UPDATE Transactions
    SET ApprovalStatus = 'WaitingList'
    WHERE TransactionID = 52625

    --DBCC TRACEOFF(3604,1200,3916,-1) WITH NO_INFOMSGS

    IF EXISTS(SELECT * FROM Transactions WHERE TransactionID = 60981 AND ApprovalStatus = 'Booked')
    begin
        RAISERROR('The later tranasction, that should never be booked, managed to get booked!', 16, 1)
        BREAK;
    END
END

Conceptualmente, el chocar procedimiento mantiene en busca de cualquiera de las ranuras vacías. Si encuentra uno, toma la primera transacción que está en el WaitingListy lo marca como Booked.

Cuando se prueba sin concurrencia, la lógica funciona. Tenemos dos transacciones:

  • 12:00 pm: Lista de espera
  • 12:20 pm: Lista de espera

Hay 1 asignación y 0 transacciones reservadas, por lo que marcamos la transacción anterior como reservada:

  • 12:00 pm: reservado
  • 12:20 pm: Lista de espera

La próxima vez que se ejecute la tarea, ahora hay una ranura ocupada, por lo que no hay nada que actualizar.

Si luego actualizamos la primera transacción y la colocamos en WaitingList:

UPDATE Transactions SET ApprovalStatus='WaitingList'
WHERE TransactionID = 60981

Luego volvemos a donde comenzamos:

  • 12:00 pm: Lista de espera
  • 12:20 pm: Lista de espera

Nota : Puede que se pregunte por qué vuelvo a colocar una transacción en la lista de espera. Esa es una baja del modelo de juguete simplificado. En el sistema real, las transacciones pueden ser PendingApproval, que también ocupa un espacio. Una transacción de aprobación pendiente se coloca en la lista de espera cuando se aprueba. No importa No te preocupes por eso.

Pero cuando introduzco la concurrencia, al tener una segunda ventana que vuelve a colocar constantemente la primera transacción en la lista de espera después de ser reservada, la transacción posterior logró obtener la reserva:

  • 12:00 pm: Lista de espera
  • 12:20 pm: reservado

Los scripts de prueba de juguete captan esto y dejan de iterar:

Msg 50000, Level 16, State 1, Line 41
The later tranasction, that should never be booked, managed to get booked!

¿Por qué?

La pregunta es, ¿por qué en este modelo de juguete, se está desencadenando esta condición de rescate?

Hay dos estados posibles para el estado de aprobación de la primera transacción:

  • Reservado : en cuyo caso el espacio está ocupado y la transacción posterior no puede tenerlo
  • WaitingList : en cuyo caso hay un espacio vacío y dos transacciones que lo desean. Pero dado que siempre somos selectla transacción más antigua (es decir ORDER BY CreatedDate), la primera transacción debería obtenerla.

Pensé que tal vez por otros índices

Aprendí que después de que se inicia una ACTUALIZACIÓN y se modifican los datos , es posible leer los valores anteriores. En las condiciones iniciales:

  • Índice agrupado :Booked
  • Índice no agrupado :Booked

Luego hago una actualización, y aunque el nodo hoja de índice agrupado ha sido modificado, cualquier índice no agrupado todavía contiene el valor original y todavía está disponible para leer:

  • Índice agrupado (bloqueo exclusivo):Booked WaitingList
  • Índice no agrupado : (desbloqueado)Booked

Pero eso no explica el problema observado. Sí, la transacción ya no está reservada , lo que significa que ahora hay un espacio vacío. Pero ese cambio aún no se ha comprometido, todavía se mantiene exclusivamente. Si se ejecuta el procedimiento de choque, ya sea:

  • bloque: si la opción de base de datos de aislamiento de instantánea está desactivada
  • lea el valor anterior (p Booked. ej. ): si el aislamiento de instantánea está activado

De cualquier manera, el trabajo de choque no sabría que hay una ranura vacía.

Entonces no tengo idea

Hemos estado luchando durante días para descubrir cómo podrían suceder estos resultados sin sentido.

Puede que no entiendas el sistema original, pero hay un conjunto de guiones reproducibles de juguete. Se rescatan cuando se detecta el caso no válido. ¿Por qué se está detectando? Por que esta sucediendo?

Pregunta extra

¿Cómo resuelve NASDAQ esto? ¿Cómo funciona cavirtex? ¿Cómo funciona mtgox?

tl; dr

Hay tres bloques de guiones. Póngalos en 3 pestañas SSMS separadas y ejecútelos. El segundo y tercer guiones generarán un error. Ayúdame a descubrir por qué aparece el error.

Ian Boyd
fuente
Probablemente tenga algo que ver con el nivel de aislamiento de la transacción. ¿Qué nivel de aislamiento estás usando en tu sistema?
cha
@cha Predeterminado (LEÍDO COMPROMETIDO). Copie y pegue los scripts y puede confirmar que realmente es el nivel predeterminado.
Ian Boyd
Cuando su tercera pestaña "Restablezca la fila defectuosa", esa fila estará disponible. Como tal, su segunda pestaña puede asignarla antes de que la tercera pestaña marque la fila anterior como disponible. Intente hacer ambas modificaciones en ACTUALIZAR en su tercera pestaña.
AK

Respuestas:

12

El READ COMMITTEDnivel de aislamiento de transacción predeterminado garantiza que su transacción no leerá datos no confirmados. Lo hace no garantía de que todos los datos que lee seguirá siendo el mismo si lo lee de nuevo (repetible lee) o que no van a aparecer nuevos datos (fantasmas).

Estas mismas consideraciones se aplican a los múltiples accesos a datos dentro de la misma declaración .

Su UPDATEestado de cuenta produce un plan que accede a la Transactionstabla más de una vez, por lo que es susceptible a los efectos causados ​​por lecturas y fantasmas no repetibles.

Acceso multiple

Hay varias formas para que este plan produzca resultados que no espera bajo READ COMMITTEDaislamiento.

Un ejemplo

El primer Transactionsacceso a la tabla encuentra filas que tienen un estado de WaitingList. El segundo acceso cuenta el número de entradas (para el mismo trabajo) que tienen un estado de Booked. El primer acceso puede devolver solo la transacción posterior (la anterior se encuentra Bookeden este punto). Cuando se produce el segundo acceso (de conteo), la transacción anterior se ha cambiado a WaitingList. Por lo tanto, la fila posterior califica para la actualización del Bookedestado.

Soluciones

Hay varias formas de establecer la semántica de aislamiento para obtener los resultados que busca. Una opción es habilitar READ_COMMITTED_SNAPSHOTpara la base de datos. Esto proporciona consistencia de lectura a nivel de declaración para las declaraciones que se ejecutan en el nivel de aislamiento predeterminado. Las lecturas y los fantasmas no repetibles no son posibles con el aislamiento de instantáneas confirmado de lectura.

Otras observaciones

Sin embargo, debo decir que no habría diseñado el esquema o la consulta de esta manera. Se requiere bastante más trabajo del necesario para cumplir con los requisitos comerciales establecidos. Quizás esto sea en parte el resultado de las simplificaciones en la pregunta, en cualquier caso, es una pregunta separada.

El comportamiento que está viendo no representa un error de ningún tipo. Los scripts producen resultados correctos dada la semántica de aislamiento solicitada. Los efectos de concurrencia como este tampoco se limitan a los planes que acceden a los datos varias veces.

El nivel de aislamiento de lectura confirmada ofrece muchas menos garantías de las que comúnmente se supone. Por ejemplo, omitir filas y / o leer la misma fila más de una vez es perfectamente posible.

Paul White 9
fuente
Estoy tratando de averiguar el orden de las operaciones que causa el resultado erróneo. Primero se INNERune Transactionsa en Allocationsfunción del WaitingListestado. Esta unión ocurre antes de que la UPDATEtoma cualquiera IXo se Xbloquee. Debido a que la primera transacción es inmóvil Booked, la INNER JOINúnica encuentra la transacción posterior. Luego, vuelve a acceder a la Transactionstabla para realizar LEFT OUTER JOINun recuento de ranuras disponibles. En este momento la primera transacción se ha actualizado a WaitingList, lo que significa que hay un espacio.
Ian Boyd
El sistema real tiene niveles adicionales de complejidad. Por ejemplo, el JobNameno es (y no puede) almacenarse con el Transactionpero con un Employee. Entonces Transactionscontiene un EmployeeID, y tenemos que unirnos. También se definen asignaciones disponibles para un día y un trabajo . Entonces la Allocationstabla es en realidad (TransactionDate, JobName). Finalmente, una persona puede tener múltiples transacciones para el mismo día; que solo deben ocupar 1 espacio. Entonces el sistema real hace un distinct-countby Employee,Job,Date. Ignorando todo eso, ¿qué cambio le harías al juguete? Tal vez pueda ser adoptado de nuevo.
Ian Boyd
2
@IanBoyd Re: el primer comentario, sí (excepto que no es un resultado erróneo). Re: el segundo comentario, eso sería trabajo de consultoría :)
Paul White 9
2
@AlexKuznetsov De acuerdo con mi conocimiento recién descubierto, el problema de las vacaciones de boletos Arnie / Carol puede ocurrir de forma READ COMMITTEDaislada. Ir a cheques de vacaciones si hay boletos asignados a mí. Si esa verificación de la Ticketstabla usa un índice, erróneamente pensará que el ticket no está asignado a mí. Entonces alguien me asigna el boleto, y el gatillo usa un índice para pensar que aún no estoy de vacaciones. Resultado: se asigna un boleto activo a un desarrollador de vacaciones. Con este nuevo conocimiento, quiero acostarme y llorar; todo mi mundo está deshecho, todo lo que he escrito está mal.
Ian Boyd
1
@IanBoyd es por eso que usamos restricciones para hacer cumplir las reglas como la que tiene problemas. Hemos reemplazado el último disparador con restricciones hace más de dos años, y desde entonces estamos disfrutando de la integridad de los datos. Además, ya no tenemos que aprender con gran detalle bloqueos, niveles de aislamiento, etc. Las restricciones simplemente funcionan, siempre y cuando no use MERGE, por supuesto.
AK