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

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

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

Entity Framework Core поддерживает оптимистичное управление параллелизмом. В этом посте мы рассмотрим пример и реализуем элементы управления параллелизмом с использованием базы данных SQLite. Для других поставщиков баз данных реализация будет проще. Полный пример проекта можно найти в этом репозитории GitHub.

Без обнаружения параллелизма

Давайте рассмотрим сущность типа BankAccount, как определено в следующем фрагменте кода. Чрезмерно упрощенная модель отслеживает идентификатор и баланс банковского счета и имеет методы кредита / дебета для обновления баланса счета.

Теперь у нас есть два пользователя, пытающихся изменить баланс банковского счета, на котором начальный баланс составляет 1000 долларов США. Мы моделируем, что пользователь A и пользователь B совершают транзакции в двух разных потоках. Пользователь A зачисляет 100 долларов на счет в потоке 0. В то же время пользователь B списывает 200 долларов со счета в потоке 1.

Если мы ничего не сделаем, Entity Framework не знает, как определять конфликты параллелизма по умолчанию, и база данных будет принимать последнее сохраненное значение между пользователем A и пользователем B. В конце концов, только один пользователь выиграет. Результатом одновременного конфликта может быть 1100 или 800 долларов, в зависимости от того, кто победит. Другими словами, только одно действие вступит в силу, а другое действие обновления будет потеряно без уведомления. Эта проблема параллелизма связана с денежным дефицитом и совершенно неприемлема в реальной жизни.

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

Обычно есть два способа настроить обнаружение параллелизма.

  1. Настройте Entity Framework для включения исходных значений указанных полей в предложение Where команд Update и Delete. Указанные поля называются токенами параллелизма. Сравнивая значения со значениями в базе данных, Entity Framework понимает, может ли команда SQL вносить изменения в строки или нет.
  2. Включите столбец отслеживания в таблицу базы данных. Затем настройте Entity Framework для включения этого столбца в предложение Where команд SQL Update и Delete, чтобы Entity Framework могла определить, была ли изменена строка. Столбец отслеживания обычно называется RowVersion.

Обнаружение параллелизма через ConcurrencyToken

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

Затем Entity Framework переводит команду SQL обновления, чтобы включить «Balance» в предложение Where. Журналы Entity Framework вставлены во фрагмент ниже.

Во время выполнения как пользователь A, так и пользователь B читают учетную запись и знают, что баланс составляет 1000 долларов, затем они пытаются изменить значение баланса. Если пользователю A удастся изменить значение баланса на 1100 долларов, то обновление пользователя B завершится ошибкой. Точно так же, если побеждает пользователь Б, то окончательный баланс составляет 800 долларов, а кредит пользователя А в размере 200 долларов теряется. Второе обновление не удается, потому что команда SQL WHERE Id=1 AND Balance=1000 возвращает 0 строк после одного успешного обновления, поэтому таблица базы данных не может изменить ту же строку.

Это называется сценарием Store Wins, в котором значения базы данных имеют приоритет над значениями, предпринимаемыми клиентом. В этом сценарии Entity Framework обнаруживает отклонение и выдает исключение, как показано в выходных данных консоли красным цветом.

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

Обратите внимание, что мы можем настроить столько столбцов (столбцов, не являющихся первичными ключами), сколько необходимо, чтобы они были токенами параллелизма. Entity Framework включит их все в предложение WHERE команд обновления / удаления SQL.

Похоже, у нас уже есть решение для обнаружения конфликтов параллелизма. Зачем нужен второй? Это связано с тем, что слишком много токенов параллелизма может вызвать неэффективность. Подумайте об операторе SQL, который имеет очень длинное предложение WHERE из-за длинного списка столбцов для сравнения. Эффективность низкая, потому что в базу данных передается и сравнивается много данных. Это время, когда RowVersion вступает в игру.

Еще одна важная причина, по которой нам нужно RowVersion в обнаружении конфликтов параллелизма, является то, что подход RowVersion является обычным решением проблемы ABA.

Обнаружение параллелизма через RowVersion

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

Для SQL Server метка времени обычно используется в свойстве byte[], которое будет настроено как столбец RowVersion в базе данных. Для SQLite столбец RowVersion требует дополнительных настроек (см. Проблему в GitHub). В этом проекте я сделал следующие конфигурации.

Во-первых, класс BankAccount включает новое свойство, которое будет использоваться как RowVersion.

public class BankAccount
{
    public int Id { get; set; }
    public decimal Balance { get; set; }
    public byte[] Timestamp { get; set; }   // add a new property 
    
    // ... 
}

Затем свойство Timestamp отображается в столбец типа BLOB в SQLite. Этот столбец установлен как столбец RowVersion в таблице базы данных в строке 11 в следующем фрагменте кода.

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

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

После настройки свойства RowVersion и переноса схемы базы данных Entity Framework знает, что нужно включить значение свойства RowVersion в предложение WHERE команд _35 _ / _ 36_. Сразу после команды UPDATE Entity Framework проверяет, изменилось ли значение RowVersion. Взгляните на переведенную команду SQL ниже.

Почти идентичная симуляция обнаружения конфликта параллелизма показывает следующее сообщение об исключении.

Ожидается, что операция с базой данных повлияет на 1 строку (строки), но на самом деле повлияла на 0 строк. Данные могли быть изменены или удалены после загрузки объектов. См. Http://go.microsoft.com/fwlink/?LinkId=527962 для получения информации о понимании и обработке исключений оптимистичного параллелизма.

Мы также можем поймать DbUpdateConcurrencyException, чтобы отобразить соответствующее сообщение об ошибке.

Подводя итог, Entity Framework Core поддерживает два подхода к обнаружению конфликтов параллелизма: (1) настройка существующих свойств, не являющихся первичными ключами, в качестве токенов параллелизма; и (2) добавление дополнительного свойства RowVersion (и столбца в таблице базы данных) в качестве универсального токена параллелизма.

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

Это все на сегодня. Полный проект находится в этом репозитории GitHub. Спасибо за прочтение.