Изоляция параллелизма транзакций. Почему я могу обновлять подмножество записей других транзакций?

Я пытаюсь понять проблему, с которой я столкнулся, и я не верю, что это возможно при работе с транзакциями, использующими уровень изоляции чтения. У меня есть таблица, которая используется в качестве очереди. В одном потоке (соединение 1) я вставляю несколько пакетов по 20 записей в каждую таблицу. Каждая партия из 20 записей выполняется внутри транзакции. Во втором потоке (соединение 2) я выполняю обновление для изменения статуса записей, которые были вставлены в очередь, что также происходит внутри транзакции. При параллельной работе я ожидаю, что количество строк, затронутых обновлением (соединение 2), должно быть кратно 20, поскольку соединение 1 вставляет строки в таблицу, вставляется пакетами по 20 строк в рамках транзакции.

Но мое тестирование показывает, что это не всегда так, и иногда мне удается обновить подмножество записей из пакета соединения 1. Должно ли это быть возможно, или я что-то упускаю из виду в отношении транзакций, параллелизма и уровней изоляции? Ниже приведен набор тестовых сценариев, которые я создал для воспроизведения этой проблемы в T-SQL.

Этот скрипт вставляет в таблицу 20 000 записей пакетами по 20 транзакций.

USE ReadTest
GO

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO

SET NOCOUNT ON

DECLARE @trans_id INTEGER
DECLARE @cmd_id INTEGER
DECLARE @text_str VARCHAR(4000)

SET @trans_id = 0
SET @text_str = 'Placeholder String Value'                

-- First empty the table
DELETE FROM TABLE_A

WHILE @trans_id < 1000 BEGIN
    SET @trans_id = @trans_id + 1
    SET @cmd_id = 0

    BEGIN TRANSACTION
--  Insert 20 records into the table per transaction
    WHILE @cmd_id < 20 BEGIN
        SET @cmd_id = @cmd_id + 1

        INSERT INTO TABLE_A ( transaction_id, command_id, [type], status, text_field ) 
            VALUES ( @trans_id, @cmd_id, 1, 1,  @text_str )
    END             
    COMMIT

END

PRINT 'DONE'

Этот сценарий обновляет записи в таблице, изменяя статус с 1 на 2, а затем проверяет количество строк в операции обновления. Когда количество строк не кратно 20, оператор печати указывает это и количество затронутых строк.

USE ReadTest
GO

SET TRANSACTION ISOLATION LEVEL READ COMMITTED
GO

SET NOCOUNT ON
DECLARE @loop_counter INTEGER
DECLARE @trans_id INTEGER
DECLARE @count INTEGER

SET @loop_counter = 0

WHILE @loop_counter < 100000 BEGIN

    SET @loop_counter = @loop_counter + 1
    BEGIN TRANSACTION
        UPDATE TABLE_A SET status = 2 
        WHERE status = 1
            and type = 1
        SET @count = @@ROWCOUNT
    COMMIT

    IF ( @count % 20 <> 0 ) BEGIN
--      Records in concurrent transaction inserting in batches of 20 records before commit.
        PRINT '*** Rowcount not a multiple of 20. Count = ' + CAST(@count AS VARCHAR) + ' ***'
    END

    IF @count > 0 BEGIN
--      Delete the records where the status was changed.
        DELETE TABLE_A WHERE status = 2
    END
END

PRINT 'DONE'

Этот сценарий создает таблицу очереди тестов в новой базе данных с именем ReadTest.

USE master;
GO

IF EXISTS (SELECT * FROM sys.databases WHERE name = 'ReadTest')
  BEGIN;
  DROP DATABASE ReadTest;
  END;
GO

CREATE DATABASE ReadTest;
GO

ALTER DATABASE ReadTest
SET ALLOW_SNAPSHOT_ISOLATION OFF
GO

ALTER DATABASE ReadTest
SET READ_COMMITTED_SNAPSHOT OFF
GO

USE ReadTest
GO

CREATE TABLE [dbo].[TABLE_A](
    [ROWGUIDE] [uniqueidentifier] NOT NULL,
    [TRANSACTION_ID] [int] NOT NULL,
    [COMMAND_ID] [int] NOT NULL,
    [TYPE] [int] NOT NULL,
    [STATUS] [int] NOT NULL,
    [TEXT_FIELD] [varchar](4000) NULL
 CONSTRAINT [PK_TABLE_A] PRIMARY KEY NONCLUSTERED 
(
    [ROWGUIDE] ASC
) ON [PRIMARY]
) ON [PRIMARY]

ALTER TABLE [dbo].[TABLE_A] ADD  DEFAULT (newsequentialid()) FOR [ROWGUIDE]
GO

person A. Barton    schedule 23.04.2012    source источник


Ответы (2)


Ваши ожидания совершенно неуместны. Вы никогда не выражали в своем запросе требование «удалить из очереди» ровно 20 строк. UPDATE может возвращать 0, 19, 20, 21 или 1000 строк, и все результаты верны, если status равно 1, а type равно 1. Если вы ожидаете, что «удаление из очереди» происходит в порядке «постановки в очередь» ( что каким-то образом ускользает от вашего вопроса, но никогда не указывается явно), то ваша операция «удалить из очереди» должна содержать предложение ORDER BY. Если бы вы добавили такое явно заявленное требование, то ваше ожидание того, что 'dequeue' всегда возвращает целую партию строк 'enqueue' (т.е. кратных 20 строк), было бы на один шаг ближе к тому, чтобы быть разумным ожиданием. Как обстоят дела сейчас, это, как я уже сказал, совершенно неуместно.

Подробное обсуждение см. в разделе Использование таблиц в качестве очередей.

Меня не должно беспокоить, что пока одна транзакция фиксирует пакет из 20 вставленных записей, другая параллельная транзакция может обновлять только подмножество этих записей, а не все 20?

По сути, вопрос сводится к следующему: Если я выберу во время ВСТАВКИ, сколько вставленных строк я увижу?. Вы имеете право беспокоиться только в том случае, если уровень изоляции объявлен как SERIALIZABLE. Ни один из других уровней изоляции не делает никаких прогнозов относительно того, сколько строк, вставленных во время выполнения UPDATE, будет видимым. Только SERIALIZABLE утверждает, что результат должен быть таким же, как при выполнении двух операторов один за другим (т. е. сериализованных, отсюда и название). Хотя технические детали того, как UPDATE «видит» только часть пакета INSERT, легко понять, если принять во внимание физический порядок и отсутствие предложения ORDER BY, объяснение не имеет значения. Основная проблема заключается в том, что ожидания не оправданы. Даже если «проблема» «исправлена» путем добавления правильного ORDER BY и правильного ключа кластеризованного индекса (подробности объясняются в статье, указанной выше), ожидание все равно не оправдано. UPDATE по-прежнему будет совершенно законно «видеть» 1, 19 или 21 строку, хотя это вряд ли произойдет.

Я думаю, я всегда понимал, что READ COMMITTED только читает зафиксированные данные, и что фиксация транзакции является атомарной операцией, делающей все изменения, которые произошли в транзакции, доступными сразу.

Это верно. Неправильно ожидать, что параллельный SELECT (или update) увидит все изменение, независимо от того, где оно находится в процессе выполнения. Откройте запрос SSMS и выполните следующее:

use tempdb;
go

create table test (a int not null primary key, b int);
go

insert into test (a, b) values (5,0)
go

begin transaction
insert into test (a, b) values (10,0)

Теперь откройте новый запрос SSMS и выполните следующее:

update test 
    set b=1
    output inserted.*
    where b=0

Это заблокирует незафиксированный INSERT. Теперь вернитесь к первому запросу и выполните следующее:

insert into test (a, b) values (1,0)
commit

Когда это произойдет, второй запрос SSMS завершится и вернет две строки, а не три. КЭД. Это READ COMMITTED. Вы ожидаете выполнения SERIALIZABLE (в этом случае приведенный выше пример заблокируется).

person Remus Rusanu    schedule 23.04.2012
comment
В моем примере я только удаляю из очереди записи, которые были обновлены одним и тем же соединением до статуса = 2. Но давайте удалим удаление из кода, чтобы упростить, моя первоначальная проблема все еще существует, а именно тот факт, что оператор обновления возвращает количество строк, не кратное 20, что является размером пакета параллельной транзакции, которая вставляет записи в базу данных. - person A. Barton; 24.04.2012
comment
Тот факт, что вы отделяете обновление от удаления, не имеет значения. Ваше беспокойство по-прежнему совершенно необоснованно. - person Remus Rusanu; 24.04.2012
comment
Так что меня не должно беспокоить, что пока одна транзакция фиксирует пакет из 20 вставленных записей, другая параллельная транзакция может обновлять только подмножество этих записей, а не все 20? Пожалуйста, объясните, я хотел бы понять, как это возможно. - person A. Barton; 24.04.2012
comment
Спасибо за обновления. Я думаю, я всегда понимал, что READ COMMITTED только читает зафиксированные данные, и что фиксация транзакции является атомарной операцией, делающей все изменения, которые произошли в транзакции, доступными сразу. - person A. Barton; 24.04.2012
comment
Очень интересно. Таким образом, поскольку строка вставляется перед верхней строкой на основе кластеризованного индекса (столбец а), но после того, как обновление заблокировано, обновление не влияет на строку, но если вставленная строка идет после верхней строки, тогда строка обновлено. Используя эту информацию, я должен изменить кластеризованный индекс моей таблицы на transaction_id, command_id, которые представляют собой постоянно увеличивающиеся идентификаторы, которые вставляются в таблицу в порядке возрастания. - person A. Barton; 25.04.2012
comment
Это обеспечит блокировку обновления для первой строки в пакете и будет ждать завершения пакета, что позволит обновить все 20 строк в пакете. - person A. Barton; 25.04.2012
comment
Вам также необходимо добавить пункт ORDER BY в файл UPDATE. - person Remus Rusanu; 25.04.2012

Это могло произойти так:

  1. Модуль записи/вставки записывает 20 строк (не фиксирует)
  2. Программа чтения/обновления читает одну строку (которая не зафиксирована - она ​​отбрасывается)
  3. Автор/инсертер совершает
  4. Программа чтения/обновления считывает 19 строк, которые теперь зафиксированы, поэтому видны

Я считаю, что только уровень изоляции сериализуемого (или изоляция моментального снимка, что является более параллельным) исправляет это.

person usr    schedule 23.04.2012
comment
Зачем программе чтения/обновления читать одну строку на шаге 2, если уровень изоляции READ COMMITTED? Насколько я понимаю, при установленном уровне изоляции READ COMMITTED изменения, происходящие в незафиксированной транзакции, не должны быть видимы/читаемы внешней транзакцией. - person A. Barton; 23.04.2012
comment
Да их не видно. Тем не менее, они существуют на диске, поэтому их необходимо прочитать, чтобы продолжить сканирование таблицы! Вы просто не видите их, потому что они сразу же отбрасываются при сканировании. - person usr; 23.04.2012
comment
Но в этом случае я ожидаю, что количество строк в обновлении будет равно нулю, и как только фиксация будет выполнена другим потоком, количество строк обновления должно вернуть 20. Для транзакции это должно быть все или ничего, насколько это возможно. читать/обновлять записи из первой транзакции, правильно? - person A. Barton; 23.04.2012
comment
Обновления представляют собой последовательность из двух шагов: прочитать строки, которые нужно обновить, и обновить их. Эти два шага не происходят атомарно! Давайте проведем мысленный эксперимент: мы вставляем пакеты по 100 ТБ данных. Ожидается финальная фиксация. Сеанс обновления начинает читать строки, которые должны быть обновлены. Сканирование таблицы займет некоторое время (часы). На полпути сеанс вставки фиксируется. Теперь все 100 ТБ строк внезапно/атомарно видны, но сеанс чтения видит только их часть. Read commit не обеспечивает атомарность в случае параллелизма! Только по стойкости. Есть разница. - person usr; 24.04.2012