У нас были проблемы во время высокого параллелизма запросов, возвращающих бессмысленные результаты - результаты нарушают логику выполняемых запросов. Потребовалось время, чтобы воспроизвести проблему. Мне удалось распространить воспроизводимую проблему на несколько горстей 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
. Затем у нас есть периодическая задача, которая выполняет поиск пустых слотов и переводит всех, кто находится в списке ожидания, в состояние «Забронировано».
В отдельном окне 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
И, наконец, запустите это в третьем окне подключения 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
.
При тестировании без параллелизма логика работает. У нас есть две транзакции:
- 12:00: WaitingList
- 12:20 вечера: WaitingList
Существует 1 распределение и 0 зарегистрированных транзакций, поэтому мы помечаем предыдущую транзакцию как зарегистрированную:
- 12:00: забронировано
- 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 вечера: забронировано
Сценарии тестирования игрушек ловят это и перестают повторяться:
Msg 50000, Level 16, State 1, Line 41
The later tranasction, that should never be booked, managed to get booked!
Почему?
Вопрос в том, почему в этой игрушечной модели запускается это условие спасения?
Существует два возможных состояния для статуса подтверждения первой транзакции:
- Забронировано : в этом случае слот занят, и более поздняя транзакция не может иметь его
- WaitingList : в этом случае есть один пустой слот и две транзакции, которые этого хотят. Но так как мы всегда
select
самая старая транзакция (то естьORDER BY CreatedDate
), первая транзакция должна получить это.
Я думал, может быть, из-за других показателей
Я узнал , что после того, как начнется обновление, и данные уже были изменены, можно читать старые значения. В начальных условиях:
- Кластерный индекс :
Booked
- Некластеризованный индекс :
Booked
Затем я делаю обновление, и хотя конечный узел кластеризованного индекса был изменен, любые некластеризованные индексы все еще содержат исходное значение и все еще доступны для чтения:
- Кластерный индекс (эксклюзивная блокировка):
Booked
WaitingList
- Некластеризованный индекс : (разблокирован)
Booked
Но это не объясняет наблюдаемой проблемы. Да, транзакция больше не забронирована , что означает, что теперь есть пустой слот. Но это изменение еще не совершено, оно все еще проводится исключительно. Если процедура столкновения запущена, она либо:
- блок: если опция базы данных изоляции моментальных снимков выключена
- прочитайте старое значение (например
Booked
): если изоляция снимка включена
В любом случае, задница не знала, что есть пустое место.
Так что я понятия не имею,
Мы боролись в течение нескольких дней, чтобы выяснить, как эти бессмысленные результаты могут произойти.
Возможно, вы не понимаете оригинальную систему, но есть набор игрушечных воспроизводимых скриптов. Они выручают при обнаружении недействительного случая. Почему это обнаруживается? Почему это происходит?
Бонусный вопрос
Как NASDAQ решает эту проблему? Как работает кавиртекс? Как работает mtgox?
ТЛ; др
Там три скриптовых блока. Поместите их в 3 отдельные вкладки SSMS и запустите их. 2-й и 3-й сценарии вызовут ошибку. Помогите разобраться, почему у них появляется ошибка.
источник
Ответы:
Уровень
READ COMMITTED
изоляции транзакции по умолчанию гарантирует, что ваша транзакция не будет читать незафиксированные данные. Это не гарантирует, что любые прочитанные вами данные останутся прежними, если вы прочитаете их снова (повторяющиеся чтения) или что новые данные не появятся (фантомы).Эти же соображения применимы к нескольким доступам к данным в пределах одного оператора .
Ваше
UPDATE
утверждение создает план, который обращается кTransactions
таблице более одного раза, поэтому он подвержен эффектам, вызванным неповторяющимися чтениями и фантомами.В этом плане есть несколько способов получения результатов, которые вы не ожидаете в
READ COMMITTED
изоляции.Пример
Первая
Transactions
таблица доступа находит строки, которые имеют статусWaitingList
. Второй доступ подсчитывает количество записей (для той же работы), которые имеют статусBooked
. Первый доступ может вернуть только более позднюю транзакцию (более ранняяBooked
в этой точке). Когда происходит второй (счетный) доступ, более ранняя транзакция была изменена наWaitingList
. Таким образом, последняя строка соответствует обновлениюBooked
статуса.Решения
Есть несколько способов установить семантику изоляции для получения желаемых результатов. Одним из вариантов является включение
READ_COMMITTED_SNAPSHOT
для базы данных. Это обеспечивает согласованность чтения на уровне операторов для операторов, работающих на уровне изоляции по умолчанию. Неповторяемые чтения и фантомы невозможны при изолированном моментальном снимке с фиксацией чтения.Другие замечания
Я должен сказать, однако, что я не разработал бы схему или запрос таким образом. Требуется больше работы, чем необходимо для удовлетворения заявленных требований бизнеса. Возможно, это отчасти является результатом упрощений в этом вопросе, в любом случае это отдельный вопрос.
Поведение, которое вы видите, не представляет собой какую-либо ошибку. Сценарии дают правильные результаты с учетом запрошенной семантики изоляции. Подобные эффекты параллелизма также не ограничиваются планами, которые обращаются к данным несколько раз.
Уровень изоляции с фиксацией чтения обеспечивает гораздо меньше гарантий, чем принято считать. Например, пропуск строк и / или чтение одной и той же строки более одного раза вполне возможны.
источник
INNER
присоединяетсяTransactions
кAllocations
основанным наWaitingList
статусе. Это объединение происходит до того, какUPDATE
взятиеIX
илиX
блокировка. Поскольку первая транзакция все ещеBooked
,INNER JOIN
единственная находит более позднюю транзакцию. Затем онTransactions
снова обращается к таблице, чтобы выполнитьLEFT OUTER JOIN
подсчет доступных слотов. К этому времени первая транзакция была обновлена доWaitingList
, что означает наличие слота.JobName
не (и не может) храниться с,Transaction
но сEmployee
. Итак,Transactions
содержитEmployeeID
, и мы должны присоединиться. Также доступны ассигнования на день и работу . Таким образом,Allocations
таблица на самом деле (TransactionDate, JobName). Наконец, человек может иметь несколько транзакций за один день; которые должны занимать только 1 слот. Таким образом, реальная система делаетdistinct-count
поEmployee,Job,Date
. Не обращая внимания на все это, что бы вы изменили в игрушке? Может быть, это может быть принято обратно.READ COMMITTED
изоляции. Собираюсь в отпуск проверять, есть ли какие-либо билеты, назначенные мне. Если эта проверкаTickets
таблицы использует индекс, он будет ошибочно думать, что билет не назначен мне. Затем кто-то назначает мне билет, а триггер использует индекс, чтобы думать, что я еще не в отпуске. Результат: активный билет назначается разработчику в отпуске. С этим новым знанием я хочу лечь и плакать; весь мой мир разрушен, все, что я когда-либо писал, неверно.