线上日志里出现这行异常,几乎每个用过 EF Core 的人都见过:

System.InvalidOperationException: A second operation was started on this context instance
before a previous operation completed. This is usually caused by different threads
concurrently using the same instance of DbContext.

这说明有一个 DbContext 实例正在被多个线程同时使用。很多人第一反应是"我明明没开线程",或者"加个 lock 就好了"——两种想法都走偏了。先记住一件事:

DbContext 不是一个无状态的查询工具,而是一个短生命周期的、有状态的工作单元。

为什么不是线程安全的

它内部维护着一整套可变状态,一旦被并发读写必然出问题:

内部状态 并发后果
DbConnection ADO.NET 连接本身不支持并发,出现"连接未关闭"等异常
ChangeTracker 跟踪状态互相污染,SaveChanges 写入错误数据
实体标识映射(Identity Map) 同一实体被多线程各改一份,后写入的覆盖前一个
查询管道 / 事务 并发检测异常,或出现部分提交、回滚失败

所以问题不在于某个字段没加锁,而在于它从设计上就假定自己是独占的工作单元。这里要澄清最易误解的一点:

DbContext 可以被不同线程先后使用(await 之后线程可能变化,这没问题),但绝不能被多个线程同时使用。

“线程不安全"不等于"不能跨线程”,而是不能并发。

常见错误写法

错误做法 后果
注册为 Singleton 所有请求共享同一实例,并发冲突几乎必然发生
异步方法不 await 前一操作未完成就发起新操作,直接抛异常
在 Task.Run / Parallel.ForEach 中使用注入的实例 跨线程并发共享,抛异常或写脏数据
用 lock 强行保护 DbContext 查询被串行化,吞吐骤降且易死锁,属于用错工具

其中"忘了 await“最隐蔽,它等价于在同一实例上并发发起两次查询:

// 错误:发起后就走,相当于并发使用同一个 context
var result = _db.Orders.ToListAsync();
var count = _db.Orders.CountAsync();   // 抛 InvalidOperationException

正确做法

策略只有一句:每个工作单元使用独立实例,串行使用,用完即释放。区别只在于谁来创建。

1. Web 应用:默认的 Scoped 就够了

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

AddDbContext 默认注册为 Scoped:请求进入时创建、请求结束时释放,请求之间天然隔离,请求内部靠 await 保证串行。这也正是"一次业务操作 = 一个工作单元"的模型。

疑问:每个请求都新建 DbContext,不会很耗性能吗?

不会。这里要区分两层:DbContext 只是负责跟踪实体、生成 SQL 的轻量对象,不等于一条物理连接;真正建连的是 ADO.NET 驱动,它默认开启连接池——查询时借出空闲连接,完成即归还,下个请求直接复用,无需重新握手认证。所以 Scoped 并不会带来明显开销。

高并发下若想再省掉对象创建与模型初始化的成本,可以改用池化的 DbContext:

builder.Services.AddDbContextPool<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

但池化复用的是 DbContext 对象,不是一条长期共享的连接:它依旧线程不安全,不能跨请求、跨线程共享,构造函数也不应依赖 Scoped 服务或持有请求级状态。

2. 后台任务 / 多线程:用 IDbContextFactory

BackgroundService、定时任务、消息消费者都是 Singleton,没有请求边界,不能注入 DbContext,必须按需创建(EF Core 5+):

builder.Services.AddDbContextFactory<AppDbContext>(options =>
    options.UseSqlServer(connectionString));

public class OrderSyncService : BackgroundService
{
    private readonly IDbContextFactory<AppDbContext> _factory;

    public OrderSyncService(IDbContextFactory<AppDbContext> factory) => _factory = factory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await using var db = await _factory.CreateDbContextAsync(stoppingToken);
            var pending = await db.Orders.AsNoTracking().ToListAsync(stoppingToken);
            // 处理数据...
            await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
        }
    }
}

两个细节:工厂创建的实例不归 DI 管理,必须用 await using 释放;高频场景可用 AddPooledDbContextFactory 复用实例池。

3. 需要整套 Scoped 服务:手动创建 Scope

public class ReportService(IServiceScopeFactory scopeFactory)
{
    public async Task BuildAsync(CancellationToken ct)
    {
        using var scope = scopeFactory.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
        var data = await db.Orders.AsNoTracking().ToListAsync(ct);
    }
}

注入 IServiceScopeFactory 而非 IServiceProvider,可避免误用根容器。

4. 并行查询:每个分支各开一个实例

await using var db1 = await _factory.CreateDbContextAsync(ct);
await using var db2 = await _factory.CreateDbContextAsync(ct);

var ordersTask = db1.Orders.CountAsync(ct);
var productsTask = db2.Products.CountAsync(ct);
await Task.WhenAll(ordersTask, productsTask);

Blazor Server 的 DI 作用域是用户电路(Circuit)而非单个请求,同一页面多组件并发加载会撞车,同样建议使用工厂按操作创建。

总结

DbContext 线程不安全的根源,是它承载了连接、跟踪器、事务等一整套可变状态。正确策略是避免共享,而不是"保护"一个共享实例:

  • Web 请求 → 默认 Scoped(AddDbContext),连接开销由连接池兜底
  • 高并发场景 → AddDbContextPool,而不是把实例改成 Singleton
  • 后台任务 / 多线程 / Blazor Server → IDbContextFactory
  • 需要多个 Scoped 服务 → IServiceScopeFactory.CreateScope()
  • 并行查询 → 每个分支各一个实例