后台服务是个特别容易暴露依赖注入问题的位置:它在应用启动时被创建,却不属于任何一次请求。
于是「为什么我在 BackgroundService 里注入 DbContext,一启动就抛
Cannot consume scoped service from singleton」成了高频问题。
这篇笔记把生命周期模型、作用域的真实含义,以及几种正确写法整理清楚。
一、三种生命周期的真实边界
| 生命周期 | 实例何时创建 | 何时释放 | 典型对象 |
|---|---|---|---|
Singleton | 首次被解析,由根容器持有 | 应用关闭时 | 配置、缓存、连接池、IServiceScopeFactory |
Scoped | 每个作用域一次(Web 中一个请求一个作用域) | 作用域结束时 | DbContext、当前用户上下文、工作单元 |
Transient | 每次解析都新建 | 跟随持有它的作用域 | 无状态的轻量工具类 |
表格里最容易被忽略的是最后一列第二行:Transient 的释放不由你控制。
如果它被解析进根容器,就会一直活到应用关闭——在循环里反复 GetRequiredService 一个实现了 IDisposable 的 Transient 服务,
就是在稳定地累积泄漏。
二、作用域才是 Scoped 的寿命单位
「Scoped 等于请求级」只是 Web 场景下的一个特例。准确说法是:
IServiceProvider 每次 CreateScope() 都会生成一个新的作用域,
作用域内的 Scoped 服务共享同一个实例,作用域释放时它们一起被 Dispose。
所以严格来说,从根容器解析 Scoped 服务本身就是错误的。 容器会在启动时直接报错拦截,因为根容器的存活期等于应用存活期,把它当成 Scoped 的作用域, 就等于让一个「请求级」对象活成了单例。开启完整校验可以提前暴露所有这类问题:
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseDefaultServiceProvider(options =>
{
options.ValidateScopes = true; // 解析 Scoped 时校验作用域
options.ValidateOnBuild = true; // 启动时把所有注册关系遍历一遍
});
ValidateOnBuild值得在开发环境一直开着:它会把「单例依赖 Scoped」这类错误在启动瞬间全部抛出, 而不是等到某个冷门接口第一次被访问时才炸。
三、后台服务的正确写法
BackgroundService 注册为单例,所以只能在构造函数里注入单例服务。
想用 Scoped 的服务,就必须自己开作用域。先是错误示范:
// 错误:启动时直接抛异常
public sealed class OrderWorker : BackgroundService
{
private readonly AppDbContext _db;
public OrderWorker(AppDbContext db) => _db = db;
// InvalidOperationException:
// Cannot consume scoped service 'AppDbContext' from singleton 'OrderWorker'
}
正确做法是注入 IServiceScopeFactory,在每轮任务内部创建作用域:
public sealed class OrderWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OrderWorker> _logger;
public OrderWorker(IServiceScopeFactory scopeFactory, ILogger<OrderWorker> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// 每轮一个作用域,DbContext 随作用域释放
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
try
{
await ProcessBatchAsync(db, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // 正常停止,不是错误
}
catch (Exception ex)
{
_logger.LogError(ex, "批次处理失败,10 秒后重试");
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
private static Task ProcessBatchAsync(AppDbContext db, CancellationToken ct)
=> Task.CompletedTask;
}
几个细节值得强调:
- 用
CreateAsyncScope()而不是CreateScope(),因为DbContext的释放是异步的。 - 作用域放在循环体内部,而不是整个
ExecuteAsync外面——否则又退化成了一个长命对象。 - 取消导致的
OperationCanceledException要单独区分,它属于正常停机路径,不该记成错误日志。
并行处理时,每个并行单元各开一个作用域
DbContext 不是线程安全的,多个任务共享一个实例必然崩溃(或产生诡异的数据错乱)。
并行时必须让每个任务拥有独立的 scope:
await Parallel.ForEachAsync(items, stoppingToken, async (item, ct) =>
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await HandleAsync(db, item, ct);
});
四、另外两个常见误用
把 IHttpContextAccessor 带进后台任务
后台任务不在请求管道里,IHttpContextAccessor.HttpContext 返回 null 是正常结果。
需要「当前用户」这类信息时,正确做法是在请求里把必要的数据(用户 Id、租户 Id)取出来,随任务参数传下去,
而不是在后台再去读上下文。
单例里缓存 Scoped 服务
下面这种写法编译器不会拦,但效果等同于把一个请求级对象变成永久单例,而且它第一次被释放后,
后续所有调用都会命中 ObjectDisposedException:
public sealed class CacheService // 注册为 Singleton
{
private AppDbContext? _db; // 千万别这么干
public void Prime(IServiceProvider sp)
=> _db ??= sp.GetRequiredService<AppDbContext>();
}
小结
- Scoped 的寿命单位是作用域,Web 请求只是最常见的一种作用域。
- 单例不能依赖 Scoped;需要时用
IServiceScopeFactory显式开作用域,并且作用域要短。 - 开
ValidateScopes与ValidateOnBuild,让容器在启动期替你做检查。 - 并行任务、后台任务都要各自独立的作用域,别共享
DbContext。