Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
Совет
Вы можете скачать используемый в этой статье пример из репозитория 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 = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
await context.SaveChangesAsync();
- На первом шаге объект Person загружается из базы данных, сюда входит маркер параллелизма, который Entity Framework (EF) теперь отслеживает как обычно вместе с остальными свойствами.
- Затем экземпляр Person изменяется каким-то образом, мы изменяем свойство
FirstName
. - Затем мы поручаем EF Core сохранить изменения. Так как маркер параллелизма настроен, EF Core отправляет следующий SQL в базу данных:
UPDATE [People] SET [FirstName] = @p0
WHERE [PersonId] = @p1 AND [Version] = @p2;
Обратите внимание, что в условии WHERE EF Core также добавил условие и для Version
; это изменяет строку только, если столбец Version
не изменился с момента, когда он был запрошен.
В обычном (оптимистичном) случае параллельное обновление не происходит, а обновление завершается успешно, изменяя строку; База данных сообщает EF Core, что одна строка пострадала от UPDATE, как ожидалось. Однако, если произошло параллельное обновление, операция 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 = await context.People.SingleAsync(b => b.FirstName == "John");
person.FirstName = "Paul";
person.Version = Guid.NewGuid();
await context.SaveChangesAsync();
Если вы хотите, чтобы новое значение GUID всегда было назначено, вы можете это сделать с помощью SaveChanges
перехватчика. Однако одно из преимуществ ручного управления маркером параллелизма заключается в том, что вы можете точно контролировать момент его повторного создания, чтобы избежать ненужных конфликтов параллелизма.
Разрешение конфликтов параллелизма
Независимо от того, как настроен маркер параллелизма, для реализации оптимистического параллелизма приложение должно правильно обрабатывать ситуацию, когда возникает конфликт параллелизма и DbUpdateConcurrencyException выбрасывается; это называется разрешением конфликта параллелизма.
Один из вариантов — просто сообщить пользователю, что обновление завершилось ошибкой из-за конфликтующих изменений; Затем пользователь может загрузить новые данные и повторить попытку. Или если приложение выполняет автоматическое обновление, оно может просто выполнять цикл и повторить попытку сразу после повторного запроса данных.
Более сложный способ устранения конфликтов параллелизма заключается в слиянии ожидающих изменений с новыми значениями в базе данных. Точные сведения о том, какие значения объединяются, зависят от приложения, и процесс может направляться пользовательским интерфейсом, где отображаются оба набора значений.
Доступны три набора значений для разрешения конфликта параллелизма:
- Текущие значения — это значения, которые приложение пыталось записать в базу данных.
- Исходные значения — это значения, которые изначально были получены из базы данных до того, как были сделаны какие-либо изменения.
- Значения базы данных — это значения, которые в настоящее время хранятся в базе данных.
Общий подход к обработке конфликта одновременности:
- Поймайте
DbUpdateConcurrencyException
во времяSaveChanges
. - Используйте
DbUpdateConcurrencyException.Entries
, чтобы подготовить новый набор изменений для затронутых объектов. - Обновите исходные значения маркера параллелизма, чтобы отразить текущие значения в базе данных.
- Повторяйте процесс, пока не возникнут конфликты.
В следующем примере Person.FirstName
и Person.LastName
устанавливаются как маркеры параллелизма. В месте, где вы указываете конкретную логику приложения, есть комментарий // TODO:
, позволяющий выбрать значение, которое нужно сохранить.
using var context = new PersonContext();
// Fetch a person from database and change phone number
var person = await context.People.SingleAsync(p => p.PersonId == 1);
person.PhoneNumber = "555-555-5555";
// Change the person's name in the database to simulate a concurrency conflict
await context.Database.ExecuteSqlRawAsync(
"UPDATE dbo.People SET FirstName = 'Jane' WHERE PersonId = 1");
var saved = false;
while (!saved)
{
try
{
// Attempt to save changes to the database
await context.SaveChangesAsync();
saved = true;
}
catch (DbUpdateConcurrencyException ex)
{
foreach (var entry in ex.Entries)
{
if (entry.Entity is Person)
{
var proposedValues = entry.CurrentValues;
var databaseValues = await entry.GetDatabaseValuesAsync();
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
, чтобы каким-либо образом его обновить, база данных должна убедиться, что другие транзакции не вмешиваются в эту строку базы данных, пока транзакция не завершится. В зависимости от реализации базы данных это происходит одним из двух способов:
- При запросе строки ваша транзакция берет на себя общую блокировку. Любая внешняя транзакция, пытающаяся обновить строку, будет блокироваться до завершения транзакции. Это форма пессимистической блокировки и реализуется уровнем изоляции SQL Server "повторяемое чтение".
- Вместо блокировки база данных позволяет внешней транзакции обновлять строку, но когда ваша собственная транзакция пытается выполнить обновление, возникает ошибка сериализации, указывающая на то, что произошел конфликт параллелизма. Это форма оптимистической блокировки, подобно функции токена конкуренции EF, и реализуется уровнем изоляции снимков SQL Server, а также уровнем изоляции повторяющегося чтения PostgreSQL.
Обратите внимание, что уровень изоляции «сериализуемый» предоставляет те же гарантии, что и уровень "повторяемое чтение" (и добавляет дополнительные), поэтому он функционирует так же в отношении всего вышеперечисленного.
Использование более высокого уровня изоляции для управления конфликтами параллелизма проще, не требует маркеров параллелизма и предоставляет другие преимущества; Например, повторяющиеся операции чтения гарантируют, что транзакция всегда видит одни и те же данные в запросах внутри транзакции, избегая несоответствий. Однако этот подход имеет свои недостатки.
Во-первых, если реализация базы данных использует блокировку для реализации уровня изоляции, другие транзакции, пытающиеся изменить ту же строку, должны блокироваться для всей транзакции. Это может негативно повлиять на параллельную производительность (сделайте транзакцию короткой!), хотя обратите внимание, что механизм EF вызывает исключение и заставляет вас повторить попытку, что также оказывает влияние. Это относится к повторяемому уровню чтения SQL Server, но не к уровню снимка, который не блокирует запрашиваемые строки.
Более важно, что этот подход требует транзакции для охвата всех операций. Если вы, скажем, запрашиваете Person
, чтобы отобразить сведения для пользователя, а затем ждете, пока пользователь внесет изменения, транзакция должна оставаться активной в течение потенциально длительного времени, чего следует избегать в большинстве случаев. В результате этот механизм обычно подходит, если все содержащиеся операции выполняются немедленно, и транзакция не зависит от внешних входных данных, которые могут увеличить его длительность.
Дополнительные ресурсы
Пример ASP.NET Core с обнаружением конфликтов см. в разделе Обнаружение конфликтов в EF Core.