
如果你让我用一个词概括 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 ListOrder(); 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 负责跟数据库打交道我习惯管它叫MesDbContextpublic class MesDbContext : DbContext { public DbSetAlarmRecord AlarmRecords { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { if (!optionsBuilder.IsConfigured) { optionsBuilder.UseSqlServer(Server.;DatabaseEfDemoDb;Trusted_ConnectionTrue;TrustServerCertificateTrue;); } } }注意我加了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后调用一次SaveChangesEF 会帮你包在一个事务里执行要么全成功要么全失败。3. 三种开发模式怎么选别再纠结EF 从诞生起就有三种玩法DatabaseFirst、CodeFirst、ModelFirst。很多教程把它们列了一堆结果新手看完更晕。我直接结合项目场景说结论。3.1 DatabaseFirst老系统的救命稻草数据库已经存在不想手动写实体类用 Scaffold 命令从数据库反向生成模型。EF Core 对应命令是dotnet ef dbcontext scaffold Server.;DatabaseEfDemoDb;Trusted_ConnectionTrue;TrustServerCertificateTrue; 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 之后提供了ExecuteDeletecontext.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 ListAlarmRecord 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。我个人的建议是默认就别开延迟加载因为容易触发 N1 查询问题后面细讲。想看关联数据就老老实实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.Emptybyte(); }配置上要告诉 EF 这个是并发令牌modelBuilder.EntityDevice() .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 N1 问题与查询优化N1 是指先查 1 条主记录再循环查 N 次关联表。比如遍历设备查报警记录如果你没用 Include 也没开延迟加载那每个循环里 EF 就发一条 SELECT10 台设备就是 101 条 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 引入了基于游标的分页表达式返回PageDevice并带上NextPageToken配合键集分页where 条件大于上一页最后一条 ID性能稳定得多。如果你还在用 EF 6那就老老实实给排序字段建索引不然分页到后面几张表 join 的性能会很感人。索引这块EF 里的HasIndex配置会生成迁移帮你把索引建到数据库modelBuilder.EntityAlarmRecord() .HasIndex(a new { a.DeviceCode, a.AlarmTime });外键列默认不自动建索引很多查询慢了才发觉关联字段上没索引。遇到多表 join 性能差先检查外键列索引。连接池和 DbContext 实例也要注意。ASP.NET Core 里 DbContext 默认注册为 Scoped跟着请求走是没问题的。但如果你是上位机这种长驻服务别图省事用一个静态 DbContext 实例多线程并发访问会抛“DbContext 不能被多个线程同时使用”。正确做法是用IDbContextFactory按需创建services.AddDbContextFactoryMesDbContext(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 磨合得差不多了你会发现写数据库代码终于不再是一件需要反复提心吊胆的事了。最后一个提醒产品上线改表结构前先练一遍迁移脚本这是我能给你的最有价值的建议。