Comment SQL Server renvoie-t-il à la fois une nouvelle valeur et une ancienne valeur lors d'une MISE À JOUR?

8

Nous avons eu des problèmes, lors de la simultanéité élevée, de requêtes renvoyant des résultats non sensibles - les résultats violent la logique des requêtes émises. Il a fallu un certain temps pour reproduire le problème. J'ai réussi à distiller le problème reproductible jusqu'à quelques poignées de T-SQL.

Remarque : La partie du système en direct présentant le problème est composée de 5 tables, 4 déclencheurs, 2 procédures stockées et 2 vues. J'ai simplifié le système réel en quelque chose de beaucoup plus gérable pour une question publiée. Les choses ont été simplifiées, les colonnes supprimées, les procédures stockées ont été intégrées, les vues transformées en expressions de table communes, les valeurs des colonnes ont été modifiées. C'est une longue façon de dire que si ce qui suit reproduit une erreur, elle peut être plus difficile à comprendre. Vous devrez vous abstenir de vous demander pourquoi quelque chose est structuré comme il est. J'essaie ici de comprendre pourquoi la condition d'erreur se produit de manière reproductible dans ce modèle de jouet.

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

Les transactions sont toutes deux insérées en tant que WaitingList. Ensuite, nous avons une tâche périodique qui s'exécute, à la recherche d'emplacements vides et place toute personne sur la liste d'attente dans un état réservé.

Dans une fenêtre SSMS distincte, nous avons la procédure stockée récurrente simulée:

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

Et enfin exécutez cela dans une troisième fenêtre de connexion SSMS. Cela simule un problème de concurrence où la transaction précédente va de la prise d'un créneau horaire à la liste d'attente:

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

Sur le plan conceptuel, la procédure de supplantation continue de rechercher les emplacements vides. S'il en trouve un, il prend la première transaction qui se trouve sur le WaitingListet la marque comme Booked.

Lorsqu'elle est testée sans concurrence, la logique fonctionne. Nous avons deux transactions:

  • 12h00: WaitingList
  • 12h20: WaitingList

Il y a 1 allocation et 0 transactions enregistrées, nous marquons donc la transaction précédente comme enregistrée:

  • 12h00: Réservé
  • 12h20: WaitingList

La prochaine fois que la tâche s'exécute, il y a maintenant 1 emplacement occupé - il n'y a donc rien à mettre à jour.

Si nous mettons ensuite à jour la première transaction et la mettons dans WaitingList:

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

Puis nous revenons où nous avons commencé:

  • 12h00: WaitingList
  • 12h20: WaitingList

Remarque : vous vous demandez peut-être pourquoi je remets une transaction sur la liste d'attente. C'est une victime du modèle de jouet simplifié. Dans le système réel, les transactions peuvent être PendingApproval, qui occupent également un emplacement. Une transaction PendingApproval est placée sur la liste d'attente lorsqu'elle est approuvée. Peu importe. Ne t'en fais pas.

Mais lorsque j'introduis la concurrence, en ayant une deuxième fenêtre qui remet constamment la première transaction sur la liste d'attente après avoir été réservée, la dernière transaction a réussi à obtenir la réservation:

  • 12h00: WaitingList
  • 12h20: Réservé

Les scripts de test de jouets attrapent cela et arrêtent d'itérer:

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

Pourquoi?

La question est, pourquoi dans ce modèle de jouet, cette condition de renflouement est-elle déclenchée?

Il existe deux états possibles pour le statut d'approbation de la première transaction:

  • Réservé : dans ce cas, l'emplacement est occupé et la transaction ultérieure ne peut pas l'avoir
  • WaitingList : dans ce cas, il y a un emplacement vide et deux transactions qui le souhaitent. Mais comme nous avons toujours selectla transaction la plus ancienne (c'est-à-dire ORDER BY CreatedDate), la première transaction devrait l'obtenir.

J'ai pensé peut-être à cause d'autres index

J'ai appris qu'après une mise à jour a commencé, et des données a été modifié, il est possible de lire les anciennes valeurs. Dans les conditions initiales:

  • Index clusterisé :Booked
  • Index non clusterisé :Booked

Ensuite, je fais une mise à jour, et bien que le nœud de feuille d'index cluster ait été modifié, tous les index non cluster contiennent toujours la valeur d'origine et sont toujours disponibles pour la lecture:

  • Index clusterisé (verrouillage exclusif):Booked WaitingList
  • Index non groupé : (déverrouillé)Booked

Mais cela n'explique pas le problème observé. Oui, la transaction n'est plus réservée , ce qui signifie qu'il y a maintenant un emplacement vide. Mais ce changement n'est pas encore engagé, il est toujours exclusivement retenu. Si la procédure de supplantation se déroulait, elle:

  • bloc: si l'option de base de données d'isolement de capture instantanée est désactivée
  • lire l'ancienne valeur (par exemple Booked): si l'isolement de l'instantané est activé

Quoi qu'il en soit, le travail de remplacement ne saurait pas qu'il y a un emplacement vide.

Donc je n'ai aucune idée

Nous luttons depuis des jours pour comprendre comment ces résultats insensés pourraient se produire.

Vous ne comprenez peut-être pas le système d'origine, mais il existe un ensemble de scripts reproductibles jouets. Ils renflouent lorsque le cas invalide est détecté. Pourquoi est-il détecté? Pourquoi cela se produit-il?

Question bonus

Comment le NASDAQ résout-il cela? Comment fonctionne cavirtex? Comment mtgox?

tl; dr

Il y a trois blocs de script. Mettez-les dans 3 onglets SSMS distincts et exécutez-les. Les 2e et 3e scripts généreront une erreur. Aidez-moi à comprendre pourquoi leur erreur apparaît.

Ian Boyd
la source
Cela est probablement lié au niveau d'isolement des transactions. Quel niveau d'isolement utilisez-vous dans votre système?
cha
@cha Par défaut (LIRE COMMIS). Copiez-collez les scripts et vous pouvez confirmer qu'il s'agit bien du niveau par défaut.
Ian Boyd
Lorsque votre 3e onglet "Réinitialiser la ligne défectueuse", cette ligne devient disponible. En tant que tel, votre 2e onglet peut l'allouer avant que le 3e onglet marque la ligne précédente comme disponible. Essayez de faire les deux modifications dans UPDATE dans votre 3e onglet.
AK

Réponses:

12

Le READ COMMITTEDniveau d'isolement des transactions par défaut garantit que votre transaction ne lira pas les données non validées. Cela ne garantit pas que les données que vous lisez resteront les mêmes si vous les relisez (lectures répétables) ou que de nouvelles données n'apparaîtront pas (fantômes).

Ces mêmes considérations s'appliquent à plusieurs accès aux données dans la même instruction .

Votre UPDATEinstruction produit un plan qui accède à la Transactionstable plusieurs fois, il est donc susceptible aux effets causés par des lectures et des fantômes non répétables.

Accès multiple

Il existe plusieurs façons pour ce plan de produire des résultats que vous ne vous attendez pas à READ COMMITTEDisoler.

Un exemple

Le premier Transactionsaccès à la table recherche les lignes dont l'état est WaitingList. Le deuxième accès compte le nombre d'entrées (pour le même travail) qui ont le statut Booked. Le premier accès peut renvoyer uniquement la transaction ultérieure (la précédente est Bookedà ce stade). Lorsque le deuxième accès (comptage) se produit, la transaction précédente a été remplacée par WaitingList. La dernière ligne remplit donc les conditions pour la mise à jour Booked.

Solutions

Il existe plusieurs façons de définir la sémantique d'isolement pour obtenir les résultats que vous recherchez. Une option consiste à activer READ_COMMITTED_SNAPSHOTla base de données. Cela fournit une cohérence de lecture au niveau des instructions pour les instructions s'exécutant au niveau d'isolement par défaut. Les lectures et les fantômes non répétables ne sont pas possibles sous l'isolement de capture instantanée de lecture validée.

D'autres remarques

Je dois dire cependant que je n'aurais pas conçu le schéma ou interrogé de cette façon. Il y a plutôt plus de travail que nécessaire pour répondre aux exigences commerciales énoncées. C'est peut-être en partie le résultat des simplifications de la question, en tout cas c'est une question distincte.

Le comportement que vous voyez ne représente aucun bogue d'aucune sorte. Les scripts produisent des résultats corrects compte tenu de la sémantique d'isolement demandée. Les effets de concurrence comme celui-ci ne sont pas non plus limités aux plans qui accèdent aux données plusieurs fois.

Le niveau d'isolement validé en lecture fournit beaucoup moins de garanties que ce qui est généralement supposé. Par exemple, il est parfaitement possible de sauter des lignes et / ou de lire plusieurs fois la même ligne .

Paul White 9
la source
j'essaie de comprendre l'ordre des opérations qui provoque le résultat erroné. Il INNERrejoint d' abord Transactionsen Allocationsfonction du WaitingListstatut. Cette jointure se produit avant la UPDATEprise IXou le Xverrouillage. Étant donné que la première transaction est toujours Booked, la INNER JOINseule recherche la transaction ultérieure. Il accède Transactionsensuite à nouveau à la table pour effectuer le LEFT OUTER JOINcomptage des emplacements disponibles. À ce moment, la première transaction a été mise à jour WaitingList, ce qui signifie qu'il y a un emplacement.
Ian Boyd
Le système réel présente des niveaux de complexité supplémentaires. Par exemple, le JobNamen'est pas (et ne peut pas) être stocké avec le Transactionmais avec un Employee. TransactionsContient donc un EmployeeID, et nous devons nous joindre. Des allocations disponibles sont également définies pour un jour et un travail . Donc, la Allocationstable est en fait (TransactionDate, JobName). Enfin, une personne peut effectuer plusieurs transactions le même jour; qui ne doivent occuper qu'un seul emplacement. Le vrai système fait donc un distinct-countby Employee,Job,Date. En ignorant tout cela, quel changement apporteriez-vous au jouet? Peut-être qu'il peut être adopté à nouveau.
Ian Boyd
2
@IanBoyd Re: le premier commentaire, oui (sauf que ce n'est pas un résultat erroné). Re: le deuxième commentaire, ce serait du travail de consultation :)
Paul White 9
2
@AlexKuznetsov Sur la base de mes nouvelles connaissances, le problème des vacances de billets Arnie / Carol peut se produire de manière READ COMMITTEDisolée. Partir en chèques vacances si des billets m'ont été attribués. Si cette vérification de la Ticketstable utilise un index, il pensera à tort que le ticket ne m'est pas attribué. Ensuite, quelqu'un m'assigne le ticket et le déclencheur utilise un index pour penser que je ne suis pas encore en vacances. Résultat: un ticket actif est attribué à un développeur en vacances. Avec cette nouvelle connaissance, je veux me coucher et pleurer; mon monde entier est défait, tout ce que j'ai écrit est faux.
Ian Boyd
1
@IanBoyd c'est pourquoi nous utilisons des contraintes pour appliquer des règles comme celle avec laquelle vous avez des problèmes. Nous avons remplacé le dernier déclencheur par des contraintes il y a plus de deux ans et nous bénéficions depuis d'une intégrité étanche des données. De plus, nous n'avons plus à apprendre en détail les verrous, les niveaux d'isolement, etc. - les contraintes fonctionnent, tant que vous n'utilisez pas MERGE, bien sûr.
AK