Avatar ·

SqlException: Новая транзакция не разрешена, пока в сессии есть другие потоки (Entity Framework)

Всем привет! Я новичок в C# и Entity Framework, столкнулся с непонятной ошибкой. Пишу простое приложение на ASP.NET MVC с SQL Server. Есть метод в сервисе, который должен обновить несколько записей в базе данных. Я использую цикл `foreach`, и внутри цикла вызываю `SaveChanges()` для каждой записи. В итоге получаю исключение: **SqlException: New transaction is not allowed because there are other threads running in the session.** Вот упрощенный код: ```csharp public void UpdateOrders(List orders) { using (var context = new MyDbContext()) { foreach (var order in orders) { order.Status = "Processed"; context.Entry(order).State = EntityState.Modified; context.SaveChanges(); // Ошибка здесь на второй итерации } } } ``` Метод вызывается из контроллера: ```csharp [HttpPost] public ActionResult ProcessOrders(List orderIds) { var orders = db.Orders.Where(o => orderIds.Contains(o.Id)).ToList(); _orderService.UpdateOrders(orders); return RedirectToAction("Index"); } ``` Ошибка возникает не всегда, а только если в списке больше одной записи. Я читал, что это связано с транзакциями и параллельными потоками, но я не использую многопоточность явно. Использую стандартный DI (Inversion of Control) через Autofac. Может быть, проблема в том, что `DbContext` регистрируется как Singleton? Подскажите, как исправить, и объясните, почему так происходит. Заранее спасибо! <<>>

Причина ошибки

Ошибка возникает из-за того, что вы вызываете SaveChanges() внутри цикла foreach, который итерируется по коллекции, полученной из того же DbContext. Когда вы выполняете SaveChanges(), EF пытается начать новую транзакцию для отправки изменений в БД. Однако в этот момент открыт ридер (data reader) от предыдущего запроса (например, от ToList() в контроллере или от ленивой загрузки). SQL Server не позволяет начать новую транзакцию, пока не завершится чтение текущего набора данных в том же сеансе.

В вашем случае проблема усугубляется, если DbContext зарегистрирован как Singleton (один экземпляр на всё приложение). Тогда несколько запросов от разных пользователей могут одновременно использовать один и тот же контекст, что приводит к взаимоблокировкам. Никогда не используйте DbContext как Singleton! Правильная регистрация — Scoped (один экземпляр на HTTP-запрос).

Правильное решение

Нужно вынести SaveChanges() за пределы цикла. Сохраняйте все изменения сразу после цикла — это и быстрее, и не вызывает конфликтов транзакций:

public void UpdateOrders(List<Order> orders)
{
    using (var context = new MyDbContext())
    {
        foreach (var order in orders)
        {
            order.Status = "Processed";
            context.Entry(order).State = EntityState.Modified;
        }
        // Сохраняем всё одной транзакцией
        context.SaveChanges();
    }
}

Если вам нужно выполнить множество вставок/обновлений и вы беспокоитесь о производительности, можно использовать AddRange или пакетные операции. Но в большинстве случаев одного SaveChanges() достаточно.

Что делать, если SaveChanges() нужно вызывать внутри цикла?

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

public void UpdateOrdersInBatches(List<Order> orders)
{
    using (var context = new MyDbContext())
    using (var transaction = context.Database.BeginTransaction())
    {
        context.Configuration.AutoDetectChangesEnabled = false;
        int batchSize = 100;

        for (int i = 0; i < orders.Count; i++)
        {
            orders[i].Status = "Processed";
            context.Entry(orders[i]).State = EntityState.Modified;

            if (i % batchSize == 0 && i > 0)
            {
                context.SaveChanges();
                // Сбросить контекст, чтобы освободить память
                context.ChangeTracker.Clear();
            }
        }
        // Сохранить оставшиеся
        context.SaveChanges();
        transaction.Commit();
    }
}

Важно: Перед вызовом SaveChanges() убедитесь, что предыдущий ридер закрыт. В вашем исходном коде ридер открыт из-за того, что вы передаёте в метод коллекцию, полученную из того же контекста. Если вы всё же хотите вызывать SaveChanges() внутри цикла, материализуйте коллекцию заранее с помощью .ToList() или .ToArray().

Рекомендации по DI

  • Scoped (AddScoped) — правильный выбор для DbContext в ASP.NET. Один экземпляр на запрос.
  • Transient (AddTransient) — создаёт новый экземпляр каждый раз при инъекции. Подходит, если вам нужен свежий контекст в каждом сервисе.
  • Singleton (AddSingleton) — запрещён для DbContext из-за проблем с потоками и кэшированием.

Проверьте регистрацию в Autofac. Если у вас InstancePerLifetimeScope() — это аналог Scoped, всё нормально. Если SingleInstance() — срочно меняйте.

<<>>

Альтернативный подход: асинхронность и разделение контекстов

Если вы используете асинхронные методы (например, SaveChangesAsync()), проблема может быть связана с тем, что вы не дожидаетесь завершения предыдущей операции. Убедитесь, что правильно используете await. Но в вашем случае код синхронный, поэтому это не причина.

Другой вариант — полностью разделить операции чтения и записи. Получите данные, закройте контекст, затем откройте новый контекст для записи:

public void ProcessOrders(List<int> orderIds)
{
    // Чтение в одном контексте
    List<Order> orders;
    using (var readContext = new MyDbContext())
    {
        orders = readContext.Orders.Where(o => orderIds.Contains(o.Id)).ToList();
    }

    // Запись в другом контексте
    using (var writeContext = new MyDbContext())
    {
        foreach (var order in orders)
        {
            order.Status = "Processed";
            writeContext.Entry(order).State = EntityState.Modified;
        }
        writeContext.SaveChanges();
    }
}

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

Дополнительный совет: Включайте логирование SQL-запросов EF (context.Database.Log = Console.Write) — это поможет увидеть, когда открываются и закрываются транзакции и ридеры. Ошибка часто возникает при ленивой загрузке навигационных свойств внутри цикла. Проверьте, нет ли у вас обращений к order.Customer.Name или подобных — это может неявно открывать новый ридер.