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 实例,它并非线程安全。三、单例与瞬态的使用边界
后台服务自身注册为单例,因此它注入的依赖也必须是单例或瞬态。判断一个服务该用哪种生命周期,可以从三个问题入手:
| 判断维度 | Singleton | Scoped | Transient |
|---|---|---|---|
| 是否持有可变状态 | 无状态或状态需全局共享 | 状态属于单次工作单元 | 状态仅属于单次调用 |
| 是否持有非线程安全资源 | 否,或已加锁 | 是(如 DbContext) | 是,且每次调用独立 |
| 创建开销 | 高,需复用 | 中 | 低,可频繁创建 |
| 典型示例 | 配置、HttpClient 工厂、缓存 | DbContext、仓储、工作单元 | 轻量计算器、映射器 |
瞬态服务在后台服务中要特别小心“捕获”问题。若在构造函数中注入一个瞬态服务并保存为字段,该实例会被单例长期持有,实际上变成了单例,且不会随作用域释放。正确做法是:瞬态服务应在每次需要时从当前作用域解析,或直接作为方法参数传入,而不是在单例构造函数中缓存。
四、作用域、单例与瞬态的协作关系
下图展示了后台服务在根容器中作为单例运行,通过 IServiceScopeFactory 为每轮工作创建独立作用域,并在作用域内解析 Scoped 与 Transient 服务的完整链路。注意根容器与作用域容器的边界,以及实例的创建与释放时机。
从图中可以读出三条实践规则。其一,后台服务与 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 为每个工作单元创建并释放作用域;瞬态服务不要在单例构造函数中缓存。把这三条边界守住,后台服务的依赖注入就不会成为隐蔽的故障源。