← 博客
技术2026-09-043 次阅读

.NET 依赖注入实战:MES 系统里的生命周期陷阱与内存泄漏排查

# .NET,依赖注入,C## MES,内存泄漏,DI 生命周期

背景:为什么 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;
    }
}

配置原则:

  1. Transient:纯函数、工具类、无状态服务
  2. Scoped:业务逻辑层、需访问上下文的服务
  3. Singleton:缓存、配置、计数器
  4. 避免循环依赖:用事件驱动或独立服务解耦

总结

.NET 依赖注入在 MES 系统里用了三年,踩了三大坑:

  1. 生命周期不匹配:单例里注入 Scoped 服务导致内存泄漏
  2. 单例可变状态:并发写入导致竞态条件
  3. 循环依赖:服务之间互相引用导致启动失败

核心经验四条:

  • 先看生命周期:明确服务的使用场景,再选 Transient/Scoped/Singleton
  • 单例要无状态:单例里存的数据必须是只读的,或者用线程安全集合
  • Scoped 服务别乱传:不要在单例里持有 Scoped 服务引用
  • 定期抓 Dump:每月抓一次内存 Dump,用 dotnet-dump 分析泄漏

按这个规范走,MES 系统运行两年,内存占用稳定在 1.5GB 左右,再也没有出现过应用池崩溃。

附:DI 生命周期选择清单

  • 是否需要上下文? → 是:Scoped;否:Transient/Singleton
  • 是否需要缓存? → 是:Singleton;否:Transient/Scoped
  • 是否有多线程访问? → 是:用线程安全集合;否:正常
  • 是否有循环依赖? → 是:拆解服务,用事件驱动
  • 是否需要 Mock? → 是:用接口,方便测试

评论 (0)