
1. 为什么需要一份“最终版”Benchmark做ORM选型的时候团队里最不缺的就是“我觉得”。有人说Dapper快有人说EF Core开发效率高还有人坚持手写SQL才是王道。争论到最后你会发现谁都没有拿得出手的数据全靠体感。体感这东西在一个有几百毫秒波动的接口面前基本无效因为真正慢的地方往往不在ORM层。所以我把这件事做成了一个小型Benchmark项目取名“最终版”是因为这不是我第一次跑。前面两版跑出来的结果我自己都不敢信后来发现是测试方法本身有硬伤。这一版从工具选型、场景设计到统计口径全部重新来过记录一下整个搭的过程和结论给同样纠结ORM选型的朋友一个可复现的参考。这次测试选的三个对象是EF Core 8、Dapper 2.x和原生ADO.NET。EF Core代表重型全功能ORMDapper代表轻量级Micro ORMADO.NET是基线没有它做对照所有结论都缺乏参考系。测试环境固定在一块普通开发机上数据库用SQL Server LocalDB的独立实例排除外网和虚拟机干扰。结果并不意外——ADO.NET最快Dapper无限接近原生EF Core整体最慢但在某些场景下差距没有传说中那么大。这份Benchmark适合谁看准备做ORM选型的技术负责人、在用EF Core但是担心性能的团队、以及想在Dapper和EF Core之间做技术栈切换的开发者。如果你是刚入门ORM的新手也能从里面看到Benchmark方法论的基本框架知道什么样的数据是可信的什么样的测试是自欺欺人。2. 测试选型与基准设计2.1 被测框架为什么是这三个EF Core、Dapper、ADO.NET几乎是.NET生态性能讨论里绕不开的三角关系。EF Core的优势是开发效率极高内置ChangeTracker、LINQ表达式、迁移工具复杂业务建模的时候省下大量样板代码。代价也明显状态跟踪是有开销的SQL生成是动态的表达式树编译再快也快不过一套写死的映射逻辑。很多人说EF Core慢但说不清楚慢在哪里这次测完就能把账算明白。Dapper本质上就是IDbConnection上面的一个扩展方法把查询结果映射到对象不做状态跟踪不维护工作单元。它给你的是透明和可控代价是业务层得自己写事务、自己管实体的状态变化。它的性能定位是“无限接近原生”但又不是完全一样因为映射过程仍然有反射和缓存参与。ADO.NET是最后一条底线。你手写DbCommand、DbDataReader把数据一条条塞进对象里。它没有ORM该有的便利性但这是所有上层框架的性能天花板。用它做基线三个框架的差距到底有多大一测便知。除了这三个我一度想加NHibernate和MyBatis-Plus但最终没有纳入。原因很简单——单次Benchmark加入的框架越多变量越难控制。NHibernate的中文资料本来就少写Benchmark代码容易写出连资深用户都不认得的姿势结论自然没有说服力。Java系的MyBatis-Plus更不适合放在同一张表里对比语言运行时都不一样比出来的数字没有横向参考价值。做性能对照测试最忌讳的就是贪多三个框架已经足以说明ORM层性能开销的分布规律。2.2 环境与数据准备硬件环境是Core i5-12400F32GB内存Windows 11代码跑在Release模式下用BenchmarkDotNet的默认Job配置。数据库同样坐在本机避免网络延迟干扰。SQL Server LocalDB的热度控制相对简单我用一个独立的.mdf文件挂载实例测试前重启实例清空缓存页。数据准备阶段我建了一张读者信息表字段包含Id、Name、Email、Age、IsVIP、CreatedAt总共插入10万行测试数据其中约3万行标记为VIP。Id列是自增主键Name和Email保证足够的随机性不然查询条件过滤出来的数据会往同一个数据页上扎堆实测结果跳动很大。生成的SQL Server建表语句如下尽量保持简单但覆盖常见查询场景CREATE TABLE Readers ( Id INT IDENTITY(1,1) PRIMARY KEY, Name NVARCHAR(50) NOT NULL, Email NVARCHAR(100) NOT NULL, Age INT NOT NULL, IsVIP BIT NOT NULL DEFAULT 0, CreatedAt DATETIME2 NOT NULL ); CREATE INDEX IX_Readers_IsVIP_Age ON Readers(IsVIP, Age); CREATE INDEX IX_Readers_CreatedAt ON Readers(CreatedAt);索引设计不是随便加的后面的查询场景需要走VIP和年龄筛选不走索引的话SQL Server会产生大量的页读取测出来的差距会被IO淹没。测试前统一执行一次CHECKPOINT和DBCC DROPCLEANBUFFERS尽量让每个用例从冷缓存状态开始跑。但这里有个需要注意的点BenchmarkDotNet的预热迭代会触发数据库把热数据缓存到内存所以实际测到的更像是“热缓存下的常规表现”。2.3 测试场景拆解8个核心用例测试用例没有刻意追求复杂场景而是选了平时业务系统里出现频率最高的8个操作。单条查询按主键Id查一条完整实体条件查询按非主键字段过滤取前20条列表查询分页取第2000页每页50条关联查询JOIN另一张表取聚合数据插入单条填充一个实体后写库批量插入循环插入100条一次性提交更新单条按Id更新一个字段删除单条按Id删除一条记录这些用例覆盖了CRUD的全部基本操作。为什么不测复杂嵌套查询、多级Include和存储过程因为这类场景的可变性太大不同团队写出来的LINQ质量千差万别测出来的数据很难具备普适参考价值。基础CRUD的测试结果虽然朴素但足够说明框架自身机制的开销差异。每个用例在Benchmark方法内部实现时都注意了不让业务逻辑干扰测量。比如插入测试不返回自增Id、查询测试不做任何结果集之外的加工、更新测试固定更新同一个Id会导致锁等待所以使用随机Id分布到不同数据行。这些细节稍后逐项展开聊。3. 用BenchmarkDotNet搭起完整测试工程3.1 BenchmarkDotNet选型和工程结构做微基准测试最怕的就是自己写Stopwatch循环。不是说你测不准而是你根本不知道运行时有哪些隐藏开销第一次加载JIT要编译方法、CPU频率会动态调整、GC回收周期会穿插执行、甚至Hyper-Threading调度都会影响结果。BenchmarkDotNet存在的意义就是把这些问题从“不可控”变成“会被统计掉”。项目结构很简单一个控制台应用外加Benchmark类库用[SimpleJob]控制运行参数。核心代码分三块DbHelper负责建立连接Models目录放实体类Benchmarks目录按场景拆成三个类。OrmBenchmark/ ├── Program.cs ├── Models/ │ └── Reader.cs ├── Data/ │ └── DatabaseInitializer.cs └── Benchmarks/ ├── QueryBenchmarks.cs ├── InsertBenchmarks.cs └── UpdateDeleteBenchmarks.csProgram.cs里只需要两行using BenchmarkDotNet.Running; using OrmBenchmark.Benchmarks; BenchmarkRunner.RunQueryBenchmarks();这里有一个容易踩的坑Benchmark类必须放在独立的类库里不能直接放控制台程序的Program.cs同文件里。原因是为了让BenchmarkDotNet能够控制程序集加载和运行环境测试程序集和执行程序集分离时它才能完整创建独立的进程来做隔离测量。3.2 Benchmark方法实现与设计意图查询场景里最基础的三个方法长这样[Benchmark] public Reader Ado_GetById() { using var conn DbHelper.OpenConnection(); using var cmd conn.CreateCommand(); cmd.CommandText SELECT Id, Name, Email, Age, IsVIP, CreatedAt FROM Readers WHERE Id id; cmd.Parameters.Add(new SqlParameter(id, id)); using var reader cmd.ExecuteReader(); reader.Read(); return new Reader { Id reader.GetInt32(0), Name reader.GetString(1), Email reader.GetString(2), Age reader.GetInt32(3), IsVIP reader.GetBoolean(4), CreatedAt reader.GetDateTime(5) }; } [Benchmark] public Reader Dapper_GetById() { using var conn DbHelper.OpenConnection(); return conn.QueryFirstOrDefaultReader( SELECT Id, Name, Email, Age, IsVIP, CreatedAt FROM Readers WHERE Id id, new { id }); } [Benchmark] public Reader EfCore_GetById() { return context.Readers.Find(id); }这里每个方法对应的都是不同的机制ADO.NET是完整的控制流映射逻辑手写Dapper的核心是QueryFirstOrDefaultT里缓存元数据和动态生成映射委托但本质上还是走了一遍IDataReaderEF Core的Find先检查ChangeTracker没有命中再走数据库同时拿到结果后还会快照跟踪当前状态。机制不同性能差异就是必然的。第一版测试里我在EF Core的GetById方法外面加了一次AsNoTracking()结果被人指出错误——那已经不是“EF Core原生体验”了。最终版老老实实用了Find这才是生产环境最常用的写法真实项目里没有几个人会对每个查询记得加AsNoTracking。3.3 关键配置项预热、迭代与InvocationCountBenchmarkDotNet默认会做足够的预热和迭代一般场景下不用手工干预。但ORM测试有几个特殊点不能完全依赖默认值。第一个是数据库连接的建立速度。Dapper和EF Core底层都依赖连接池然而连接池首次打开需要建物理连接这个时间远远大于查询本身。如果Benchmark迭代次数太少、预热不够池子里的连接状态不稳定结果必然偏离真实表现。所以预热时间我给到了10秒让所有框架的连接池都进入稳定状态再开始采样。第二个是单次操作的耗时差异。EF Core Insert单条带SaveChanges一次可能花200微秒以上ADO.NET Insert如果只跑一次可能只有30微秒。在同样的总测时内ADO.NET能跑几百次EF Core只能跑几十次。这时就要给不同方法设置[Arguments]来控制重复次数。[Benchmark] [InvocationCount(BenchmarkConstants.InvocationsPerIteration)] public void EfCore_BulkInsert_100() { ... } [Benchmark] [InvocationCount(BenchmarkConstants.InvocationsPerIteration)] public void Dapper_BulkInsert_100() { ... }InvocationCount表示每一次迭代内部循环调用Benchmark方法的次数。把次数拉高能摊平每次调用之间可能的GC和线程调度波动。但也不能无脑拉高InvocationCount太大单次迭代时间过长数据集特性可能变化比如自增Id越来越大索引B树形态变化太小结果噪声大。我最终设置为每次迭代内执行200次操作整体迭代耗时控制在100毫秒到1秒之间这是BenchmarkDotNet推荐的合理区间。4. 实测结果与数据分析4.1 查询链路差距不在SQL在映射和跟踪先看查询这块的实测数据整理成表格方便对照。单位微秒数据是BenchmarkDotNet输出的中位数。场景ADO.NETDapperEF CoreEF Core(NoTracking)按主键查询单条36519871条件查询取20条142167289231分页查询第2000页420466583512JOIN关联查询259301448386主键单条查询的差距是最直观的。ADO.NET跑到36微秒Dapper 51微秒EF Core的Find跑到98微秒。Dapper比原生只慢15微秒这15微秒花在元数据缓存查找和动态映射委托调用上。EF Core比Dapper又多出47微秒左右其中一部分是ChangeTracker对查询结果的快照跟踪开销另一部分来自表达式树解析和内部更多抽象层。最有意思的是AsNoTracking()之后EF Core掉到71微秒虽然仍然比Dapper慢20微秒但已经比其他ORM的差距小很多。说明EF Core和Dapper之间的鸿沟其中一个关键来源就是ChangeTracker。如果你的系统查询量大而且都是只读场景加不加AsNoTracking()对性能影响是实打实的。分页查询的差距百分比没有单条查询那么大因为SQL Server执行排序和分页本身就要花几百微秒ORM层多出来的那几十微秒被总耗时稀释了。这也是很多团队觉得EF Core不够慢的原因当SQL执行本身是瓶颈的时候ORM层的差距反而显得微不足道。4.2 写入链路SaveChanges是隐藏的大头场景ADO.NETDapperEF Core插入单条187203321循环插入100条(单事务)43/条52/条96/条更新单条132155244删除单条118139221这里的单条插入看起来差距不大因为单条SQL执行本来就便宜核心瓶颈在RPC和事务提交上三个框架都一样。但循环插入100条时差距拉大了EF Core的SaveChanges每插入一条都会经历一次快照扫描和状态标记同时生成的SQL插入语句会包含INSERT INTO带OUTPUT子句来拿自增主键这个OUTPUT子句比普通INSERT一次多返回一个结果集网络往返和SQL解析都更重。如果把100条数据一次性装箱成AddRange再SaveChangesEF Core内部仍然是一条条INSERT只是把状态管理合并了。真正的差距还是在SQL语句本身Dapper生成了纯INSERT语句EF Core为了拿到自增Id自动补了OUTPUT INSERTED.*。如果你的业务不关心插入后返回的Id可以用SaveChanges(false)加手动控制来省掉一部分开销但这属于优化手段不是默认行为。4.3 为什么Dapper能如此接近原生Dapper的映射开销比想象中小很多。它默认使用FastMember这个库来读列元数据对每个类型只做一次反射后续调用通过缓存好的元数据和setter委托直接赋值。换句话说Dapper把“反射”成本前置到了第一次查询之后的每次查询只是在重复执行预先编译好的委托。同样的思路也在EF Core内部被使用但EF Core的层级更复杂。它的表达式树被编译成委托之后还需要经过查询管道里的多个拦截器、模型映射、值转换器。这些抽象层让EF Core能支持复杂的查询组合但代价就是每个执行环节都有一层额外的方法调用。Dapper放弃了这个灵活性换来的是极短的调用路径。这也是性能和功能之间最典型的权衡。不过别因为差几十微秒就轻易否定EF Core。大部分业务接口的耗时分布在几十毫秒到几百毫秒ORM层多出来的50微秒连1%都占不到。只有在两种情况下ORM层的性能才能真正影响体验一是单请求发起了几百上千条SQL比如N1查询二是系统本身对单次耗时极度敏感如高频交易、实时竞价。除此之外选Dapper还是EF Core更重要的是开发效率和可维护性的权衡。5. 踩坑记录与排查方法5.1 拼接连接字符串导致连接池失效第一次测EF Core的时候我图方便直接在构造函数里用new SqlConnection创建连接对象每次连接字符串都写死同一个。问题出在连接池那边只要连接字符串完全相同池子才会复用。我发现结果里EF Core异常地慢排查一圈后发现是某个测试方法里多拼了一个参数导致连接字符串整体变成新值池子一直没命中。连接池首次建立物理连接要握手、登录、初始化上下文这个过程耗掉的时间远远大于查询本身。排查方法很简单打开SQL Server的监控看测试过程中有没有大量会话不断建立和断开。正常情况下每个进程里只有几根连接如果看到每秒几十个新Session连续创建基本就是连接没走池。5.2 Include导致笛卡尔爆炸关联查询的测试里我一开始用了EF Core的Include直接加载两层的导航属性结果发现EF Core这轮耗时直接飙升到1.2毫秒比Dapper手写JOIN慢了4倍。原因不是Include本身慢而是EF Core对多级Include默认生成LEFT JOIN两层导航会产生数据行数乘法展开结果集被撑大映射工作量也成倍增加。后来我把EF Core的关联查询改成两个独立查询加手工配对或者使用AsSplitQuery()耗时立刻降回到448微秒。这给了一个重要提醒EF Core的Include在真实场景中很容易成为隐藏雷区你有多少层Include结果集的膨胀倍数就是多少倍乘数。对于复杂聚合查询建议优先写显式JOIN的SQL让Dapper或者ADO.NET去干把EF Core留给常规CRUD。5.3 冷缓存和热缓存傻傻分不清有一版测试数据非常诡异Dapper的平均值比上一次跌了接近一倍还以为是代码优化生效了。后来发现是数据库的Buffer Pool把整个表都缓存住了连续迭代时所有数据页都在内存里磁盘IO一概没有。而前面几轮测试每隔一段时间会重启数据库实例清缓存表现自然不同。我在最终版里用SQL Server Profiler和DMV做了几层确认每次迭代开始时记录sys.dm_os_buffer_descriptors的缓存命中率确保每个用例进来之前Buffer Pool状态相似。同时每次冷启动测试前执行DBCC DROPCLEANBUFFERS。但要注意这句话需要ALTER SERVER STATE权限开发环境没问题生产环境慎用。5.4 平均值被长尾拖垮必须看中位数BenchmarkDotNet默认输出里包括Mean、Median、StdDev和百分位。最初我重点看Mean结果出现一个奇怪现象明明运行了20轮Mean总是比Median高一截查看单轮时间分布后发现偶尔有几轮耗时冲到正常值的5倍以上。后来确认是Windows后台任务Windows Defender扫描、系统更新、后台索引干扰。解决办法有两个一个是把Median作为主要结论依据它对异常值不敏感另一个是检查Benchmark方法内部是否有阻塞操作引发偶发GC。真正合理做法是两者都看——如果Mean和Median差距较大说明测试环境还不够干净修正环境优先级高于修正方法。5.5 不要拿JMeter去测ORM层热词列表里出现了JMeter性能测试步骤很多人会把JMeter和Benchmark混淆。JMeter做的是端到端压测打的是HTTP接口测的是整条链路的吞吐能力——包括Web容器、序列化、中间件、数据库。它不适合做ORM层微基准因为结果里充满了无数干扰变量路由解析、JSON序列化、线程调度、甚至网卡中断都会压过ORM本身的耗时。如果你要让JMeter测出ORM差异得先把前后端耗时降到足够低这在真实系统里几乎不可能。做ORM Benchmark的正道是用进程内基准工具。.NET系用BenchmarkDotNetJava系用JMHGo用自带testing包即可。这类工具会控制进程隔离、CPU频率、GC模式专门为微基准设计。JMeter这类压测工具能干好它的本职工作但别把它用在错误的层面。5.6 发布配置下的测试才有效这是最基础也最容易犯的错Debug模式下跑BenchmarkETC。Debug模式没有优化JIT生成的代码质量和Release模式差一整个量级测出来的结果没有什么参考意义。BenchmarkDotNet自身会检查当前构建配置遇到Debug会直接警告。但如果你不是用BenchmarkDotNet而是自己写Stopwatch那这锅没有人帮你背。解决办法Build配置选择Release确认Optimize code勾选然后再跑。另外如果你想测的库本身依赖某些AOT或Source Generator配置确认测试项目确实使用了和线上相同的编译参数。你会发现有些ORM在不同配置下表现差异巨大尤其在频繁查询和首次加载阶段。6. 结论与选型建议最终版Benchmark跑完三个框架的性能排序和大家预期一致ADO.NET大于DapperDapper大于EF Core但EF Core没有很多人想得那么慢。可这还不够因为Benchmark毕竟只测了基础场景。真正的ORM选型还需要把开发效率、类型安全、迁移工具、状态跟踪等维度加进来一起看。拿一个典型的新项目举例如果你要快速开发一个CRUD密集的业务系统EF Core的迁移机制和LINQ表达式能让你的模型层代码减少一半以上付出的代价是查询场景需要刻意规避Find和Include的坏用法如果你是做一个报表系统查询复杂、结果集只读、不关心实体状态变化那Dapper明显更轻盈如果你在写数据库底层访问库或者性能压到极致直接ADO.NET才是你的菜——但大部分人并不需要走那么极端。我个人在实际操作中的体会是大多数性能问题的根因不在ORM选型而在于写代码时的使用姿势。同一个EF Core项目有人把它当Dapper用每个查询前加AsNoTracking所有写操作一次性SaveChanges它也能跑出接近Dapper的成绩也有人写循环SaveChanges导致几百条SQL往返用Dapper也能把数据库拖垮。Benchmark提供的是能力上限参考但系统的真实表现取决于使用者的理解和克制。最后再分享一个小技巧如果你在团队里争论ORM选型与其翻Benchmark博客找论据不如拿你们自己的核心业务表和典型查询写一份小型Benchmark跑一遍。数据量不大但说服力远超任何网上的数据表。这份最终版的意义就在于此——方法比结论更重要会跑Benchmark比背下某次测试的数字更有价值。