Подтвердить что ты не робот

Предотвращение состояния гонки if-exists-update-else-insert в Entity Framework

Я читал другие вопросы о том, как реализовать семантику if-exists-insert-else-update в EF, но я не понимаю, как работают ответы, или они на самом деле не решают проблему. Общим решением является перенос работы в области транзакции (например: Внедрение if-not-exist-insert с использованием Entity Framework без условий гонки):

using (var scope = new TransactionScope()) // default isolation level is serializable
using(var context = new MyEntities())
{
    var user = context.Users.SingleOrDefault(u => u.Id == userId); // *
    if (user != null)
    {
        // update the user
        user.property = newProperty;
        context.SaveChanges();
    }
    else
    {
        user = new User
        {
             // etc
        };
        context.Users.AddObject(user);
        context.SaveChanges();
    }
}

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

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

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

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

Любые идеи?

EDIT. Я попытался выполнить вышеуказанный код одновременно на двух разных потоках, используя один и тот же идентификатор пользователя, и, несмотря на то, что вы делали сериализуемые транзакции, они оба могли одновременно входить в критический раздел (*). Это приводит к выходу UpdateException, когда второй поток попытался вставить тот же идентификатор пользователя, который только что вставил. Это связано с тем, что, как указывает Ладислав ниже, сериализуемая транзакция принимает исключительные блокировки только после того, как она начала изменять данные, а не читать.

4b9b3361

Ответ 1

При использовании сериализуемой транзакции SQL Server выдает общие блокировки для чтения записей/таблиц. Совлокальные блокировки не позволяют другим транзакциям изменять заблокированные данные (транзакции будут блокироваться), но он позволяет другим транзакциям считывать данные до того, как транзакция, в которой были сделаны блокировки, начинает изменять данные. Вот почему пример не работает - одновременные чтения разрешены с помощью общих блокировок до тех пор, пока первая транзакция не начнет изменять данные.

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

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

Ответ 2

Вы можете изменить уровень изоляции транзакций, используя TransactionOptions для TransactionScope более строгие (я думаю, для вашего случая это RepeatableRead или Serializable), но помните, что любые блокировки уменьшают масштабируемость.

Действительно ли имеет значение обеспечить такой уровень управления concurrency? Будет ли ваша заявка использоваться в тех же случаях в производственной среде? Вот хороший пост Уди Дахана о состоянии гонки.

Ответ 3

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

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

Предполагая, что этого userId не существует, обе транзакции пытаются вставить новую запись с userId - что невозможно. Из-за уровня изоляции Serializable обе транзакции не могут вставить новую запись в таблицу users, так как это приведет к чтению phantom для другой транзакции.

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

Является ли платформа Entity Framework этой переменной? Я подозреваю, что вы закончите с UpdateException с вложенным SqlException, идентифицирующим тупик.

Ответ 4

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

Я просто использую исключение и сначала попробую вставить. Я использую модификацию исходного кода в качестве примера:

using(var context = new MyEntities())
{
    EntityEntry entityUser = null;
    try 
    {
        user = new User
        {
             // etc
        };
        entityUser = context.Users.Add(user);
        context.SaveChanges(); // Will throw if the entity already exists
    } 
    catch (DbUpdateException x)
    when (x.InnerException != null && x.InnerException.Message.StartsWith("Cannot insert duplicate key row in object"))
    {
        if (entityUser != null)
        {
            // Detach the entity to stop it hanging around on the context
            entityUser.State = EntityState.Detached;
        }
        var user = context.Users.Find(userId);
        if (user != null) // just in case someone deleted it in the mean time
        {
            // update the user
            user.property = newProperty;
            context.SaveChanges();
        }
    }
}

Это не очень, но он работает и может быть полезен кому-то.