AddScoped 和 AddSingleton 都属于 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 服务特别适合保存真正跨请求共享的数据。
四、两者最直观的区别
可以通过下面的表格快速理解:
| 对比维度 | AddScoped | AddSingleton |
|---|---|---|
| 生命周期 | 一个 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 服务向下依赖了请求级对象,就需要重新审视整个设计。
十八、总结
AddScoped 与 AddSingleton 的本质区别,可以浓缩成一句话:
Scoped 是以 Scope 为边界复用实例,Singleton 是以 Service Provider 为边界复用实例。
ASP.NET Core Web 应用中,一个 HTTP 请求通常对应一个 Scope,所以 Scoped 经常表现为“一请求一实例”;Singleton 则通常表现为“整个应用共享一个实例”。
选择生命周期时,不应该单纯考虑对象创建成本,而应该重点判断:
是否需要请求隔离? 是否需要跨请求共享? 是否保存状态? 是否能够安全并发访问? 依赖链中的生命周期是否匹配?
只要掌握“Scoped 强调隔离,Singleton 强调共享”这一核心思路,再结合数据库上下文、业务服务、缓存、配置和后台任务等实际场景,就能更加准确地选择 ASP.NET Core 依赖注入中的服务生命周期,避免因错误使用 Singleton 或 Scoped 而产生数据串扰、线程安全问题以及生命周期异常。