Как стать автором
Обновить

Любопытный случай взаимной блокировки транзакций в базе данных при использовании TransactionScope

Время на прочтение10 мин
Количество просмотров2.1K
Автор оригинала: Aman Agrawal

Привет, Хабр! Тем временем у нас распродажа в честь черной пятницы. Там найдется много интересных книг по базам данных, и именно о взаимных блокировках при транзакциях в базах данных - сегодняшний пост.

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

У сервиса одновременно работает 3 инстанса: по одному на хост EC2 в AWS. Сервис написан на C#/.NET Core 3.1 с использованием BackgroundService. Информация хранится в базе данных Aurora MySql. Сервис подхватывает события предметной области из множества очередей, обрабатывает их, меняя состояние локальных сущностей предметной области, после чего отправляя их обратно на долговременное хранение в базу данных MySql. Все это осуществляется конкурентно, иногда – прямо в транзакционных блоках базы данных, которые выглядят примерно так:

static async Task Main(string[] args)
{
    using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
    {
	await Save(product);
	// мы можем обновить и больше сущностей 
	scope.Complete();
    }
}

public static async Task Save(Product product)
{
    using (var connection = new MySqlConnection(“...”))
    {
	await connection.OpenAsync();
	
	// выбрать count(1) из Products где Id = @id
	if (await Exists(product, connection))
	    // обновить Products set ... где Id = @id
	    await Update(product, connection);
	else
	    // вставить в Products values(...)
	    await Insert(product, connection);
    }
}

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

Однако, запуская этот код в продакшене, мы регулярно утыкались во взаимные блокировки:

Поэтому я решил вычленить путь выполнения транзакционного кода и запустить его в цикле Parallel.For, нацелившись на локальную базу данных MySql – и посмотреть, смогу ли я воспроизвести эти взаимные блокировки:

Parallel.For(0, 500,
    async index =>
    {
        var product = new Product
        {
            Id = id,
            Stock = index+1
        };
        
        using (var scope = new TransactionScope(
            TransactionScopeAsyncFlowOption.Enabled))
        {
            await Save(product);
            scope.Complete();
        }        
    });

Прошло не так много итераций, и ошибка воспроизвелась:

MySql.Data.MySqlClient.MySqlException (0x80004005): Взаимная блокировка обнаружена при попытке приобрести блокировку; попытка перезапуска транзакции …

Хммм! 

Обратите внимание: взаимная блокировка сама по себе – еще не конец света, так как можно повторно выполнить заблокированную транзакцию, ведь она откатывается обратно в MySql. Это превращается в проблему, если такие блокировки происходят с некоторой регулярностью и начинают тормозить прогресс в выполнении задач и/или приводить к потере данных тем или иным образом. В нашем случае потеря данных не представляла особой проблемы, но сами по себе многократные блокировки вызывали беспокойство. В будущем эта беда вполне могла усугубиться в случае, если бы увеличился объем потока сообщений. Поэтому я решил исследовать эту проблему и по возможности ее устранить.

Сначала я заподозрил, что транзакция попросту протекает слишком долго из-за того, что мы применяем к ней операцию SELECT, а затем либо INSERT, либо UPDATE, именно поэтому возникает слишком продолжительный конфликт при блокировках. Но как рассмотреть, что в действительности здесь происходит?

Отладка блокировок в MySql

Есть как минимум два простейших способа посмотреть, что происходит с блокировками в MySql:

a. Просмотреть самые свежие блокировки, направив к базе данных запрос show engine innodb status. Получим вот такой вывод:

a.	=====================================
b.	2021-07-23 21:27:55 0x7f0fec4a3700 INNODB MONITOR OUTPUT
c.	=====================================
d.	...
e.	------------------------
f.	LATEST DETECTED DEADLOCK
g.	------------------------
h.	2021-07-23 21:26:29 0x7f0fd3558700
i.	*** (1) TRANSACTION:
j.	TRANSACTION 2631, ACTIVE 0 sec starting index read
k.	mysql tables in use 1, locked 1
l.	LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
m.	MySQL thread id 15, OS thread handle 139706363459328, query id 3531 172.20.0.1 root updating
n.	update TrxDb.Products set stock = 495 where Id = 1000 and Version = 1
o.	
p.	*** (1) HOLDS THE LOCK(S):
q.	RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `TrxDb`.`Products` trx id 2631 lock mode S locks rec but not gap
r.	Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
s.	...
t.	
u.	
v.	*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
w.	RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `TrxDb`.`Products` trx id 2631 lock_mode X locks rec but not gap waiting
x.	Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
y.	 ...
z.	
aa.	
bb.	*** (2) TRANSACTION:
cc.	TRANSACTION 2632, ACTIVE 0 sec starting index read
dd.	mysql tables in use 1, locked 1
ee.	LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
ff.	MySQL thread id 9, OS thread handle 139706363754240, query id 3533 172.20.0.1 root updating
gg.	update TrxDb.Products set stock = 357 where Id = 1000 and Version = 1
hh.	
ii.	*** (2) HOLDS THE LOCK(S):
jj.	RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `TrxDb`.`Products` trx id 2632 lock mode S locks rec but not gap
kk.	Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
ll.	...
mm.	
nn.	*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
oo.	RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `TrxDb`.`Products` trx id 2632 lock_mode X locks rec but not gap waiting
pp.	Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
qq.	...
rr.	
ss.	*** WE ROLL BACK TRANSACTION (2)
tt.	...
uu.	----------------------------
vv.	END OF INNODB MONITOR OUTPUT
ww.	============================

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

b. Вывести все взаимные блокировки в логи MySql, включив (т.e. установив в “ON”, по умолчанию “OFF”) настройку innodb_print_all_deadlocks в системной переменной. Вывод обычно будет один и тот же, но в этом логе будут учтены все взаимные блокировки. В документации по MySql рекомендуется включать эту настройку только на время отладки, а затем отключать, как только проблема будет решена – вероятно, потому, что логирование взаимных блокировок может негативно повлиять на нормальную производительность транзакций. Правда, я оставил эту настройку включенной в нашей базе данных, даже когда она пошла в продакшен – и не заметил никакого существенного влияния на обработку транзакций.

Так или иначе, в данном случае я вижу, что обе транзакции 1 и 2 держат S (разделяемую) блокировку и ждут X (монопольную) блокировку, чтобы обновить строку. Таким образом, каждая из транзакций ждет блокировку, чтобы внести запись, но в то же время держит и другую блокировку. Эта другая блокировка должна быть высвобождена хотя бы одной из транзакций, прежде, чем они смогут получить монопольные блокировки. Вот вам и ВЗАИМНАЯ БЛОКИРОВКА!!!

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

Но давайте разберемся, почему S вообще блокируется? Я заинтересовался, не приводит ли к этому ненароком простая операция SELECT, поэтому также направил к SQL транзакцию с единственной SELECT, не фиксируя транзакцию и не откатывая ее назад, чтобы можно было применить диагностические запросы к транзакции, по-прежнему находящейся в состоянии RUNNING:

set autocommit = off;
start transaction;
select * from TrxDb.Products where Id = 1000;

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

select * from performance_schema.data_locks;

Хмм! Никаких блокировок! Что, если оно блокируется, если одновременно выполняется операция UPDATE в той же транзакции?

set autocommit = off;
Start transaction; 
select count(1) from TrxDb.Products where Id = 1000; 
update TrxDb.Products set Stock = 8 where Id = 1000 and Version = 1;

Результат диагностического запроса:

Oк! Операция UPDATE берет только блокировку X (как и должно быть), а блокировки S по-прежнему не происходит! Блокировку IX игнорируем, так как это плановая блокировка, которая, по-видимому, всегда предоставляется всем транзакциям, которые ее запрашивают. Кажется, она не сказывается ни на каких других блокировках или транзакциях!

Если я запускаю 2 транзакции почти одновременно (в двух отдельных вкладках/сеансах и со слегка отличающимися значениями данных), то вывод диагностического запроса показывает: одна транзакция получает блокировку X, а другая ожидает ее, но по-прежнему не просматривается блокировка S*:

Подождав еще немного, получаю ошибку LOCK WAIT TIMEOUT, а не DEADLOCK:

Интрига закручивается!!!

Влияет ли на блокировки уровень изоляции транзакций? 

По умолчанию в MySql принят уровень изоляции REPEATABLE READ, поэтому я предположу, что в TransactionScope по умолчанию также действует уровень изоляции REPEATABLE READ, из-за чего взаимная блокировка становится еще более странной, ведь выполнение SQL-скрипта к взаимной блокировке не приводит. Поэтому я заглянул под капот TransactionScope и нашел вот что:

Здесь внутри создается экземпляр CommittableTransaction

Использующий SERIALISABLE в качестве уровня изоляции, заданного по умолчанию (это также документировано, но на тот момент я еще не удосужился закопаться в документацию так глубоко)!…ну… oк! Значит, вот в чем причина взаимной блокировки?

Я изменил мои SQL-скрипты так, чтобы установить уровень изоляции в значение SERIALISABLE, и снова запустил их одновременно (причина, по которой нужны искусственные задержки – так я хочу повысить вероятность взаимной блокировки, чуть-чуть изменив порядок, в котором приобретаются блокировки. Вероятно, это же происходит и в коде из репозитория, и вот почему должно пройти несколько итераций, прежде, чем начнут всплывать взаимные блокировки. Без этого единственная ошибка, которую я получаю - LOCK WAIT TIMEOUT! ) :

#Транзакция 1
set autocommit = off;
set SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE ;
start transaction;
select * from TrxDb.Products where Id = 1000;
do sleep(8);
update TrxDb.Products set stock = 8 where Id = 1000 and Version = 1;

#Транзакция 2
set autocommit = off;
set SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE ;
start transaction;
do sleep(5);
select * from TrxDb.Products where Id = 1000;
update TrxDb.Products set stock = 8 where Id = 1000 and Version = 1;

И…ВЗАИМНАЯ БЛОКИРОВКА!!!

В результатах диагностического запроса показаны блокировки:

Обе транзакции приобрели S-блокировки, а потом остались дожидаться X-блокировки, поскольку, как только принята S-блокировка, никакие другие блокировки не предоставляются. Именно это я и видел в исходном выводе лога с взаимной блокировкой с самого начала.

Но почему операция SELECT принимает S-блокировку при уровне изоляции serialisable**?

Оказывается, что ответ на первый вопрос совсем прост и даже как-то ломает интригу:

SERIALIZABLE
Этот уровень подобен REPEATABLE READно InnoDB неявно преобразует все обычные операции SELECT в SELECT … FOR SHARE, если отключена автофиксация. Если автофиксация включена, то SELECT – это самостоятельная транзакция. Соответственно, известно, что она предназначена только для чтения и поддается сериализации, если выполняется как согласованное (неблокирующее) чтение, и ее не нужно блокировать, чтобы могли пройти другие транзакции. (Чтобы принудительно заставить обычную SELECT блокироваться, если другие транзакции модифицировали выбранные ряды, отключите автофиксацию.)

Документация MySql

А SELECT…FOR SHARE так устроена, что принимает блокировку S – значит, это и есть первопричина блокировки S!

Как мы это исправили?

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

Разделяя UPSERT, мы, фактически, не просто укорачиваем отдельные транзакции, но и делаем их более явными и простыми. Затем код предметной области может определять, создавать ли новую запись, если записи для данного id пока не существует, либо обновить уже имеющуюся тем состоянием, которое было принесено в событии. С любой практической точки зрения это решение должно приниматься именно на уровне предметной области, а не на уровне данных, поскольку именно в предметной области найдется достаточный контекст и осведомленность о том, хватает ли у нас информации для продолжения работы. Проверка на “существование” не требует что-либо блокировать, поэтому при таком разделении уменьшиться и общее количество блокировок. Может начаться гонка, когда проверка на сервере A сообщает: «товар не существует, создать его», и одновременно на другом сервере B происходит та же проверка, но там этот товар создается раньше, и тогда на сервере A операция не удается из-за нарушения первичного ключа. Но, поскольку наша система основана на сообщениях, простая повторная попытка, сделанная чуть позже, позволит корректно обновить запись, и система снова станет согласованной. С небольшой отложенной обработкой мы справимся.

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

Весь код к посту (плюс некоторые дополнительные сценарии) выложен на GitHub!

*Разумеется, здесь я выполняю транзакции не так, как в коде, поскольку вообще не делаю фиксации транзакций (так как хочу отловить их, пока они работают), и у этого есть побочный эффект: транзакции удерживают блокировки дольше, чем требуется. Но тот факт, что код все равно приходит к взаимной блокировке, приводит меня к обоснованной догадке, что на данный момент сделанная мной конфигурация еще не совершенна, но не безнадежна! Если вы знаете лучший или более надежный способ поставить такой эксперимент – высказывайтесь в комментариях!

** Оказывается, что уровень изоляции serialisable весьма недопонимают (я точно недопонимал). Из-за этого можно подумать, что транзакции будут выполняться одна за другой детерминированным образом, поэтому и возникает вопрос: откуда взаимные блоуировки, если все блокировки приобретаются детерминированным образом. Но на самом деле все не совсем так! Если у вас создается впечатление, как будто транзакции происходят одна за другой, это еще не значит, что они физически идут друг за другом. Блокировки, принимаемые в рамках сериализуемой транзакции, не мешают другим сериализуемым транзакциям начаться, они только предотвращают фантомные считывания и грязные считывания незафиксированных данных. Это означает, что транзакции все равно будут перекрываться, и порядок «одна за другой» достигается только ценой взаимных блокировок, ответственность за которые ложится на программиста, то есть, на вас!

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии0

Публикации

Информация

Сайт
piter.com
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия