1. 起因

一个后台任务服务偶发性地拿到”过期”的数据库连接,报连接已关闭的异常,排查下来是把一个 Scoped 服务错误地注入到了单例服务里,记一下 .NET 内置 DI 容器三种生命周期的实际差异和容易踩的坑。

2. 三种生命周期简单回顾

1
2
3
services.AddSingleton<IFoo, Foo>();  // 整个应用生命周期内只创建一个实例
services.AddScoped<IBar, Bar>(); // 每个请求(scope)创建一个实例
services.AddTransient<IBaz, Baz>(); // 每次注入都创建一个新实例

DbContext 通常注册成 Scoped,这个应该是写 ASP.NET Core 项目的人都知道的常识,但真正容易出问题的不是这条规则本身,而是”捕获”(Captive Dependency)问题。

3. Singleton 里注入 Scoped 服务,是最常见的一种坑

1
2
3
4
5
6
7
8
9
10
11
12
public class CacheWarmupService // 注册成 Singleton
{
private readonly AppDbContext _db; // Scoped 服务

public CacheWarmupService(AppDbContext db)
{
_db = db;
}
}

services.AddSingleton<CacheWarmupService>();
services.AddScoped<AppDbContext>();

这段代码在开发环境跑 dotnet run 时,如果开了作用域校验(ValidateScopes,Development 环境默认开着),启动时就会直接抛异常,提示”Cannot consume scoped service from singleton”,算是比较友好,能第一时间发现问题。

但如果是生产环境(默认关闭作用域校验,或者被手动关掉了),这行代码是能跑起来的,只是行为很危险:CacheWarmupService 是单例,它构造函数里拿到的 AppDbContext 实例也会被”捕获”成单例的一部分,整个应用生命周期内只用这一个 DbContext 实例。DbContext 本身不是线程安全的,被多个请求并发使用会直接报错;即使不并发,DbContext 内部的 change tracker 也会随着使用不断累积旧数据,内存越用越多,还可能因为连接池、连接超时之类的原因间歇性抛出连接已关闭的异常——开头提到的那个 bug 就是这么来的。

4. 正确的处理方式:用 IServiceScopeFactory 按需创建作用域

单例服务如果确实需要用到 Scoped 服务,不能直接构造函数注入,得手动创建一个作用域:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class CacheWarmupService
{
private readonly IServiceScopeFactory _scopeFactory;

public CacheWarmupService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}

public async Task WarmupAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 用完这个 scope 结束就释放,不会被单例捕获
await db.Categories.ToListAsync();
}
}

IHostedServiceBackgroundService 这类本身就是单例注册的后台服务,凡是里面要用数据库、要用任何 Scoped 服务的,都得走这个套路,这也是官方文档里反复强调的模式,但项目里新人接手后台任务代码时还是会漏掉。

5. 一定要在开发和测试环境开启作用域校验

1
2
3
4
5
6
7
8
var host = Host.CreateDefaultBuilder(args)
.UseDefaultServiceProvider((context, options) =>
{
options.ValidateScopes = true;
options.ValidateOnBuild = true; // 应用启动时就校验,而不是等到实际用到才报错
})
.ConfigureServices(...)
.Build();

ValidateOnBuild 这个选项容易被忽略,它的作用是应用启动的那一刻就把所有注册的服务过一遍依赖关系校验,而不是等到某个具体请求真正触发这条依赖链时才报错。没开这个选项的话,一个很少被调用的服务里藏着这种生命周期错误,可能要等到线上流量恰好触发那条路径才会暴露,比在启动阶段就失败的排查成本高得多。

6. 小结

DI 生命周期这一块,Singleton 里直接注入 Scoped 是最容易踩、也是后果最隐蔽的一种坑——它不像编译错误那样立刻现形,而是在特定条件下(并发、长时间运行后)才会表现出连接异常或者内存增长。养成习惯:写单例服务时留意构造函数注入的每一个依赖是不是也是单例;开发环境务必打开 ValidateScopesValidateOnBuild,让这类问题在启动阶段就暴露,而不是留到生产环境。