☰
EF Core 仓储模式从落地到反思:接口设计、工作单元与性能优化
2026/10/16 4:37:39 网站建设 项目流程

我先说个背景,方便大家判断这篇手记适不适合你。去年中旬我接手某电商后台系统的重构,数据库是SQL Server,EF 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"的问题。如果你在仓储实现里还是写 N+1 查询,还是让默认追踪状态开着导致重复查询,那仓储模式只是把性能问题挪了个位置而已。这一点我在第 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 IRepository<TEntity> where TEntity : class { Task<TEntity?> GetByIdAsync<TKey>(TKey id, CancellationToken cancellationToken = default); Task<IReadOnlyList<TEntity>> GetListAsync( Expression<Func<TEntity, bool>>? predicate = null, Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null, bool includeDeleted = false, CancellationToken cancellationToken = default); Task<TEntity?> FirstOrDefaultAsync( Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default); Task<bool> AnyAsync( Expression<Func<TEntity, bool>>? predicate, CancellationToken cancellationToken = default); Task<int> CountAsync( Expression<Func<TEntity, bool>>? predicate, CancellationToken cancellationToken = default); void Add(TEntity entity); void Update(TEntity entity); void Remove(TEntity entity); }

有几个刻意为之的地方,值得解释一下为什么这样设计。

首先是GetByIdAsync用了泛型主键TKey。实体主键类型各不相同,有int、有Guid、有string,如果接口里写死int,那每个实体都要新增方法。泛型主键可以规避这个问题。

其次是GetListAsync的排序参数,我用了Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>?,而不是传一个字符串。这样做的原因是排序逻辑经常要写多级排序,比如ThenByDescending,字符串排序界面上不好表达。缺点是调用方会和IQueryable沾点边,但这是我衡量后接受的妥协。

再一个是includeDeleted。因为这套系统里有软删除标记,通过一个布尔参数来控制是否查询已删除数据,比每个查询都写Where(x => !x.IsDeleted)干净得多。

最后一个核心点是:没有把IQueryable<TEntity>作为接口的公开返回类型。这样做的原因是防止业务层拿着IQueryable各处拼接查询条件,导致仓储层变成摆设。但GetListAsync内部接受表达式树和排序表达式,又保留了一定的动态查询能力,算是在"仓储纯粹性"和"EF Core 实际能力"之间找一个平衡点。

2.2 实现类里最容易翻车的地方:异步、排序、Include、软删除

泛型仓储的实现类,我按照 EF Core 的实际行为分块处理。

public class EfRepository<TEntity> : IRepository<TEntity> where TEntity : class { protected readonly DbContext DbContext; protected readonly DbSet<TEntity> DbSet; public EfRepository(DbContext dbContext) { DbContext = dbContext; DbSet = dbContext.Set<TEntity>(); } public async Task<TEntity?> GetByIdAsync<TKey>(TKey id, CancellationToken cancellationToken = default) { return await DbSet.FindAsync(new object[] { id! }, cancellationToken); } public async Task<IReadOnlyList<TEntity>> GetListAsync( Expression<Func<TEntity, bool>>? predicate = null, Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>>? orderBy = null, bool includeDeleted = false, CancellationToken cancellationToken = default) { IQueryable<TEntity> query = DbSet; if (!includeDeleted && typeof(TEntity).GetInterface(nameof(ISoftDelete)) != null) { query = query.Where(e => !EF.Property<bool>(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 专用仓储接口与泛型仓储如何优雅共存

比如订单实体,我定义了一个IOrderRepository:

public interface IOrderRepository : IRepository<Order> { Task<IReadOnlyList<Order>> GetOrdersWithItemsAndPaymentAsync( DateTime startDate, DateTime endDate, OrderStatus? status = null, CancellationToken cancellationToken = default); Task<Dictionary<string, decimal>> GetSalesSummaryByProductAsync( DateTime startDate, DateTime endDate, CancellationToken cancellationToken = default); }

实现类继承EfRepository<Order>,再补上专用方法:

public class OrderRepository : EfRepository<Order>, IOrderRepository { public OrderRepository(OrderDbContext dbContext) : base(dbContext) { } public async Task<IReadOnlyList<Order>> GetOrdersWithItemsAndPaymentAsync( DateTime startDate, DateTime endDate, OrderStatus? status = null, CancellationToken cancellationToken = default) { IQueryable<Order> 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,就无法保证多个仓储之间的写操作在同一事务里。

我引入了一个轻量级的IUnitOfWork:

public interface IUnitOfWork : IDisposable { Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); }

实现类直接基于DbContext:

public class EfUnitOfWork : IUnitOfWork { private readonly OrderDbContext _dbContext; public EfUnitOfWork(OrderDbContext dbContext) { _dbContext = dbContext; } public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) { return _dbContext.SaveChangesAsync(cancellationToken); } }

然后在依赖注入容器里,让DbContext、仓储、工作单元都注册成Scoped生命周期:

services.AddDbContext<OrderDbContext>(options => options.UseSqlServer(connectionString)); services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)); services.AddScoped<IUnitOfWork, 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消耗较高,实体全被追踪
仓储查询(AsNoTracking)190ms消耗降低,无追踪
仓储查询 + 本地缓存120ms(首次 190ms)缓存命中后分配极低
仓储查询 + 仅 Select 投影字段90ms分配最小,SQL 只取所需列

从这个对比里能看出,仓储封装本身不会带来明显性能损耗,损耗更多还是来自于 EF Core 的使用方式。只要你把追踪状态、投影粒度、缓存位置处理对,仓储模式不影响性能。

4. 使用仓储模式必踩的坑与对应排查细节

这一节我把实际开发里遇到的、以及身边同事遇到的几类典型问题汇总一下,每一类我都写了完整的排查链路和解决方式。

4.1 导航属性懒加载导致的 N+1 查询,完整排查链路

现象是:前端请求一个订单列表,返回 20 条订单。发现耗时 1.5 秒,而且数据库 CPU 飙升。打开日志查看 SQL,发现执行了 1 次查询订单表,随后对每个订单又各执行了一次订单明细表的查询。这就是典型的 N+1。

排查过程分三步:

  1. 检查 EF Core 的查询日志,确认是否每个订单都有一条SELECT ... FROM OrderItems WHERE OrderId = ...。
  2. 确认是否启用了懒加载代理。如果配置了UseLazyLoadingProxies,访问未加载的导航属性就会触发额外查询。
  3. 找到这些访问发生的位置,通常是在视图层或服务层循环里,比如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)对接口设计的反向约束

写单元测试是仓储模式带来的最大红利,但它也反过来约束了接口设计。一开始我的泛型仓储接口里有Task<IReadOnlyList<TEntity>> 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 非常熟悉,能自觉限制查询边界。
  • 不需要单元测试,或者测试成本远小于封装的复杂度。
  • 你的系统大量使用存储过程、视图、原始 SQL,EF Core 的实体映射只是辅助。

这些情况下,ORM 本身已经提供了不错的数据访问抽象,强行套仓储反而多一层维护负担。但一旦项目到了"多个人协作、业务逻辑增长快、需要把数据访问隔离起来"的阶段,仓储模式的价值会非常明显。

5.3 我个人的一点经验体会

最后把我在这个项目里提炼出的最关键的经验分享给你。

第一,仓储不是银弹,它是一层"契约":业务代码和数据库之间的契约。契约定义得越清晰,协作越顺畅。如果你定义了一堆方法,但业务层还是能通过IQueryable到处自由发挥,那这层契约就废了。

第二,SaveChanges的调用点必须收敛。谁提交事务、什么时候提交,应该让调用者一眼看清楚,而不是埋在各种仓储方法里。

第三,不要为了"纯粹"而拒绝 EF Core 的能力。仓储可以封装查询语义,但不需要把IQueryable、表达式树这些技术细节都藏起来。现实项目里,完全规避 EF Core 特性反而会写出更复杂的代码。

如果你准备在下一个项目里用这套思路,我建议第一步不用搭一个大而全的框架,先从一个高频业务模块开始:设计一个专用仓储接口,配合泛型仓储和工作单元,跑通一个查询加一个更新的完整链路。体验到"业务层不再关心数据库"的感觉之后,再决定要不要全面铺开。这样试错的成本很低,得到的经验却是实打实的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询