依赖注入的生命周期陷阱:Scoped 在后台任务里为什么会崩

后台服务是个特别容易暴露依赖注入问题的位置:它在应用启动时被创建,却不属于任何一次请求。 于是「为什么我在 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。
上一篇 Span<T> 与 Memory<T>:把分配降到零的实践