资讯动态

EF Core 仓储模式从落地到反思:接口设计、工作单元与性能优化

发布时间:2026/10/17 21:48:00 来源:尧图企业网站定制
我先说个背景方便大家判断这篇手记适不适合你。去年中旬我接手某电商后台系统的重构数据库是SQL ServerEF Core 已经在跑但代码里到处都是直接操作 DbContext 的业务逻辑。一个订单查询要改得顺着仓储Repository层一路查到 Controller再回头检查有没有漏掉 Include写单元测试更痛苦得先准备好测试库跑完还得清数据。当时团队里有两派声音一派说上仓储模式另一派说这年头谁还用仓储直接用 EF Core 不香吗。最后我们的选择是用但必须有边界、有取舍地用。这篇文章就是把这套 .NET5 仓储框架从接口定义、EF Core 落地、工作单元Unit of Work配合到性能实测、排查坑点、以及后续要不要继续用的反思完整记下来。如果你正打算在 .NET 项目里搞仓储模式或者已经在用但总觉得哪里别扭这篇手记应该能帮你少走几条弯路。1. 为什么一个中期项目最终选择了仓储模式很多人一听到仓储模式就说是过度设计但那是把仓储模式理解成了把所有数据库操作再包一层壳。我这次选择它的动机很实际不是跟风也不是为了简历上多写一行字。1.1 业务层直接操作 EF Core 的三个具体痛点接手这套系统时最典型的问题有三个。第一是查询逻辑散落。同一个查询订单列表的需求在 A 控制器里是这样写条件在 B 服务里条件又不一样而且都有各自的排序和分页逻辑。改一个字段名要全局搜索才能确认所有引用点。这其实就是数据访问没有统一出入口带来的连锁反应。第二是单元测试几乎没有。Controller 或服务直接 new 了一个 DbContext测试时要么连真实数据库要么引入一堆第三方内存数据库兼容层但 SQL Server 特有的语法又不支持测试和真实行为差距很大。第三是领域逻辑和持久化逻辑混在一起。比如订单金额超过阈值需要走人工审核这种规则原本应该属于业务层的判断但因为数据访问代码就在手边写着写着就顺手把判断逻辑塞进了查询方法里。时间一长业务规则反而被埋在 SQL 片段下面想改都找不到。1.2 仓储模式解决的是什么问题又解决不了什么仓储模式Repository Pattern的核心价值是让业务层面向集合编程而不是面向数据库编程。业务代码写await orderRepository.GetByIdAsync(id)它不关心这个订单是存在 SQL Server 里、Oracle 里还是来自一个内存集合。这样写的好处有三个层调用方代码稳定数据库访问细节被隔离DbContext不会出现在业务层。测试替身容易造只要接口定义合理mock 一个 FakeRepository 非常轻松单元测试不再依赖真实数据库。查询逻辑可复用公共的筛选、排序、分页条件收敛到仓储实现里不会每个调用方各写一份。但它解决不了滥用 EF Core的问题。如果你在仓储实现里还是写 N1 查询还是让默认追踪状态开着导致重复查询那仓储模式只是把性能问题挪了个位置而已。这一点我在第 3 节会详细讲。1.3 关于要不要用 IQueryable的争议我的实际选择仓储模式被吐槽最多的就是接口里返回了IQueryable那跟直接暴露 DbSet 有什么区别甚至有观点认为仓储模式已经完全不适合 EF Core。我当时的判断是泛型仓储接口里允许返回IQueryable但必须加上限制条件不能让它变成传 JSON 进来拼 SQL的野路子。实践证明这个妥协让团队既保住了可测试性又没有陷入写一堆GetOrdersByStatusAndDateRange这种爆炸式方法的窘境。更详细的接口边界设计放在下一节。2. 从接口定义到 EF Core 落地的完整设计这一节是整个框架的核心我会把接口怎么定、实现类怎么写、专用仓储怎么和泛型仓储共存、以及工作单元怎么落地都展开讲清楚。代码版本是 .NET5 EF Core 5.0 SQL Server但设计思路迁移到 .NET 6/8 也一样适用。2.1 泛型仓储接口的边界控制我先拍板定了泛型接口原则是够用但不过度public interface IRepositoryTEntity where TEntity : class { TaskTEntity? GetByIdAsyncTKey(TKey id, CancellationToken cancellationToken default); TaskIReadOnlyListTEntity GetListAsync( ExpressionFuncTEntity, bool? predicate null, FuncIQueryableTEntity, IOrderedQueryableTEntity? orderBy null, bool includeDeleted false, CancellationToken cancellationToken default); TaskTEntity? FirstOrDefaultAsync( ExpressionFuncTEntity, bool? predicate null, CancellationToken cancellationToken default); Taskbool AnyAsync( ExpressionFuncTEntity, bool? predicate, CancellationToken cancellationToken default); Taskint CountAsync( ExpressionFuncTEntity, bool? predicate, CancellationToken cancellationToken default); void Add(TEntity entity); void Update(TEntity entity); void Remove(TEntity entity); }有几个刻意为之的地方值得解释一下为什么这样设计。首先是GetByIdAsync用了泛型主键TKey。实体主键类型各不相同有int、有Guid、有string如果接口里写死int那每个实体都要新增方法。泛型主键可以规避这个问题。其次是GetListAsync的排序参数我用了FuncIQueryableTEntity, IOrderedQueryableTEntity?而不是传一个字符串。这样做的原因是排序逻辑经常要写多级排序比如ThenByDescending字符串排序界面上不好表达。缺点是调用方会和IQueryable沾点边但这是我衡量后接受的妥协。再一个是includeDeleted。因为这套系统里有软删除标记通过一个布尔参数来控制是否查询已删除数据比每个查询都写Where(x !x.IsDeleted)干净得多。最后一个核心点是没有把IQueryableTEntity作为接口的公开返回类型。这样做的原因是防止业务层拿着IQueryable各处拼接查询条件导致仓储层变成摆设。但GetListAsync内部接受表达式树和排序表达式又保留了一定的动态查询能力算是在仓储纯粹性和EF Core 实际能力之间找一个平衡点。2.2 实现类里最容易翻车的地方异步、排序、Include、软删除泛型仓储的实现类我按照 EF Core 的实际行为分块处理。public class EfRepositoryTEntity : IRepositoryTEntity where TEntity : class { protected readonly DbContext DbContext; protected readonly DbSetTEntity DbSet; public EfRepository(DbContext dbContext) { DbContext dbContext; DbSet dbContext.SetTEntity(); } public async TaskTEntity? GetByIdAsyncTKey(TKey id, CancellationToken cancellationToken default) { return await DbSet.FindAsync(new object[] { id! }, cancellationToken); } public async TaskIReadOnlyListTEntity GetListAsync( ExpressionFuncTEntity, bool? predicate null, FuncIQueryableTEntity, IOrderedQueryableTEntity? orderBy null, bool includeDeleted false, CancellationToken cancellationToken default) { IQueryableTEntity query DbSet; if (!includeDeleted typeof(TEntity).GetInterface(nameof(ISoftDelete)) ! null) { query query.Where(e !EF.Propertybool(e, IsDeleted)); } if (predicate ! null) { query query.Where(predicate); } if (orderBy ! null) { query orderBy(query); } return await query.AsNoTracking().ToListAsync(cancellationToken); } }这块有个非常容易踩的坑FindAsync默认是跟踪实体状态的如果实体已经被跟踪它直接返回内存里的实例不会去数据库重新查。这在某些场景下是好事避免重复查询但在另一些场景下会拿到过期数据。比如同一个DbContext上下文里之前有一个Update改了内存里实体的属性随后你调用GetByIdAsync拿同一个实体拿到的会是改过的值而不是数据库里的最新值。我的处理方式是查询列表用AsNoTracking()单主键查询保留默认跟踪。为什么因为单主键查询通常紧接着就是要做更新操作跟踪状态可以帮助 EF Core 生成正确的UPDATE语句。如果全用AsNoTracking()更新前还得手动Attach更容易出问题。Include导航属性这个需求我把它留给了专用仓储。原因是泛型仓储如果支持任意深度的Include接口设计会变得很复杂而且字符串形式的Include在编译期没有检查维护成本高。2.3 专用仓储接口与泛型仓储如何优雅共存比如订单实体我定义了一个IOrderRepositorypublic interface IOrderRepository : IRepositoryOrder { TaskIReadOnlyListOrder GetOrdersWithItemsAndPaymentAsync( DateTime startDate, DateTime endDate, OrderStatus? status null, CancellationToken cancellationToken default); TaskDictionarystring, decimal GetSalesSummaryByProductAsync( DateTime startDate, DateTime endDate, CancellationToken cancellationToken default); }实现类继承EfRepositoryOrder再补上专用方法public class OrderRepository : EfRepositoryOrder, IOrderRepository { public OrderRepository(OrderDbContext dbContext) : base(dbContext) { } public async TaskIReadOnlyListOrder GetOrdersWithItemsAndPaymentAsync( DateTime startDate, DateTime endDate, OrderStatus? status null, CancellationToken cancellationToken default) { IQueryableOrder query DbSet .Include(o o.Items) .Include(o o.Payment); query query.Where(o o.CreatedAt startDate o.CreatedAt endDate); if (status ! null) { query query.Where(o o.Status status); } return await query.AsNoTracking().ToListAsync(cancellationToken); } }这种方案的另一个好处是泛型方法照顾 80% 的通用需求专用方法照顾复杂查询和导航属性加载。团队约定谁需要导航属性谁就去扩展专用仓储接口。这样代码不会出现几十种命名各异的查询方法每个实体对应一个专用仓储职责边界清晰。2.4 工作单元Unit of Work与会话Session的落地方式仓储模式离了工作单元会有点松散。因为我的泛型仓储没有单独暴露SaveChanges的入口如果每个仓储方法自己调用SaveChanges就无法保证多个仓储之间的写操作在同一事务里。我引入了一个轻量级的IUnitOfWorkpublic interface IUnitOfWork : IDisposable { Taskint SaveChangesAsync(CancellationToken cancellationToken default); }实现类直接基于DbContextpublic class EfUnitOfWork : IUnitOfWork { private readonly OrderDbContext _dbContext; public EfUnitOfWork(OrderDbContext dbContext) { _dbContext dbContext; } public Taskint SaveChangesAsync(CancellationToken cancellationToken default) { return _dbContext.SaveChangesAsync(cancellationToken); } }然后在依赖注入容器里让DbContext、仓储、工作单元都注册成Scoped生命周期services.AddDbContextOrderDbContext(options options.UseSqlServer(connectionString)); services.AddScoped(typeof(IRepository), typeof(EfRepository)); services.AddScopedIUnitOfWork, EfUnitOfWork();这样同一个请求范围内仓储和工作单元拿到的是同一个DbContext实例SaveChanges时所有变更一起提交。不用刻意开启数据库事务因为 EF Core 的SaveChanges本身就在一个隐式事务里完成。如果有跨请求的事务需求再显式用IDbContextTransaction。我在实际项目里服务层通常只操作一个或多个仓储最后统一调用一次await _unitOfWork.SaveChangesAsync()。这个方法的意义在提交点唯一避免每个仓储方法内部各提交各的导致一半成功一半失败。3. 实测记录读写性能、追踪状态与缓存补救框架搭好之后最需要担心的就是性能。仓储模式把数据访问收口了但如果实现不当等于把性能瓶颈也收口了。下面几项是本框架上线后我自己跑的和线上环境反馈的真实情况。3.1 默认追踪行为导致的越查越慢现象第一次压测时我发现一个现象同一个请求里既有订单列表查询又要统计订单数量耗时从 200ms 涨到了 700ms。先怀疑是数据库索引问题查了执行计划SQL 本身没有问题。后来追查发现罪魁祸首是 EF Core 的默认追踪。EF Core 在DbContext实例生命周期内会跟踪所有从数据库查询出来的实体。当同一个查询条件反复执行时如果实体已经存在于变更跟踪器中EF Core 会返回跟踪器里的实例并对每个已跟踪的实体执行一遍额外检查。数据量一旦上千这个额外检查累积起来的耗时比 SQL 执行本身还长。我当时的修正策略列表和统计类查询统一使用AsNoTracking()。更新和删除场景保留追踪。对于需要多次读取同一批数据的场景尽量用一次Include查询所有关联数据而不是分多次查询。这条经验在 EF Core 5 里适用到了 EF Core 6/8 也适用。只要记住一句话只读场景默认不要追踪写场景再开追踪大多数性能问题能消掉一半。3.2 读写分离在仓储内部如何落地我们项目没有做数据库物理层面的主从分离但代码层面做了读仓储和写仓储的隐式分离。方案很简单只读的方法GetListAsync、AnyAsync、CountAsync统一加上AsNoTracking()写操作不做特殊处理。后来在 EF Core 5 上也测试了AsNoTrackingWithIdentityResolution这个选项会保留实体唯一性解析避免同一个实体的多次查询结果被当作多个不同实例同时又不进入变更跟踪器。但如果不需要复杂的导航属性合并普通AsNoTracking更轻量。我在GetListAsync里默认用AsNoTracking()但留下了参数tracked: false让特殊场景选择开启追踪。从实测看90% 的查询保持只读模式就够了没必要为了让少数场景灵活而让所有查询都背追踪的开销。3.3 缓存与仓储的位置关系仓储模式加缓存是个很容易加错位置的点。如果缓存加在仓储方法内部那么一旦某个更新方法没清缓存就会读到旧数据。我在生产里踩过一次订单状态已改为已支付列表查询因为缓存没有及时失效前端仍旧显示待支付。后面定下的规范是仓储层做缓存只针对高频、低实时要求、数据量稳定的字典型数据核心业务数据的缓存放在仓储实现之外的服务层做。也就是说仓储还是老老实实访问数据库由服务层在做业务变更的同时主动清理或重建缓存。这样设计的好处是职责单一仓储不关心缓存失效策略服务层知道什么时候数据发生了变化。我的优先级排序是先保证数据正确再谈缓存带来的性能提升。3.4 实测一组性能对比数据我在本地环境做了一组简单的模拟测试一张订单表 5 万条数据分页查询每页 20 条同一查询条件下不同方法耗时如下方式耗时平均值内存分配DbSet 直接查询默认追踪280ms消耗较高实体全被追踪仓储查询AsNoTracking190ms消耗降低无追踪仓储查询 本地缓存120ms首次 190ms缓存命中后分配极低仓储查询 仅 Select 投影字段90ms分配最小SQL 只取所需列从这个对比里能看出仓储封装本身不会带来明显性能损耗损耗更多还是来自于 EF Core 的使用方式。只要你把追踪状态、投影粒度、缓存位置处理对仓储模式不影响性能。4. 使用仓储模式必踩的坑与对应排查细节这一节我把实际开发里遇到的、以及身边同事遇到的几类典型问题汇总一下每一类我都写了完整的排查链路和解决方式。4.1 导航属性懒加载导致的 N1 查询完整排查链路现象是前端请求一个订单列表返回 20 条订单。发现耗时 1.5 秒而且数据库 CPU 飙升。打开日志查看 SQL发现执行了 1 次查询订单表随后对每个订单又各执行了一次订单明细表的查询。这就是典型的 N1。排查过程分三步检查 EF Core 的查询日志确认是否每个订单都有一条SELECT ... FROM OrderItems WHERE OrderId ...。确认是否启用了懒加载代理。如果配置了UseLazyLoadingProxies访问未加载的导航属性就会触发额外查询。找到这些访问发生的位置通常是在视图层或服务层循环里比如foreach (var order in orders) { list.Add(order.Items.Sum(i i.Amount)); }。解决方式也很直接在仓储的专用方法里统一Include好关联数据让查询变成一次 JOIN。代码可以参考第 2.3 节里的GetOrdersWithItemsAndPaymentAsync。另一个建议是在项目里禁用懒加载代理。它会掩盖查询性能问题也让仓储边界变得模糊。宁可显式 Include也不要让导航属性在层与层之间偷偷触发数据库请求。4.2 事务边界到底该放在仓储还是服务层有一个普遍纠结的问题事务要不要放到仓储层我的建议是常规业务的事务边界放在服务层用工作单元的SaveChangesAsync来保证跨服务或者跨数据库级别事务才用到仓储层去显式开启IDbContextTransaction。我踩过的一个典型坑是仓储内部的某个写操作方法里调用了SaveChanges服务层以为没提交又调了一次SaveChanges。结果同一批修改被提交两次生成了两条重复的审计日志。排查过程略微曲折先是对比了两次审计日志的时间戳发现间隔不到 1ms然后查代码才看到两个SaveChanges叠加。后来我定了一条硬规矩SaveChanges只允许出现在工作单元的实现里业务代码统一调用工作单元仓储方法一律不提交。这样事务边界就不会散落各处。4.3 多对多关联更新时的幽灵实体问题系统有一个需求更新一个商品的标签列表前端传回一组新的标签 id。最开始我写的更新逻辑是先RemoveRange所有旧关联再AddRange新关联。结果每次保存后旧数据总能变成幽灵记录数据库里存在但查询里看不到。逐步排查后发现问题出在 EF Core 的关联跟踪机制。直接Remove关联实体时如果实体不在跟踪器里EF Core 不知道要生成DELETESQL而重新Add关联时如果主键已存在又可能生成INSERT冲突。正确的做法是让关联实体走DbContext追踪后再操作。为此我专门在专用仓储里写了先查询旧关联列表带跟踪再按集合差异做添加和删除的逻辑。如果你也在做多对多更新不要只盯着接口传参先确认 EF Core 是否认识这些实体。4.4 测试替身Mock对接口设计的反向约束写单元测试是仓储模式带来的最大红利但它也反过来约束了接口设计。一开始我的泛型仓储接口里有TaskIReadOnlyListTEntity GetListAsync(...)参数是表达式树和排序委托。测试时 mock 起来非常痛苦每个测试都得构造好表达式再验证结果。后来我发现如果测试代码写得异常艰难通常意味着接口可能过度复杂或者职责不纯。于是我把高频的业务查询收敛到专用仓储让 mock 对象只暴露语义明确的方法比如GetOrdersWithItemsAndPaymentAsync。这样测试代码读起来像在描述业务规则而不是在拼 EF Core 表达式。实践中我的经验是接口方法数量和参数复杂度很大程度上决定了 mock 的维护成本。让仓储接口的方法尽量面向业务场景而不是面向查询技术细节。5. 框架演进与我的最终反思框架在 .NET5 上稳定运行了半年后我又在几个新项目里调整过它的设计也重新思考了仓储模式的适用边界。5.1 从 .NET5 升级到 .NET 6/8 需要注意什么如果你按这套框架跑在 .NET5 上升级时基本不用改接口层代码EF Core 的 API 变化也不大。但有几个提醒EF Core 6 开始内置了更好的AsNoTrackingWithIdentityResolution可以考虑逐步替换部分复杂只读查询。EF Core 7/8 引入了ExecuteUpdate和ExecuteDelete可以高效地做批量更新和删除。这类操作不需要经过仓储的Update方法可以在专用仓储里暴露专门的批量方法。依赖注入注册方式没有改变AddScoped依然推荐。如果项目切到 .NET 8可以看看Primary Constructors是否让仓储构造函数更简洁但这属于风格问题不影响框架设计。5.2 什么情况下可以放弃仓储模式写这篇手记时我也在反思不是所有项目都适合仓储模式。如果你满足以下情况放弃它可能会更轻松项目很小业务逻辑简单一个DbContext足够。团队成员对 EF Core 非常熟悉能自觉限制查询边界。不需要单元测试或者测试成本远小于封装的复杂度。你的系统大量使用存储过程、视图、原始 SQLEF Core 的实体映射只是辅助。这些情况下ORM 本身已经提供了不错的数据访问抽象强行套仓储反而多一层维护负担。但一旦项目到了多个人协作、业务逻辑增长快、需要把数据访问隔离起来的阶段仓储模式的价值会非常明显。5.3 我个人的一点经验体会最后把我在这个项目里提炼出的最关键的经验分享给你。第一仓储不是银弹它是一层契约业务代码和数据库之间的契约。契约定义得越清晰协作越顺畅。如果你定义了一堆方法但业务层还是能通过IQueryable到处自由发挥那这层契约就废了。第二SaveChanges的调用点必须收敛。谁提交事务、什么时候提交应该让调用者一眼看清楚而不是埋在各种仓储方法里。第三不要为了纯粹而拒绝 EF Core 的能力。仓储可以封装查询语义但不需要把IQueryable、表达式树这些技术细节都藏起来。现实项目里完全规避 EF Core 特性反而会写出更复杂的代码。如果你准备在下一个项目里用这套思路我建议第一步不用搭一个大而全的框架先从一个高频业务模块开始设计一个专用仓储接口配合泛型仓储和工作单元跑通一个查询加一个更新的完整链路。体验到业务层不再关心数据库的感觉之后再决定要不要全面铺开。这样试错的成本很低得到的经验却是实打实的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑