C# 依赖注入中 AddScoped 与 AddSingleton 的本质区别

2026-09-01 20:22:24 15 次阅读

AddScopedAddSingleton 都属于 ASP.NET Core 内置依赖注入容器提供的服务生命周期配置方式,但两者真正的区别并不只是“一个请求一次、一个程序一次”。理解它们的本质,需要从依赖注入容器、作用域、对象实例复用以及线程安全几个方面来看。

一、AddScoped 与 AddSingleton 分别是什么

典型的注册方式如下:

C#
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<ICacheService, CacheService>();

两种写法都完成了接口与实现类的映射,但生命周期完全不同。

AddScoped 表示服务以“作用域”为生命周期。同一个 Scope 内解析服务时,容器会复用同一个实例;进入新的 Scope 后,会创建新的实例。

ASP.NET Core Web 应用中,每个 HTTP 请求通常对应一个独立的 Scope。因此,最常见的表现就是:

请求 A → UserService 实例 A
请求 B → UserService 实例 B
请求 C → UserService 实例 C

同一个请求中,即使多个 Controller、Service 同时依赖 IUserService,通常拿到的都是同一个 UserService 实例。

AddSingleton 则表示整个依赖注入容器生命周期内只创建一个实例:

请求 A ─┐
请求 B ─┼→ UserService 实例 X
请求 C ─┘

只要还是同一个 Service Provider,这个实例就会被持续复用。

所以可以简单理解为:

Scoped    = 一个 Scope 一个实例
Singleton = 一个容器一个实例

这就是两者生命周期层面的核心差异。

二、AddScoped 的本质:绑定到 Scope

理解 AddScoped,关键是理解 Scope。

依赖注入容器并不是简单地执行:

C#
new UserService()

而是根据服务的生命周期决定“什么时候创建”和“什么时候复用”。

例如:

C#
public interface IUserService
{
    Guid GetId();
}

public class UserService : IUserService
{
    private readonly Guid _id = Guid.NewGuid();

    public Guid GetId()
    {
        return _id;
    }
}

注册:

C#
builder.Services.AddScoped<IUserService, UserService>();

Controller:

C#
public class UserController : ControllerBase
{
    private readonly IUserService _userService;

    public UserController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet]
    public Guid Get()
    {
        return _userService.GetId();
    }
}

一次 HTTP 请求内部,如果多个组件都依赖 IUserService,DI 容器会从当前 Scope 中查找已经创建的实例。

如果找到了,就直接返回。

如果没有,就创建一个,然后缓存到当前 Scope 中。

因此可以把 Scoped 的工作过程理解成:

创建 Request Scope
        ↓
第一次解析 IUserService
        ↓
创建 UserService
        ↓
保存到当前 Scope
        ↓
后续再次解析
        ↓
返回之前的实例
        ↓
Request Scope 结束
        ↓
释放 Scoped 对象

下一次请求会创建新的 Scope,自然也会得到新的 UserService

三、AddSingleton 的本质:绑定到 Service Provider

Singleton 的范围比 Scoped 更大。

例如:

C#
builder.Services.AddSingleton<IConfigService, ConfigService>();

容器第一次解析 IConfigService 时创建对象:

ServiceProvider
      │
      └── ConfigService 实例

之后无论哪个请求、哪个 Controller、哪个 Service 获取它,只要使用的是同一个 Service Provider,都会得到这个实例。

因此:

C#
var service1 = provider.GetRequiredService<IConfigService>();
var service2 = provider.GetRequiredService<IConfigService>();

Console.WriteLine(ReferenceEquals(service1, service2));

通常会输出:

True

Singleton 并不是“每次调用方法都执行一次单例逻辑”,而是 DI 容器负责缓存该实例,并在后续解析过程中重复返回。

这也是为什么 Singleton 服务特别适合保存真正跨请求共享的数据。

四、两者最直观的区别

可以通过下面的表格快速理解:

对比维度AddScopedAddSingleton
生命周期一个 Scope整个 Service Provider
Web 请求中的常见表现每个请求一个实例所有请求共享一个实例
实例数量随 Scope 创建通常只有一个
是否跨请求共享
是否适合保存请求状态可以不适合
是否需要考虑并发需要更需要
典型用途业务服务、DbContext缓存、配置、无状态工具
生命周期结束时释放Scope 结束Service Provider 结束

需要特别注意,“Scoped 就一定等于 HTTP 请求”并不严谨。

HTTP 请求只是 ASP.NET Core 中最常见的一种 Scope。

开发者也可以手动创建 Scope:

C#
using var scope = serviceProvider.CreateScope();

var service = scope.ServiceProvider.GetRequiredService<IUserService>();

所以更准确的说法是:

Scoped 服务绑定的是 Scope,而不是 HTTP 请求本身。

五、为什么 DbContext 通常使用 AddScoped

这是实际开发中非常典型的应用场景。

例如:

C#
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString);
});

AddDbContext 默认就是 Scoped 生命周期。

原因与数据库上下文的设计方式有关。

一个 HTTP 请求可能完成这样的操作:

Controller
   ↓
OrderService
   ↓
OrderRepository
   ↓
AppDbContext

如果这些组件在同一个请求中使用同一个 DbContext,就可以共享:

  • Change Tracker

  • 数据库连接管理

  • 当前工作单元中的实体状态

  • SaveChanges 操作

但如果把 DbContext 注册成 Singleton,就会导致不同请求共享同一个上下文。

假设两个用户同时操作:

请求 A → DbContext X
请求 B → DbContext X
请求 C → DbContext X

这不仅会造成状态相互干扰,而且 DbContext 本身也不是设计成跨多个并发请求共享的对象。

因此,类似数据库上下文、请求级缓存、当前业务上下文等资源,一般更适合 Scoped。

六、为什么 Singleton 更适合缓存和无状态服务

Singleton 的价值在于减少对象重复创建,并让多个请求共享同一个对象。

例如一个应用需要维护一份全局缓存:

C#
public class MemoryCacheService
{
    private readonly Dictionary<string, object> _cache = new();

    public void Set(string key, object value)
    {
        _cache[key] = value;
    }

    public object? Get(string key)
    {
        return _cache.TryGetValue(key, out var value) ? value : null;
    }
}

注册:

C#
builder.Services.AddSingleton<MemoryCacheService>();

所有请求都可以访问同一个缓存实例。

但这里马上会产生一个问题:线程安全。

Web 应用通常同时处理大量请求,因此 Singleton 对象很可能被多个线程同时访问。

如果直接使用:

C#
Dictionary<string, object>

进行并发读写,就需要考虑线程安全问题。

更合理的实现可能使用:

C#
ConcurrentDictionary<string, object>

例如:

C#
private readonly ConcurrentDictionary<string, object> _cache = new();

所以使用 Singleton 时必须问自己一个问题:

这个对象能否安全地被多个请求并发访问?

如果答案不明确,就不能仅仅因为“它只需要创建一次”而使用 Singleton。

七、Singleton 最大的风险:保存了不应该共享的状态

这是实际项目中最容易出现的问题之一。

假设有这样一个服务:

C#
public class UserContextService
{
    public int UserId { get; set; }
}

如果注册为:

C#
builder.Services.AddSingleton<UserContextService>();

那么所有请求都会共享这个对象。

请求 A:

UserId = 1001

紧接着请求 B:

UserId = 2002

此时请求 A 和请求 B 实际上操作的是同一个对象。

如果两个请求同时执行,就可能出现非常严重的数据串扰。

这类服务通常应该注册为 Scoped:

C#
builder.Services.AddScoped<UserContextService>();

这样每个请求拥有自己的实例:

请求 A → UserContextService A → UserId 1001

请求 B → UserContextService B → UserId 2002

彼此之间互不影响。

八、Scoped 服务能不能被 Singleton 依赖

这是理解 ASP.NET Core DI 生命周期非常重要的一点。

假设:

C#
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddSingleton<IOrderService, OrderService>();

OrderService 构造函数依赖 IUserService

C#
public class OrderService : IOrderService
{
    private readonly IUserService _userService;

    public OrderService(IUserService userService)
    {
        _userService = userService;
    }
}

这就产生了典型的生命周期问题:

Singleton
    ↓
Scoped

Singleton 生命周期比 Scoped 更长。

如果 Singleton 在创建时直接获取 Scoped 实例,那么这个 Scoped 对象就可能被长期持有,从而失去原本的 Scope 隔离意义。

ASP.NET Core 在开发环境下通常可以通过 Scope 验证机制发现这类问题。

因此一般应遵循:

Singleton
   ↓
Singleton

Scoped
   ↓
Scoped / Singleton

Transient
   ↓
Transient / Scoped / Singleton

更准确地说,依赖关系应该避免让长生命周期对象直接持有短生命周期对象

尤其需要避免:

Singleton → Scoped
Singleton → 请求级对象

九、如果 Singleton 确实需要使用 Scoped 服务怎么办

有些后台任务或基础设施组件确实是 Singleton,但业务执行过程中又需要访问 Scoped 服务。

这时不能简单地把 Scoped 服务强行注入 Singleton。

可以注入:

C#
IServiceScopeFactory

然后在需要执行业务逻辑时创建 Scope:

C#
public class WorkerService
{
    private readonly IServiceScopeFactory _scopeFactory;

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

    public async Task ExecuteAsync()
    {
        using var scope = _scopeFactory.CreateScope();

        var userService =
            scope.ServiceProvider.GetRequiredService<IUserService>();

        // 使用 userService
    }
}

这里的关键不是“Singleton 使用 Scoped”,而是:

Singleton 不直接持有 Scoped 实例,而是在合适的 Scope 中临时解析 Scoped 服务。

这也是后台任务、定时任务等场景中非常常见的写法。

十、AddScoped 与 AddSingleton 对性能的影响

从对象创建次数来看,Singleton 通常更节省实例化成本。

假设一个服务处理 10000 个请求。

Scoped:

10000 个请求
    ↓
大约创建 10000 个实例

Singleton:

10000 个请求
    ↓
通常只创建 1 个实例

但不能因此得出“Singleton 一定性能更高”的结论。

因为性能不仅取决于对象创建,还涉及:

  • 内存占用

  • 锁竞争

  • 并发访问

  • 数据共享

  • 缓存策略

  • 对象内部状态

  • GC 压力

  • 外部资源管理

如果一个 Singleton 为了保证线程安全,大量使用锁:

C#
lock (_lock)
{
    // ...
}

高并发情况下反而可能成为性能瓶颈。

所以生命周期选择的优先级应该是:

正确性
  ↓
生命周期匹配
  ↓
线程安全
  ↓
资源管理
  ↓
性能优化

而不是看到“只创建一次”就全部改成 Singleton。

十一、AddScoped 和 AddSingleton 如何选择

实际项目中可以从“数据是否需要隔离”开始判断。

如果服务保存的是请求相关数据,例如:

当前用户
当前请求
当前事务
当前数据库上下文
请求级缓存
业务工作单元

通常优先考虑 Scoped。

如果服务本身没有请求级状态,并且适合全局复用,例如:

配置读取器
静态元数据
线程安全缓存
无状态计算服务
某些基础设施客户端

可以考虑 Singleton。

例如:

C#
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IUserService, UserService>();

builder.Services.AddSingleton<IAppConfig, AppConfig>();
builder.Services.AddSingleton<ICacheService, CacheService>();

这样的生命周期划分通常更加合理。

十二、一个简单的判断方法

遇到一个新的服务,不知道应该使用哪种生命周期时,可以连续问自己三个问题。

第一个问题:这个对象是否保存请求级状态?

如果保存:

UserId
RequestId
当前事务
当前 DbContext

优先考虑 Scoped。

第二个问题:不同请求之间能否安全共享?

如果答案是否定的,就不要使用 Singleton。

第三个问题:多个线程同时访问是否安全?

如果注册成 Singleton,必须认真检查这一点。

例如下面这种类:

C#
public class CounterService
{
    private int _count;

    public void Increment()
    {
        _count++;
    }
}

如果它是 Singleton:

C#
builder.Services.AddSingleton<CounterService>();

那么多个请求可能同时修改 _count

更安全的方式至少需要使用适合并发场景的实现,例如:

C#
private int _count;

public void Increment()
{
    Interlocked.Increment(ref _count);
}

但即使解决了线程安全问题,也还要确认“全局共享计数器”是否真的是业务需要。

十三、不要用 Singleton 替代静态类

有些开发者看到 Singleton 的特点后,会认为:

C#
Singleton = static class

实际上两者并不相同。

静态类通过类型本身保存状态:

C#
public static class AppHelper
{
}

Singleton 则仍然是普通对象:

C#
public class AppHelper
{
}

通过 DI 容器管理:

C#
builder.Services.AddSingleton<AppHelper>();

Singleton 具有依赖注入、接口抽象、生命周期管理等优势:

C#
builder.Services.AddSingleton<IAppHelper, AppHelper>();

之后业务代码只依赖:

C#
IAppHelper

而不需要直接依赖具体实现。

这也是现代 .NET 应用更倾向使用 DI 管理服务,而不是大量使用静态全局对象的原因之一。

十四、Singleton 是否一定只创建一次

从通常的 DI 使用方式来看,Singleton 会在同一个 Service Provider 中共享实例。

但需要注意“同一个 Service Provider”这个前提。

如果程序创建了多个独立的根容器:

C#
var provider1 = services.BuildServiceProvider();
var provider2 = services.BuildServiceProvider();

那么它们实际上属于不同的 DI 容器。

对应的 Singleton 也可能分别创建:

provider1 → Singleton A

provider2 → Singleton B

因此更准确的定义是:

Singleton 是针对一个 Service Provider 实例的单例,而不是整个操作系统进程中绝对只能存在一个对象。

正常的 ASP.NET Core 应用通常由框架管理根 Service Provider,因此开发过程中不应该随意调用 BuildServiceProvider() 创建额外容器。

十五、生命周期选择的常见误区

误区一:Singleton 一定比 Scoped 好

错误。

Singleton 只是生命周期更长,并不意味着业务上更合适。

误区二:Scoped 就是每调用一次创建一个对象

错误。

同一个 Scope 内,Scoped 服务通常会复用同一个实例。

误区三:Singleton 不能保存任何状态

也不准确。

Singleton 可以保存状态,但这些状态必须是允许跨请求共享的状态,并且要正确处理并发访问。

误区四:所有 Service 都注册成 Scoped

这种做法虽然通常比随意使用 Singleton 更安全,但也不是最佳实践。

如果一个对象完全无状态,并且初始化成本较高、可以安全共享,那么 Singleton 反而更合适。

误区五:看到线程安全就可以使用 Singleton

线程安全只是必要条件之一。

一个服务即使线程安全,如果它包含用户级数据、请求级数据或事务级状态,同样不应该因为“线程安全”就注册为 Singleton。

十六、从对象生命周期理解两者的真正区别

如果把 DI 容器想象成一个对象管理系统,那么两者的差别可以概括成:

AddScoped

Root ServiceProvider
        │
        ├── Scope A
        │     └── Service A
        │
        ├── Scope B
        │     └── Service B
        │
        └── Scope C
              └── Service C

而 Singleton 更接近:

Root ServiceProvider
        │
        └── Singleton Service
             ↑     ↑     ↑
           Scope A Scope B Scope C

Scoped 的核心价值是隔离

Singleton 的核心价值是共享

一个服务究竟应该隔离还是共享,才是选择生命周期时真正应该思考的问题。

十七、实际项目中的推荐原则

可以将常见情况简单归纳为:

数据库 DbContext
        ↓
Scoped

业务 Service
        ↓
通常 Scoped

Repository
        ↓
通常 Scoped

请求相关上下文
        ↓
Scoped

线程安全缓存
        ↓
Singleton

全局配置
        ↓
Singleton

无状态、可安全复用的基础设施服务
        ↓
通常可以 Singleton

当然,具体生命周期仍然应该结合实现类的内部状态和依赖关系判断,而不能仅凭类名决定。

尤其需要关注依赖链:

Controller
    ↓
OrderService
    ↓
Repository
    ↓
DbContext

如果 OrderService 依赖 Scoped 的 Repository,那么它本身通常也应该保持 Scoped。

如果一个 Singleton 服务向下依赖了请求级对象,就需要重新审视整个设计。

十八、总结

AddScopedAddSingleton 的本质区别,可以浓缩成一句话:

Scoped 是以 Scope 为边界复用实例,Singleton 是以 Service Provider 为边界复用实例。

ASP.NET Core Web 应用中,一个 HTTP 请求通常对应一个 Scope,所以 Scoped 经常表现为“一请求一实例”;Singleton 则通常表现为“整个应用共享一个实例”。

选择生命周期时,不应该单纯考虑对象创建成本,而应该重点判断:

是否需要请求隔离?
是否需要跨请求共享?
是否保存状态?
是否能够安全并发访问?
依赖链中的生命周期是否匹配?

只要掌握“Scoped 强调隔离,Singleton 强调共享”这一核心思路,再结合数据库上下文、业务服务、缓存、配置和后台任务等实际场景,就能更加准确地选择 ASP.NET Core 依赖注入中的服务生命周期,避免因错误使用 Singleton 或 Scoped 而产生数据串扰、线程安全问题以及生命周期异常。