Обработка конфликтов параллелизма

Совет

Вы можете скачать используемый в этой статье пример из репозитория GitHub.

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

Оптимистическая блокировка

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

В EF Core оптимистическое параллелизм реализуется путем настройки свойства в качестве маркера параллелизма. Маркер параллелизма загружается и отслеживается при запросе сущности так же, как и любое другое свойство. Затем при выполнении SaveChanges()операции обновления или удаления значение маркера параллелизма в базе данных сравнивается с исходным значением, считываемым EF Core.

Чтобы понять, как это работает, предположим, что мы на SQL Server и определим типичный тип сущности Person с особым Version свойством:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

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

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
context.SaveChanges();
  1. На первом шаге пользователь загружается из базы данных; сюда входит маркер параллелизма, который теперь отслеживается как обычно EF вместе с остальными свойствами.
  2. Затем экземпляр Person изменяется каким-то образом— мы изменяем FirstName свойство.
  3. Затем мы поручаем EF Core сохранить изменения. Так как маркер параллелизма настроен, EF Core отправляет следующий SQL в базу данных:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;

Обратите внимание, что в дополнение к PersonId предложению WHERE EF Core также добавил условие Version . Это изменяет только строку, если Version столбец не изменился с момента запроса.

В обычном (оптимистичном) случае параллельное обновление не происходит, а обновление завершается успешно, изменяя строку; База данных сообщает EF Core, что одна строка пострадала от UPDATE, как ожидалось. Однако если произошло параллельное обновление, обновление не сможет найти соответствующие строки и отчеты, которые были затронуты нулю. В результате EF Core SaveChanges() создает исключение DbUpdateConcurrencyException, которое приложение должно перехватывать и обрабатывать соответствующим образом. Описанные ниже методы описаны в разделе "Устранение конфликтов параллелизма".

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

Маркеры параллелизма, созданные собственной базой данных

В приведенном выше коде атрибут использовался [Timestamp] для сопоставления свойства с столбцом SQL Server rowversion . Так как rowversion автоматически изменяется при обновлении строки, это очень полезно как маркер параллелизма с минимальными усилиями, который защищает всю строку. Настройка столбца SQL Server rowversion в качестве маркера параллелизма выполняется следующим образом:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }

    [Timestamp]
    public byte[] Version { get; set; }
}

Приведенный выше тип — это функция, связанная с SQL Server. Сведения rowversion о настройке маркера параллелизма автоматического обновления отличаются между базами данных, а некоторые базы данных не поддерживают их вообще (например, SQLite). Дополнительные сведения см. в документации по поставщику.

Маркеры параллелизма, управляемые приложением

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

Ниже описано, как настроить свойство GUID в виде токена параллелизма:

public class Person
{
    public int PersonId { get; set; }
    public string FirstName { get; set; }

    [ConcurrencyCheck]
    public Guid Version { get; set; }
}

Так как это свойство не создается базой данных, его необходимо назначить в приложении при сохранении изменений:

var person = context.People.Single(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
context.SaveChanges();

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

Разрешение конфликтов параллелизма

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

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

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

Доступны три набора значений для разрешения конфликта параллелизма:

  • Текущие значения — это значения, которые приложение пыталось записать в базу данных.
  • Исходные значения — это значения, которые изначально были получены из базы данных до того, как были сделаны какие-либо изменения.
  • Значения базы данных — это значения, которые в настоящее время хранятся в базе данных.

Общий подход к обработке конфликтов параллелизма:

  1. Получите DbUpdateConcurrencyException в процессе SaveChanges.
  2. Используйте DbUpdateConcurrencyException.Entries, чтобы подготовить новый набор изменений для затронутых объектов.
  3. Обновите исходные значения маркера параллелизма, чтобы отразить текущие значения в базе данных.
  4. Повторяйте процесс, пока не возникнут конфликты.

В следующем примере Person.FirstName и Person.LastName устанавливаются как маркеры параллелизма. В месте, где вы указываете конкретную логику приложения, есть комментарий // TODO:, позволяющий выбрать значение, которое нужно сохранить.

using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = context.People.Single(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";

// Change the person's name in the database to simulate a concurrency conflict
context.Database.ExecuteSqlRaw(
    "UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");

var saved = false;
while (!saved)
{
    try
    {
        // Attempt to save changes to the database
        context.SaveChanges();
        saved = true;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            if (entry.Entity is Person)
            {
                var proposedValues = entry.CurrentValues;
                var databaseValues = entry.GetDatabaseValues();

                foreach (var property in proposedValues.Properties)
                {
                    var proposedValue = proposedValues[property];
                    var databaseValue = databaseValues[property];

                    // TODO: decide which value should be written to database
                    // proposedValues[property] = <value to be saved>;
                }

                // Refresh original values to bypass next concurrency check
                entry.OriginalValues.SetValues(databaseValues);
            }
            else
            {
                throw new NotSupportedException(
                    "Don't know how to handle concurrency conflicts for "
                    + entry.Metadata.Name);
            }
        }
    }
}

Использование уровней изоляции для элемента управления параллелизмом

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

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

  1. При запросе строки транзакция принимает общую блокировку. Любая внешняя транзакция, пытающаяся обновить строку, будет блокироваться до завершения транзакции. Это форма пессимистической блокировки и реализуется уровнем изоляции SQL Server "повторяемое чтение".
  2. Вместо блокировки база данных позволяет внешней транзакции обновлять строку, но при попытке обновления собственной транзакции возникает ошибка сериализации, указывающая на то, что произошел конфликт параллелизма. Это форма оптимистичной блокировки , не в отличие от функции токена параллелизма EF - и реализуется уровнем изоляции моментальных снимков SQL Server, а также уровнем изоляции повторяющихся операций чтения PostgreSQL.

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

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

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

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

Дополнительные ресурсы

Пример ASP.NET Core с обнаружением конфликтов см. в разделе Обнаружение конфликтов в EF Core.