Как SQL Server возвращает как новое, так и старое значение во время ОБНОВЛЕНИЯ?


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. Затем у нас есть периодическая задача, которая выполняет поиск пустых слотов и переводит всех, кто находится в списке ожидания, в состояние «Забронировано».

В отдельном окне 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-й сценарии вызовут ошибку. Помогите разобраться, почему у них появляется ошибка.


Вероятно, это связано с уровнем изоляции транзакции. Какой уровень изоляции вы используете в своей системе?
Ча

@cha По умолчанию (READ COMMITTED). Скопируйте и вставьте сценарии, и вы можете подтвердить, что это действительно уровень по умолчанию.
Ян Бойд

Когда ваша третья вкладка «Сбросить ошибочную строку», эта строка становится доступной. Таким образом, ваша вторая вкладка может выделить его до того, как третья вкладка пометит более раннюю строку как доступную. Попробуйте внести обе модификации в UPDATE на третьей вкладке.
AK

Ответы:


12

Уровень 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. Не обращая внимания на все это, что бы вы изменили в игрушке? Может быть, это может быть принято обратно.
Ян Бойд

2
@IanBoyd Re: первый комментарий, да (за исключением того, что это не ошибочный результат). Re: второй комментарий, это будет консультационная работа :)
Пол Уайт 9

2
@AlexKuznetsov Основываясь на моих новых знаниях, проблема отпуска билетов на Арни / Кэрол может произойти в READ COMMITTEDизоляции. Собираюсь в отпуск проверять, есть ли какие-либо билеты, назначенные мне. Если эта проверка Ticketsтаблицы использует индекс, он будет ошибочно думать, что билет не назначен мне. Затем кто-то назначает мне билет, а триггер использует индекс, чтобы думать, что я еще не в отпуске. Результат: активный билет назначается разработчику в отпуске. С этим новым знанием я хочу лечь и плакать; весь мой мир разрушен, все, что я когда-либо писал, неверно.
Ян Бойд

1
@IanBoyd, поэтому мы используем ограничения для обеспечения соблюдения правил, подобных тем, с которыми у вас проблемы. Мы заменили последний триггер на ограничения более двух лет назад, и с тех пор мы наслаждаемся надежной целостностью данных. Также нам больше не нужно подробно изучать блокировки, уровни изоляции и т. Д. Ограничения просто работают, если, конечно, вы не используете MERGE.
AK
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.