高い同時実行中に、意味のない結果を返すクエリの問題がありました-結果は、発行されるクエリのロジックに違反しています。問題の再現にはしばらく時間がかかりました。再現可能な問題を数握りの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の誰もをBookedステータスにバンプします。
別の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
概念的には、bumpingプロシージャは空のスロットを探し続けます。見つかった場合は、WaitingList
にあるearliestトランザクションを取得し、Booked
としてマークします。
並行性なしでテストすると、ロジックは機能します。 2つのトランザクションがあります。
1つの割り当てがあり、0の予約済みトランザクションがあるため、前のトランザクションを予約済みとしてマークします。
次にタスクが実行されるときに、1つのスロットが使用されているため、更新するものはありません。
その後、最初のトランザクションを更新し、WaitingList
に配置すると、次のようになります。
UPDATE Transactions SET ApprovalStatus='WaitingList'
WHERE TransactionID = 60981
その後、私たちは元の場所に戻ります。
注:なぜトランザクションを待機リストに戻すのか疑問に思われるかもしれません。これは、簡略化されたおもちゃモデルの犠牲者です。実際のシステムでは、トランザクションは
PendingApproval
であり、これもスロットを占有します。 PendingApprovalトランザクションは、承認されると待機リストに入れられます。関係ありません。心配しないでください。
しかし、同時実行性を導入するときは、2番目のウィンドウが常に最初のトランザクションを予約後に待機リストに戻すことにより、次にlater予約を取得するために管理されたトランザクション:
おもちゃのテストスクリプトはこれをキャッチし、反復を停止します。
Msg 50000, Level 16, State 1, Line 41
The later tranasction, that should never be booked, managed to get booked!
問題は、なぜこのおもちゃモデルでは、この救済状態が引き起こされているのかということです。
最初のトランザクションの承認ステータスには、次の2つの状態があります。
select
oldestトランザクション(つまりORDER BY CreatedDate
)最初のトランザクションで取得する必要があります。UPDATEが開始され、データhasが変更された後、古い値を読み取ることができることを知りました。初期状態では:
Booked
Booked
次に、更新を行います。クラスター化インデックスのリーフノードが変更されている間、非クラスター化インデックスにはまだ元の値が含まれており、読み取りに使用できます。
Booked
WaitingList
Booked
しかし、それは観察された問題を説明していません。はい、トランザクションはもうありません予約済み、つまり空のスロットができました。しかし、その変更はまだコミットされておらず、まだ独占的に保持されています。バンピング手順が実行された場合、次のいずれかになります。
Booked
):スナップショット分離がオンの場合いずれにせよ、バンピングジョブは空のスロットがないことを認識しません。
これらの無意味な結果がどのように発生するかを理解するために、私たちは何日も苦労しています。
あなたは元のシステムを理解していないかもしれませんが、おもちゃの再現可能なスクリプトのセットがあります。無効なケースが検出されると、救済されます。なぜ検出されるのですか?なぜそれが起こっているのですか?
NASDAQはこれをどのように解決しますか? cavirtexはどうですか? mtgoxはどうですか?
3つのスクリプトブロックがあります。それらを3つの別々のSSMSタブに入れて実行します。 2番目と3番目のスクリプトはエラーを発生させます。それらのエラーが表示される理由を理解してください。
デフォルトのREAD COMMITTED
トランザクション分離レベルは、トランザクションがコミットされていないデータを読み取らないことを保証します。それはしないもう一度読み取る場合(繰り返し読み取り)または新しいデータが表示されない場合(ファントム)、読み取ったデータが同じままであることを保証します。
これらと同じ考慮事項が同じステートメント内の複数のデータアクセスにも適用されます。
UPDATE
ステートメントは、Transactions
テーブルに複数回アクセスするプランを作成するため、繰り返し不可の読み取りやファントムによる影響を受けやすくなります。
この計画がREAD COMMITTED
分離の下で予期しない結果を生成する方法はいくつかあります。
最初のTransactions
テーブルアクセスは、WaitingList
のステータスを持つ行を検索します。 2番目のアクセスは、Booked
のステータスを持つ(同じジョブの)エントリの数をカウントします。最初のアクセスでは、後のトランザクションのみが返される場合があります(この時点では、前のトランザクションはBooked
です)。 2番目の(カウントする)アクセスが発生すると、以前のトランザクションはWaitingList
に変更されました。したがって、後の行はBooked
ステータスへの更新の対象となります。
分離セマンティクスを設定して目的の結果を得るには、いくつかの方法があります。 1つのオプションは、データベースに対してREAD_COMMITTED_SNAPSHOT
を有効にすることです。これにより、デフォルトの分離レベルで実行されているステートメントのステートメントレベルの読み取りの一貫性が提供されます。読み取りコミットされたスナップショット分離では、繰り返し不可の読み取りとファントムは不可能です。
私はこの方法でスキーマやクエリを設計しなかったとは言えないと言わざるを得ません。記載されたビジネス要件を満たすために必要なはずの作業よりも、かなり多くの作業が必要です。おそらく、これは部分的には質問の簡略化の結果であり、いずれの場合も別の質問です。
表示されている動作は、いかなる種類のバグでもありません。スクリプトは、要求された分離セマンティクスが与えられた場合に正しい結果を生成します。このような同時実行の影響は、データに複数回アクセスするプランに限定されません。
コミットされた読み取り分離レベルは、一般的に想定されているよりもはるかに少ない保証を提供します。たとえば、 行をスキップしたり、同じ行を複数回読み取ったりする は完全に可能です。