Como o SQL Server está retornando um novo valor e um valor antigo durante uma atualização?

8

Tivemos problemas, durante alta simultaneidade, de consultas retornando resultados não sensoriais - resultados que violam a lógica das consultas sendo emitidas. Demorou um pouco para reproduzir o problema. Consegui destilar o problema reproduzível em alguns punhados de T-SQL.

Nota : A parte do sistema ativo com o problema é composta por 5 tabelas, 4 gatilhos, 2 procedimentos armazenados e 2 visualizações. Simplifiquei o sistema real para algo muito mais gerenciável para uma pergunta postada. As coisas foram reduzidas, as colunas removidas, os procedimentos armazenados alinhados, as visualizações transformadas em expressões comuns da tabela, os valores das colunas alterados. É um longo caminho para dizer que, embora o que se segue reproduza um erro, pode ser mais difícil de entender. Você terá que se abster de se perguntar por que algo está estruturado do jeito que está. Estou aqui tentando descobrir por que a condição de erro ocorre de forma reproduzível neste modelo de brinquedo.

/*
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

As transações são inseridas como WaitingList. Em seguida, temos uma tarefa periódica que é executada, procurando slots vazios e colide com qualquer um na lista de espera para um status de contratado.

Em uma janela separada do SSMS, temos o procedimento armazenado recorrente 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

E, finalmente, execute isso em uma terceira janela de conexão SSMS. Isso simula um problema de simultaneidade em que a transação anterior deixa de ocupar um slot e fica na 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

Conceitualmente, o procedimento de colisão continua procurando por slots vazios. Se encontrar um, ele pega a transação mais antiga que está no WaitingListe marca como Booked.

Quando testada sem simultaneidade, a lógica funciona. Temos duas transações:

  • 12:00 pm: WaitingList
  • 12:20: WaitingList

Há 1 alocação e 0 transações registradas, portanto, marcamos a transação anterior como registrada:

  • 12:00: Reservado
  • 12:20: WaitingList

Na próxima vez que a tarefa for executada, agora há 1 slot sendo ocupado - portanto, não há nada para atualizar.

Se atualizarmos a primeira transação e a colocarmos em WaitingList:

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

Então estamos de volta onde começamos:

  • 12:00 pm: WaitingList
  • 12:20: WaitingList

Nota : Você pode estar se perguntando por que estou colocando uma transação de volta na lista de espera. Isso é uma vítima do modelo de brinquedo simplificado. No sistema real, as transações podem ser PendingApproval, que também ocupa um slot. Uma transação PendingApproval é colocada na lista de espera quando é aprovada. Não importa. Não se preocupe com isso.

Mas quando eu introduzo a simultaneidade, ao ter uma segunda janela constantemente colocando a primeira transação de volta na lista de espera depois de ser registrada, a transação posterior conseguiu obter a reserva:

  • 12:00 pm: WaitingList
  • 12:20: Reservado

Os scripts de teste de brinquedo capturam isso e param de iterar:

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

Por quê?

A questão é: por que, neste modelo de brinquedo, essa condição de resgate está sendo acionada?

Existem dois estados possíveis para o status de aprovação da primeira transação:

  • Reservado : nesse caso, o slot está ocupado e a transação posterior não pode tê-lo
  • WaitingList : nesse caso, há um slot vazio e duas transações que o desejam. Mas como sempre temos selecta transação mais antiga (ou seja ORDER BY CreatedDate), a primeira transação deve ser obtida.

Eu pensei que talvez por causa de outros índices

Eu aprendi que, depois de um UPDATE já começou, e os dados tem sido modificado, é possível ler os valores antigos. Nas condições iniciais:

  • Índice agrupado :Booked
  • Índice não clusterizado :Booked

Em seguida, faço uma atualização e, enquanto o nó da folha de índice em cluster foi modificado, qualquer índice não em cluster ainda contém o valor original e ainda está disponível para leitura:

  • Índice em cluster (bloqueio exclusivo):Booked WaitingList
  • Índice não agrupado : (desbloqueado)Booked

Mas isso não explica o problema observado. Sim, a transação não é mais contratada , o que significa que agora existe um espaço vazio. Mas essa mudança ainda não foi confirmada, ainda é realizada exclusivamente. Se o procedimento de colisão fosse executado, seria:

  • bloco: se a opção de banco de dados de isolamento de captura instantânea estiver desativada
  • leia o valor antigo (por exemplo Booked): se o isolamento de captura instantânea estiver ativado

De qualquer forma, o trabalho de reposição não saberia que há um slot vazio.

Então eu não tenho ideia

Estamos lutando há dias para descobrir como esses resultados sem sentido poderiam acontecer.

Você pode não entender o sistema original, mas há um conjunto de scripts reproduzíveis em brinquedos. Eles saem quando o caso inválido é detectado. Por que está sendo detectado? Por que isso está acontecendo?

Pergunta bônus

Como a NASDAQ resolve isso? Como o cavirtex? Como mtgox?

tl; dr

Existem três blocos de script. Coloque-os em três guias separadas do SSMS e execute-os. Os scripts 2 e 3 geram um erro. Ajude-me a descobrir por que eles aparecem.

Ian Boyd
fonte
Provavelmente é algo a ver com o nível de isolamento da transação. Qual nível de isolamento você está usando no seu sistema?
cha
@cha Padrão (READ COMMITTED). Copie e cole os scripts e você pode confirmar que realmente é o nível padrão.
Ian Boyd
Quando sua terceira guia "Redefinir a linha defeituosa", essa linha fica disponível. Assim, sua segunda guia pode alocá-la antes que a terceira guia marque a linha anterior como disponível. Tente fazer as duas modificações em UPDATE na sua terceira guia.
AK

Respostas:

12

O READ COMMITTEDnível de isolamento da transação padrão garante que sua transação não leia dados não confirmados. Ele faz não garantia de que todos os dados que você lê permanecerá o mesmo se você lê-lo novamente (repetíveis) ou que novos dados não aparecerá (fantasmas).

Essas mesmas considerações se aplicam a vários acessos de dados na mesma instrução .

Sua UPDATEdeclaração produz um plano que acessa a Transactionstabela mais de uma vez, portanto é suscetível a efeitos causados ​​por leituras e fantasmas não repetíveis.

Acesso múltiplo

Existem várias maneiras para esse plano produzir resultados que você não espera READ COMMITTEDisoladamente.

Um exemplo

O primeiro Transactionsacesso à tabela localiza linhas com status de WaitingList. O segundo acesso conta o número de entradas (para o mesmo trabalho) com status de Booked. O primeiro acesso pode retornar apenas a transação posterior (a anterior é Bookedneste momento). Quando o segundo acesso (contando) ocorre, a transação anterior foi alterada para WaitingList. A linha posterior, portanto, se qualifica para a atualização do Bookedstatus.

Soluções

Existem várias maneiras de definir a semântica de isolamento para obter os resultados desejados. Uma opção é ativar READ_COMMITTED_SNAPSHOTo banco de dados. Isso fornece consistência de leitura no nível da instrução para instruções executadas no nível de isolamento padrão. Leituras e fantasmas não repetíveis não são possíveis no isolamento de captura instantânea confirmada por leitura.

Outras observações

Devo dizer, porém, que não teria projetado o esquema ou a consulta dessa maneira. Há muito mais trabalho envolvido do que seria necessário para atender aos requisitos comerciais estabelecidos. Talvez isso seja parcialmente o resultado das simplificações da pergunta, em qualquer caso que seja uma pergunta separada.

O comportamento que você está vendo não representa nenhum tipo de erro. Os scripts produzem resultados corretos, dada a semântica de isolamento solicitada. Efeitos de simultaneidade como esse também não se limitam a planos que acessam dados várias vezes.

O nível de isolamento confirmado pela leitura fornece muito menos garantias do que normalmente é assumido. Por exemplo, pular linhas e / ou ler a mesma linha mais de uma vez é perfeitamente possível.

Paul White 9
fonte
Estou tentando descobrir a ordem das operações que causam o resultado incorreto. Ele INNERingressa primeiro Transactionscom Allocationsbase no WaitingListstatus. Essa junção acontece antes que o UPDATEtake any IXou Xbloqueie. Como a primeira transação ainda está parada Booked, a INNER JOINúnica encontra a transação posterior. Em seguida, ele acessa a Transactionstabela novamente para executar a LEFT OUTER JOINcontagem de slots disponíveis. Nesse momento, a primeira transação foi atualizada para WaitingList, o que significa que há um slot.
Ian Boyd
O sistema real possui níveis extras de complexidade. Por exemplo, o JobNamenão é (e não pode) ser armazenado com o Transactionmas com um Employee. Então Transactionscontém um EmployeeID, e nós temos que participar. Também são definidas alocações disponíveis para um dia e um trabalho . Portanto, a Allocationstabela é realmente (TransactionDate, JobName). Finalmente, uma pessoa pode ter várias transações para o mesmo dia; que precisam ocupar apenas 1 slot. Então o sistema real faz um distinct-countby Employee,Job,Date. Ignorando tudo isso, que mudança você faria no brinquedo? Talvez possa ser adotado de volta.
Ian Boyd
2
@ IanBoyd Re: o primeiro comentário, sim (exceto que não é um resultado errado). Re: o segundo comentário, que seria trabalho de consultoria :)
Paul White 9
2
@AlexKuznetsov Com base no meu conhecimento recente, a questão das férias com ingressos da Arnie / Carol pode ocorrer READ COMMITTEDisoladamente. Sair de férias se houver algum bilhete atribuído a mim. Se essa verificação da Ticketstabela usar um índice, pensará erroneamente que o ticket não foi atribuído a mim. Então alguém me atribui a passagem e o gatilho usa um índice para pensar que ainda não estou de férias. Resultado: um ticket ativo é atribuído a um desenvolvedor em férias. Com esse novo conhecimento, quero me deitar e chorar; meu mundo inteiro está desfeito, tudo que eu já escrevi está errado.
Ian Boyd
11
@IanBoyd, é por isso que usamos restrições para impor regras como a que você tem problemas. Substituímos o último gatilho por restrições há mais de dois anos e, desde então, desfrutamos da integridade dos dados à prova d'água. Além disso, não precisamos mais aprender detalhadamente bloqueios, níveis de isolamento, etc. - as restrições funcionam, desde que você não use MERGE, é claro.
AK