In che modo SQL Server restituisce sia un nuovo valore che un valore precedente durante un AGGIORNAMENTO?


8

Abbiamo riscontrato problemi, durante un'elevata concorrenza, delle query che restituiscono risultati non sensati: i risultati violano la logica delle query emesse. Ci è voluto del tempo per riprodurre il problema. Sono riuscito a distillare il problema riproducibile fino a poche manciate di T-SQL.

Nota : la parte del sistema live che presenta il problema è composta da 5 tabelle, 4 trigger, 2 stored procedure e 2 viste. Ho semplificato il sistema reale in qualcosa di molto più gestibile per una domanda postata. Le cose sono state ridotte, le colonne rimosse, le procedure memorizzate rese in linea, le viste trasformate in espressioni comuni delle tabelle, i valori delle colonne cambiati. Questo è un lungo modo di dire che mentre ciò che segue riproduce un errore, può essere più difficile da capire. Dovrai astenersi dal chiederti perché qualcosa è strutturato così com'è. Sto qui cercando di capire perché la condizione di errore si verifica in modo riproducibile in questo modello di giocattolo.

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

Le transazioni sono entrambe inserite come WaitingList. Successivamente abbiamo un'attività periodica che viene eseguita, cercando slot vuoti e portando tutti gli utenti nella lista di attesa in uno stato Prenotato.

In una finestra SSMS separata, abbiamo la procedura memorizzata ricorrente simulata:

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

Ed infine eseguilo in una terza finestra di connessione SSMS. Questo simula un problema di concorrenza in cui la transazione precedente passa dall'assunzione di uno slot, all'essere nella lista di attesa:

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

Concettualmente, la procedura di bumping continua a cercare eventuali slot vuoti. Se ne trova uno, accetta la prima transazione presente su WaitingListe la contrassegna come Booked.

Se testato senza concorrenza, la logica funziona. Abbiamo due transazioni:

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

Vi è 1 allocazione e 0 transazioni prenotate, quindi contrassegniamo la transazione precedente come prenotata:

  • 12:00: prenotato
  • 12:20: WaitingList

Alla successiva esecuzione dell'attività, ora viene occupato 1 slot, quindi non c'è nulla da aggiornare.

Se quindi aggiorniamo la prima transazione e la inseriamo nel WaitingList:

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

Quindi siamo tornati da dove abbiamo iniziato:

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

Nota : ti starai chiedendo perché sto rimettendo una transazione nella lista di attesa. Questa è una vittima del modello di giocattolo semplificato. Nel sistema reale possono essere le transazioni PendingApproval, che occupa anche uno slot. Una transazione PendingApproval viene inserita nella lista di attesa quando viene approvata. Non importa Non ti preoccupare.

Ma quando introduco la concorrenza, avendo una seconda finestra che rimette costantemente la prima transazione nella lista d'attesa dopo essere stata prenotata, la transazione successiva è riuscita a ottenere la prenotazione:

  • 12:00: WaitingList
  • 12:20: prenotato

Gli script di test del giocattolo catturano questo e smettono di iterare:

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

Perché?

La domanda è: perché in questo modello di giocattolo viene innescata questa condizione di salvataggio?

Esistono due stati possibili per lo stato di approvazione della prima transazione:

  • Prenotato : nel qual caso lo slot è occupato e la transazione successiva non può averlo
  • WaitingList : nel qual caso c'è uno slot vuoto e due transazioni che lo vogliono. Ma poiché abbiamo sempre selectla transazione più vecchia (ovvero ORDER BY CreatedDate) la prima transazione dovrebbe ottenerla.

Ho pensato forse a causa di altri indici

Ho imparato che dopo un aggiornamento è iniziata, e dei dati è stato modificato, è possibile leggere i vecchi valori. Nelle condizioni iniziali:

  • Indice cluster :Booked
  • Indice non cluster :Booked

Quindi faccio un aggiornamento e mentre il nodo foglia indice cluster è stato modificato, tutti gli indici non cluster contengono ancora il valore originale e sono ancora disponibili per la lettura:

  • Indice cluster (blocco esclusivo):Booked WaitingList
  • Indice non cluster : (sbloccato)Booked

Ma ciò non spiega il problema osservato. Sì, la transazione non è più prenotata , il che significa che ora c'è uno slot vuoto. Ma quel cambiamento non è ancora stato impegnato, è ancora riservato. Se la procedura di bumping fosse eseguita, sarebbe:

  • blocco: se l'opzione del database di isolamento dello snapshot è disattivata
  • leggere il vecchio valore (ad es. Booked): se l'isolamento dello snapshot è attivo

Ad ogni modo, il lavoro di bumping non saprebbe che c'è uno slot vuoto.

Quindi non ne ho idea

Abbiamo lottato per giorni per capire come questi risultati senza senso potevano accadere.

Potresti non capire il sistema originale, ma c'è una serie di script riproducibili giocattolo. Si salvano quando viene rilevato il caso non valido. Perché viene rilevato? Perché sta succedendo?

Domanda bonus

Come risolve il NASDAQ? Come funziona cavirtex? Come funziona mtgox?

tl; dr

Ci sono tre blocchi di script. Inseriscili in 3 schede SSMS separate ed eseguili. Il 2 ° e il 3 ° script genereranno un errore. Aiutami a capire perché compaiono i loro errori.


Probabilmente ha a che fare con il livello di isolamento delle transazioni. Quale livello di isolamento stai usando nel tuo sistema?
cha

@cha Impostazione predefinita (LEGGI IMPEGNATO). Copia e incolla gli script e puoi confermare che è davvero il livello predefinito.
Ian Boyd,

Quando la terza scheda "Reimposta la riga difettosa", quella riga diventa disponibile. Come tale, la tua seconda scheda può allocarla prima che la terza scheda contrassegni la riga precedente come disponibile. Prova ad apportare entrambe le modifiche in AGGIORNAMENTO nella terza scheda.
AK,

Risposte:


12

Il READ COMMITTEDlivello di isolamento della transazione predefinito garantisce che la transazione non leggerà i dati non impegnati. Lo fa non garantisce che tutti i dati che si leggono rimarrà lo stesso se lo si legge ancora una volta (ripetibile legge), o che non compariranno i nuovi dati (fantasmi).

Queste stesse considerazioni si applicano a più accessi ai dati all'interno della stessa istruzione .

La tua UPDATEdichiarazione produce un piano che accede alla Transactionstabella più di una volta, quindi è suscettibile agli effetti causati da letture e fantasmi non ripetibili.

Accesso multiplo

Esistono diversi modi per questo piano per produrre risultati che non ti aspetti in READ COMMITTEDisolamento.

Un esempio

Il primo Transactionsaccesso alla tabella trova le righe con uno stato di WaitingList. Il secondo accesso conta il numero di voci (per lo stesso lavoro) che hanno uno stato di Booked. Il primo accesso può restituire solo la transazione successiva (la prima è Bookeda questo punto). Quando si verifica il secondo accesso (conteggio), la transazione precedente è stata modificata in WaitingList. La riga successiva pertanto si qualifica per l'aggiornamento allo Bookedstato.

soluzioni

Esistono diversi modi per impostare la semantica di isolamento per ottenere i risultati desiderati. Un'opzione è abilitare READ_COMMITTED_SNAPSHOTper il database. Ciò fornisce coerenza di lettura a livello di istruzione per le istruzioni eseguite al livello di isolamento predefinito. Le letture e i fantasmi non ripetibili non sono possibili con l'isolamento dell'istantanea con commit della lettura.

Altre osservazioni

Devo dire però che non avrei progettato lo schema o interrogato in questo modo. C'è un po 'più di lavoro coinvolto di quanto dovrebbe essere necessario per soddisfare i requisiti aziendali dichiarati. Forse questo è in parte il risultato delle semplificazioni nella domanda, in ogni caso si tratta di una domanda separata.

Il comportamento che stai vedendo non rappresenta un bug di alcun tipo. Gli script producono risultati corretti data la semantica di isolamento richiesta. Gli effetti di concorrenza come questo non si limitano ai piani che accedono ai dati più volte.

Il livello di isolamento di commit della lettura offre molte meno garanzie di quanto si pensi comunemente. Ad esempio, saltare le righe e / o leggere la stessa riga più di una volta è perfettamente possibile.


sto cercando di capire l'ordine delle operazioni che causano il risultato errato. Si INNERunisce prima Transactionsa in Allocationsbase allo WaitingListstato. Questo join si verifica prima che UPDATEprende qualsiasi IXo Xblocchi. Poiché la prima transazione è ancora Booked, l' INNER JOINunica trova la transazione successiva. Quindi accede Transactionsnuovamente alla tabella per eseguire il LEFT OUTER JOINconteggio degli slot disponibili. A questo punto la prima transazione è stata aggiornata a WaitingList, il che significa che c'è uno slot.
Ian Boyd,

Il sistema reale ha livelli extra di complessità. Ad esempio, il JobNamenon è (e non può) essere archiviato con il Transactionma con un Employee. Quindi Transactionscontiene un EmployeeIDe dobbiamo unirci. Anche le allocazioni disponibili sono definite per un giorno e un lavoro . Quindi la Allocationstabella è in realtà (TransactionDate, JobName). Infine, una persona può avere più transazioni per lo stesso giorno; che devono occupare solo 1 slot. Quindi la vera sistema esegue un distinct-countby Employee,Job,Date. Ignorando tutto ciò, quale cambiamento faresti al giocattolo? Forse può essere adottato di nuovo.
Ian Boyd,

2
@IanBoyd Re: il primo commento, sì (tranne che non è un risultato errato). Ri: il secondo commento, sarebbe un lavoro di consulenza :)
Paul White 9

2
@AlexKuznetsov Sulla base delle mie nuove conoscenze, il problema delle vacanze dei biglietti per Arnie / Carol può accadere da READ COMMITTEDsolo. Andare in vacanza controlla se ci sono dei biglietti assegnati a me. Se quel controllo della Ticketstabella utilizza un indice, penserà erroneamente che il ticket non mi sia stato assegnato. Quindi qualcuno mi assegna il biglietto e il grilletto usa un indice per pensare che non sono ancora in vacanza. Risultato: un ticket attivo viene assegnato a uno sviluppatore in vacanza. Con questa nuova conoscenza, voglio sdraiarmi e piangere; il mio intero mondo è distrutto, tutto ciò che ho mai scritto è sbagliato.
Ian Boyd,

1
@IanBoyd questo è il motivo per cui usiamo i vincoli per far rispettare le regole come quella con cui hai problemi. Abbiamo sostituito l'ultimo trigger con vincoli più di due anni fa e da allora godiamo dell'integrità dei dati a tenuta stagna. Inoltre, non dobbiamo più apprendere in modo molto dettagliato blocchi, livelli di isolamento, ecc. - I vincoli funzionano, purché non si usi MERGE, ovviamente.
AK,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.