SqlException: Новая транзакция не разрешена, пока в сессии есть другие потоки (Entity Framework)
Причина ошибки
Ошибка возникает из-за того, что вы вызываете 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 или подобных — это может неявно открывать новый ридер.