Стратегия работы с тупиками для backend-разработчиков.

При использовании MS SQL Server в своих проектах разработчики программного обеспечения могут столкнуться со следующим сообщением об ошибке:

Транзакция (идентификатор процесса 69) зашла в тупик при блокировке ресурсов другим процессом и была выбрана жертвой взаимоблокировки. Повторите транзакцию.

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

· Что такое тупик?
· Логика повторных попыток для транзакций жертвы
· Как минимизировать взаимоблокировки?
Повышение производительности базы данных
Понижение версии Уровень изоляции транзакций
Доступ к объектам в одном порядке
· Ограничение количества одновременных транзакций
· Сводка

Что такое тупик?

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

Давайте посмотрим, как воспроизвести тупик на следующем примере:

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

Если вы запустите первую транзакцию в одном сеансе, а затем сразу же запустите вторую транзакцию в другом сеансе, вы увидите сообщение об ошибке взаимоблокировки, потому что:

  • Первая транзакция запускается и получает общую блокировку (строка 9) для строки и удерживает ее до конца транзакции (общие блокировки снимаются только в конце транзакции на уровне изоляции повторяемого чтения).
  • Вторая транзакция запускается и получает общую блокировку (строка 18) для той же строки, которую заблокировала первая транзакция.
  • Первая транзакция пытается получить монопольную блокировку (строка 11), но ждет, пока вторая транзакция не снимет общую блокировку со строки.
  • Вторая транзакция пытается получить монопольную блокировку (строка 19), но ждет, пока первая транзакция не освободит общую блокировку строки.

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

Между тем, отдельный поток сервера MS SQL под названием LOCK_MONITOR в фоновом режиме проверяет все активные блокировки на наличие циклов.

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

  • Выбирает одну из транзакций в качестве жертвы.
  • Завершает транзакцию жертвы.
  • Откатить все изменения, внесенные транзакцией-жертвой.

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

SET DEADLOCK_PRIORITY LOW;

Доступные варианты: НИЗКИЙ, НОРМАЛЬНЫЙ или ВЫСОКИЙ. Кроме того, приоритет может быть установлен от -10 до 10. Приоритет по умолчанию является нормальным для транзакции.

Логика повторных попыток для транзакций жертвы

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

Однако взаимодействие с пользователем можно улучшить, и совет по улучшению находится в сообщении об ошибке взаимоблокировки, а именно во втором предложении:

Транзакция (идентификатор процесса 69) зашла в тупик при блокировке ресурсов другим процессом и была выбрана жертвой взаимоблокировки. Повторите транзакцию.

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

Уровень доступа к данным вашего проекта должен иметь возможность повторно запускать транзакции жертвы с использованием линейного или экспоненциального отката с ограниченным количеством попыток.

private int retryCount = 3;
private readonly TimeSpan delay = TimeSpan.FromSeconds(2);
private const int DeadlockErrorCode = 1205;
public async Task DatabaseCallWithRetryAsync()
{
    int currentRetry = 0;
    for (; ;)
    {
        try
        {
            await ExecuteDatabaseCallAsync();
            break;
        }
        catch (SqlException ex)
        {
            currentRetry++;
            if (currentRetry > retryCount ||
                   ex.ErrorCode == DeadlockErrorCode)
            {
                throw;
            }
        }
        await Task.Delay(delay);
    }
}

Используя шаблон повтора, вы можете разработать более надежное и удобное приложение.

Как минимизировать тупики?

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

Повышение производительности базы данных

Когда транзакция выполняется в течение длительного времени из-за длительного выполнения SELECT, UPDATE или другого оператора, вероятность взаимоблокировок увеличивается. Чем дольше выполняется инструкция, тем дольше она удерживает блокировку ресурса, с которым имеет дело.

Различные распространенные методы повышения производительности базы данных снизят вероятность возникновения взаимоблокировок. Вот некоторые из этих техник:

  • Использование кластерных, некластеризованных, покрывающих индексов.
  • Использование денормализованных данных для чтения и нормализованных данных для записи.
  • Получение только определенных столбцов и сужение предикатов запроса.
  • Использование наименьших типов данных для столбцов таблицы. Например, tinyint, а не int, достаточно для хранения возраста человека.
  • Никаких курсоров.

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

Понижение уровня изоляции транзакции

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

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

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



Доступ к объектам в том же порядке

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

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

  • Первая транзакция запускается и получает исключительную блокировку для пользовательской строки (строка 10). Эксклюзивная блокировка сохраняется до окончания транзакции.
  • Вторая транзакция запускается и получает эксклюзивную блокировку для строки администратора (строка 17). Эксклюзивная блокировка сохраняется до окончания транзакции.
  • Первая транзакция продолжает выполняться и пытается получить блокировку для строки администратора (строка 12), которая заблокирована второй транзакцией.
  • Вторая транзакция продолжает выполнение и пытается получить блокировку для пользовательской строки (строка 18), которая заблокирована первой транзакцией.

Вторая таблица доступа к транзакции находится в обратном порядке по сравнению с первой, и простая замена двух операторов обновления (строки 17 и 18) во второй транзакции устранит взаимоблокировку.

Ограничение количества одновременных транзакций

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

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

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

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

Эта архитектура не масштабируется. Одним из индикаторов плохой масштабируемости будут регулярные тупиковые ситуации в базе данных.

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

Весь поток изменится следующим образом:

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

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

Резюме

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

Другие мои статьи