Привет, Хабр! Тем временем у нас распродажа в честь черной пятницы. Там найдется много интересных книг по базам данных, и именно о взаимных блокировках при транзакциях в базах данных - сегодняшний пост. Пару лет назад мы с командой построили событийно-ориентированную систему, работавшую с транзакциями и время от времени застывавшую в загадочных взаимных блокировках. Как-то раз я решил заглянуть в нее поглубже и посмотреть, что же на самом деле там происходит. Этот небольшой пост можно считать отчетом, документирующим мои изыскания. У сервиса одновременно работает 3 инстанса: по одному на хост EC2 в AWS. Сервис написан на C#/.NET Core 3.1 с использованием BackgroundService. Информация хранится в базе данных Aurora MySql. Сервис подхватывает события предметной области из множества очередей, обрабатывает их, меняя состояние локальных сущностей предметной области, после чего отправляя их обратно на долговременное хранение в базу данных MySql. Все это осуществляется конкурентно, иногда – прямо в транзакционных блоках базы данных, которые выглядят примерно так: Мы используем TransactionScope, так как операции в предметной области охватывают множество сущностей предметной области, и соответствующая логика доступа к данным для этих сущностей реализуется в отдельных классах. Единственный способ сложить их в единую транзакцию на уровне предметной области – использовать единицу выполнения работы, например, TransactionScope. Однако, запуская этот код в продакшене, мы регулярно утыкались во взаимные блокировки: Поэтому я решил вычленить путь выполнения транзакционного кода и запустить его в цикле Parallel.For, нацелившись на локальную базу данных MySql – и посмотреть, смогу ли я воспроизвести эти взаимные блокировки:  Прошло не так много итераций, и ошибка воспроизвелась: MySql.Data.MySqlClient.MySqlException (0x80004005): Взаимная блокировка обнаружена при попытке приобрести блокировку; попытка перезапуска транзакции … Хммм!  Обратите внимание: взаимная блокировка сама по себе – еще не конец света, так как можно повторно выполнить заблокированную транзакцию, ведь она откатывается обратно в MySql. Это превращается в проблему, если такие блокировки происходят с некоторой регулярностью и начинают тормозить прогресс в выполнении задач и/или приводить к потере данных тем или иным образом. В нашем случае потеря данных не представляла особой проблемы, но сами по себе многократные блокировки вызывали беспокойство. В будущем эта беда вполне могла усугубиться в случае, если бы увеличился объем потока сообщений. Поэтому я решил исследовать эту проблему и по возможности ее устранить. Сначала я заподозрил, что транзакция попросту протекает слишком долго из-за того, что мы применяем к ней операцию SELECT, а затем либо INSERT, либо UPDATE, именно поэтому возникает слишком продолжительный конфликт при блокировках. Но как рассмотреть, что в действительности здесь происходит?  Есть как минимум два простейших способа посмотреть, что происходит с блокировками в MySql: a. Просмотреть самые свежие блокировки, направив к базе данных запрос show engine innodb status. Получим вот такой вывод: Так выводится список самых свежих блокировках, показывается, какие блокировки удерживались при транзакции, и какие блокировки находились в состоянии ожидания на момент взаимной блокировки. b. Вывести все взаимные блокировки в логи MySql, включив (т.e. установив в “ON”, по умолчанию “OFF”) настройку innodb_print_all_deadlocks в системной переменной. Вывод обычно будет один и тот же, но в этом логе будут учтены все взаимные блокировки. В документации по MySql рекомендуется включать эту настройку только на время отладки, а затем отключать, как только проблема будет решена – вероятно, потому, что логирование взаимных блокировок может негативно повлиять на нормальную производительность транзакций. Правда, я оставил эту настройку включенной в нашей базе данных, даже когда она пошла в продакшен – и не заметил никакого существенного влияния на обработку транзакций. Так или иначе, в данном случае я вижу, что обе транзакции 1 и 2 держат S (разделяемую) блокировку и ждут X (монопольную) блокировку, чтобы обновить строку. Таким образом, каждая из транзакций ждет блокировку, чтобы внести запись, но в то же время держит и другую блокировку. Эта другая блокировка должна быть высвобождена хотя бы одной из транзакций, прежде, чем они смогут получить монопольные блокировки. Вот вам и ВЗАИМНАЯ БЛОКИРОВКА!!!  Принцип работы этих блокировок очень хорошо документирован в MySql, поэтому, если хотите разобраться подробнее – почитайте там. Это очень глубокая тема, и сложно постоянно держать в голове все пограничные случаи. Но давайте разберемся, почему S вообще блокируется? Я заинтересовался, не приводит ли к этому ненароком простая операция SELECT, поэтому также направил к SQL транзакцию с единственной SELECT, не фиксируя транзакцию и не откатывая ее назад, чтобы можно было применить диагностические запросы к транзакции, по-прежнему находящейся в состоянии RUNNING: Чтобы посмотреть, блокировки какого рода берутся, я выполнил следующий диагностический запрос: Хмм! Никаких блокировок! Что, если оно блокируется, если одновременно выполняется операция UPDATE в той же транзакции?  Результат диагностического запроса: 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! ) : И…ВЗАИМНАЯ БЛОКИРОВКА!!! В результатах диагностического запроса показаны блокировки: Обе транзакции приобрели 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 весьма недопонимают (я точно недопонимал). Из-за этого можно подумать, что транзакции будут выполняться одна за другой детерминированным образом, поэтому и возникает вопрос: откуда взаимные блоуировки, если все блокировки приобретаются детерминированным образом. Но на самом деле все не совсем так! Если у вас создается впечатление, как будто транзакции происходят одна за другой, это еще не значит, что они физически идут друг за другом. Блокировки, принимаемые в рамках сериализуемой транзакции, не мешают другим сериализуемым транзакциям начаться, они только предотвращают фантомные считывания и грязные считывания незафиксированных данных. Это означает, что транзакции все равно будут перекрываться, и порядок «одна за другой» достигается только ценой взаимных блокировок, ответственность за которые ложится на программиста, то есть, на вас!