4. 依赖注入生命周期与性能

在 ASP.NET Core 中,依赖注入(DI)不只是“把对象塞进构造函数”的语法糖,它直接决定了高并发请求下对象的创建次数、内存分配速率、GC 压力以及共享状态的线程安全边界。很多性能问题并非来自业务算法,而是来自生命周期选错:一个本应单例的昂贵对象被注册为 Transient,在每秒数千请求下被反复构造;一个本应 Scoped 的请求上下文被注册为 Singleton,导致跨请求数据串扰。理解三种生命周期的并发行为,是重构 DI 注册策略的前提。

三种生命周期的并发语义

Transient(瞬态):每次从容器解析(包括每次注入到构造函数、每次 GetService)都会创建一个新实例。它不共享、不缓存,天然避免了共享状态竞争,但代价是创建频率与解析次数成正比。若对象构造涉及反射、连接、缓存初始化或大对象分配,高并发下会迅速放大 CPU 与 GC 开销。

Scoped(作用域):在同一个作用域内(ASP.NET Core 中默认每个 HTTP 请求一个作用域)只创建一次,同一请求内多次解析得到同一实例。它适合承载“请求级上下文”,如 DbContext、用户身份、请求级缓存。跨请求不共享,因此线程安全压力较小,但要注意:Scoped 实例不应被 Singleton 捕获,否则会形成“捕获依赖”,把请求级对象提升为进程级生命周期。

Singleton(单例):整个应用生命周期内只创建一次,所有请求、所有线程共享同一实例。它创建次数最少、内存分配最省,但必须自行保证线程安全。无状态服务、配置读取器、缓存客户端、HttpClient 工厂等适合单例;而持有可变字段、非线程安全集合、请求相关数据的对象绝不适合。

并发请求下的 DI 生命周期:创建次数、共享范围与线程安全 请求 R1 请求 R2 请求 R3 并发进入 Transient 每次解析都新建 实例 T1 实例 T2 实例 T3 创建次数 = 解析次数 分配最多 · 无共享竞争 GC 压力随并发上升 Scoped 每请求一个实例 实例 S1(R1) 实例 S2(R2) 实例 S3(R3) 创建次数 = 请求数 请求内共享 · 跨请求隔离 适合 DbContext / 用户上下文 Singleton 全进程唯一实例 唯一实例 G1 被 R1/R2/R3 共享 创建次数 = 1 分配最少 · 必须线程安全 禁止捕获 Scoped 依赖 重构判断要点 • 无状态且创建昂贵 → Singleton;有请求级状态 → Scoped;轻量且必须隔离 → Transient。 • Singleton 注入 Scoped 会抛 InvalidOperationException(作用域验证开启时),需用 IServiceScopeFactory 手动开域。 • 高频解析的 Transient 是 GC 与 CPU 的隐形放大器,应优先评估其构造成本与解析次数。 • 线程安全责任:Singleton 由开发者承担,Scoped 由请求隔离承担,Transient 由实例独立承担。

并发场景下的真实代价

假设一个服务每秒处理 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 / 锁保护
判断口诀:先问“状态属于谁”——属于进程就 Singleton,属于请求就 Scoped,属于每次调用就 Transient;再问“创建有多贵”——越贵越应向上收敛生命周期;最后问“谁保证线程安全”——Singleton 靠自己,Scoped 靠请求隔离,Transient 靠实例独立。
常见陷阱:把 HttpClient 注册为 Transient 会导致套接字耗尽;把 DbContext 注册为 Singleton 会因非线程安全而随机抛异常;把带请求数据的服务注册为 Singleton 会造成用户 A 看到用户 B 的数据。这些都不是“偶发 bug”,而是生命周期选错的必然结果。

重构 DI 注册策略的核心,不是记住三条定义,而是建立“创建次数—共享范围—线程安全”三者的对应关系。在高并发系统中,优先让昂贵且无状态的对象收敛为 Singleton,让请求级对象稳定在 Scoped,让真正需要隔离的轻量对象保持 Transient,并用压测数据验证每一次调整是否真正降低了分配与延迟,而不是凭直觉替换生命周期。