EF Core DbContext与Parallel.ForEachAsync并发操作异常排查
问题描述
已将MyDbContext和CarRepository注册为Transient生命周期,在CarController的Get接口中使用Parallel.ForEachAsync并发调用GetById方法时,抛出System.InvalidOperationException异常,提示同一DbContext实例被多线程并发操作。所有异步方法均已正确使用await,同步遍历无异常。
相关代码
CarRepository代码
public class CarRepository<Car> : ICarRepository { // ... 其他成员 public async Task<Car?> GetById(TId id, CancellationToken cancellationToken) { return await _dbContext.Cars().FindAsync(id, cancellationToken); } }
服务注册代码(Program.cs)
var builder = WebApplication.CreateBuilder(args); // ... 其他配置 builder.Services.AddDbContext<MyDbContext>(options => { options.UseSqlServer("conn-string"); }, ServiceLifetime.Transient); builder.Services.AddTransient<ICarRepository, CarRepository>();
DbContext定义
public class MyDbContext: DbContext { public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) { Database.EnsureCreated(); } // ... DbSet定义 }
Controller代码
public class CarController : ControllerBase { private ICarRepository _repository; public CarController(ICarRepository repository) { _repository = repository; } [HttpGet] public async Task<IActionResult> Get() { ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = 3 }; var cars = new List<Car>(); await Parallel.ForEachAsync(request.CarIds, parallelOptions, async (id, _) => { Car? car = await _repository.GetById(id, cancellationToken); if (car != null) { cars.Add(car); } }); return Ok(); } }
异常信息
System.InvalidOperationException: 在此上下文实例上启动第二个操作之前,前一个操作尚未完成。通常是由于不同线程同时使用同一个DbContext实例导致的。
问题原因
核心问题在于Controller的默认生命周期是Scoped:
- 尽管
MyDbContext和CarRepository注册为Transient,但在同一个HTTP请求的生命周期内,Controller实例是唯一的,其构造函数注入的_repository也是同一个实例,对应的DbContext自然也是同一个。 Parallel.ForEachAsync会同时启动多个并发任务,这些任务共享同一个DbContext实例进行数据库操作,而EF Core的DbContext本身是非线程安全的,因此触发了多线程操作异常。- 另外,代码中使用
List<Car>存储结果,List同样是非线程安全的,并发调用Add会导致数据损坏(即使没有DbContext异常,也会有这个问题)。
解决方案
方案1:为每个并发任务创建独立的服务作用域(推荐)
通过IServiceScopeFactory在每个并行任务中创建新的作用域,获取独立的ICarRepository和DbContext实例,同时改用线程安全的ConcurrentBag<Car>存储结果:
public class CarController : ControllerBase { private readonly IServiceScopeFactory _scopeFactory; public CarController(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } [HttpGet] public async Task<IActionResult> Get([FromQuery] List<int> carIds, CancellationToken cancellationToken) { ParallelOptions parallelOptions = new() { MaxDegreeOfParallelism = 3 }; var cars = new ConcurrentBag<Car>(); await Parallel.ForEachAsync(carIds, parallelOptions, async (id, ct) => { using var scope = _scopeFactory.CreateScope(); var repository = scope.ServiceProvider.GetRequiredService<ICarRepository>(); var car = await repository.GetById(id, ct); if (car != null) { cars.Add(car); } }); return Ok(cars); } }
方案2:改用批量查询替代并发调用(更优)
EF Core支持批量查询,一次请求即可获取所有目标数据,避免并发带来的线程安全问题,同时减少数据库连接开销:
首先在CarRepository中添加批量查询方法:
public async Task<List<Car>> GetByIds(IEnumerable<TId> ids, CancellationToken cancellationToken) { return await _dbContext.Cars.Where(c => ids.Contains(c.Id)).ToListAsync(cancellationToken); }
然后修改Controller代码:
[HttpGet] public async Task<IActionResult> Get([FromQuery] List<int> carIds, CancellationToken cancellationToken) { var cars = await _repository.GetByIds(carIds, cancellationToken); return Ok(cars); }
方案3:将Controller改为Transient生命周期(不推荐)
通过修改Controller的生命周期为Transient,让每个请求的Controller实例不同,但这种方式会打破Controller默认的Scoped生命周期设计,可能引发其他依赖注入问题,因此不建议使用:
// Program.cs中添加以下代码 builder.Services.AddControllers().AddControllersAsServices(); builder.Services.AddTransient<CarController>();
总结
EF Core的DbContext本质是非线程安全的,即使注册为Transient,只要被同一个Scoped实例(如Controller)持有,就会在并发场景下被共享。解决的核心是确保每个并发操作拥有独立的DbContext实例,或者直接使用批量查询避免并发调用。
内容的提问来源于stack exchange,提问作者user1765862

