先交代一下背景:这套 ORM 性能测试我前后折腾了三轮,前两轮基本都废了,要么是数据样本设计有硬伤,要么是预热做得不到位,测出来的数据连我自己都不信。直到第三轮把所有变量按工程标准抠了一遍,才终于敢把结果放出来见人。这篇博文就围绕这版最终测试展开,把方法、结果、踩坑和我的个人判断都写清楚,希望能给正在做 ORM 选型或者被 ORM 性能问题困扰的人一些参考。
网上关于 ORM 性能对比的文章已经很多了,随手一搜就是一大把,但说句实话,真正能做到“别人可以照着复现”的很少。最常见的毛病是:测试数据固定导致缓存命中过高、预热不够让冷启动误差混进结果、并发模型与连接池设置脱离真实场景、统计次数太少直接给结论。所以我做这版测试时给自己定了三条纪律:环境可复现、操作有依据、结论可验证。
1. 为什么还要再做一版 ORM 性能测试
先说动机。我所在的项目正在做数据访问层迁移,从早期基于 ADO.NET 封装的老框架迁到现代化 ORM。团队内部对选型争论了很久,社区里的观点又非常撕裂:有人说轻量级 ORM 起步就是快,有人说全功能 ORM 在复杂业务里开发效率碾压一切,吵来吵去没有定论。与其听别人说,不如自己跑一轮完整的 Benchmark——这是最笨但也最可靠的办法。
促使我做最终版的直接原因是,我发现市面上的 ORM 测试文章大多只测“单表 CRUD 谁快谁慢”,很少考虑批量写入、复杂关联查询、分页、统计聚合这些真实业务里一定会出现的操作。而这几种操作的性能特征差异非常大,甚至会改变选型结论。举个例子:一个 ORM 在单行查询上很慢,但在批量插入上有独特的批量优化,那对于以写入为主的业务来说,它的整体表现未必差。只看单一指标做决策,很容易得出偏差结论。
这版测试的另一层意义在于,它把“谁快”这个粗粒度问题细化成了“在什么场景下,哪一类操作差了多少,原因是什么”。因为实际项目里真正需要的不是冠军,而是合适的选择——每个 ORM 的性能代价和开发收益必须放在一起权衡。所以在这篇文章里,我不会只扔一张对比表,还会把测试环境、数据设计、执行细节和背后的原理都讲清楚。这个思路同样适用于你自己做技术选型时跑 Benchmark——方法比结果更重要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 测试环境与数据设计:先排除干扰变量
2.1 硬件、软件与数据库环境的固定
环境不固定是 Benchmark 最大的坑。我第一轮测试是用开发笔记本跑的,跑完数据波动非常大,同一个操作两次结果能差三倍。原因很简单:笔记本 CPU 频率受温度和电源策略影响,后台随便开个浏览器、同步工具都会抢资源。最终版我换成了固定配置的机器,并把系统电源模式设为高性能。这里最大的教训是:如果不能在固定环境里测试,至少要把测试机和日常开发机分开,并且不要让测试期间有其他重量级进程运行。
具体环境参数如下:
| 项目 | 配置 |
|---|---|
| CPU | 8 核 16 线程(固定频率模式) |
| 内存 | 16 GB |
| 存储 | NVMe SSD |
| 操作系统 | Ubuntu 22.04(容器层)+ Windows Server 测试宿主 |
| 运行时 | .NET 8 |
| 数据库 | PostgreSQL 15 与 SQL Server 2022 各测一轮 |
版本固定是必须的,因为不同 .NET 版本下的 ORM 实现差异很大。比如 EF Core 7 以后才默认支持批量更新,EF Core 8 又调整了查询缓存逻辑,这些都会直接影响测试结果。最终结论我以 SQL Server 下的数据为主,PostgreSQL 的测试数据作为对照,主要确认比值关系在不同数据库上是否一致。
2.2 数据模型与数据规模:模拟真实业务
测试对象我选了一个非常典型的电商订单模型:用户表、商品表、订单主表、订单明细表。选择这个模型的原因很朴素——几乎所有业务系统都有类似结构,用它测出来的结论对大多数团队都有参考意义。表结构上保持了三层关联:用户下单产生订单,订单包含多条明细,明细关联商品。这与真实系统中的数据关系形态一致。
数据量我是这样设计的:
- 用户表:10 万条
- 商品表:20 万条
- 订单表:200 万条
- 订单明细表:800 万条
这个量级不算大,但足够让索引、连接池、SQL 生成策略这些因素显出差异。数据量太小的时候,所有操作都几乎在内存中完成,差距会被掩盖;数据量太大时,数据库本身的执行能力又成了瓶颈,ORM 的差异反而不重要。200 万订单加 800 万明细,在普通 SSD 上单条查询大概毫秒级返回,既能暴露问题,又不至于让测试跑不动。
2.3 随机化数据与预热机制
我再三强调随机化,是因为见过太多测试用固定 ID 连续查询,跑几次之后缓存全命中,结果完全失真。比如固定查订单 ID = 1,第一次执行后数据库缓冲池里有这一页,ORM 的查询计划缓存也建立好了,后面九万九千次查询全是打缓存,性能当然好看,但这个数据没有任何业务参考价值。
我的做法是预生成 5000 个随机订单号,每次迭代随机取一个,确保查询落在不同的数据页上。同时避免连续命中同一批数据导致的内存缓存效应。
预热机制也是被普遍忽略的环节。没有预热之前,EF Core 第一次查询光表达式树编译就要几十毫秒,直接计入统计会把平均耗时拉高一大截。最终版我设置了两轮预热:第一轮跑冒烟集合让 JIT 完成方法编译,第二轮再执行目标操作让数据库的查询计划缓存和连接池热起来。预热后停 2 到 3 秒,再进入正式统计,确保系统状态回到稳定区间。
3. 测试场景设计:四个维度覆盖真实业务
3.1 单行主键查询
单行主键查询是所有 ORM 评测的基础科目,但恰恰是它最容易测出“假数据”。单次查询耗时极短,通常在几毫秒甚至零点几毫秒量级,任何一点系统噪声都会被放大。所以我在这个场景里把迭代次数拉到 10 万次,然后统计 P50、P95、P99 三个百分位。
为什么看百分位数而不是只看平均值?一个简单例子:如果 99 次查询都耗时 1 毫秒,但有 1 次因为 GC 或者系统线程调度耗时 500 毫秒,平均值会变成大约 6 毫秒,看起来很可怕,可这完全不代表典型体验。而 P99 会告诉你 99% 的情况都在某个值以内,这个值才更接近用户真实感知。另外,百分位数对评估系统稳定性更有效,如果 P99 远高于 P50,说明系统存在明显的长尾抖动,这在优化时有完全不同的排查方向。
单行查询这个场景,理论上轻量级 ORM 优势最大。因为它的执行路径最短:写好 SQL 字符串、创建命令、执行、映射结果。全功能 ORM 则需要解析表达式树、生成 SQL、跟踪实体状态、维护快照,每一步都要付时间成本。但到底差多少,必须实测,我见过不少凭感觉猜测差距 10 倍以上的,实际跑下来往往是 3 到 5 倍。
3.2 批量插入
批量插入是很能反映 ORM 设计水平的一个场景。EF Core 的老版本默认逐条 INSERT,每个实体一次数据库往返,性能非常差;从 EF Core 7 开始增加了批量更新能力,能把多条 INSERT 合并成一条 SQL。
Dapper 本身没有“批量插入”功能,需要自己写批量 SQL 或者借助表值参数来优化。而 SqlSugar 等半自动 ORM 则提供了相对完整的批量插入接口,内部做了分批提交。这些差异如果不实际测,很难了解对吞吐量的影响。
我设计了两个批次档位:一批 100 条和一批 1000 条。每批作为一个事务提交,重复 100 批,最终看总耗时和单批耗时。批次大小对性能的影响是测试的重点之一。通常批次越大,网络往返越少,吞吐量越好;但超过某个阈值后,单条 SQL 过于庞大,数据库解析和日志写入压力上升,性能反而可能下降。这个拐点正是实际项目中调优批量写入需要找的。
3.3 复杂关联查询
复杂关联查询是我最看重的一个场景,因为它最接近真实业务。我用了一个“订单 30 天统计”查询:从最近 30 天的已完成订单中,筛选金额大于某阈值的订单,关联用户和订单明细表,按日期分组聚合金额。这个查询包含 join、where、group by、聚合和日期条件,能真实反映 ORM 在复杂 SQL 生成上的能力与代价。
在这个场景里,全功能 ORM 的表达式树能自动翻译出复杂的 SQL,开发成本低;而轻量级 ORM 基本只能手写 SQL。测试结果会受 SQL 生成质量影响——同一个业务逻辑,手写 SQL 和表达式生成的 SQL 可能有完全不同的执行计划。所以我对所有 ORM 生成的 SQL 都做了人工检查,排除了生成的 SQL 有明显低效写法的情况,这样才把“ORM 加工开销”和“SQL 质量差异”分开看待。
3.4 分页查询与统计聚合
分页是后台管理系统最常见的操作,我顺带把统计聚合也归到这个场景里一起测。分页在不同数据库上有不同的实现方式:SQL Server 用 OFFSET FETCH,PostgreSQL 用 LIMIT OFFSET,ORM 对分页的处理也会影响最终 SQL 的生成方式。
统计聚合我测了 sum、count、group by 的组合。这类查询在 ORM 里通常走表达式树聚合管道,复杂度越高,翻译开销越大。如果 ORM 的翻译质量不行,生成的 SQL 可能把本来可以在数据库端完成的计算拉到应用端做,性能就会很差。所以统计聚合场景非常考验 ORM 的表达式树翻译能力和 SQL 优化意识。
4. Benchmark 工具链与执行细节
4.1 BenchmarkDotNet 的关键配置
.NET 生态做性能测试,BenchmarkDotNet 是绕不开的选择。我这一版也是用它跑的,它最大的价值是自动处理了进程隔离、垃圾回收状态和统计显著性检测。每个 Benchmark 方法默认在独立进程中运行,前一个测试的残留状态不会影响后一个测试,这比自己在 Main 方法里写 Stopwatch 要严谨太多。
配置上几个关键参数值得说明。Toolchain 必须显式指定为 .NET 8 的版本,否则可能默认跑老版本运行时。IterationTime 我设置为 200 毫秒,确保每个操作有足够多的迭代次数来稳定统计。WarmupCount 设置为 10,让预热做到位。还有一个容易被忽略的项是环境变量 DOTNET_gcServer,服务器 GC 和工作站 GC 对吞吐和延迟的影响完全不同,特别是高并发场景,必须根据测试目标显式设置。
完整配置大致长这样:
csharp复制[MemoryDiagnoser]
[SimpleJob(RunStrategy.ColdStart, IterationCount = 5, WarmupCount = 10)]
public class OrmBenchmark
{
private readonly string _connectionString = "Server=...;Database=bench;Integrated Security=true;";
[Benchmark]
public Order GetOrderById_Dapper()
{
using var connection = new SqlConnection(_connectionString);
return connection.QueryFirstOrDefault<Order>("SELECT * FROM Orders WHERE Id = @Id", new { Id = _random.Next(1, 5000000) });
}
[Benchmark]
public Order? GetOrderById_EFCore()
{
using var context = new BenchDbContext();
return context.Orders.AsNoTracking().FirstOrDefault(o => o.Id == _random.Next(1, 5000000));
}
}
4.2 MemoryDiagnoser 与分配量观察
BenchmarkDotNet 的 MemoryDiagnoser 能输出每个操作的平均分配量和 GC 触发次数。ORM 的性能差异不只是耗时,内存分配同样关键。全功能 ORM 因为要建立实体跟踪和快照,分配量通常远大于轻量级封装。在高并发服务里,内存分配直接关联 GC 压力,GC 次数多了就会引发停顿,最终拖垮 P99。
最终版本我测到的分配量差异很直观:单行查询场景,Dapper 每次操作分配在 1KB 以下,EF Core 默认跟踪模式分配到 4KB 到 5KB,开启 AsNoTracking 之后降到 2KB 左右。这个数据对理解长尾延迟很有帮助——分配量大意味着 GC 触发更频繁,GC 触发更频繁意味着偶发停顿更多。很多 P99 恶化的根因就在这。
4.3 测试执行顺序与数据库状态管理
测试顺序是另一个容易踩坑的地方。所有测试按复杂度从低到高执行:先单行查询,再批量插入,再复杂关联,最后统计聚合。这样安排的原因是,重操作会改变系统的缓存状态和 JIT 状态,如果先跑重操作再跑轻量查询,轻量查询的结果可信度会大打折扣。
数据库端的配合同样关键。测试开始前我运行了 UPDATE STATISTICS 更新所有表的统计信息,并逐项检查了查询条件字段的索引。没有这一步,ORM 测出来的差异很多来自数据库选择的全表扫描,而非 ORM 本身的加工开销。每跑完一个场景,我会打开数据库的慢查询日志和实际执行计划,确认没有隐式转换或索引失效。这套检查流程虽然繁琐,但能过滤掉大量“假异常”。
5. Benchmark 结果:数据汇总与解读
5.1 关键指标对比表
先上最核心的数据。以下结果以 SQL Server 2022 为主,单位是毫秒/操作,数值越小越快。注意这里是相对结果,换机器跑绝对值一定不同,但比值关系有稳定参考价值。
| 操作 | Dapper | SqlSugar | EF Core |
|---|---|---|---|
| 单行主键查询 P50 | 0.82 | 1.24 | 3.58 |
| 单行主键查询 P99 | 1.95 | 2.87 | 7.66 |
| 批量插入(100 条/批) | 8.2 | 6.8 | 11.9 |
| 批量插入(1000 条/批) | 24.5 | 31.2 | 58.3 |
| 复杂关联查询 P50 | 4.67 | 6.91 | 12.45 |
| 分页查询 P50 | 3.12 | 4.08 | 9.23 |
看这个表,几个直观的结论是:单行查询上 Dapper 领先 EF Core 超过 4 倍;批量插入小批量场景 SqlSugar 反而领先,但大批量场景 Dapper 在自己写优化 SQL 时优势巨大;复杂关联查询上 EF Core 与 Dapper 的差距缩小到了 2 到 3 倍。
5.2 结果背后的三个关键判断
单看数据容易带节奏,真正有价值的是理解差异从哪来。第一个判断:单行查询的巨大差距来自 ORM 的固定加工链路而不是数据库执行。EF Core 每次查询要完成表达式树解析、SQL 生成、实体映射、变更跟踪快照创建这一整套流程;Dapper 只是 ADO.NET 的薄封装,跳过了绝大多数加工步骤,自然快得多。这不是 EF Core 有 bug,而是它的功能设计本身决定了这个代价。
第二个判断:一旦进入复杂查询和批处理场景,ORM 间的差距会缩小。原因很简单,真正的耗时大头转移到了数据库执行、网络往返和事务处理上,ORM 自身的加工开销占比被摊薄了。所以在以复杂报表为主的项目里,不同 ORM 间的实际用户体验差距没有单行查询看起来那么夸张。
第三个判断:内存分配量差异对高并发场景的影响不亚于耗时差异。EF Core 的实体跟踪机制带来额外的内存分配和 GC 压力,这在单次操作里可能只有几 KB 的差距,但在每秒上万次请求的高并发下会被放大成明显的 GC 停顿。这也是为什么我在最终版里特意开启了 MemoryDiagnoser,并把内存分配量作为选型的重要参考。
6. 差距背后的原因深入拆解
6.1 表达式树解析与 SQL 生成开销
EF Core 的核心抽象是表达式树,它让开发者可以用强类型语法写查询,体验相当舒服。但代价是每次查询都要经历“表达式树解析 → SQL 生成 → 命令执行 → 结果映射”的过程。虽然新版本加入了查询计划缓存,能够缓存翻译结果,但首次解析和缓存未命中时的开销仍然明显。
更关键的是,缓存机制对动态构造的查询几乎无效。比如根据前端传入的条件动态拼接 LINQ 的 Where,每次构造出的表达式树结构都不一样,缓存键无法命中,只能重新编译。我在实际项目里见过不少这样的代码:一个查询方法里根据几个可选参数动态加条件,看起来灵活,实际上让 EF Core 的查询缓存形同虚设,性能在不知不觉中退化。
6.2 实体跟踪与快照机制的隐性成本
EF Core 默认启用了查询跟踪,意味着每次查询返回的实体都会进入 ChangeTracker。这个机制用来支持后续 SaveChanges 的变更检测,非常有用,但代价也很高:每个实体都要创建快照、保存原始值,后续变更检测还要逐属性对比。对于只读查询场景,这完全是浪费。
我特意跑了一组对照测试,EF Core 单行查询默认跟踪是 3.58 毫秒,开启 AsNoTracking 之后降到 2.1 毫秒左右,提升接近 40%。这说明很大一部分时间花在了与查询本身无关的跟踪机制上。对只读报表、列表页这类接口,加 AsNoTracking 是成本最低、收益最大的优化手段,没有之一。
6.3 连接池与批量策略的放大效应
连接池配置经常是性能测试里的隐藏变量。默认连接池大小通常为 100,如果并发连接超过这个值,新增连接请求就要排队等待,性能会断崖式下跌。
批量插入场景也受连接池策略影响。EF Core 旧版本逐条 INSERT 时,每条都要与数据库交互并占用一次往返;从 EF Core 7 开始默认将多条 INSERT 合并到一条 SQL 里执行,吞吐大幅提升。但即便合并,它的批量 SQL 性能和 Dapper 搭配表值参数或原生批量拷贝相比还是有差距,这个差距在大批次下会更加明显。
6.4 查询计划缓存与动态构造的两难
查询计划缓存对轻量级 ORM 同样重要。Dapper 对每条 SQL 会生成一个委托缓存,第二次执行同一 SQL 时直接跳过解析过程。所以 Dapper 下参数化 SQL 的写法比字符串拼接快很多,因为参数化 SQL 才能稳定命中缓存。
EF Core 的查询计划缓存键与表达式树结构相关,只要查询结构完全一致才能命中。这给了开发者一个启示:热点查询应该尽量写成固定结构,避免按条件动态拼 LINQ;必要时可以用 CompiledQuery 预编译查询,或者对最复杂的 SQL 直接用 Dapper 原生 SQL 兜底。
7. 测试中的典型问题与排查经验
7.1 连接池耗尽导致的假瓶颈
我第三轮测试遇到过非常诡异的现象:并发模型下某个 ORM 的 P99 直接飙到几十秒,怎么排查都找不到原因。数据库 CPU 不高、锁等待没有、网络也正常。最后打开数据库的活动连接视图,发现连接数一直顶在池上限附近。
这就是连接池耗尽——不是 ORM 慢,是并发连接数超过了池容量,大量请求在排队等连接。解决办法不是无脑调大 Max Pool Size,而是先分析业务并发模型。如果并发峰值是 50,默认 100 完全够用;真正需要调大的是并发毛刺明显的场景。我最终把连接池设为 200 并针对测试并发模型做了验证,P99 就恢复正常了。这个案例提醒我:性能测试发现问题时,先排查基础设施,不要急着骂 ORM。
7.2 冷启动与 JIT 干扰
.NET 方法的第一次调用会触发 JIT 编译,库的首次使用会触发静态构造函数,这些耗时会进入统计。如果不预热,第一次调用耗时为后面调用的几十倍甚至上百倍都很正常。
之前有人在社区吐槽某个 ORM “第一次查询要 300 毫秒”,其实就是典型的冷启动干扰。BenchmarkDotNet 的 WarmupCount 参数就是来解决这个问题的,我设置 10 次预热调用后再进入统计。另外,.NET Core 的分层编译(Tiered Compilation)可能在统计过程中触发重新 JIT,干扰测量结果。我在 Benchmark 环境变量里加了 DOTNET_TieredCompilation=0 关闭分层编译,让结果更稳定。如果你手动写测试程序而不是用 BenchmarkDotNet,这两个问题极难避开。
7.3 GC 干扰与内存分配差异
GC 干扰同样会污染测试结果。如果测试过程中发生了垃圾回收,执行线程会被暂停,单次操作的耗时就会被拉长。这正是为什么我在配置里启用 MemoryDiagnoser,并把分配量作为关键输出指标。
分配量大的 ORM 在统计上更容易出现 GC 抖动,这在单次操作里看不出来,但在高频调用下会表现为 P99 的周期性恶化。实际优化时,我会优先查有没有不必要的实体跟踪、有没有一次性加载过多列,这些都能直接减少分配量,进而改善长尾延迟。
7.4 索引缺失导致对比失真
参与对比的查询如果缺索引,所有 ORM 都会退化到全表扫描,这时测出来的性能差异基本是数据库的,不是 ORM 的。特别是 join 和 group by 操作,一个索引缺失就可能让执行计划完全改变。
我在测试前逐表检查了测试查询涉及的过滤条件和关联字段,补齐了缺失索引,并更新了统计信息。每跑完一个场景,还会执行一次实际执行计划检查,确认没有隐式类型转换或者索引失效。这一步不能省,否则测试结论可能完全不可信。
8. 如何把 Benchmark 结论落地到实际项目
8.1 选型建议:不要用一把尺子量所有场景
跑完这轮测试,我的结论不是“谁赢谁输”,而是“不同业务形态对应不同最优解”。
如果项目以简单的 CRUD 为主,查询结构固定,复杂聚合不多,Dapper 加少量手写 SQL 是性能最优组合,代价是要自己维护 SQL 和映射代码。如果业务模型复杂、关联层级深、对象关系多,团队开发效率很重要,EF Core 这类全功能 ORM 更合适,但要注意在只读查询里大量使用 AsNoTracking、热点查询用 CompiledQuery 预编译。
SqlSugar 这类半自动 ORM 处在中间地带:既有实体映射的便利,又保留了写 SQL 的灵活性,批量操作控制也比较直接。很多中小型项目的体验在这轮测试里表现得比较均衡,可以作为折中选项。
8.2 从 Benchmark 数据反推容量规划
这轮测试的 P99 数据对我做容量估算很有帮助。比如单行查询 P99 从 7.66 毫秒降到 1.95 毫秒,看起来只是几毫秒的差距。但一个接口如果串行查询 10 次,差距就是 76.6 毫秒对 19.5 毫秒,用户能明确感知到。
我给自己定了一个实用原则:先量接口各环节耗时分布,如果数据库查询占比不足三成,优化 ORM 不如优化业务逻辑、加缓存;如果数据库查询是绝对大头,换轻量级 ORM 或者关闭实体跟踪是更有效的方案。别一上来就重构 ORM,那是成本最高、风险最大的路径。
8.3 优化收益最大的三个动作
如果不更换 ORM,也有三个性价比极高的优化动作可以立即执行。
第一,查询统一加 AsNoTracking。这个动作对只读接口的收益通常在 30% 到 60%,改造成本极低,只需要在查询入口加一个方法调用。
第二,把循环调用 SaveChanges 改成 AddRange 后一次性提交。很多旧代码为了省事在 foreach 里调用 SaveChanges,导致每行一次数据库往返。改成批量提交后写入性能往往能提升数倍。
第三,对最耗时的热点查询使用 CompiledQuery 或切换到原生 SQL。当一个查询在 TPS 中占比很高时,优化它的价值远超优化十个低频接口。
9. 写在最后的几点体会
这套测试做完后,我对 ORM 性能这件事有了更具体的认识。以前我也会被网上的言论带节奏,觉得某个 ORM 快所以应该选它,或者某个 ORM 慢所以不能碰。现在我的态度是:脱离场景谈快慢没有意义,脱离数据谈选型更是空谈。真正有价值的不是“谁赢了”,而是“在什么条件下差多少、为什么差、值不值得用功能换性能”。
最后再分享一个实操细节:无论你测什么 ORM,都建议把测试代码和脚本放到项目源码库里,配合 CI 留一个回归任务。因为 ORM 的新版本经常在性能和内存分配上做调整,今天测出的结论半年后可能就不再成立。把 Benchmark 变成可自动重复运行的能力,比测试本身更有长期价值。这套最终版的结果我已经要求团队在每次依赖升级时重新执行一轮,确保选型结论始终建立在最新数据上。
