.NET 依赖注入实战:MES 系统里的生命周期陷阱与内存泄漏排查
背景:为什么 MES 系统要用依赖注入
MES 系统上线三年,代码库从 2 万行涨到 15 万行,早期那种 new Service() 的写法彻底玩不转了。产线设备接口换了 5 种,数据源从 SQL Server 加到了 PostgreSQL,每次改代码都要牵一发而动全身。
依赖注入(DI) 是解耦的唯一解法:通过构造函数注入接口,实现类之间松耦合,测试时能 Mock,重构时不崩盘。
但 DI 不是银弹。用错了,轻则 Cannot resolve service 报错,重则内存泄漏——工站页用久了内存吃光,IIS 应用池崩溃,产线停摆。
解决思路:三步走 DI 规范
我的做法是:先定生命周期 → 再查使用场景 → 最后监控验证。
第一步:生命周期选择铁律
ASP.NET Core 的 DI 容器有三种生命周期,选错就是坑:
1. Transient(瞬时)
每次请求新实例,适合轻量、无状态的服务:
public interface IStationService
{
Task<List<Station>> GetStationsAsync();
}
public class StationService : IStationService
{
private int _instanceCount;
private readonly string _instanceId = Guid.NewGuid().ToString();
public StationService(IStationRepository repo)
{
_instanceCount++;
_logger.LogInformation($"StationService 实例 {_instanceId} 被创建,第{_instanceCount} 次");
}
public async Task<List<Station>> GetStationsAsync()
{
// 查询逻辑...
}
}
使用场景:数据访问层、工具类、纯函数计算服务。
2. Scoped(作用域)
每个 HTTP 请求一个实例,适合有状态、需要上下文的服务:
public class StationService : IStationService
{
private readonly IStationRepository _repo;
private readonly string _requestId;
public StationService(IStationRepository repo, HttpContext context)
{
_repo = repo;
_requestId = context.TraceIdentifier;
}
public async Task<List<Station>> GetStationsAsync()
{
return await _repo.GetStationsAsync();
}
}
使用场景:业务逻辑层、需要访问 HttpContext 的服务。
3. Singleton(单例)
整个应用生命周期一个实例,适合无状态、高性能要求的缓存服务:
public class StationCache : IStationCache
{
private readonly ConcurrentDictionary<string, Station> _cache = new();
private static int _createCount;
public StationCache()
{
_createCount++;
Console.WriteLine($"StationCache 单例实例创建,第{_createCount} 次");
}
public Station Get(string stationId)
{
_cache.TryGetValue(stationId, out var station);
return station;
}
public void Set(string stationId, Station station)
{
_cache[stationId] = station;
}
}
使用场景:内存缓存、配置读取器、计数器。
第二步:三大坑与真实案例
坑 1:单例里注入了 Scoped 服务
这是 MES 系统最早踩的坑。当时的代码:
// ❌ 错误:单例服务注入了 Scoped 服务
public class StationMonitorService : IStationMonitorService
{
private readonly StationService _stationService; // StationService 是 Scoped
private DateTime _lastCheckTime;
public StationMonitorService(StationService stationService)
{
_stationService = stationService; // 构造函数只被调用一次
_lastCheckTime = DateTime.MinValue;
}
public async Task MonitorAsync()
{
var stations = await _stationService.GetStationsAsync();
}
}
问题在哪?StationService 是 Scoped,依赖的 HttpContext 和 DbContext 在单例创建时就被固定了。第一次请求后,这些 Scoped 对象不会被释放,内存里越积越多。
症状:应用池运行 3 天后,内存占用从 500MB 飙升到 8GB,IIS 崩溃。
解决:改用 IServiceScopeFactory 创建临时作用域:
// ✅ 正确:在单例里按需创建作用域
public class StationMonitorService : IStationMonitorService
{
private readonly IServiceScopeFactory _scopeFactory;
private DateTime _lastCheckTime;
public StationMonitorService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
_lastCheckTime = DateTime.MinValue;
}
public async Task MonitorAsync()
{
using var scope = _scopeFactory.CreateScope();
var stationService = scope.ServiceProvider.GetRequiredService<StationService>();
var stations = await stationService.GetStationsAsync();
}
}
坑 2:单例里保存可变状态
坑警告:单例里绝对不能注入 Scoped 服务,否则会导致内存泄漏。
// ❌ 错误:单例里存了可变列表
public class UPHCalculatorService : IUPHCalculatorService
{
private readonly ConcurrentDictionary<string, List<ProductionRecord>> _cache = new();
public void AddRecord(string stationId, ProductionRecord record)
{
if (!_cache.ContainsKey(stationId))
{
_cache[stationId] = new List<ProductionRecord>();
}
_cache[stationId].Add(record); // 竞态条件
}
}
解决:用 ConcurrentDictionary 的 GetOrAdd 方法,或者直接存到数据库:
// ✅ 正确:用 ConcurrentDictionary 保证线程安全
public void AddRecord(string stationId, ProductionRecord record)
{
var list = _cache.GetOrAdd(stationId, _ => new ConcurrentBag<ProductionRecord>());
((ConcurrentBag<ProductionRecord>)list).Add(record);
}
坑 3:循环依赖
循环依赖是 DI 容器的死穴:
// StationService 依赖 StationValidator
public class StationService : IStationService
{
private readonly StationValidator _validator;
public StationService(StationValidator validator) { _validator = validator; }
public async Task ValidateAsync(Station station) => await _validator.ValidateAsync(station);
}
// StationValidator 依赖 StationService
public class StationValidator
{
private readonly IStationService _stationService;
public StationValidator(IStationService stationService) { _stationService = stationService; }
public async Task ValidateAsync(Station station)
{
var current = await _stationService.GetAsync(station.Id);
}
}
症状:应用启动时报 Cannot resolve service,或者无限递归栈溢出。
解决:拆解循环依赖——用事件驱动解耦:
// ✅ 正确:用事件驱动解耦
public class StationEventPublisher : IStationEventPublisher
{
public event Action<Station> StationValidated;
public async Task PublishAsync(Station station)
{
StationValidated?.Invoke(station);
}
}
public class StationService : IStationService
{
private readonly IStationEventPublisher _publisher;
public StationService(IStationEventPublisher publisher) { _publisher = publisher; }
public async Task ValidateAsync(Station station) => await _publisher.PublishAsync(station);
}
第三步:内存泄漏排查实战
MES 系统部署半年后,工站管理页用久了会卡顿。排查路径:
1. 抓内存 Dump
# 用 WinDbg 或 dotnet-dump 抓取
dotnet-dump collect -p <pid> --output memory.dmp
2. 分析对象数量
loadby sos coreclr
!dumpheap -stat
查看对象数量统计,重点关注 DbContext、HttpContext、StreamReader 等:
Type Count Size
Microsoft.EntityFrameworkCore.Infrastructure.DbContext 12543 8.2 MB
Microsoft.AspNetCore.Http.HttpContext 8234 5.1 MB
3. 找泄漏路径
!dumpheap -type DbContext | select -first 1 | !gcroot
输出会显示哪些对象持有了 DbContext,比如单例服务之间互相持有,导致无法回收。
第四步:DI 配置规范
MES 系统最终的 DI 配置规范:
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddMEServices(this IServiceCollection services)
{
// 数据访问层:Transient
services.AddTransient<IStationRepository, StationRepository>();
services.AddTransient<IReportRepository, ReportRepository>();
// 业务逻辑层:Scoped
services.AddScoped<IStationService, StationService>();
services.AddScoped<IReportService, ReportService>();
// 缓存服务:Singleton
services.AddSingleton<IStationCache, StationCache>();
services.AddSingleton<IConfigCache, ConfigCache>();
// 后台服务:Singleton(需继承 IHostedService)
services.AddSingleton<IMonitorService, StationMonitorService>();
return services;
}
}
配置原则:
- Transient:纯函数、工具类、无状态服务
- Scoped:业务逻辑层、需访问上下文的服务
- Singleton:缓存、配置、计数器
- 避免循环依赖:用事件驱动或独立服务解耦
总结
.NET 依赖注入在 MES 系统里用了三年,踩了三大坑:
- 生命周期不匹配:单例里注入 Scoped 服务导致内存泄漏
- 单例可变状态:并发写入导致竞态条件
- 循环依赖:服务之间互相引用导致启动失败
核心经验四条:
- 先看生命周期:明确服务的使用场景,再选 Transient/Scoped/Singleton
- 单例要无状态:单例里存的数据必须是只读的,或者用线程安全集合
- Scoped 服务别乱传:不要在单例里持有 Scoped 服务引用
- 定期抓 Dump:每月抓一次内存 Dump,用 dotnet-dump 分析泄漏
按这个规范走,MES 系统运行两年,内存占用稳定在 1.5GB 左右,再也没有出现过应用池崩溃。
附:DI 生命周期选择清单
- 是否需要上下文? → 是:Scoped;否:Transient/Singleton
- 是否需要缓存? → 是:Singleton;否:Transient/Scoped
- 是否有多线程访问? → 是:用线程安全集合;否:正常
- 是否有循环依赖? → 是:拆解服务,用事件驱动
- 是否需要 Mock? → 是:用接口,方便测试