Транзакции

80

Транзакция — это набор операций, который должен либо выполниться успешно, либо не выполниться, причем как единое целое. Целью транзакции является гарантия того, что данные всегда находятся в корректном, согласованном состоянии.

Например, рассмотрим транзакцию, которая передает $1000 со счета A на счет B. Ясно, что здесь присутствует две операции:

Предположим, что приложение успешно выполнило шаг 1, но из-за какой-то ошибки шаг 2 завершился сбоем. Это ведет к несогласованию данных, поскольку общая сумма денег в системе теперь не точна. Пропало $1000.

Транзакции позволяют избежать проблем подобного рода, поскольку гарантируют, что изменения будут зафиксированы в источнике данных только в том случае, если все шаги пройдут успешно. Поэтому в рассматриваемом примере, если шаг 2 сорвется, то изменения, выполненные на шаге 1, не будут зафиксированы в базе данных. Это гарантирует, что система останется в одном из двух корректных состояний — начальном (когда деньги не переведены) или конечном (когда деньги дебетованы с одного счета и кредитованы на другой).

Транзакции характеризуются четырьмя свойствами, которые называются свойствами ACID. Здесь ACID представляет перечисленные ниже концепции:

Атомарность (Atomic)

Все шаги транзакции должны либо выполниться успешно, либо отмениться. Если не все шаги транзакции завершены, она не трактуется как завершенная.

Согласованность (Consistent)

Транзакция переводит лежащую в основе базу данных из одного стабильного состояния в другое.

Изолированность (Isolated)

Каждая транзакция — независимая сущность. Одна транзакция не должна влиять на другие, запущенные одновременно с ней.

Устойчивость (Durable)

Изменения, которые происходят во время транзакции, сохраняются постоянно на каком-то носителе (обычно на жестком диске), прежде чем транзакция будет объявлена успешной. Поддерживается ведение журналов, чтобы база данных могла быть восстановлена в корректное состояние, даже в случае сбоя оборудования или сети.

Обратите внимание, что эти идеальные характеристики транзакции не всегда полностью достижимы. Одна из проблем связана с обеспечением изоляции. Реляционная СУБД должна блокировать данные, чтобы другие пользователи не имели к ним доступа, пока транзакция выполняется. Чем больше используется блокировок, и чем они крупнее, тем выше шансы того, что пользователь не сможет выполнить другую работу, пока транзакция находится в процессе выполнения. Другими словами, иногда приходится идти на компромисс между степенью параллелизма работы пользователей и изоляцией.

Транзакции и приложения ASP.NET

В приложениях ASP.NET можно использовать три базовых типа транзакций. Ниже представлен их список (от наименее до наиболее затратных):

Несмотря на то что ADO.NET предоставляет хорошую поддержку транзакций, вы не всегда должны их использовать. Фактически всякий раз, когда применяется какого-то рода транзакция, автоматически привносятся накладные расходы. К тому же транзакции предполагают некоторого рода блокировку строк таблицы. Поэтому транзакции, используемые без необходимости, могут нанести ущерб масштабируемости приложения.

Чтобы достичь наилучшего результата, при реализации транзакций нужно следовать перечисленным ниже рекомендациям:

В ADO.NET также поддерживается высокоуровневая модель распространяемых транзакций. Однако распространяемая транзакция — это не какой-то новый тип транзакции, а просто способ создания инициируемой клиентом транзакции, которая при необходимости может автоматически расширяться до транзакции COM+. Вы не должны использовать распространяемые транзакции, если только действительно в них не нуждаетесь, потому что трудно предсказать их влияние на производительность и масштабируемость окончательных решений.

Запомните эмпирическое правило: применяйте транзакции только тогда, когда операция того требует. Например, если вы просто выбираете записи из базы данных либо инициируете запрос, то транзакции не нужны. С другой стороны, если вы вставляете запись Order, которая связана с последовательностью зависимых записей OrderItem, то транзакция может понадобиться. Вообще транзакции никогда не требуются для одиночных команд, таких как индивидуальные операторы UPDATE, DELETE или INSERT, поскольку они по определению транзакционны.

Транзакции хранимых процедур

Если возможно, то лучшим решением будет помещение транзакции в код хранимой процедуры. Это гарантирует, что код стороны сервера всегда под контролем, исключая вероятность того, что клиент нечаянно будет удерживать транзакцию открытой слишком долго, тем самым потенциально создавая проблемы для обновлений данных другими клиентами.

Это также гарантирует максимально возможную производительность, поскольку все действия могут быть выполнены на источнике данных, без потребности в каком-либо сетевом взаимодействии. В общем случае, чем короче промежуток времени транзакции, тем выше степень параллелизма базы данных и меньше количество запросов, которые будут сериализоваться (ожидающих, пока существует временная блокировка записей).

Код хранимых процедур варьируется в зависимости от используемой базы данных, но большинство реляционных СУБД поддерживают SQL-оператор BEGIN TRANSACTION. После запуска транзакции все последующие операторы рассматриваются как ее часть. Завершается транзакция с помощью оператора COMMIT или ROLLBACK. Если этого не сделать, транзакция будет автоматически отменена.

Ниже показан пример псевдокода, выполняющий перевод средств между счетами. Это упрощенная версия, в которой допускается установка для счета отрицательного баланса:

CREATE Procedure TransferAmount
(
	@Amount Money 
	@ID_A int 
	@ID_B int
)
AS
	BEGIN TRANSACTION
	UPDATE Accounts SET Balance = Balance + @Amount WHERE AccountID = @ID_A
	IF (@@ERROR > 0)
		GOTO PROBLEM
        
	UPDATE Accounts SET Balance = Balance - @Amount WHERE AccountID = @ID_B
	IF (@@ERROR > 0)
		GOTO PROBLEM

	-- Никаких проблем обнаружено не было
	COMMIT RETURN
    
	-- Код для обработки ошибок
	PROBLEM:
		ROLLBACK;
		RAISERROR('Обновление выполнить не удалось', 16, 1)

В приведенном примере применяются средства ограниченной обработки ошибок Transact-SQL (вариант SQL, реализованный в SQL Server). При использовании значения @@ERROR в Transact-SQL следует соблюдать осторожность и проверять его немедленно после каждой операции. Это связано с тем, что @@ERROR сбрасывается в 0 при успешном завершении оператора SQL. В результате, если первое обновление потерпит неудачу, а второе выполнится успешно, @@ERROR вернет 0. В этой точке проверять его уже слишком поздно.

Инициированные клиентом транзакции ADO.NET

Большинство поставщиков данных ADO.NET включают поддержку баз данных. Транзакции стартуют через объект Connection вызовом метода BeginTransaction(). Этот метод возвращает специфичный для поставщика объект Transaction, используемый для управления транзакцией. Все классы Transaction реализуют интерфейс IDbTransaction. Поставщики включают классы наподобие SqlTransaction, OleDbTransaction, OracleTransaction и т.д. реализующие этот интерфейс.

Класс Transaction предоставляет два ключевых метода:

Commit()

Этот метод указывает завершение транзакции и помещение выполненных изменений в источнике данных.

Rollback()

Этот метод указывает отмену транзакции. Неоконченные изменения отменяются, и состояние базы остается прежним.

Обычно метод Commit() используется в конце операции. Однако если в процессе возникнет любое исключение, должен быть вызван Rollback(). Рассмотрим пример вставки двух записей в таблицу Employees:

protected void Page_Load(object sender, EventArgs e)
{
        string connectionString = WebConfigurationManager.ConnectionStrings["Northwind"].ConnectionString;
        SqlConnection con = new SqlConnection(connectionString);

        SqlCommand cmd1 = new SqlCommand(
"INSERT INTO Employees (LastName, FirstName) VALUES ('Petrov', 'Vasya')", con);

        SqlCommand cmd2 = new SqlCommand(
"INSERT INTO Employees (LastName, FirstName) VALUES ('Ivanov', 'Vadim')", con);
        SqlTransaction transaction = null;

        try
        {
// Открыть соединение и создать транзакцию
con.Open();
transaction = con.BeginTransaction();

// Включить в транзакцию две команды
cmd1.Transaction = transaction;
cmd2.Transaction = transaction;

// Выполнить обе команды
cmd1.ExecuteNonQuery();
cmd2.ExecuteNonQuery();

// Зафиксировать транзакцию
transaction.Commit();
        }
        catch
        {
// В случае ошибки отменить транзакцию
transaction.Rollback();
        }
        finally
        {
// В любом случае закрыть соединение с базой данных
con.Close();
        }
}

Обратите внимание, что просто создать и зафиксировать транзакцию недостаточно. Также необходимо явно включить каждый объект Command в состав транзакции, установив значение их свойства Command.Transaction равным объекту Transaction. Если вы попытаетесь выполнить команду, которая не является частью текущей транзакции, пока транзакция находится в процессе выполнения, то получите ошибку. Однако в будущем эта объектная модель может разрешить поддержку более одной транзакции одновременно в пределах одного соединения.

Вместо использования отдельных объектов команд можно также выполнить один и тот же объект дважды, просто по ходу модифицируя его свойство CommandText (если это динамический оператор SQL) или значения его параметров (если это параметризованная команда). Например, если команда вставляет новую запись, такой подход можно применить для вставки двух записей в одной транзакции.

Чтобы протестировать свойство отката (отмены) транзакции, вставьте следующую строку непосредственно перед вызовом метода Commit() в предыдущем примере:

throw new ApplicationException();

Эта строка генерирует исключение, которое приведет к откату транзакции, гарантируя, что ни одна запись не попадет в базу данных.

Хотя транзакции ADO.NET вращаются вокруг объектов Command и Transaction, лежащие в основе команды не отличаются от тех, что применяются в транзакциях хранимых процедур. Например, когда вызывается метод BeginTransaction() с поставщиком данных SQL Server, он отправляет базе данных команду BEGIN TRANSACTION.

Транзакция должна быть завершена как можно скорее (запущена как можно позже и завершена как можно раньше). К тому же активная транзакция блокирует различные задействованные ресурсы, поэтому следует выбирать только те строки таблиц, которые действительно необходимы.

Уровни изоляции

Уровень изоляции определяет, насколько чувствительна транзакция к изменениям, производимым в других выполняющихся транзакциях. Например, по умолчанию, когда транзакции выполняются независимо друг от друга, записи, вставляемые одной транзакцией, невидимы для другой транзакции до тех пор, пока первая транзакция не будет зафиксирована.

Концепция уровней изоляции тесно связана с понятием блокировок, поскольку уровень изоляции, указанный для данной транзакции, определяет типы необходимых блокировок. Разделяемые блокировки — это те блокировки, которые устанавливаются, когда транзакция желает читать данные из базы. Пока разделяемая блокировка установлена на таблице, строке или диапазоне, никакая другая транзакция не может модифицировать данные. Однако более одного пользователя могут применять разделяемую блокировку для одновременного чтения данных.

Исключительные блокировки — это блокировки, которые предотвращают одновременную модификацию данных двумя или более транзакциями. Исключительная блокировка устанавливается, когда транзакция собирается обновить данные, и никакой другой блокировки на этот момент не установлен. Ни один другой пользователь не сможет модифицировать данные, пока на них установлена исключительная блокировка.

В SQL Server на самом деле поддерживается несколько типов блокировок, работающих совместно, чтобы предотвратить взаимные блокировки и другие неприятные ситуации. За дополнительной информацией о блокировках обращайтесь в руководство SQL Server Books Online Help, которое устанавливается вместе с SQL Server.

В хранимых процедурах SQL Server можно устанавливать уровни изоляции, используя команду SET TRANSACTION ISOLATION LEVEL. В ADO.NET можно передать значение перечисления IsolationLevel перегруженному методу Connection.BeginTransaction(). В таблице ниже показаны его возможные значения:

Значения перечисления IsolationLevel
Значение Описание
ReadUncommitted

Никаких разделяемых блокировок и никаких исключительных блокировок. Этот тип уровня изоляции подходит, когда вы хотите работать со всеми данными, соответствующими определенным условиям, независимо от того, были ли они зафиксированы. Возможно недействительное чтение, но производительность возрастает

ReadCommitted

Разделяемые блокировки удерживаются, пока данные читаются транзакцией. Это исключает случаи недействительного чтения, но данные могут быть изменены до завершения транзакции. Это может привести к невоспроизводимым чтениям или фантомным записям. Этот уровень изоляции в SQL Server используется по умолчанию

Snapshot

Сохраняет копию данных, к которым обращается транзакция. В результате транзакция не может видеть изменений, выполненных другими транзакциями. Этот подход ограничивает блокировки, поскольку даже если другие транзакции удерживают блокировки на этих данных, транзакция с таким уровнем изоляции будет способна читать копии данных. Этот уровень изоляции поддерживается только в SQL Server 2005 и последующих версиях и должен быть включен через параметр уровня базы данных

RepeatableRead

В этом случае разделяемые блокировки устанавливаются на все записи, использованные в запросе. Это предотвращает модификацию данных другими клиентами, а также предотвращает невоспроизводимые чтения. Однако фантомные записи возможны

Serializable

Блокировка диапазона, устанавливаемая на используемые данные, предотвращает обновление и вставку другими пользователями записей, которые могут попасть в диапазон. Это единственный уровень изоляции, который исключает возможность фантомных строк. Однако оказывает исключительно негативное влияние на возможность параллельной работы пользователей, и редко используется в сценариях работы со многими пользователями

В этой таблице введена терминология, связанная с базами данных, которая требует пояснений:

Грязное чтение (dirty read)

Грязное чтение — это чтение, имеющее дело со значением из другой незавершенной транзакции, которая позже может быть отменена.

Невоспроизводимое чтение (unrepeatable read)

Если разрешено невоспроизводимое чтение, существует возможность повторного выполнения запроса в той же транзакции с получением отличающихся данных. Это происходит потому, что простое чтение не мешает другим изменять данные в процессе транзакции. Чтобы предотвратить невоспроизводимое чтение, сервер базы данных должен блокировать строки, которые читает ваша транзакция.

Фантомная строка (phantom row)

Фантомная строка — это строка, которая не появляется при начальном чтении, но появляется, когда те же данные читаются вновь в той же транзакции. Это может произойти, если другой пользователь вставляет запись до завершения вашей транзакции. Чтобы предотвратить появление фантомных строк, когда ваша транзакция выполняет запрос, сервер базы данных должен применять блокировку диапазона на основе конструкции WHERE этого запроса.

Будут эти феномены безобидными причудами или потенциальными источниками ошибок — зависит от специфических требований. Большую часть времени невоспроизводимые чтения и фантомные строки не представляют собой серьезных проблем, а усилия по их предотвращению слишком дорогостоящи, чтобы имело смысл их предпринимать. Однако если вам нужно обновлять множество строк сразу, и эти строки содержат некоторые взаимосвязанные данные, может понадобиться более строгая блокировка, чтобы предотвратить перекрывающиеся изменения, чреватые нарушением согласованности данных.

Уровни изоляции в приведенной выше таблице располагаются от наименьшей степени блокировки до наибольшей. Уровень по умолчанию, ReadCommitted, является хорошим компромиссом для большинства транзакций. В следующей таблице описано поведение блокировки для разных уровней изоляции:

Сравнение уровней изоляции транзакций
Уровень изоляции Грязное чтение Невоспроизводимое чтение Фантомные данные Параллелизм (несколько пользователей)
ReadUncommitted Да Да Да Наилучший
ReadCommitted Нет Да Да Хороший
Snapshot Нет Нет Нет Хороший
RepeatableRead Нет Нет Да Слабый
Serializable Нет Нет Нет Очень слабый

Точки сохранения

Всякий раз, когда производится откат транзакции, аннулируются результаты всех команд, выполненных с момента старта транзакции. Но как быть, если вы хотите откатить только часть текущей транзакции? В SQL Server для этого предназначено средство, которое называется точками сохранения (savepoints).

Точки сохранения — это метки, которые работают подобно книжным закладкам. Вы отмечаете определенную точку в потоке транзакции, и затем можете выполнять ее откат до этой точки. Точка сохранения устанавливается с помощью метода Transaction.Save(). Обратите внимание, что метод Save() доступен только в классе SqlTransaction, поскольку не является частью стандартного интерфейса IDbTransaction.

Ниже демонстрируется концепция использования точки сохранения:

// Запустить транзакцию
SqlTransaction tran = con.BeginTransaction();

// (Включить и выполнить некоторые команды в транзакции.)

// Отметить точку сохранения
tran.Save("CompletedInsert") ;

// (Включить и выполнить еще какие-то команды в транзакции.)

// Если необходимо, выполнить откат до точки сохранения
tran.Rollback("CompletedInsert");

// Зафиксировать или откатить всю транзакцию
tran.Commit();

Обратите внимание на использование метода Rollback() с точкой сохранения в качестве параметра. Если вы хотите откатить всю транзакцию, просто пропустите этот параметр. При откате транзакции до точки сохранения все точки сохранения, определенные после данной, теряются. Если они нужны, их придется установить заново.

Пройди тесты
Лучший чат для C# программистов