4. 依赖注入生命周期与性能
在 ASP.NET Core 中,依赖注入(DI)不只是“把对象塞进构造函数”的语法糖,它直接决定了高并发请求下对象的创建次数、内存分配速率、GC 压力以及共享状态的线程安全边界。很多性能问题并非来自业务算法,而是来自生命周期选错:一个本应单例的昂贵对象被注册为 Transient,在每秒数千请求下被反复构造;一个本应 Scoped 的请求上下文被注册为 Singleton,导致跨请求数据串扰。理解三种生命周期的并发行为,是重构 DI 注册策略的前提。
三种生命周期的并发语义
Transient(瞬态):每次从容器解析(包括每次注入到构造函数、每次 GetService)都会创建一个新实例。它不共享、不缓存,天然避免了共享状态竞争,但代价是创建频率与解析次数成正比。若对象构造涉及反射、连接、缓存初始化或大对象分配,高并发下会迅速放大 CPU 与 GC 开销。
Scoped(作用域):在同一个作用域内(ASP.NET Core 中默认每个 HTTP 请求一个作用域)只创建一次,同一请求内多次解析得到同一实例。它适合承载“请求级上下文”,如 DbContext、用户身份、请求级缓存。跨请求不共享,因此线程安全压力较小,但要注意:Scoped 实例不应被 Singleton 捕获,否则会形成“捕获依赖”,把请求级对象提升为进程级生命周期。
Singleton(单例):整个应用生命周期内只创建一次,所有请求、所有线程共享同一实例。它创建次数最少、内存分配最省,但必须自行保证线程安全。无状态服务、配置读取器、缓存客户端、HttpClient 工厂等适合单例;而持有可变字段、非线程安全集合、请求相关数据的对象绝不适合。
并发场景下的真实代价
假设一个服务每秒处理 2000 个请求,每个请求在管道中解析某服务 3 次。若该服务注册为 Transient,则每秒创建 6000 个实例;若其构造函数中分配了 2 KB 的临时对象,则每秒产生约 12 MB 的短命对象,Gen0 GC 会被频繁触发。若改为 Scoped,创建次数降为每秒 2000 次;若改为 Singleton,则整个进程只创建 1 次。创建次数的量级差异,直接决定了内存分配速率与 GC 暂停频率。
但 Singleton 并非“免费午餐”。它把所有并发线程引向同一个实例,任何可变字段、非线程安全集合、延迟初始化逻辑都会成为竞争点。例如一个单例服务内部使用 Dictionary<string, decimal> 做缓存,多个线程同时写入会破坏内部结构甚至导致死循环。正确做法是使用 ConcurrentDictionary、IMemoryCache 或加锁保护,并明确哪些状态是只读的。
重构注册策略的实操路径
第一步是盘点。对每个注册项问三个问题:它是否持有请求相关数据?它的构造是否昂贵?它是否被高频解析?据此形成初步分类。第二步是验证作用域合法性。ASP.NET Core 在开发环境下会启用作用域验证,若 Singleton 直接依赖 Scoped,启动或首次解析时会抛出异常。第三步是压测对比。用同一组接口在相同并发下分别测试三种注册方式,观察吞吐量、Gen0/Gen1 GC 次数、分配字节数与 P99 延迟。
// 反例:Singleton 捕获 Scoped,导致 DbContext 被跨请求共享
builder.Services.AddScoped<AppDbContext>();
builder.Services.AddSingleton<OrderCacheService>(); // 若其构造函数注入 AppDbContext 即违规
// 正例:Singleton 通过 IServiceScopeFactory 在需要时创建作用域
public sealed class OrderCacheService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderCacheService(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
public async Task<decimal> GetTotalAsync(int orderId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await db.Orders.Where(o => o.Id == orderId).SumAsync(o => o.Amount);
}
}
第四步是处理“伪单例”场景。有些服务本身无状态,但依赖了 Scoped 的 DbContext。此时不应强行提升为 Singleton,而应把数据访问职责下沉到 Scoped 仓储,或让单例服务通过 IServiceScopeFactory 按需开域。这样既保留了单例的创建优势,又避免了跨请求状态污染。
注册策略速查表
| 对象特征 | 推荐生命周期 | 并发关注点 |
|---|---|---|
| 无状态、构造昂贵、全局共享 | Singleton | 线程安全、禁止捕获 Scoped |
| 请求级上下文、DbContext、用户身份 | Scoped | 请求内共享、跨请求隔离 |
| 轻量、必须每次独立、无共享状态 | Transient | 创建频率与 GC 分配 |
| 高频解析且构造简单 | 可考虑 Transient | 评估解析次数是否放大开销 |
| 持有可变缓存或计数器 | Singleton + 并发容器 | 用 ConcurrentDictionary / 锁保护 |
HttpClient 注册为 Transient 会导致套接字耗尽;把 DbContext 注册为 Singleton 会因非线程安全而随机抛异常;把带请求数据的服务注册为 Singleton 会造成用户 A 看到用户 B 的数据。这些都不是“偶发 bug”,而是生命周期选错的必然结果。重构 DI 注册策略的核心,不是记住三条定义,而是建立“创建次数—共享范围—线程安全”三者的对应关系。在高并发系统中,优先让昂贵且无状态的对象收敛为 Singleton,让请求级对象稳定在 Scoped,让真正需要隔离的轻量对象保持 Transient,并用压测数据验证每一次调整是否真正降低了分配与延迟,而不是凭直觉替换生命周期。