线上日志里出现这行异常,几乎每个用过 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() - 并行查询 → 每个分支各一个实例