SQL Serverは、UPDATE中に新しい値と古い値の両方をどのように返しますか?


8

高い同時実行中に、意味のない結果を返すクエリの問題がありました-結果は、発行されるクエリのロジックに違反しています。問題の再現にはしばらく時間がかかりました。私は再現可能な問題を数握りのT-SQLにまで掘り下げました。

:問題が発生しているライブシステムの部分は、5つのテーブル、4つのトリガー、2つのストアドプロシージャ、および2つのビューで構成されています。実際のシステムを簡略化して、投稿された質問をはるかに扱いやすいものにしています。物事が整理され、列が削除され、ストアドプロシージャがインラインになり、ビューが共通のテーブル式に変わり、列の値が変更されました。これは長い説明ですが、これはエラーを再現するものの、理解するのが難しい場合があることを示しています。なぜ何かがそのように構造化されているのか不思議に思わないでください。私はここで、このおもちゃモデルでエラー状態が再現可能に発生する理由を理解しようとしています。

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

トランザクションはどちらもとして挿入されWaitingListます。次に、定期的なタスクが実行され、空のスロットが検索され、WaitingListのすべてのユーザーが予約済みの状態になります。

別のSSMSウィンドウに、シミュレートされた繰り返しストアドプロシージャがあります。

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

そして最後に、これを3番目のSSMS接続ウィンドウで実行します。これは、以前のトランザクションがスロットを占有してから待機リストに入るまでの同時実行の問題をシミュレートします。

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

概念的には、バンピング手順は空のスロットを探し続けます。見つかった場合は、にある最も古いトランザクションを受け取り、WaitingListとしてマークしますBooked

並行性なしでテストすると、ロジックは機能します。2つのトランザクションがあります。

  • 午後12:00:WaitingList
  • 午後12時20分:WaitingList

1つの割り当てがあり、0の予約済みトランザクションがあるため、前のトランザクションを予約済みとしてマークします。

  • 午後12時:予約済み
  • 午後12時20分:WaitingList

次にタスクが実行されるとき、1つのスロットが使用されているため、更新するものはありません。

次に、最初のトランザクションを更新し、それをWaitingList

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

その後、私たちは元の場所に戻ります。

  • 午後12:00:WaitingList
  • 午後12時20分:WaitingList

:なぜトランザクションを待機リストに戻すのか疑問に思われるかもしれません。これは、簡略化されたおもちゃモデルの犠牲者です。実際のシステムではトランザクションも可能でありPendingApproval、これもスロットを占有します。PendingApprovalトランザクションは、承認されると待機リストに入れられます。関係ありません。心配しないでください。

私は、並行性を導入する場合でも、常に予約された後、順番待ちリストに最初のトランザクションのバックを置く第二のウィンドウを持つことで、その後、後でトランザクションが予約を得ることができました。

  • 午後12:00:WaitingList
  • 12:20 pm:予約

おもちゃのテストスクリプトはこれをキャッチし、反復を停止します。

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

どうして?

問題は、なぜこのおもちゃモデルで、この救済状態が引き起こされているのかということです。

最初のトランザクションの承認ステータスには、次の2つの状態があります。

  • 予約済み:この場合、スロットは占有されており、後のトランザクションではそれを使用できません
  • WaitingList:この場合、1つの空のスロットと、それを必要とする2つのトランザクションがあります。しかし、我々は常に以来、最も古いトランザクション(つまり)最初のトランザクションは、それを取得する必要があります。selectORDER BY CreatedDate

多分他のインデックスのせいだと思いました

UPDATEが開始され、データ変更された後、古い値を読み取ることができることを知りました。初期状態では:

  • クラスター化インデックスBooked
  • 非クラスター化インデックスBooked

次に、更新を行います。クラスター化インデックスリーフノードが変更されている間、非クラスター化インデックスにはまだ元の値が含まれており、読み取りに使用できます。

  • クラスター化インデックス(排他ロック):Booked WaitingList
  • 非クラスター化インデックス:(ロック解除)Booked

しかし、それは観察された問題を説明しません。はい、トランザクションはBookedではなくなりました。つまり、空のスロットができました。しかし、その変更はまだコミットされておらず、まだ独占的に保持されています。バンピング手順が実行された場合、次のいずれかになります。

  • ブロック:スナップショット分離データベースオプションがオフの場合
  • 古い値を読み取る(例Booked:)スナップショット分離がオンの場合

いずれにしても、バンピングジョブは空のスロットがあることを認識しません。

だから私にはわからない

これらの無意味な結果がどのように発生するかを理解するために、私たちは何日も苦労してきました。

あなたは元のシステムを理解していないかもしれませんが、おもちゃの再現可能なスクリプトのセットがあります。無効なケースが検出されると、救済されます。なぜ検出されるのですか?なぜそれが起こっているのですか?

ボーナス質問

NASDAQはこれをどのように解決しますか?cavirtexはどうですか?mtgoxはどうですか?

tl; dr

3つのスクリプトブロックがあります。それらを3つの別々のSSMSタブに入れて実行します。2番目と3番目のスクリプトはエラーを発生させます。それらのエラーが表示される理由を理解してください。


おそらくトランザクション分離レベルと関係があります。システムでどの分離レベルを使用していますか?
2014

@chaデフォルト(コミットされた読み取り)。スクリプトをコピーして貼り付けると、それが本当にデフォルトレベルであることを確認できます。
Ian Boyd 2014

3番目のタブ「障害のある行をリセット」すると、その行が使用可能になります。そのため、2番目のタブは、3番目のタブが前の行を使用可能としてマークする前にそれを割り当てることができます。3番目のタブのUPDATEで両方の変更を行ってみてください。
AK

回答:


12

デフォルトのREAD COMMITTEDトランザクション分離レベルは、トランザクションがコミットされていないデータを読み取らないことを保証します。読み取ったデータが再度読み取られた場合(繰り返し読み取り)、または新しいデータが表示されない場合(ファントム)は、読み取ったデータが同じであること保証されませ

これらの同じ考慮事項は、同じステートメント内の複数のデータアクセスに適用されます。

あなたのUPDATE文は、アクセスプラン生成しTransactions、それは非反復読み取りとファントムによる影響を受けやすいので、複数回のテーブルを。

複数アクセス

この計画には、READ COMMITTED孤立した状態で予期しない結果を生成する方法がいくつかあります。

最初のTransactionsテーブルアクセスでは、ステータスがの行が検索されますWaitingList。2番目のアクセスは、ステータスがの(同じジョブの)エントリの数をカウントしますBooked。最初のアクセスは、後のトランザクションのみを返す場合があります(前のトランザクションはBookedこの時点です)。2番目の(カウントする)アクセスが発生すると、以前のトランザクションはに変更されWaitingListます。したがって、後の行はBookedステータスの更新の対象となります。

ソリューション

分離セマンティクスを設定して目的の結果を得るには、いくつかの方法があります。1つのオプションはREAD_COMMITTED_SNAPSHOT、データベースを有効にすることです。これにより、デフォルトの分離レベルで実行されているステートメントに対して、ステートメントレベルの読み取りの一貫性が提供されます。読み取りコミットされたスナップショット分離では、繰り返し不可の読み取りとファントムは不可能です。

その他の備考

私はこの方法でスキーマやクエリを設計しなかったとは言えないと言わざるを得ません。記載されたビジネス要件を満たすために必要なはずの作業よりも、かなり多くの作業が必要です。おそらく、これは、問題の単純化の結果である可能性があります。

表示されている動作は、いかなる種類のバグでもありません。スクリプトは、要求された分離セマンティクスが与えられた場合に正しい結果を生成します。このような同時実行の影響は、データに複数回アクセスするプランに限定されません。

コミット読み取り分離レベルでは、一般的に想定されているよりもはるかに少ない保証しか提供されません。たとえば、行をスキップしたり、同じ行を複数回読み取ったりすることは完全に可能です。


私は誤った結果を引き起こす操作の順序を理解しようとしています。まず、ステータスに基づいてINNER参加Transactionsします。この結合は、がany またはロックを取得する前に発生します。最初のトランザクションがまだあるので、それ以降でのみ取引を検索します。次に、テーブルに再度アクセスして、利用可能なスロットのカウントを実行します。この時点で、最初のトランザクションはに更新されています。つまり、スロットがあります。AllocationsWaitingListUPDATEIXXBookedINNER JOINTransactionsLEFT OUTER JOINWaitingList
Ian Boyd 2014

実際のシステムには、さらに複雑なレベルがあります。たとえば、JobNameisは、Transactionbut ではなくで保存されます(保存できません)Employee。だから、Transactions含まれていEmployeeIDて、私たちは参加する必要があります。また、利用可能な割り当ては、ジョブに定義されています。したがって、Allocationsテーブルは実際には(TransactionDate、JobName)です。最後に、人は同じ日に複数のトランザクションを持つことができます。1スロットのみを使用する必要があります。したがって、実際のシステムはdistinct-countbyを実行しEmployee,Job,Dateます。それをすべて無視して、おもちゃにどのような変更を加えますか?多分それは採用されることができます。
Ian Boyd 2014

2
@IanBoyd Re:最初のコメント、はい(誤った結果ではない場合を除く)。再:2番目のコメント、それはコンサルティング作業になります:)
ポールホワイト9

2
@AlexKuznetsov私の新たな知識に基づいて、Arnie / Carolチケットの休暇の問題はREAD COMMITTED単独で発生する可能性があります。休暇中は、自分に割り当てられたチケットがあるかどうかを確認します。Ticketsテーブルのチェックでインデックスが使用されていると、チケットが自分に割り当てられていないと誤って判断されます。次に、誰かが私にチケットを割り当て、トリガーはインデックスを使用して、私がまだ休暇中でないと考えます。結果:休暇中にアクティブなチケットが開発者に割り当てられます。この新しい知識で、私は横になって泣きたいです。私の世界全体が元に戻され、私が書いたすべてが間違っています。
Ian Boyd 2014

1
@IanBoydこれが、制約を使用して、問題のあるルールのようなルールを適用する理由です。2年以上前に最後のトリガーを制約に置き換え、それ以来、完全なデータ整合性を享受しています。また、ロックや分離レベルなどを詳細に学習する必要もなくなりました。もちろん、MERGEを使用しない限り、制約は機能します。
AK
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.