☰
Entity Framework Core实战:从ORM原理到CRUD与性能优化
2026/9/28 5:16:43 网站建设 项目流程

如果你让我用一个词概括 Entity Framework,我会说:省心。在 .NET 生态里摸爬滚打这么多年,我见过太多刚入行的朋友打开 Visual Studio,新建一个 Web 项目,然后第一件事就是手写 ADO.NET 那一套 Connection、Command、DataReader,写出来的代码又长又容易错。明明 C# 世界里早就有了一套成熟的 ORM 方案,却还有人在重复造轮子。Entity Framework(简称 EF)就是那个轮子,而且是个相当好用的轮子。

这篇教程不是官方文档的翻译,也不是单纯的 API 罗列。我会结合自己在上位机项目、MES 对接、Web 服务这些场景里的实际经历,把 EF 从选型到落地、从 CRUD 到性能优化,按一条能直接往项目里套的路线讲清楚。不管你是刚学 C# 的新手,还是已经写了几年 C# 但一直没系统用过 EF 的老兵,这篇文章应该都能让你少踩几个坑。

1. 别急着写 SQL:先搞懂 EF 到底帮你解决了什么

很多人一提到 ORM 就想到“慢”“不靠谱”“复杂 SQL 没法写”,这种印象大多来自没搞懂 ORM 的设计动机就开始用。我先把 EF 为什么存在讲透,你后面用起来才不会心虚。

1.1 手写 ADO.NET 的真实痛苦

先说最原始的 ADO.NET 写法。假设你要查一批订单,传统代码长这样:

using (var conn = new SqlConnection(connectionString)) { conn.Open(); var cmd = new SqlCommand("SELECT * FROM Orders WHERE CustomerId = @customerId", conn); cmd.Parameters.AddWithValue("@customerId", customerId); var reader = cmd.ExecuteReader(); var list = new List<Order>(); while (reader.Read()) { list.Add(new Order { Id = reader.GetInt32(0), OrderNo = reader.GetString(1), Amount = reader.GetDecimal(2) }); } return list; }

这段代码本身不算复杂,但问题是:你的项目里只要每个表写一遍这种查询,再来点联表、分页、条件拼接,代码量立刻爆炸。更麻烦的是字段改动时,SQL 字符串、DataReader 索引、实体属性这三处要同步改,漏一处就在运行时才能发现。我做上位机项目时接过一个老系统,里面全是这种代码,光一个报警表查询就有十几个参数拼接,后来改成 EF,差不多删掉了三分之二的数据库访问代码。

1.2 ORM 的核心价值:让对象和表互相对话

EF 的全称是 Entity Framework,核心思想是对象关系映射(ORM)。你可以把它理解成一个“翻译官”:数据库里的表是关系型的二维结构,而 C# 代码里是对象和集合,EF 负责把这两者互相转换。

为什么需要这个翻译?因为直接操作 DataTable 和 DataReader 时,你脑子里要一直惦记着“第几列是什么类型”“表结构长什么样”。但用 EF 之后,你面对的是DbContext和实体类,增删改查在代码层面变成了context.Orders.Where(...)这种强类型 LINQ 写法。编译期就能发现字段名拼错的问题,IDE 还能自动补全。说白了,EF 把“数据库字段”和“业务对象”之间那层胶水代码省掉了,让你把精力放在业务逻辑上。

1.3 EF6 和 EF Core,到底学哪个

这是个老问题,直接给结论:新项目无脑选 EF Core,老项目维护才考虑 EF6。EF6 是 .NET Framework 时代的产物,功能成熟,但跨平台能力和性能都不如 EF Core。EF Core 从 3.x 开始基本追平了 EF6 的主要功能,到现在的 8.x、9.x,性能在很多场景下已经优于 EF6,而且支持 SQLite、PostgreSQL、MySQL 等多种数据库,不锁死在 SQL Server 上。

我自己的选型标准很简单:只要是新写的应用,一律 EF Core;只有维护那种跑了很多年的 .NET Framework 老系统,不得不保留 EF6 时才用 EF6。这篇教程的示例也都基于 EF Core 8。

2. 从零搭一个 EF Core 项目

光讲概念不行,你得真的把项目跑起来。这一章我带你把 EF Core 环境搭好,目标只有一个:能用代码往数据库里写入第一条数据。

2.1 创建项目并安装依赖

我建议先建一个控制台项目用来练习,命令很简单:

dotnet new console -n EfDemo cd EfDemo dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Design dotnet add package Microsoft.EntityFrameworkCore.Tools

如果你用 Visual Studio,也可以直接右键项目“管理 NuGet 程序包”,搜索对应包名安装。这里有个容易忽略的点:Microsoft.EntityFrameworkCore.Design这个包是给迁移命令用的,很多人只装了 SqlServer 包,后面执行Add-Migration时会报“找不到设计时服务”,这就是原因。

提示:如果只是做练习,也可以先用 SQLite,安装Microsoft.EntityFrameworkCore.Sqlite包即可,不需要额外装数据库服务,对新手更友好。下面我的示例会用 SQL Server 的写法,但切换到 SQLite 只需要改连接字符串和 UseSqlite 这一行。

2.2 定义实体和 DbContext

我先用一个工控场景里的报警记录来举例。假设产线设备产生的报警要入库,实体类定义如下:

public class AlarmRecord { public int Id { get; set; } public string DeviceCode { get; set; } = string.Empty; public DateTime AlarmTime { get; set; } public string Level { get; set; } = "Info"; public string Message { get; set; } = string.Empty; public DateTime? RecoveryTime { get; set; } }

DbContext 负责跟数据库打交道,我习惯管它叫MesDbContext:

public class MesDbContext : DbContext { public DbSet<AlarmRecord> AlarmRecords { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlServer("Server=.;Database=EfDemoDb;Trusted_Connection=True;TrustServerCertificate=True;"); } } }

注意我加了IsConfigured判断,这样在依赖注入场景里不会被 OnConfiguring 覆盖,算是个好习惯。

2.3 首次写入:Add + SaveChanges

现在在 Main 方法里写点数据:

using var context = new MesDbContext(); context.AlarmRecords.Add(new AlarmRecord { DeviceCode = "CNC-01", AlarmTime = DateTime.Now, Level = "Error", Message = "主轴过载报警" }); context.SaveChanges();

SaveChanges是 EF 的一个关键方法,它会把所有状态为 Added 的实体生成 INSERT 语句并一次性提交。这里一定记住:EF 不是每Add一次就执行一次数据库写入,真正触发 SQL 的是SaveChanges。多次Add后调用一次SaveChanges,EF 会帮你包在一个事务里执行,要么全成功,要么全失败。

3. 三种开发模式怎么选?别再纠结

EF 从诞生起就有三种玩法:DatabaseFirst、CodeFirst、ModelFirst。很多教程把它们列了一堆,结果新手看完更晕。我直接结合项目场景说结论。

3.1 DatabaseFirst:老系统的救命稻草

数据库已经存在,不想手动写实体类,用 Scaffold 命令从数据库反向生成模型。EF Core 对应命令是:

dotnet ef dbcontext scaffold "Server=.;Database=EfDemoDb;Trusted_Connection=True;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer -o Models

执行完会在 Models 目录下生成一堆实体类和 DbContext。这种模式适合改造老系统、数据库由 DBA 团队主导的场景。注意一个问题:生成的代码不要手动改,配置类(xxxConfiguration)也要保留,后续想扩展逻辑就写 partial 类,否则重新生成时改动会被覆盖。

3.2 CodeFirst:新项目的主流玩法

先写实体和 DbContext,再用迁移命令生成数据库。我的推荐姿势:

dotnet ef migrations add InitDatabase dotnet ef database update

第一条命令会对比当前实体模型和数据库的差异,生成一个迁移文件;第二条命令把迁移应用到数据库。这个机制有点像 Git:迁移文件就是一个个提交记录,数据库就是最终的工作区。实体改了,再migrations add一个新迁移,database update一次,数据库结构就跟着升级了。

我在做新项目时几乎无脑选 CodeFirst,因为业务模型一开始往往没定型,改实体再迁移的成本远低于手工改数据库。

3.3 ModelFirst,直接放弃

ModelFirst 在 EF6 里可以通过可视化设计器画模型生成数据库,但 EF Core 已经明确不支持了。而且实际项目里,哪个团队愿意维护一个 .edmx 的 XML 可视化模型?我见过一个老项目,改个字段要把设计器拖来拖去,还不如直接写代码。所以这条不纠结,忘了它。

选择逻辑就一句话:数据库已有且不能大动,用 DatabaseFirst;数据库可以跟着代码走,用 CodeFirst。别混着用,混着用迁移历史会乱成一个坑。

4. CRUD 和查询,真正干活的核心代码

环境搭好、模式定好,接下来就是天天要写的增删改查。这里我把最常用的写法和容易踩的坑一起说。

4.1 增删改查的规范写法

查询这块,EF 的 LINQ 语法比拼接 SQL 舒服太多:

// 查询单个 var alarm = context.AlarmRecords.FirstOrDefault(a => a.Id == 1); if (alarm == null) return; // 带条件、排序、分页 var page = context.AlarmRecords .Where(a => a.Level == "Error") .OrderByDescending(a => a.AlarmTime) .Skip(0).Take(20) .ToList();

修改要先查到实体,再改属性,最后SaveChanges。这里面有个技巧:如果全部字段都可能被更新,直接调用Update方法会标记整个实体为 Modified,更新时带上所有列。但你只想更新某个字段时,这么做既浪费又容易覆盖别处同时修改的值,更稳的是手动指定属性:

var alarm = context.AlarmRecords.Find(1); if (alarm == null) return; alarm.Message = "更新后的报警描述"; context.SaveChanges();

删除更简单,查到实体后Remove,然后SaveChanges。如果是批量删除,EF Core 7 之后提供了ExecuteDelete:

context.AlarmRecords.Where(a => a.AlarmTime < DateTime.Now.AddDays(-30)).ExecuteDelete();

这个会直接生成 DELETE 语句,不走实体跟踪,性能非常好。但注意它不会触发 SaveChanges 事务,内部自己处理。

4.2 关联数据加载:Include、延迟加载与显式加载

实际项目中不存在单表玩到底的情况。我做一个设备状态页面时,要同时显示设备和它的报警记录,这就涉及导航属性。实体定义要改:

public class Device { public int Id { get; set; } public string Code { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public List<AlarmRecord> AlarmRecords { get; set; } = new(); }

查询时用Include把关联数据一起捞出来,这就是所谓的贪婪加载:

var device = context.Devices .Include(d => d.AlarmRecords) .FirstOrDefault(d => d.Id == 1);

多级关联用ThenInclude:

.Include(d => d.AlarmRecords) .ThenInclude(a => a.Operator)

如果你的AlarmRecord里还有个Operator导航属性,要一直这么链下去。这里最坑的是延迟加载。EF Core 默认不启用延迟加载,如果你没有Include,访问导航属性返回的是空集合而不是自动查数据库。很多人第一次用 EF Core,发现device.AlarmRecords是空的,还以为代码写错了。想启用延迟加载需要在 DbContext 配置里加:

optionsBuilder.UseLazyLoadingProxies().UseSqlServer(...);

并且实体的导航属性必须标记为virtual。我个人的建议是默认就别开延迟加载,因为容易触发 N+1 查询问题(后面细讲)。想看关联数据就老老实实Include,这是最可控的写法。

4.3 跟踪与 AsNoTracking:什么时候用投影

EF 默认会跟踪查询出来的实体。跟踪的好处是:改了属性后SaveChanges会自动检测变化并更新。但如果你只是查出来展示,不打算改,跟踪就白白增加了开销。我的习惯是:只读报表、导出、下拉框数据一律加AsNoTracking():

var list = context.AlarmRecords.AsNoTracking().Where(...).ToList();

还有一种是纯查询不想映射成实体,直接用匿名类型投影:

var result = context.AlarmRecords .Where(a => a.Level == "Error") .Select(a => new { a.Id, a.Message, a.AlarmTime }) .ToList();

这种写法只查需要的列,生成的 SQL 也更精简,特别适合做统计报表。

5. 迁移、事务、并发:上线后的三个硬话题

CRUD 玩熟之后,项目要部署了,接着就轮到迁移、事务、并发这些不那么“日常”但绝对不能轻视的问题。

5.1 迁移不是一次性命令,而是一套发布流程

开发环境你随便dotnet ef database update,但生产环境千万别直接跑这个命令。我踩过一次坑:直接在生产库执行 update,结果迁移脚本把一张大表加了索引,锁表十几分钟,所有业务卡死。正确的发布流程应该是用Script-Migration生成 SQL 脚本,交给 DBA 在维护窗口执行:

dotnet ef migrations script -o upgrade.sql

生成的 SQL 脚本拿到生产环境前,先在测试库完整跑一遍,确认影响的数据量和执行时间。这个习惯能救命的次数,比我数得上的都多。另一个坑是迁移文件一旦合到主干,就不要回滚修改它。错了就新增一个迁移去改,而不是编辑旧迁移,否则多个开发者的本地迁移链会断裂。

5.2 事务与并发控制

SaveChanges本身是隐式事务,但一次业务操作涉及多个SaveChanges时,就要显式开启事务。

using var transaction = await context.Database.BeginTransactionAsync(); try { context.AlarmRecords.Add(new AlarmRecord { ... }); await context.SaveChangesAsync(); context.ParamRecords.Add(new ParamRecord { ... }); await context.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }

并发控制是另一大块。比如上位机多个线程同时更新同一条设备状态,可能互相覆盖。EF 常见做法是乐观并发:给实体加一个RowVersion字段,数据库类型为 rowversion,实体上标记为并发标记。

public class Device { public int Id { get; set; } public string Status { get; set; } = string.Empty; public byte[] RowVersion { get; set; } = Array.Empty<byte>(); }

配置上要告诉 EF 这个是并发令牌:

modelBuilder.Entity<Device>() .Property(d => d.RowVersion) .IsRowVersion() .IsConcurrencyToken();

这样当两条请求同时读到同一条记录,先提交的成功,后提交的更新时数据库 RowVersion 已经变了,EF 会抛出DbUpdateConcurrencyException,让你感知到并发冲突并决定重试或提示用户。这个机制实现简单,而且不用锁数据库表,大多数项目够用了。

5.3 日志和 SQL 排查

EF 有个容易被忽视的好功能:把生成的 SQL 打印出来。在 OnConfiguring 里加上:

optionsBuilder.LogTo(Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information) .EnableSensitiveDataLogging();

LogTo会在控制台输出每条 SQL 语句。EnableSensitiveDataLogging会显示参数值,生产环境不要开,但开发调试非常有用。遇到“EF 查出来的数据不对”“莫名其妙报错”,先看日志里的 SQL 到底是什么。我见过太多人上来就怀疑 EF 有 bug,其实九成是自己实体配置写错,生成的 SQL 压根不是自己想的那条。

6. 性能优化与常见问题排查

这一章我把日常性能优化和高频报错整理在一起。你能少走几步弯路,这篇文章就算没白写。

6.1 N+1 问题与查询优化

N+1 是指先查 1 条主记录,再循环查 N 次关联表。比如遍历设备查报警记录,如果你没用 Include 也没开延迟加载,那每个循环里 EF 就发一条 SELECT,10 台设备就是 10+1 条 SQL。数据量一上来,数据库直接累趴。解决办法就是批量用Include或投影,一次把数据拉回来。

另外,多级 Include 时 EF 默认会生成一个大的 JOIN 查询,如果关联数据多,会造成数据膨胀。EF Core 5 之后可以用AsSplitQuery()把 JOIN 拆成多条独立的 SQL 查询:

var devices = await context.Devices .Include(d => d.AlarmRecords) .AsSplitQuery() .ToListAsync();

这样结果一致,但每张表单独查,避免了笛卡尔积式的结果集膨胀。我通常在含多个集合导航属性时都会加这个。

6.2 分页、索引和连接池

分页如果数据量超过几十万,Skip/Take的偏移式分页越翻越慢。EF Core 8 引入了基于游标的分页表达式,返回Page<Device>并带上NextPageToken,配合键集分页(where 条件大于上一页最后一条 ID),性能稳定得多。如果你还在用 EF 6,那就老老实实给排序字段建索引,不然分页到后面几张表 join 的性能会很感人。

索引这块,EF 里的HasIndex配置会生成迁移,帮你把索引建到数据库:

modelBuilder.Entity<AlarmRecord>() .HasIndex(a => new { a.DeviceCode, a.AlarmTime });

外键列默认不自动建索引,很多查询慢了才发觉关联字段上没索引。遇到多表 join 性能差,先检查外键列索引。

连接池和 DbContext 实例也要注意。ASP.NET Core 里 DbContext 默认注册为 Scoped,跟着请求走是没问题的。但如果你是上位机这种长驻服务,别图省事用一个静态 DbContext 实例,多线程并发访问会抛“DbContext 不能被多个线程同时使用”。正确做法是用IDbContextFactory按需创建:

services.AddDbContextFactory<MesDbContext>(options => options.UseSqlServer(...));

需要时CreateDbContext(),用完就释放,既避免线程冲突,又不会频繁建连接。这个模式在工控、WPF、控制台这类非 Web 场景里真的非常实用。

6.3 常见错误速查与排坑手册

我把这几年见过最多的报错列个表,方便你直接对号入座:

症状原因处理办法
无法解析 DbContext 服务没注册AddDbContext或注册错了生命周期检查 DI 注册,确保构造函数能拿到实例
同一主键实体已被跟踪同一个 DbContext 查询了两次相同实体,没用 AsNoTracking合并查询,或改为 AsNoTracking
迁移生成 SQL 为空,或新列始终查不到迁移没执行到数据库执行dotnet ef database update或用脚本应用
某列不能为 null,实体是 int实体类非空属性的列在数据库里是 nullable检查迁移,要么数据库改成 not null,要么实体改成可空
跨线程访问 DbContext 报错DbContext 被静态实例或单例共享改成工厂模式,每个任务新建实例
SQLite 下 DateTime 精度丢失,Decimal 不支持SQLite 本身类型限制改用 SQL Server,或由应用层处理精度

另外提一句非 EF 但常见的热词场景:C# 调用 C++ 报 access violation c0000005,多半是 P/Invoke 或委托指针的内存释放问题,跟 EF 没啥关系,但如果你在上位机里用了 EF 做数据落库,又想用 C++ 组件做图像处理,务必把非托管资源和 DbContext 的生命周期分开管理,不然排查起来真的会疯。

7. 写在最后:关于 EF 我再多啰嗦几句

最后说点个人的经验体会。前几年给一条自动化产线写数据采集服务时,最初的数据库访问层是 ADO.NET 封装的一个帮助类,业务一复杂,SQL 和实体映射满屏都是。后来我重构成 EF Core,数据模型、位移迁移、查询日志、并发控制一次到位,代码量少了,问题也好查了。但这并不代表 EF 是所有场景的最优解。我的选型标准很简单,供你参考。

实体关系复杂、模型经常变动、需要快速开发业务,优先 EF Core,它的迁移机制和强类型查询能省掉大量机械劳动。如果只是查一条数据、跑一个报表,或者极端追求吞吐量,用 Dapper 这类微 ORM 完全没问题。真正重要的不是哪个框架更“高级”,而是你的代码可不可控、数据库能不能跑稳。

如果你现在正准备在公司项目里引入 EF,我的建议是从一个不起眼的小模块开始,比如报警记录、操作日志这类结构简单、风险低的数据表,先在真实环境里摸清迁移和部署流程,再逐步铺开。等你和 EF 磨合得差不多了,你会发现写数据库代码终于不再是一件需要反复提心吊胆的事了。最后一个提醒:产品上线改表结构前,先练一遍迁移脚本,这是我能给你的最有价值的建议。

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

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

立即咨询