3. 依赖注入在后台服务中的应用

后台服务(BackgroundService、IHostedService)与 ASP.NET Core 请求管道最大的差异在于生命周期模型。Web 请求天然拥有一个请求作用域,请求进入时创建、请求结束时释放;而后台服务通常在整个应用生命周期内只启动一次,它本身被注册为单例,因此它没有一个自动存在的请求作用域。理解这一点,是正确使用依赖注入的前提。

一、三种生命周期在后台服务中的实际含义

在 Microsoft.Extensions.DependencyInjection 中,服务有三种注册方式,它们决定了实例被创建和释放的时机:

  • Singleton(单例):整个 IServiceProvider 根容器内只有一个实例,首次解析时创建,应用关闭时释放。适合无状态、线程安全、持有长生命周期资源的服务,如配置读取器、缓存客户端、IHostApplicationLifetime。
  • Scoped(作用域):每个 IServiceScope 内一个实例,作用域释放时一并释放。典型代表是 DbContext、工作单元、请求级缓存。
  • Transient(瞬态):每次解析都创建新实例,由解析方负责释放。适合轻量、无状态、每次调用都需要独立状态的服务。

关键约束是:单例服务不能直接依赖 Scoped 服务。因为单例在根容器中解析,而 Scoped 服务需要一个作用域来承载,若强行注入,DI 容器在启用作用域验证(ValidateScopes,开发环境默认开启)时会抛出 InvalidOperationException:Cannot consume scoped service from singleton。这不是框架的刁难,而是防止一个 Scoped 实例被单例长期持有、永不释放,从而造成内存泄漏与状态污染。

二、IServiceScopeFactory:后台服务获取作用域的正确入口

后台服务需要访问数据库或其它 Scoped 服务时,标准做法是注入 IServiceScopeFactory(它本身是单例),在每次工作单元开始时手动创建一个作用域:

public class OrderSyncService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<OrderSyncService> _logger;

    public OrderSyncService(IServiceScopeFactory scopeFactory,
                            ILogger<OrderSyncService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using (var scope = _scopeFactory.CreateScope())
            {
                var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                var pending = await db.Orders
                    .Where(o => o.Status == OrderStatus.Pending)
                    .Take(100)
                    .ToListAsync(stoppingToken);

                foreach (var order in pending)
                    order.Status = OrderStatus.Syncing;

                await db.SaveChangesAsync(stoppingToken);
                _logger.LogInformation("本轮同步 {Count} 条订单", pending.Count);
            } // 作用域在此释放,DbContext 随之 Dispose

            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

这里有几个必须注意的细节。第一,using 块保证作用域在每轮循环结束时释放,DbContext 的连接与变更跟踪器被清理,避免长驻内存。第二,作用域应按工作单元创建,而不是在 ExecuteAsync 开头创建一个贯穿整个服务生命周期的作用域——那等同于把 Scoped 服务变成了事实上的单例,失去作用域的意义。第三,stoppingToken 要贯穿到数据库调用,使应用关闭时正在执行的查询能被取消。

如果后台服务需要并发处理多个独立任务,可以为每个任务各创建一个作用域,让它们各自持有独立的 DbContext。切勿让多个线程共享同一个 DbContext 实例,它并非线程安全。

三、单例与瞬态的使用边界

后台服务自身注册为单例,因此它注入的依赖也必须是单例或瞬态。判断一个服务该用哪种生命周期,可以从三个问题入手:

判断维度SingletonScopedTransient
是否持有可变状态无状态或状态需全局共享状态属于单次工作单元状态仅属于单次调用
是否持有非线程安全资源否,或已加锁是(如 DbContext)是,且每次调用独立
创建开销高,需复用中低,可频繁创建
典型示例配置、HttpClient 工厂、缓存DbContext、仓储、工作单元轻量计算器、映射器

瞬态服务在后台服务中要特别小心“捕获”问题。若在构造函数中注入一个瞬态服务并保存为字段,该实例会被单例长期持有,实际上变成了单例,且不会随作用域释放。正确做法是:瞬态服务应在每次需要时从当前作用域解析,或直接作为方法参数传入,而不是在单例构造函数中缓存。

四、作用域、单例与瞬态的协作关系

下图展示了后台服务在根容器中作为单例运行,通过 IServiceScopeFactory 为每轮工作创建独立作用域,并在作用域内解析 Scoped 与 Transient 服务的完整链路。注意根容器与作用域容器的边界,以及实例的创建与释放时机。

根容器(Root ServiceProvider)· 应用生命周期 OrderSyncService BackgroundService 生命周期:Singleton 随应用启动 / 关闭 IServiceScopeFactory 生命周期:Singleton CreateScope() 创建独立作用域 ICacheClient 生命周期:Singleton 线程安全 / 无状态 可直接注入后台服务 注入 直接注入(同为单例) 作用域容器(IServiceScope)· 每轮工作单元创建并释放 AppDbContext 生命周期:Scoped 非线程安全 作用域释放时 Dispose 连接与跟踪器被清理 OrderMapper 生命周期:Transient 每次解析新实例 轻量 / 无状态 由解析方负责释放 工作单元 查询待同步订单 更新状态 SaveChangesAsync 完成后释放作用域 CreateScope() 作用域释放 → Scoped 实例 Dispose

从图中可以读出三条实践规则。其一,后台服务与 IServiceScopeFactory、单例缓存客户端同处根容器,彼此可直接注入。其二,AppDbContext 这类 Scoped 服务只能存在于作用域容器内,必须通过 CreateScope() 获得。其三,OrderMapper 这类瞬态服务在作用域内解析,随工作单元结束而释放,不应被单例字段长期持有。

五、常见陷阱与验证方式

实际工程中最容易出问题的是“作用域泄漏”和“生命周期错配”。前者表现为在 ExecuteAsync 中创建了一个作用域却忘记释放,或把 scope.ServiceProvider 保存到字段中反复使用;后者表现为单例直接注入 Scoped 服务,在开发环境启动时即报错。建议在开发环境保持 ValidateScopes = true 与 ValidateOnBuild = true,让容器在构建阶段就暴露问题:

Host.CreateDefaultBuilder(args)
    .UseDefaultServiceProvider((context, options) =>
    {
        options.ValidateScopes = context.HostingEnvironment.IsDevelopment();
        options.ValidateOnBuild = true;
    })
    .ConfigureServices(services =>
    {
        services.AddSingleton<ICacheClient, RedisCacheClient>();
        services.AddScoped<AppDbContext>();
        services.AddTransient<OrderMapper>();
        services.AddHostedService<OrderSyncService>();
    });
不要用 serviceProvider.GetRequiredService<AppDbContext>() 从根容器直接解析 Scoped 服务来“绕过”报错。这会让 DbContext 变成事实上的单例,多个后台任务并发时会出现“A second operation was started on this context”之类的异常,且连接池与内存会持续增长。

总结本节的核心判断:后台服务是单例,它只能安全地直接依赖单例或瞬态服务;需要 Scoped 服务时,通过 IServiceScopeFactory 为每个工作单元创建并释放作用域;瞬态服务不要在单例构造函数中缓存。把这三条边界守住,后台服务的依赖注入就不会成为隐蔽的故障源。