Wie gibt SQL Server während eines UPDATE sowohl einen neuen als auch einen alten Wert zurück?


8

Bei hoher Parallelität hatten wir Probleme mit Abfragen, die unsinnige Ergebnisse zurückgaben - Ergebnisse, die gegen die Logik der ausgegebenen Abfragen verstoßen. Es dauerte eine Weile, um das Problem zu reproduzieren. Ich habe es geschafft, das reproduzierbare Problem auf ein paar Handvoll T-SQL zu reduzieren.

Hinweis : Der Teil des Live-Systems mit dem Problem besteht aus 5 Tabellen, 4 Triggern, 2 gespeicherten Prozeduren und 2 Ansichten. Ich habe das reale System zu etwas vereinfacht, das für eine gepostete Frage viel einfacher zu handhaben ist. Die Dinge wurden reduziert, Spalten entfernt, gespeicherte Prozeduren inline erstellt, Ansichten in allgemeine Tabellenausdrücke umgewandelt und Werte von Spalten geändert. Dies alles ist ein langer Weg zu sagen, dass das Folgende zwar einen Fehler reproduziert, aber möglicherweise schwieriger zu verstehen ist. Sie müssen sich nicht fragen, warum etwas so strukturiert ist, wie es ist. Ich versuche hier herauszufinden, warum der Fehlerzustand in diesem Spielzeugmodell reproduzierbar auftritt.

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

Die Transaktionen werden beide als eingefügt WaitingList. Als nächstes haben wir eine periodische Aufgabe, die ausgeführt wird, nach leeren Slots sucht und jeden auf der Warteliste in den Status "Gebucht" versetzt.

In einem separaten SSMS-Fenster haben wir die simulierte wiederkehrende gespeicherte Prozedur:

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

Führen Sie dies schließlich in einem dritten SSMS-Verbindungsfenster aus. Dies simuliert ein Parallelitätsproblem, bei dem die frühere Transaktion von der Einnahme eines Slots auf die Warteliste übergeht:

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

Konzeptionell sucht das Stoßverfahren immer nach leeren Steckplätzen. Wenn es eine findet, nimmt es die früheste Transaktion, die sich auf dem befindet, WaitingListund markiert sie als Booked.

Beim Testen ohne Parallelität funktioniert die Logik. Wir haben zwei Transaktionen:

  • 12:00 Uhr: Warteliste
  • 12:20 Uhr: Warteliste

Es gibt 1 Zuordnung und 0 gebuchte Transaktionen, daher markieren wir die frühere Transaktion als gebucht:

  • 12:00 Uhr: Gebucht
  • 12:20 Uhr: Warteliste

Wenn die Aufgabe das nächste Mal ausgeführt wird, wird jetzt 1 Steckplatz belegt - es gibt also nichts zu aktualisieren.

Wenn wir dann die erste Transaktion aktualisieren und auf Folgendes setzen WaitingList:

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

Dann sind wir wieder da, wo wir angefangen haben:

  • 12:00 Uhr: Warteliste
  • 12:20 Uhr: Warteliste

Hinweis : Sie fragen sich möglicherweise, warum ich eine Transaktion wieder auf die Warteliste setze. Das ist ein Opfer des vereinfachten Spielzeugmodells. Im realen System können Transaktionen sein PendingApproval, die ebenfalls einen Slot einnehmen. Eine PendingApproval-Transaktion wird bei ihrer Genehmigung auf die Warteliste gesetzt. Ist egal. Mach dir keine Sorgen.

Aber wenn ich Parallelität einführe, indem ich ein zweites Fenster habe, in dem die erste Transaktion nach der Buchung ständig wieder auf die Warteliste gesetzt wird, hat die spätere Transaktion die Buchung erhalten:

  • 12:00 Uhr: Warteliste
  • 12:20 Uhr: Gebucht

Die Spielzeug-Testskripte erfassen dies und hören auf zu iterieren:

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

Warum?

Die Frage ist, warum bei diesem Spielzeugmodell diese Rettungsbedingung ausgelöst wird.

Es gibt zwei mögliche Zustände für den Genehmigungsstatus der ersten Transaktion:

  • Gebucht : In diesem Fall ist der Slot belegt und die spätere Transaktion kann ihn nicht haben
  • Warteliste : In diesem Fall gibt es einen leeren Steckplatz und zwei Transaktionen, die dies wünschen. Aber da wir immer selectdie älteste Transaktion (dh ORDER BY CreatedDate) haben, sollte die erste Transaktion sie bekommen.

Ich dachte vielleicht wegen anderer Indizes

Ich erfuhr , dass nach einem Update gestartet wird, und Daten werden geändert worden ist , ist es möglich, die alten Werte zu lesen. In den Anfangsbedingungen:

  • Clustered-Index :Booked
  • Nicht gruppierter Index :Booked

Dann mache ich ein Update und während der Clustered Index Leaf Node geändert wurde, enthalten alle nicht Clustered Indizes immer noch den ursprünglichen Wert und stehen weiterhin zum Lesen zur Verfügung:

  • Clustered Index (exklusive Sperre):Booked WaitingList
  • Nicht gruppierter Index : (freigeschaltet)Booked

Das erklärt aber nicht das beobachtete Problem. Ja, die Transaktion ist nicht mehr gebucht , dh es ist jetzt ein leerer Slot vorhanden. Diese Änderung ist jedoch noch nicht festgelegt, sondern wird ausschließlich durchgeführt. Wenn das Stoßverfahren ausgeführt würde, würde es entweder:

  • Block: Wenn die Option für die Snapshot-Isolationsdatenbank deaktiviert ist
  • Lesen Sie den alten Wert (z. B. Booked): Wenn die Snapshot-Isolation aktiviert ist

In beiden Fällen würde der Stoßjob nicht wissen, dass ein freier Steckplatz vorhanden ist.

Ich habe also keine Ahnung

Wir haben tagelang darum gekämpft, herauszufinden, wie diese unsinnigen Ergebnisse passieren könnten.

Möglicherweise verstehen Sie das ursprüngliche System nicht, aber es gibt eine Reihe von reproduzierbaren Spielzeugskripten. Sie werden gerettet, wenn der ungültige Fall erkannt wird. Warum wird es erkannt? Warum passiert es?

Bonus-Frage

Wie löst NASDAQ das? Wie funktioniert Cavirtex? Wie funktioniert mtgox?

tl; dr

Es gibt drei Skriptblöcke. Legen Sie sie in 3 separate SSMS-Registerkarten und führen Sie sie aus. Das 2. und 3. Skript lösen einen Fehler aus. Helfen Sie mir herauszufinden, warum der Fehler auftritt.


Dies hängt wahrscheinlich mit der Transaktionsisolationsstufe zusammen. Welche Isolationsstufe verwenden Sie in Ihrem System?
Cha

@cha Standard (READ COMMITTED). Kopieren Sie die Skripte, fügen Sie sie ein und Sie können bestätigen, dass es sich wirklich um die Standardstufe handelt.
Ian Boyd

Wenn Ihre dritte Registerkarte "Fehlerhafte Zeile zurücksetzen", wird diese Zeile verfügbar. Daher kann Ihre 2. Registerkarte sie zuweisen, bevor die 3. Registerkarte die frühere Zeile als verfügbar markiert. Versuchen Sie, beide Änderungen in UPDATE auf Ihrer dritten Registerkarte vorzunehmen.
AK

Antworten:


12

Die Standard- READ COMMITTEDTransaktionsisolationsstufe garantiert, dass Ihre Transaktion keine nicht festgeschriebenen Daten liest. Es kann nicht garantiert werden, dass die von Ihnen gelesenen Daten gleich bleiben, wenn Sie sie erneut lesen (wiederholbare Lesevorgänge) oder dass keine neuen Daten angezeigt werden (Phantome).

Dieselben Überlegungen gelten für mehrere Datenzugriffe innerhalb derselben Anweisung .

Ihre UPDATEAnweisung erstellt einen Plan, der Transactionsmehrmals auf die Tabelle zugreift , sodass er anfällig für Effekte ist, die durch nicht wiederholbare Lesevorgänge und Phantome verursacht werden.

Mehrfachzugriff

Es gibt mehrere Möglichkeiten für diesen Plan, Ergebnisse zu erzielen, die Sie unter READ COMMITTEDIsolation nicht erwarten .

Ein Beispiel

Der erste TransactionsTabellenzugriff findet Zeilen mit dem Status WaitingList. Der zweite Zugriff zählt die Anzahl der Einträge (für denselben Job) mit dem Status Booked. Der erste Zugriff gibt möglicherweise nur die spätere Transaktion zurück (die frühere befindet sich Bookedzu diesem Zeitpunkt). Wenn der zweite (Zähl-) Zugriff erfolgt, wurde die frühere Transaktion in geändert WaitingList. Die spätere Zeile qualifiziert sich daher für die Aktualisierung des BookedStatus.

Lösungen

Es gibt verschiedene Möglichkeiten, die Isolationssemantik festzulegen, um die gewünschten Ergebnisse zu erzielen. Eine Möglichkeit besteht darin, READ_COMMITTED_SNAPSHOTdie Datenbank zu aktivieren . Dies bietet Lesekonsistenz auf Anweisungsebene für Anweisungen, die auf der Standardisolationsstufe ausgeführt werden. Nicht wiederholbare Lesevorgänge und Phantome sind unter isolierter Snapshot-Isolierung nicht möglich.

Sonstige Anmerkungen

Ich muss allerdings sagen, dass ich das Schema oder die Abfrage nicht so entworfen hätte. Es ist eher mehr Arbeit erforderlich, als erforderlich sein sollte, um die angegebenen Geschäftsanforderungen zu erfüllen. Vielleicht ist dies teilweise das Ergebnis der Vereinfachungen in der Frage, in jedem Fall handelt es sich um eine separate Frage.

Das Verhalten, das Sie sehen, stellt keinerlei Fehler dar. Die Skripte liefern angesichts der angeforderten Isolationssemantik korrekte Ergebnisse. Solche Parallelitätseffekte sind auch nicht auf Pläne beschränkt, die mehrmals auf Daten zugreifen.

Die isolierte Leseisolationsstufe bietet viel weniger Garantien als allgemein angenommen. Zum Beispiel ist es durchaus möglich , Zeilen zu überspringen und / oder dieselbe Zeile mehrmals zu lesen .


Ich versuche, die Reihenfolge der Operationen herauszufinden, die das fehlerhafte Ergebnis verursacht. Es zuerst INNERschließt sich Transactionsan Allocationsauf der Basis WaitingListStatus. Dieser Join erfolgt vor dem UPDATETake IXoder XLock. Da die erste Transaktion noch vorhanden ist Booked, INNER JOINfindet nur die spätere Transaktion. Anschließend greift es erneut auf die TransactionsTabelle zu, um die LEFT OUTER JOINAnzahl der verfügbaren Slots zu ermitteln. Zu diesem Zeitpunkt wurde die erste Transaktion auf aktualisiert WaitingList, was bedeutet, dass ein Slot vorhanden ist.
Ian Boyd

Das reale System weist eine zusätzliche Komplexität auf. Zum Beispiel JobNameist das nicht (und kann nicht) mit dem Transactionaber mit einem gespeichert werden Employee. Also Transactionsenthält ein EmployeeID, und wir müssen mitmachen. Auch verfügbare Zuordnungen werden für einen Tag und einen Job definiert . Die AllocationsTabelle lautet also tatsächlich (TransactionDate, JobName). Schließlich kann eine Person mehrere Transaktionen für denselben Tag ausführen. die nur 1 Steckplatz belegen müssen. Das reale System macht also ein distinct-countBy Employee,Job,Date. Welche Änderung würden Sie am Spielzeug vornehmen, wenn Sie all das ignorieren? Vielleicht kann es wieder übernommen werden.
Ian Boyd

2
@ IanBoyd Re: der erste Kommentar, ja (außer es ist kein fehlerhaftes Ergebnis). Betreff: der zweite Kommentar, das wäre Beratungsarbeit :)
Paul White 9

2
@AlexKuznetsov Nach meinem neu gewonnenen Wissen kann das Urlaubsproblem mit Arnie / Carol-Tickets READ COMMITTEDisoliert auftreten. Wenn Sie in den Urlaub fahren, überprüfen Sie, ob mir Tickets zugewiesen wurden. Wenn diese Überprüfung der TicketsTabelle einen Index verwendet, wird fälschlicherweise angenommen, dass das Ticket mir nicht zugewiesen ist. Dann weist mir jemand das Ticket zu und der Auslöser verwendet einen Index, um zu glauben, dass ich noch nicht im Urlaub bin. Ergebnis: Ein aktives Ticket wird einem Entwickler im Urlaub zugewiesen. Mit diesem neuen Wissen möchte ich mich hinlegen und weinen; Meine ganze Welt ist rückgängig gemacht, alles, was ich jemals geschrieben habe, ist falsch.
Ian Boyd

1
@IanBoyd Aus diesem Grund verwenden wir Einschränkungen, um die Regeln durchzusetzen, mit denen Sie Probleme haben. Wir haben den letzten Auslöser vor mehr als zwei Jahren durch Einschränkungen ersetzt und genießen seitdem die wasserdichte Datenintegrität. Außerdem müssen wir Sperren, Isolationsstufen usw. nicht mehr detailliert lernen - Einschränkungen funktionieren einfach, solange Sie MERGE natürlich nicht verwenden.
AK
Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.