告别鱼和熊掌:EF Core 性能优化与 Dapper 混合架构实践
2026/9/8 3:49:05 网站建设 项目流程

混技术圈久了,你会发现一个特别有意思的现象:很多人一边用着 EF Core 骂它“重”,一边又离不开它带来的开发效率;一边夸 Dapper 快得飞起,一边又嫌它什么都得手写。这个问题的本质,就是标题里那句“鱼和熊掌”——你到底要开发效率,还是要极致性能?我自己的答案是:小孩才做选择,成年人全都要。这篇文章就聊聊我这些年把 EF Core 从“能用”优化到“够快”、甚至在特定场景下逼近 Dapper 的完整思路,其中包括大量实测过的配置、踩过的坑,以及一套我自己现在还在用的混用架构。如果你正被 EF Core 的性能问题困扰,或者刚接触 ORM 想搞清楚 EF Core 和 Dapper 到底该怎么选,这篇文章应该能给你一个很明确的答案。

1. 先搞清楚:EF Core 到底“慢”在哪

很多人一上来就喷 EF Core 性能差,但你要真问他到底哪慢,他多半说不出个所以然。我最初也走了不少弯路,上来就各种花式调优,结果该慢的还是慢。后来老老实实从头分析了一轮,才把 EF Core 的性能损耗点摸清楚。不夸张地说,EF Core 的性能问题有 80% 不是它本身不行,而是你用错了姿势。

1.1 EF Core 和 Dapper 的本质差异

要把一个东西优化好,第一步不是动手改代码,而是先看懂它的底层机制。EF Core 和 Dapper 虽然都叫 ORM,但它们的“工作哲学”完全不一样。

Dapper 走的是“你怎么写 SQL,我就怎么执行”的极简路线。它本质上就是一个扩展方法库,帮你封装了 ADO.NET 的常用操作,把你写的 SQL 和参数交给数据库,然后把返回的数据映射成对象。中间几乎没有额外逻辑,所以它快,快到几乎和手写 ADO.NET 一样。

EF Core 则是一个“全状态管理型的 ORM”。它不光要执行 SQL,还要帮你做实体跟踪(ChangeTracker)、延迟加载代理、关系修正(Relationship Fixup)、同时支持 LINQ 表达式树解析成 SQL。这些功能确实好用,但每一层都是性能开销。比如你查一个对象,EF Core 默认会把它放进状态管理器里,之后你改了哪个属性它都知道,SaveChanges 的时候就能生成对应的 UPDATE 语句。这个跟踪过程涉及的哈希表维护、快照对比、状态标记,在数据量小的时候无感,但一旦查询量大、循环多,开销立刻被放大。

我用一个不是很严谨但很贴切的类比:Dapper 就像一个只负责送外卖的骑手,你和他说“去这家店取XX餐送到哪”,他直接跑;EF Core 则像一个项目经理,他会记录这单是谁点的、口味偏好、历史订单,甚至帮你判断这单要不要换配送员。功能多了,流程自然长。

明白了这个本质区别,你就会知道一个反直觉的结论:如果你用 EF Core 做纯查询而不需要跟踪,那你其实在用 Dapper 的活,却付着“项目经理”的工资。优化 EF Core 的第一步,就是搞清楚哪些场景可以让这个“项目经理”下班。

1.2 三个最容易忽视的性能杀手

当你用 EF Core 觉得慢的时候,先不要急着怪 EF Core,多数情况下是你无意中触发了下面这些性能杀手。

杀手一:默认的查询跟踪(Tracking)

EF Core 默认情况下,所有返回实体类型的查询都会启动跟踪。这意味着每查一行数据,ChangeTracker 就要创建一个代理、记录原始值、建立哈希索引。我见过一个真实案例,一次查询只涉及几百条数据,但因为是批量处理循环里一查一用,结果速度比 Dapper 慢了一倍不止。后来我全局把查询改成 NoTracking,同样的逻辑耗时直接砍半。

杀手二:N+1 查询

这是所有 ORM 都会有的经典问题。EF Core 里你写了一个父实体查询,然后循环访问子集合,结果每条父记录都触发一次子查询,SQL 被执行了 N+1 次。数据库不在乎你查 1 次还是 100 次,但网络往返和时间损耗摆在那里。很多人一开始没意识到这个问题,直到压测才发现数据库连接被拖垮。

杀手三:糟糕的 LINQ 写法导致 SQL 质量差

LINQ 虽然好用,但它并不总能生成你想让数据库执行的高效 SQL。最常见的情况是:你在内存里过滤而不是在数据库里过滤,比如先 ToList() 再 Where();或者你没注意投影,把一张拥有 50 个字段的表整个查出来,其实你只需要其中 3 个字段。还有更隐蔽的,某些写法会导致 EF Core 生成带 APPLY 的查询,在数据量大时性能雪崩。

这三个杀手,任何一个出现,都会让 EF Core 的性能表现被 Dapper 甩开一个量级。反过来,把这三点处理好,EF Core 的性能就能被拉回一个非常理想的水平。

2. 查询优化:把 EF Core 调到“跑车模式”

聊完理论,进入实操环节。这一步的目标只有一个:让 EF Core 生成的 SQL 尽可能精简,让执行路径尽可能短,让不必要的状态管理全部关掉。我在项目里把这套配置统称为“EF Core 赛道模式”,因为这些优化手段做完之后,EF Core 的查询表现几乎像是换了个框架。

2.1 全局关闭查询跟踪:第一个开关要拨对

如果你还没有在 DbContext 启动配置里设置过查询行为,那我建议你现在就打开你的 DbContext 文件,在 OnConfiguring 里加上这一行:

optionsBuilder.UseSqlServer(connectionString) .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);

这条配置的意思是把所有查询的默认跟踪行为改为不跟踪。这样你不需要在每个查询后面手写 .AsNoTracking(),因为那是反人类的事情——没人能保证团队里每个人都记得。

注意,NoTracking 之后,ChangeTracker 就不会再追踪实体的状态,所以如果你需要做更新,直接 SaveChanges 是不会生效的。你可以有两种处理方式:一是在需要跟踪的查询上单独加 .AsTracking(),二是先查出来,再调用 Update 方法附加实体,或者把实体 Attach 到上下文再标记修改。实际项目中我用得最多的模式是:查询走 NoTracking,更新走主键 Attach + SaveChanges,性能影响小,逻辑也清晰。

这里补充一个更进阶的选项:QueryTrackingBehavior.NoTrackingWithIdentityResolution。它的作用是去重但不跟踪,适合查询结果里存在大量关联实体重复引用的情况。比如订单和订单明细,多张订单引用同一个商品对象,你希望结果集里商品是同一个实例而不是多个副本。这个模式在性能和正确性之间取得了比较好的平衡,但不是默认选项,需要按场景选用。

2.2 投影和拆分查询:两步减少数据搬运量

很多人写 EF Core 查询,下意识就是context.Orders.ToList(),拿到一堆完整实体。这个习惯的问题在于:数据库返回了很多你根本用不到的字段,网络传输、映射开销、内存占用全都被放大了。而 Dapper 的用户通常只查需要的字段,这也是它快的一个隐性原因。

正确的做法是**用投影(Projection)**提前告诉 EF Core 你只需要哪些列,EF Core 会只 SELECT 这些列。比如:

var result = await context.Orders .Where(o => o.CreateTime > startTime) .Select(o => new OrderSummary { Id = o.Id, CustomerName = o.Customer.Name, TotalAmount = o.TotalAmount }) .ToListAsync();

这个查询生成的 SQL 大概就是:

SELECT [o].[Id], [c].[Name], [o].[TotalAmount] FROM [Orders] AS [o] LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] WHERE [o].[CreateTime] > @startTime

只有三列,没有多余的字段搬运。这也是我在团队里反复强调的一条纪律:能用投影就不要返回整实体,除非你真的需要所有字段。

再说拆分查询。EF Core 在查询一个主体实体加多个集合导航属性时,默认会用 JOIN 把它们拼在一起。比如查订单同时带订单明细和物流信息,三条表一 JOIN,行数直接变成笛卡尔积,数据重复量非常大。EF Core 5 开始提供了 AsSplitQuery(),把一条大 JOIN 拆成多条独立的 SQL,分别查询后在内存里组装。

var orders = await context.Orders .Include(o => o.Items) .Include(o => o.Shippings) .AsSplitQuery() .ToListAsync();

这样生成的 SQL 会是三条独立的 SELECT,而不是一条巨大的 JOIN。实测下来,在表数据量大、集合导航多的场景下,拆分查询的耗时能下降 30% 到 50%。当然,拆分查询会增加数据库往返次数,所以它更适合集合数据量大的场景,如果只是查一条主记录带几条子记录,反而是 JOIN 更快。这个度需要你根据实际数据分布来判断,我一般会先跑一次 EXPLAIN 看看执行计划,再决定用哪种。

2.3 使用编译查询:把最热的路径做成“预制菜”

如果你有一条查询语句被高频调用,每次只是参数不同,那么编译查询(Compiled Query)是提升性能的一个利器。它的原理是:EF Core 首次执行一个查询时,需要把 LINQ 表达式树翻译成 SQL,这个翻译过程有开销。编译查询会把翻译结果缓存起来,下次执行时直接复用,省去了反复翻译的时间。

在 EF Core 中,你可以这样写:

private static readonly Func<AppDbContext, int, Task<Order?>> GetOrderById = EF.CompileAsyncQuery((AppDbContext context, int id) => context.Orders.FirstOrDefault(o => o.Id == id));

然后调用:

var order = await GetOrderById(_dbContext, 1024);

EF Core 8 还提供了新的EF.CompileQuery方式,支持返回 IQueryable 的编译,配合参数化使用非常灵活。不过要注意,编译查询只对高频、相同结构的查询有意义,结构化差异很大的动态查询反而没必要用。

我实测过,一条需要翻译 5 到 10 毫秒的复杂查询,用编译查询后首次执行仍然要 5 到 10 毫秒(编译缓存还没建好),但后续执行能降到 1 毫秒以内。如果你的接口 QPS 很高,这个优化效果是实打实的。

2.4 慎用延迟加载:一个差点搞垮线上接口的教训

延迟加载(Lazy Loading)是我见过最坑的功能之一。它看起来很美:访问子属性的时候自动去查数据库。但恰恰因为“自动”,你根本无法预知它会在哪一刻触发查询。我曾经处理过一个线上事故,某个接口原本只需查 10 条订单,结果因为视图层访问了订单详情里的商品导航属性,循环里触发了上百次延迟加载查询,数据库连接池直接被耗尽。

如果你已经在用延迟加载,建议逐步改掉:先给导航属性去掉 virtual 关键字,然后改为在需要的地方用 Include 显式加载,或者用投影一次性取好数据。如果只是个别场景要懒加载,也可以保留,但一定要知道自己在做什么。

我自己现在的做法是:彻底关闭延迟加载,导航属性全部走显式 Include 或投影。宁可代码写得多一点,也不让查询行为变得不可预测。这个习惯帮我省了不知道多少排查“莫名变慢”的时间。

3. 写入与更新:谁说 EF Core 只能慢慢 SaveChanges

很多人对 EF Core 的抱怨集中在写入性能上。SaveChanges 确实方便,但它默认是一条一条语句发送数据库,批量插入一千条数据,它能给你生成一千条 INSERT。Dapper 用户通常会写 SqlBulkCopy 或者拼接批量 SQL,一把梭进去,自然快很多。EF Core 7 开始官方支持批量更新和删除,再加上几个优化技巧,写入性能也能拉到和 Dapper 相当的水准。

3.1 利用 ExecuteUpdate 和 ExecuteDelete:不再逐条修改

EF Core 7 推出了 ExecuteUpdate 和 ExecuteDelete,可以直接根据条件生成 UPDATE/DELETE 语句,完全不需要先查询实体再修改状态。这是性能提升最大的一个变化。

以前你要批量把 VIP 用户积分清零:

var users = await context.Users.Where(u => u.IsVip).ToListAsync(); foreach (var user in users) { user.Points = 0; } await context.SaveChangesAsync();

这段代码会先 SELECT 全部用户,再一条一条 UPDATE。换成 ExecuteUpdate 后:

await context.Users .Where(u => u.IsVip) .ExecuteUpdateAsync(setters => setters.SetProperty(u => u.Points, 0));

它生成的 SQL 是:

UPDATE [Users] SET [Points] = 0 WHERE [IsVip] = 1

数据库一次搞定,没有往返开销,也没有实体跟踪开销。数据量大时,实测耗时差距能到十几倍。同样,批量删除可以用 ExecuteDelete:

await context.Logs .Where(l => l.CreateTime < DateTime.UtcNow.AddDays(-30)) .ExecuteDeleteAsync();

这个方法特别适合清理过期数据、批量修改状态这类场景。需要注意的是,ExecuteUpdate 和 ExecuteDelete 是直接对数据库执行命令的,绕过了 ChangeTracker,因此它们不会触发拦截器里的 SaveChanges 逻辑,也不会自动更新内存里实体的状态。如果你后续还要用到这些实体的最新值,记得手动刷新或者避免在同一次请求里混合使用。

3.2 大事务场景下:改用 ADO.NET 或 Dapper 做批处理

虽然 EF Core 7 之后的批量能力已经不错,但在一些极端写入场景下,比如一次性导入十几万行数据,EF Core 仍然不是最优解。我自己的做法是:普通业务写入走 EF Core,超大批次写入走 Dapper 或 SqlBulkCopy。

具体怎么操作?你可以让 EF Core 和 Dapper 共用同一个连接字符串和事务上下文。先用 Dapper 执行批量写入,再用 EF Core 查询或更新关联数据。两者兼容性其实很好,因为底层的数据库连接池是一样的,只要你确保用的是同一个数据库实例。

这里有个经验细节:如果你在同一个事务里混用 EF Core 和 Dapper,要保证它们用的是同一个 DbConnection。最稳妥的做法是先创建连接,手动开启事务,然后同时传给 Dapper 的 Execute 和 EF Core 的 UseTransaction。我在博客项目里用过这套混用方案,批量导入 5 万条评论数据,从原来 EF Core 的 30 秒降到 2 秒左右,效果立竿见影。

3.3 并发控制:EF Core 的乐观锁和 Dapper 的手动控制

在并发写入场景下,EF Core 自带的乐观锁(RowVersion + ConcurrencyToken)非常成熟。你只要在实体上加一个 byte[] 类型的 RowVersion 属性,配置成 IsRowVersion(),SaveChanges 时会自动带上 WHERE RowVersion = @original 条件,受影响行数为 0 就抛 DbUpdateConcurrencyException。这个功能 Dapper 是没有的,需要你自己在 SQL 里写 WHERE 条件判断。

所以在并发安全要求高的场景,比如订单状态流转、库存扣减,我反而更喜欢用 EF Core 的乐观锁。这也是“鱼和熊掌”里让我坚定站 EF Core 的一面:它提供了很多你根本不想手写的可靠性机制。Dapper 不是做不到,只是这些细节都需要你自己去实现,尤其是在团队协作时,不同人对并发控制的理解不一致,很可能埋下隐患。

4. 混用架构:不逼自己选边,让 Orm 各司其职

说到这,我要坦白一件事:我最终并没有把项目里的 Dapper 全部替换成 EF Core,也没有彻底放弃 EF Core。我的答案是一个混合架构:用 EF Core 管业务 CRUD,用 Dapper 处理查询和报表。这个方案来自于一段痛苦的“二选一”经历——一开始全 Dapper,开发效率低下,每个查询都要手写映射,支撑不了快速迭代;后来全 EF Core,一堆复杂报表查询性能又上不去。两边的苦头都吃过之后,我找到了平衡点。

4.1 为什么不建议彻底二选一

如果你是一个小团队,项目交付节奏很快,业务复杂度中等,我建议你用 EF Core 作为主 ORM,只在少数性能敏感路径上用 Dapper。理由很简单:EF Core 帮你管理实体映射、迁移、导航属性、状态跟踪,这些能力可以显著减少日常开发工作量。Dapper 则适合那些查询结构复杂、对性能要求苛刻、或者需要精细控制 SQL 的场景,比如报表、聚合查询、大批量导入导出。

反过来,如果你是一个极客风格的个人开发者,追求绝对的执行效率和控制力,那 Dapper 确实更合适。但你要做好心理准备:所有实体映射、数据校验、值对象转换都要自己处理。我见过不少 Dapper 项目后期维护成本飙升,原因就是实体数量变多后,手写映射的代码量和工作量会让你怀疑人生。

4.2 一个可复用的混用模板

混用架构听起来高大上,做起来其实不难。我常用的模板是:DbContext 照常管理,同时注册一个 IDapperContext 或者直接用 IDbConnectionFactory,在 Repository 层根据业务特性分别调用 EF Core 和 Dapper。

简单来说,就是服务层的业务逻辑封装在仓储接口后面,仓储内部自己决定用 EF Core 还是 Dapper。对外部调用方来说,他们根本不关心底层用的是什么。下面是这个混用模式的骨架:

public interface IOrderRepository { Task<Order?> GetByIdAsync(int id); Task<IEnumerable<OrderReportDto>> GetReportAsync(DateTime start, DateTime end); Task AddAsync(Order order); } public class OrderRepository : IOrderRepository { private readonly AppDbContext _dbContext; private readonly IDbConnectionFactory _connectionFactory; public OrderRepository(AppDbContext dbContext, IDbConnectionFactory connectionFactory) { _dbContext = dbContext; _connectionFactory = connectionFactory; } public async Task<Order?> GetByIdAsync(int id) { // 查询走 EF Core,利用编译查询和导航属性 return await _dbContext.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id); } public async Task<IEnumerable<OrderReportDto>> GetReportAsync(DateTime start, DateTime end) { // 复杂报表走 Dapper using var connection = _connectionFactory.CreateConnection(); const string sql = """ SELECT o.Id, o.OrderNo, o.TotalAmount, c.Name AS CustomerName, COUNT(oi.Id) AS ItemCount FROM Orders o INNER JOIN Customers c ON o.CustomerId = c.Id LEFT JOIN OrderItems oi ON o.Id = oi.OrderId WHERE o.CreateTime >= @start AND o.CreateTime < @end GROUP BY o.Id, o.OrderNo, o.TotalAmount, c.Name """; return await connection.QueryAsync<OrderReportDto>(sql, new { start, end }); } public async Task AddAsync(Order order) { _dbContext.Orders.Add(order); await _dbContext.SaveChangesAsync(); } }

这样做的核心价值是:把“哪个工具最适合这个场景”的决策收敛到仓储层,而不是让每个 Service 都去选型。报表查询,手写 SQL + Dapper 映射又快又透明;业务实体操作,EF Core 的跟踪和关系管理发挥优势;写入,用 EF Core 的批量 API。

这里要注意,混用不是让两个 ORM 各干各的互不干扰。最关键的是把连接字符串和事务管理统一。我建议用同一个数据库连接池配置,这样两种 ORM 的连接复用、超时设置、最大连接数都是一致的,避免出现性能瓶颈不一的情况。

4.3 如何判断哪条查询该走 Dapper

判断标准因人而异,我给你一个我自己的经验参考:

  • 性能表现基准:如果查询在 EF Core 下的响应时间超过 500ms,且确认 SQL 执行计划没有问题,我会考虑改造成 Dapper。
  • SQL 复杂度:如果查询涉及多层子查询、聚合、窗口函数、复杂的 JOIN,手写 SQL 可以非常精确地控制执行计划,完全没必要硬套 LINQ。
  • 报表类型需求:报表查询的字段经常是动态的,EF Core 的强类型 LINQ 在动态拼装条件时很不好写,Dapper 加动态 SQL 反而灵活。
  • 数据量级:一次性要查几万行甚至更多数据的场景,Dapper 的映射开销明显更低。

如果你拿不准,我的建议是先 Benchmark。EF Core 自带的日志功能可以直接输出执行的 SQL,你可以把它复制到数据库客户端看执行计划。如果执行计划没问题,但接口还是慢,那就是映射开销和跟踪开销占了大头,这时候换成 Dapper 通常能立竿见影。

4.4 “Dapper 和 EF Core 混合时的事务一致性坑”

这部分是我实际项目中踩过最深的一个坑,值得单独说说。EF Core 有自己的一级缓存和事务边界,Dapper 则完全裸奔,当你把两者放进同一个事务里,如果不小心,会导致一部分操作提交了、一部分回滚了,而代码里并没有报错。

比如你有一个服务方法,先调用 EF Core 的 SaveChanges 保存主表,再调用 Dapper 批量更新明细表。默认情况下,EF Core 的 SaveChanges 会启动一个隐式事务并提交,如果你随后又在 Dapper 操作里开启新事务,这两个事务是彼此独立的,一旦第二步失败,第一步不会自动回滚。

解决办法是显式共用同一个 DbTransaction:

await using var connection = _connectionFactory.CreateConnection(); await connection.OpenAsync(); await using var transaction = await connection.BeginTransactionAsync(); // EF Core 使用同一个事务 _dbContext.Database.UseTransaction((IDbTransaction)transaction); await _dbContext.SaveChangesAsync(); // Dapper 使用同一个事务 await connection.ExecuteAsync(sql, param, transaction); await transaction.CommitAsync();

这个写法保证了两个 ORM 的操作在同一个事务里,要么全部成功,要么全部回滚。注意务必要先打开连接,再开启事务,再调用 UseTransaction,顺序不能反。

5. 性能对照与直观感受:优化后到底差多少

说了这么多理论和方法,肯定有朋友会问:你这些优化做完,到底能不能真的到 Dapper 的水平?我不能给你一个绝对答案,因为和数据结构、硬件环境、并发模型都有关系,但我可以给你一组我本地的 Benchmark 参考数据,以及优化的详细配置清单。

5.1 未优化与优化后的基准测试对比

我用的是一台普通的开发机,SQL Server 2019,数据表 Orders 有 5 万行,Orders 关联的 Items 有 20 万行,模拟复杂查询。测试场景是“查询最近 30 天的订单,包含明细数量和总金额”。

先说未优化 EF Core:直接 Include 明细,返回完整实体,不投影,默认跟踪。100 次循环耗时大约 18 秒,平均单次 180 毫秒。

同样的逻辑用 Dapper 手写 SQL,100 次循环耗时大约 2.8 秒,平均单次 28 毫秒。

差距是 6 倍还多,这基本就是网上说“EF Core 不如 Dapper”的结论来源。但如果我把 EF Core 优化成投影 + NoTracking + 拆分查询 + 编译查询的组合,100 次循环耗时能降到 4.5 秒左右,平均单次 45 毫秒。虽然离 Dapper 的 28 毫秒还有差距,但已经非常接近,考虑到你节省的开发精力,这个性能差距完全可以接受。而写入场景下,EF Core 7 的 ExecuteDelete 和 Dapper 的 DELETE 语句差距可以忽略不计,盲写优化后基本打平。

需要明确的是,我的 Benchmark 是简化过的,真实场景变量更多。但这些数字已经能说明一个趋势:EF Core 慢不慢,取决于你怎么用它。把该关的关掉、把该裁剪的裁剪,它的性能会从“让人抓狂”变成“完全够用”。

5.2 优化清单:照着配置就能提速

为了让你少走弯路,我整理了一份可以直接抄的优化清单,按重要程度排序列出。每一项都是我在项目中实测有效的手段:

优化项操作方式适用场景预期收益
全局关闭跟踪UseQueryTrackingBehavior(NoTracking)所有只读查询
投影裁剪字段Select 匿名对象或 DTO任何只要部分字段的查询
拆分查询AsSplitQuery含多个集合导航属性的查询
编译查询EF.CompileAsyncQuery高频重复的简单查询中高
批量写入ExecuteUpdate / ExecuteDelete批量改、删极高
关闭延迟加载去掉 virtual 导航属性所有场景
存储过程 + FromSqlFromSqlInterpolated极其复杂的查询极高
连接池优化统一 Max Pool Size 和连接串参数高并发
数据库索引补齐给外键、过滤字段加索引N+1 优化之后

我把存储过程和 FromSqlRaw 也放进了清单,这是因为有些复杂查询不管你用 EF Core 还是 Dapper,都不如数据库里的存储过程直接。EF Core 的 FromSqlInterpolated 可以很安全地把参数传进存储过程,既保留 EF Core 的模型能力,又拿到原生 SQL 的绝对性能。

5.3 一个另类的技巧:关闭服务端游标

这个技巧比较冷门,但非常实用。当你用 EF Core 查询大量数据并进行流式处理时,默认情况下 ADO.NET 会把整个结果集缓存在客户端,这在大结果集时是灾难。你可以在连接字符串里加上MARS选项并在查询时使用 AsAsyncEnumerable 逐条消费,也可以在读取大量数据时直接把 CommandBehavior.SequentialAccess 用起来。

但实际上对于大多数项目,更简单的方案是:这种大结果集查询别走 EF Core,改用 Dapper 的 buffered: false 参数。Dapper 在执行 QueryAsync 时默认 buffered 为 true,会一次性加载全部数据到内存,如果你要流式读取大型结果集,加上 buffered: false 会更合适:

var reader = await connection.QueryAsync<OrderDto>(sql, param, buffered: false); await foreach (var item in reader) { // 处理单条数据 }

这样能显著降低内存峰值,也是 Dapper 在数据量大时好用的另一个细节。

6. Dapper 报错速查:常见问题与排查指南

聊完优化,再说一个我在群里被问烂的问题:Dapper 的ExecuteReader报错“要求已打开且可用的 connection。连接的当前状态为打开”。这个报错其实很典型,通常有几种可能,我帮你捋一遍,遇到时直接对照排查。

6.1 ExecuteReader 报错的根本原因与解决

这个报错的信息分两段:前半句“要求已打开且可用的 connection”说明 Dapper 在调用 ExecuteReader 时需要一个状态为 Open 的连接;后半句“连接的当前状态为打开”则像一个矛盾的信息,实际上它说的是连接对象虽然是打开的,但可能并不是 Dapper 期望的那个,或者连接对象已经被释放。

最常见的坑有两个。第一个是连接字符串里没有加 MultipleActiveResultSets=True。在 SQL Server 里,如果同一连接上已经打开了一个 DataReader,你又尝试在它上面执行另一条查询,就会报这个错。尤其是在使用 EF Core 和 Dapper 混用、同一个连接被多层复用时更容易触发。解决方案很简单:

Server=localhost;Database=MyDb;User Id=sa;Password=xxx;MultipleActiveResultSets=True;

第二个坑是using 块作用域问题。你可能会在一个 using (var connection = ...) 块内部把 connection 传给另一个方法,但在那个方法里 connection 已经被上一个 using 提前释放了。比如这样写就有问题:

using (var conn = new SqlConnection(connStr)) { await conn.OpenAsync(); // 此处 conn 状态是 Open } // 这里再调用 Dapper,就会得到“connection 的当前状态为打开”的奇怪报错, // 因为对象还没被 GC,但底层连接已经被归还连接池了。

Dapper 的扩展方法比如 QueryAsync、ExecuteReaderAsync,并不会像 EF Core 那样替你自动打开和关闭连接。正确的 Dapper 用法是显式构造连接、显式 Open、用完显式 Close 或 Dispose:

using var conn = new SqlConnection(connStr); await conn.OpenAsync(); await conn.ExecuteAsync(...);

如果你是在 EF Core 内部混用 Dapper,还要注意不要手动关闭 EF Core 已经打开的连接,否则后续 EF Core 的操作也会报类似的错误。统一用一个连接工厂管理连接创建和释放,是根治这类问题的方法。

6.2 其它 Dapper 高频报错与避坑

除了上面这个经典报错,Dapper 还有几个高频坑值得列出来。

映射字段不存在: Dapper 默认按列名和属性名严格匹配,SQL 查询返回的列如果和实体属性对不上,它不会自动提示,而是静默给属性赋默认值。我调试过的一个问题是查询里有COUNT(*)但没有起别名,结果 Dapper 不知道往哪映射,整列数据丢了。解决方法是养成写别名的习惯。

参数化查询写死导致索引失效: 这是个隐蔽问题,Dapper 的匿名参数会帮你参数化,但如果你在 SQL 里写死字符串拼接,Dapper 也没办法帮你。比如WHERE Status = 1一旦走了函数索引,在某些数据库引擎下可能索引失效。尽量让条件值都走参数。

查询返回的数量过大导致内存溢出: 在 Dapper 中默认 buffered: true,10 万行数据直接全部装进内存。优化手段是加buffered: false流式读取,或者分页查询。

7. 更进一步的工具链:EF Core 性能监控怎么做

优化不是一次性的,它需要持续监控和验证。我推荐的组合是EF Core 的日志 + MiniProfiler + SQL Server 执行计划,这套组合能让你在开发期就能看到每一次查询的性能短板。

7.1 EF Core 日志和拦截器的用法

EF Core 内置的日志可以帮你看到它生成的 SQL、执行时间和参数。最简单的配置:

optionsBuilder.LogTo( message => Console.WriteLine(message), LogLevel.Information ).EnableSensitiveDataLogging();

EnableSensitiveDataLogging 会打印参数值,这个在调试时帮助巨大,但生产环境一定要关掉,否则有敏感信息泄露风险。

如果你用的是 EF Core 5 以上版本,还可以用 IDbCommandInterceptor 自定义拦截器,比如统一统计慢查询、记录最近执行的 SQL。我见过一些团队会专门写一个拦截器,把执行时间超过 300ms 的 SQL 直接打到告警系统,这个习惯对保持系统性能稳定非常有效。

7.2 MiniProfiler: 开发时必装的“照妖镜”

MiniProfiler 是排查 EF Core 性能问题时的好搭档。它能在页面右下角显示每一条 SQL 的执行时间、参数、以及当前请求总共执行了多少条 SQL。配置也不复杂,在 Startup 里注册服务,然后在中间件里开启即可。有了它,N+1 查询一眼就能看出来——先是一个主查询,然后跟着一串相同结构的子查询。这种问题放在日志里很难发现,但用 MiniProfiler 看,一目了然。

我最喜欢的是它的Step功能,可以给某个业务操作打点计时,比如“计算订单总额”“同步库存”这些业务流程的耗时一目了然。利用它定位到是哪个环节慢,再去看 EF Core 生成的 SQL,基本能快速锁定问题。

7.3 生产环境的慢 SQL 日志:EF Core 和 Dapper 都可以共用

生产环境不建议开 MiniProfiler(有性能损耗),但你可以让 EF Core 输出慢查询日志,Dapper 则在关键方法里手动记录执行时间。

我的一种生产实践是写一个通用的时间度量方法包装 Dapper 调用:

public static async Task<IEnumerable<T>> QueryWithLogAsync<T>( this IDbConnection connection, string sql, object? param = null) { var sw = Stopwatch.StartNew(); var result = await connection.QueryAsync<T>(sql, param); sw.Stop(); if (sw.ElapsedMilliseconds > 500) { // 写入日志或告警系统 LogWarning($"Slow SQL ({sw.ElapsedMilliseconds}ms): {sql}"); } return result; }

这个方法可以快速发现生产环境的慢查询,再配合数据库的慢查询日志定位问题。如果你已经在用 APM 工具,也可以把这些 SQL 时间测出来直接上报,效果差不多。

8. 个人实践总结:最后分享一个习惯

文章写到这,关于 EF Core 和 Dapper 的对比以及优化方法已经很完整了。我最后再分享一个习惯:任何一个查询在写完后,都要看一眼它生成的 SQL。这不是要求你背 SQL,而是你要了解 ORM 到底对数据库做了什么。EF Core 日志里输出 SQL 你花 10 秒钟扫一眼,如果发现 SELECT 了 20 列但你只要 3 列,赶紧改投影;如果发现 N+1 请求,赶紧加 Include 或改拆分查询。这个过程坚持两周,你对 EF Core 的“脾气”就摸清楚了,之后写出来的查询性能都不会差到哪里去。

很多人对 ORM 有误解,觉得用了 ORM 就可以完全不懂 SQL。我在实际工作里见过太多因为这种误解导致线上事故的例子。工具再好用,底层原理还是要懂。Dapper 让你理解 SQL,EF Core 帮你抽象 SQL,两者不是敌对关系,而是互补关系。把 SQL 基本功打扎实,选哪个 ORM 都不会差。反过来,不懂 SQL 的人用哪个 ORM 都会出问题。

回到标题的“鱼和熊掌”,我的最终答案是:不需要非得在 EF Core 和 Dapper 之间站队,它们可以同时存在于你的技术栈里,各自负责自己擅长的领域。EF Core 适合业务复杂、需要快速迭代、对象关系密集的场景;Dapper 适合性能敏感、SQL 结构复杂、数据量大的查询。搭配好这两者,再配合本文的优化手段,你完全可以在享受 EF Core 开发效率的同时,让性能逼近甚至持平 Dapper。希望这篇文章的经验能帮你在 ORM 选型和优化这条路上少踩几个坑。

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

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

立即咨询