先说个场景,几年前我做一个订单模块,一个查询要关联用户、订单、明细、商品四张表,手写 ADO.NET 的时候,SqlConnection 开一次,SqlCommand 拼一个复杂的 JOIN,再一个字段一个字段地读 DataReader,等四层嵌套对象装配完,光映射代码就快一百行。那时候我就在想,数据访问这块一定有更优雅的做法。后来我接触了 ORM,然后一头扎进了 Entity Framework——也就是 .NET 生态里最常见的那个 ORM 框架。这篇文章不是复述官方文档,而是把我从建实体、配映射、写查询、做迁移到性能调优这一路踩过的坑和沉淀下来的方法,完整整理一遍。如果你是刚接触 EF 的 .NET 开发者,或者用了很久但没搞明白 DbContext 和 LINQ 背后原理的人,这篇应该对你有用。
1. 为什么我会在.NET项目里坚持用EF——从手写ADO.NET到ORM的转变
1.1 手写数据访问层的日子,谁写谁知道
很多老 .NET 开发者都有过这样的经历:一个简单查询,写起来却不简单。
using (var connection = new SqlConnection(connectionString)) { var command = new SqlCommand( "SELECT Id, Name, Price, CreatedAt FROM Products WHERE Id = @id", connection); command.Parameters.AddWithValue("@id", productId); connection.Open(); using (var reader = command.ExecuteReader()) { if (reader.Read()) { var product = new Product { Id = reader.GetInt32(0), Name = reader.GetString(1), Price = reader.GetDecimal(2), CreatedAt = reader.GetDateTime(3) }; } } }这段代码看着不长,但它在真实项目里会膨胀得非常快。查询列表要写一份,按条件筛选要加 WHERE 拼接,插入要写 INSERT,更新要写 UPDATE,还要处理参数化防止 SQL 注入。每个实体都要配一套这样的代码。更别提关联查询时,你还要手动维护对象之间的父子关系。可以说,大部分时间都花在了和数据访问机制搏斗上,而不是真正在写业务逻辑。
1.2 EF到底帮你省了什么
Entity Framework 本质上是一个对象-关系映射框架,简称 ORM。它做的事可以用一句话概括:把数据库里的表和关系,映射成 C# 类和对象属性,让你用操作内存对象的方式去操作数据库。相当于在数据库和业务代码之间请了一位"翻译官",你只管跟它说要什么数据,它负责翻译成 SQL,去数据库查了再翻译回来。
具体来说,EF 帮我省掉了这几类工作:
- 映射样板代码:实体类定义好,DbContext 配好,CRUD 操作基本不写 SQL。
- 参数化安全:用 LINQ 写查询,参数会自动处理,SQL 注入风险大大降低。
- 变更跟踪:你改了实体的属性,EF 能自动检测,SaveChanges 时只生成必要的 UPDATE 语句。
- 跨数据库能力:同样的代码,可以跑在 SQL Server、SQLite、PostgreSQL 等不同数据库上,换库时不用重写数据访问层。
说白了,ORM 的核心价值不是"不用写 SQL",而是把数据访问从"手工作坊"变成"流水线",开发效率提升非常明显。刚用的时候你可能觉得不习惯,但一旦适应了这种思路,再回去写手写 ADO.NET 真的会觉得很痛苦。
1.3 这套框架适合谁用
我觉得没有绝对"最好"的框架,只有"合不合适"。如果你和我一样,做的是信息管理系统、电商后台、API 服务这类以业务逻辑为主的 .NET 应用,EF 非常合适。它能让你把注意力放在业务流程上,快速迭代功能。
但如果你的场景是极高性能要求的报表服务,或者需要精细控制每一条 SQL 语句,那 EF 这种高度抽象的框架反而不方便。这时候可以搭配 Dapper 这类轻量型工具,或者干脆直接写存储过程。我见过不少团队的做法是:EF 负责常规 CRUD,复杂报表查询用 Dapper 或者视图。两条腿走路,互相补充。
2. DbContext的生命周期就是整个数据层的命门
2.1 一次SaveChanges就是一个完整的工作单元
DbContext 是 EF 里最核心的类,很多人只把它当成"用来查数据库的对象",其实它是一个工作单元(Unit of Work)的实现。所谓工作单元,就是把一组相关操作放进同一个"事务性边界"里,要么全部成功,要么全部失败。
打个比方,你在电商后台做一个"订单 + 扣库存 + 加积分"三个操作,如果分开执行三步,中间一步失败就麻烦了。用 DbContext 的写法是:三个操作都改完,最后统一调一次 SaveChanges。EF 会把所有变更放在一个数据库事务里提交,任何一个失败,所有变更回滚。这个特性让业务代码干净很多,不用到处手动开事务、提交、回滚。
using (var context = new AppDbContext()) { var order = new Order { UserId = userId, Amount = 100 }; context.Orders.Add(order); var product = await context.Products.FindAsync(productId); product.Stock -= 1; var pointLog = new PointLog { UserId = userId, Points = 10 }; context.PointLogs.Add(pointLog); await context.SaveChangesAsync(); }你以为自己在操作几个对象,其实 EF 在幕后统一管理了所有变更,保证一致性。理解这一点,是后面用好 DbContext 的基础。
2.2 生命周期太长或太短,结果都很惨
DbContext 的生命周期设计,是我见过新手最容易翻车的地方,也是最影响性能的地方。
生命周期过长,比如一个桌面应用把同一个 DbContext 当成单例一直用,会出现几个问题:第一,ChangeTracker(变更跟踪器)会记住所有查询过的实体,时间一长内存占用越来越高;第二,因为缓存了太多旧数据,每次查询都可能返回过期值;第三,上下文连接长时间挂着,数据库连接池被占用。
生命周期过短,比如每个方法都 new 一个 DbContext,看起来省事,但频繁创建、销毁上下文对象也是有开销的。在 Web 应用里,更好的做法是每个请求一个 DbContext,请求结束就释放。这正是依赖注入容器里 AddDbContext 默认的 Scoped 生命周期做的事。
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));这样注册之后,同一个 HTTP 请求范围内共享同一个 DbContext,请求结束由容器统一释放。不用自己手动 new,也不用纠结 using 包裹,框架层面帮你做了正确的事。
2.3 线程安全:一个很隐蔽的坑
DbContext 不是线程安全的,这一点文档写得很清楚,但实际项目里还是经常有人踩。原因是写异步代码的时候,一不小心就会在多个任务里共享同一个 DbContext。
我见过一个真实案例:某同事在后台批量处理数据时,用 Task.WhenAll 同时跑多个查询操作,共享同一个 DbContext,结果时不时抛出"InvalidOperationException: A second operation was started on this context instance before a previous operation completed"。报错信息里的"second operation started before previous completed"就是典型的并发冲突。
解决办法很简单:不要在多个并发任务里共享一个 DbContext。如果确实需要并行处理,每个任务单独创建自己的 DbContext。上一条说的 Scoped 生命周期,在每个请求内是安全的,因为请求本身就是串行处理的。
2.4 Disposable:别被using绑架了思维
很多人习惯用 using 包裹 DbContext,这没错,但依赖注入场景下你再手动 using 反而是错。比如在 ASP.NET Core 的注入模式下,容器负责释放,你再手动 Dispose,反而可能导致后续操作无法进行。
我的习惯是:依赖注入的 DbContext 不手动释放,交给容器;非依赖注入场景(比如控制台程序或单元测试),用 using 或 try-finally 确保释放。核心原则只有一个:谁创建,谁负责释放。
3. 实体映射三选一:约定、数据注解还是Fluent API
3.1 先让约定帮你干80%的活
EF 有一组默认的映射规则,只要你的实体类符合这些规则,几乎不用写任何配置。比如:
- 名为 Id 或者 类名+Id 的属性会被识别为主键。
- 类型为某个实体集合的属性会被识别为一对多导航属性。
- 类型为某个实体类型的属性会被识别为外键导航属性,自动创建外键关系。
- 表名默认用实体类的复数,比如 Product 对应 Products。
public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int CategoryId { get; set; } public Category Category { get; set; } }这段代码不写任何配置,EF 就能自动生成 Products 表和 Categories 表,并且建立 CategoryId 的外键关系。这 80% 的场景其实不需要额外配置,很多新人不知道这一点,一上来就写一大堆 Fluent API,反而画蛇添足。
3.2 数据注解:简单直观,但有侵入性
当约定不能满足需求时,第一个补强手段是数据注解(Data Annotations),直接在实体类属性上打特性标签。
public class Product { [Key] public int ProductId { get; set; } [Required] [MaxLength(100)] public string Name { get; set; } [Column("Price")] public decimal Amount { get; set; } [NotMapped] public string DisplayName => $"{Name} ({Id})"; }[Key] 指定主键,[Required] 指定非空,[MaxLength(100)] 限制长度,[Column] 把属性映射到指定列名,[NotMapped] 表示这个属性不映射到数据库。数据注解的优点是阅读代码时马上能看到规则,缺点是实体类会掺杂一堆和业务无关的数据库细节,职责不够纯粹。
3.3 Fluent API:最灵活但容易写乱的方式
第三种方式是在 DbContext 的 OnModelCreating 方法里用链式 API 配置。它的最大优势是配置和实体类分离,所有映射规则集中在一个地方,复杂关系、联合主键、索引等配置能力最强。
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Product>(entity => { entity.ToTable("Products"); entity.HasKey(p => p.Id); entity.Property(p => p.Name) .IsRequired() .HasMaxLength(100); entity.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId) .OnDelete(DeleteBehavior.Restrict); entity.HasIndex(p => p.Name) .HasDatabaseName("IX_Products_Name"); }); }这段配置做了四件事:指定表名、主键、Name 字段的约束,以及 Category 关系的外键删除行为(防止误删分类时连带商品一起删)。对于复杂项目,我倾向于把每个实体的配置单独放到一个 IEntityTypeConfiguration 实现类中,避免 OnModelCreating 越来越臃肿。
3.4 三个阶段的选择策略
我现在的选择顺序是:约定优先,Fluent API 处理特殊需求,数据注解只在这种场景用——类很小、规则很简单,不想为了几个配置再拆文件。
| 特点 | 约定 | 数据注解 | Fluent API |
|---|---|---|---|
| 配置位置 | 无 | 实体类属性上 | 独立的配置类或 OnModelCreating |
| 可读性 | 最好,无额外代码 | 直观,但夹杂在实体中 | 集中,但需要找 |
| 表达能力 | 弱,只覆盖常规场景 | 中等 | 最强,支持复杂关系/索引 |
| 侵入性 | 无 | 实体类被污染 | 实体类保持纯净 |
| 适合场景 | 简单的实体 | 少量规则 | 复杂映射、团队协作 |
还有个经验:一个项目里尽量统一使用一种主要方式,不要三种混着用。混用的项目后面维护起来很头痛,因为同样的外键关系,你在三个地方都能改,最后都不知道哪条规则生效。
4. LINQ查询的幕后:延迟执行、表达式树与SQL翻译
4.1 一次查询从写出到执行的完整旅程
使用 EF 写查询,你可能写过类似这样的代码:
var expensiveProducts = await context.Products .Where(p => p.Price > 500) .OrderBy(p => p.Name) .Select(p => new { p.Id, p.Name, p.Price }) .ToListAsync();很多人以为这段代码执行时,会先在内存里把所有商品加载进来,再 Where、OrderBy、Select。如果真的这样,EF 的性能早就被喷成筛子了。实际上,这段 LINQ 查询会被构建成表达式树(Expression Tree),EF 把表达式树翻译成 SQL 语句,然后在数据库端执行。
数据库里实际执行的 SQL 大致是这样:
SELECT [p].[Id], [p].[Name], [p].[Price] FROM [Products] AS [p] WHERE [p].[Price] > 500.0 ORDER BY [p].[Name]也就是说,过滤、排序、投影都发生在数据库里,只返回需要的字段。这正是 LINQ to Entities 和 LINQ to Objects 最大的区别:前者是让数据库干活,后者是让内存干活。
4.2 IQueryable的懒本质:延迟执行
关键概念在这里:IQueryable 类型的查询是延迟执行的。什么叫延迟执行?就是你写 Where、OrderBy、Select 时,EF 只是在"攒 SQL 片段",还没真正访问数据库。当你执行 ToListAsync、FirstOrDefaultAsync、CountAsync 这类"终结操作"时,SQL 才会真正发送到数据库执行。
这个特性有好处也有坑。好处是你可以分步构建查询——根据条件动态拼接 Where,而不会产生多次数据库访问。
IQueryable<Product> query = context.Products; if (!string.IsNullOrEmpty(keyword)) { query = query.Where(p => p.Name.Contains(keyword)); } if (categoryId.HasValue) { query = query.Where(p => p.CategoryId == categoryId.Value); } var results = await query.OrderBy(p => p.Id).ToListAsync();注意这里我在 if 里不断往 query 上追加 Where,最终只执行一次数据库查询。但坑也随之而来——如果你不小心在中间调用了 ToList(),结果就会变成"每走一步查一次库",性能瞬间恶化。记住一个口诀:能拖到最后的操作,不要提前执行;ToList 这类终结操作只出现在最终需要数据的那个环节。
4.3 表达式树:EF能翻译与不能翻译的边界
表达式树之所以能被翻译成 SQL,是因为它保留了"代码逻辑的结构",而不是直接执行。EF 的查询翻译器会遍历表达式树,把节点转换为 SQL 表达式。
这个机制决定了一件事:EF 只能翻译它认识的、能被转换成 SQL 的表达式。一旦你写了无法翻译的代码,EF 会在运行时报错,说"could not be translated"。
最常见的反例是在查询里调用自定义方法:
// 报错:could not be translated var list = await context.Products .Where(p => IsExpensive(p.Price)) .ToListAsync(); // C# 方法 static bool IsExpensive(decimal price) => price > 500;EF 没法把一个自定义 C# 方法翻译成 SQL,因为数据库里没有对应实现。正确的做法是展开表达式,让 EF 能理解:
var list = await context.Products .Where(p => p.Price > 500) .ToListAsync();一个实用建议:写查询时,在脑子里把代码里的每个操作"翻译成 SQL 试试",翻译不了的立刻拆出来。比如字符串操作里,EF 支持 Contains、StartsWith、EndsWith,但对 Regex 这类高级操作无能为力。
4.4 关联查询的三个层次:Include、ThenInclude与投影
查询关联数据时,最容易犯的错误是 N+1 问题。比如查订单列表,然后循环里逐个访问每个订单的用户信息:
var orders = await context.Orders.ToListAsync(); // 1次查询 foreach (var order in orders) { Console.WriteLine(order.User.Name); // 每个订单又查一次User }如果订单有 100 个,就是 1 + 100 次查询,这就是著名的 N+1 问题。解决方法是使用 Include 预加载(Eager Loading),让 EF 用 JOIN 一次把关联数据查出来:
var orders = await context.Orders .Include(o => o.User) // 加载用户 .Include(o => o.OrderItems) // 加载订单明细 .ThenInclude(i => i.Product) // 再加载明细里的商品 .ToListAsync();但这也不是万能的。如果关联层级很深、数据量很大,JOIN 会产生笛卡尔爆炸,返回的行数暴增,传输数据量巨大。这时候可以选择投影(Projection),只挑需要的字段:
var orders = await context.Orders .Select(o => new OrderDto { Id = o.Id, UserName = o.User.Name, // 只取用户名 ProductNames = o.OrderItems.Select(i => i.Product.Name).ToList() }) .ToListAsync();投影后的 SQL 通常比 Include 更高效,因为只 SELECT 了需要的列。这是我在实际项目里强烈推荐的方式。
4.5 调试利器:把EF生成的SQL打出来看
很多时候,你写完 LINQ 以为它生成了高效的 SQL,实际一看根本不是那么回事。我的习惯是开发环境开启日志,把 EF 生成的 SQL 直接打印到控制台或写入日志文件。
在 ASP.NET Core 里可以用 EnableSensitiveDataLogging 输出参数值,但要小心敏感数据泄露,生产环境千万别开。更轻量的是在 LINQ 执行前调用 ToQueryString() 拿到 SQL:
var query = context.Products.Where(p => p.Price > 500); var sql = query.ToQueryString(); Console.WriteLine(sql);每次写完复杂查询,我都会先 ToQueryString 看一眼 SQL:有没有多余的 SELECT 列,有没有把本该在数据库做的操作拉到内存里做,连接是否合理。这一步能帮我发现大量潜在问题。
5. 数据库迁移实操:开发、协作与上线的完整流程
5.1 为什么需要迁移机制
很多刚接触 EF 的人有个疑问:我的实体类改了,数据库结构怎么跟着变?手工改数据库脚本,然后和代码对来对去,早晚会出现"代码和库对不上"的问题。EF 的迁移(Migrations)机制就是来解决这件事的:它把每一次模型变更记录下来,变成一个可重复执行的版本化脚本。
我用一句话向新人解释迁移:它就像代码仓库里的 commit,每当你修改了实体结构,用命令生成一次"模型变更的快照",数据库就沿着这些快照一路演进到最新状态。
5.2 从零开始:创建初始迁移
首先要安装 EF 命令行工具:
dotnet tool install --global dotnet-ef然后在项目目录执行:
dotnet ef migrations add InitialCreate dotnet ef database update第一条命令会生成一个 Migrations 文件夹,里面有迁移文件。第二条命令把迁移应用到数据库,建出所有表。这只是开始,真正的核心是日常迭代时的流程。
每当你改了实体类,比如加了一个字段,正确的流程是:
dotnet ef migrations add AddProductDescription dotnet ef database update每个迁移文件都有两个方法:Up 表示"正向应用变更",Down 表示"回滚这个变更"。这是迁移机制能前滚、能回滚的根本原因。
5.3 团队协作中的三条铁律
迁移文件在团队协作中是个容易出事的东西,我总结了三条铁律:
第一条,迁移文件一定要提交到版本控制里。有人觉得迁移文件是自动生成的、不用管,这是大错特错。没有迁移文件,同事拉下代码后根本无法把数据库升到最新版本。
第二条,已经生成并提交的迁移文件,不要手动修改。因为它记录的是"从上一个版本到当前版本的变更",手动改会导致迁移历史的哈希校验失败(EF Core 会检测模型快照是否与迁移一致)。如果发现迁移写错了,正确做法是回滚(migrations remove)再重新生成。
第三条,多个开发者并行开发时,尽量各自在独立分支上工作,合并代码时如果出现迁移冲突——比如两个人同时生成了一个 AddXxx 迁移——不要硬合并,让其中一方的迁移改名,或者重新基于最新的迁移基类再生成。我见过最让人头疼的情况,就是两个人改同一个实体,生成两份冲突的迁移,强行合并后 Up 和 Down 乱成一锅粥。
5.4 上线时的选择
生产环境要不要自动执行 database update?我的建议是不要。自动迁移在部署时执行 database update,一旦遇到锁表、长时间执行的变更,会直接影响线上服务。更稳妥的方式是:在发布流程中生成 SQL 脚本,由 DBA 或运维审阅后执行。
生成迁移脚本的命令是:
dotnet ef migrations script --idempotent -o upgrade.sql加 --idempotent 生成的脚本是幂等的,可以安全地重复执行。这样上线流程变成:先执行 SQL 脚本升级数据库,再发布新版本代码,顺序不能反。如果先发代码、再升级数据库,新代码可能报"列名不存在"之类的错。
5.5 回滚策略
即使流程做得再严谨,生产迁移出意外也是可能发生的。这时候要有回滚预案。EF 迁移支持向下回滚到指定版本:
dotnet ef database update PreviousMigrationName它会执行 PreviousMigrationName 之后所有迁移的 Down 方法。但我要提醒一句:涉及删除表、删除列的 Down 操作是不可恢复的,执行前一定要备份数据库。还有,如果已经在线跑了很久,表里已经有大量业务数据,回滚不只是结构问题,还牵扯数据一致性,这种情况下我通常宁可用"追加修复迁移"的方式往前修,而不是贸然回滚。
6. EF项目中的性能陷阱清单与实测调优
6.1 N+1查询问题:排查链路要完整
前文提到了 N+1,这个坑值得单独拿出来讲,因为它太常见了。典型代码长这样:
var categories = await context.Categories.ToListAsync(); foreach (var category in categories) { var count = await context.Products.CountAsync(p => p.CategoryId == category.Id); Console.WriteLine($"{category.Name}: {count}"); }这个循环里,每处理一个分类就执行一次 CountAsync,分类有 100 个就多出 100 次查询。我第一次遇到这个问题时,还以为是数据库慢,后来开着日志一看,瞬间几十条 SQL 刷屏。
正确做法是用分组聚合一次搞定:
var counts = await context.Products .GroupBy(p => p.CategoryId) .Select(g => new { CategoryId = g.Key, Count = g.Count() }) .ToListAsync();然后在内存里把分类和数量组装起来。排查 N+1 的最有效方法,就是打开 EF 的 SQL 日志,统计一次请求实际执行的查询次数。超过预期次数很多的情况下,基本就是 N+1。
6.2 追踪查询与非追踪查询的取舍
EF 默认会跟踪它从数据库查出来的实体,这带来一个好处:修改实体属性后 SaveChanges 能自动感知。但同时也有代价:跟踪实体需要额外内存,而且每次查询返回的实体如果已经被跟踪,EF 还要做实体身份解析(Identity Resolution),性能会打折扣。
对于只读查询,我基本都会用 AsNoTracking:
var products = await context.Products .AsNoTracking() .Where(p => p.IsActive) .ToListAsync();加了 AsNoTracking 之后,EF 不跟踪这些实体,查询速度更快,内存占用更低。但要注意,从这些实体拿到后如果做修改,SaveChanges 不会更新到数据库,因为 EF 不认识它们。
一个典型的场景是"列表展示 + 详情编辑":列表页用 AsNoTracking 提高性能,详情页进入编辑模式时再重新查出可跟踪的实体来修改。用对了,性能差别还是很明显的。
6.3 笛卡尔爆炸与拆分查询
当你用 Include 加载多层集合导航属性时,SQL 的 JOIN 会把结果行数乘起来。一个订单有 10 个明细,每个明细有 5 个商品,查 100 个订单就可能返回 100 × 5 × 10 = 5000 行。数据量不大还好,一旦数据量大,网络传输和内存占用都会爆炸,这就是笛卡尔爆炸。
EF Core 5.0 之后支持拆分查询(Split Query),让每个 Include 单独发一条查询,在内存里把关系重新组装:
var orders = await context.Orders .Include(o => o.OrderItems) .Include(o => o.User) .AsSplitQuery() .ToListAsync();但这不是银弹。拆分查询的代价是多次往返数据库,在网络延迟高的环境反而更慢。我的经验是:数据量小、集合层级简单时用单查询;关联集合很大、需要一次返回大量数据时,用 AsSplitQuery,或者直接用投影只取需要的字段。
6.4 分页的正确姿势:一定要OrderBy
做分页最容易踩的坑是 Skip 一个不稳定的顺序。比如:
var page = await context.Products .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync();如果这个查询没有显式 OrderBy,数据库返回的顺序是不保证稳定的。两次查询可能返回不同的数据,导致分页时数据重复或遗漏。ETag 的做法是分页查询里必须加上 OrderBy,而且排序字段最好是唯一的或组合唯一的:
var page = await context.Products .OrderBy(p => p.Id) .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync();深层分页(第1000页)还有一个性能问题:数据库要跳过前面 20000 行才能取 20 行。这时候用键集分页(Keyset Pagination)更合适,即记住上一页的最后一条记录,用 WHERE 条件继续往下取:
var lastId = ...; // 上一页最后一条的Id var page = await context.Products .Where(p => p.Id > lastId) .OrderBy(p => p.Id) .Take(pageSize) .ToListAsync();这种查询能直接走索引定位,翻到多深的页性能都不变。缺点是页面需要传一个"游标"参数,而不是简单的页码。
6.5 索引与查询的联动排查
有时候查询慢,问题不在 EF,而在数据库索引。EF 迁移可以很方便地创建索引:
entity.HasIndex(p => p.Name); entity.HasIndex(p => new { p.CategoryId, p.Price });但更重要的习惯是:每次写完一个重要查询,都该顺手看看执行计划有没有走索引。我有个习惯,在数据库里手动跑一遍 EF 生成的 SQL,打开执行计划,看有没有 Table Scan。有的话,要么加索引,要么改查询结构。这个习惯帮我解决过不少线上慢查询。
7. EF6与EF Core的差异,以及给新项目的选型建议
7.1 两代产品,定位完全不同
很多老项目还在用 EF6,新项目已经全部 EF Core 了。这两者的区别不只是大版本号不同,定位都变了。
EF6 是跟随 .NET Framework 时代成长起来的框架,功能非常全,设计上倾向于"一站式"解决数据访问。当时它在 .NET Framework 上运行得很好,但到了需要跨平台的时候,就暴露出问题。EF Core 是后来重新设计的产品,目标是轻量、可扩展、跨平台,支持 .NET (Core) 跨平台运行。
最直观的区别:EF6 没有官方提供针对 Linux/macOS 的完整支持方案,而 EF Core 本身就是为跨平台设计的。在 Docker 容器、Linux 服务器上跑 .NET 应用,EF Core 是自然的选择。
7.2 功能差异对照
用一个表格快速对照主要差异:
| 特性 | EF6 | EF Core |
|---|---|---|
| 平台支持 | 主要面向 .NET Framework | .NET (Core),跨平台 |
| 开源模式 | 代码后来才开源 | 完全开源,社区驱动 |
| 迁移机制 | 有,但命令和配置方式不同 | 全新设计,快照校验 |
| LINQ 查询翻译 | 支持但有限制 | 翻译能力强,拆分查询等 |
| 默认变更跟踪 | 快照跟踪 | 快照 + 变更通知 |
| 主键/值生成 | 依赖数据库 | Hugely 改进 |
| 丰富的关系配置 | 强大 | 几乎齐平 |
EF Core 在一些地方反而做了减法,比如起初不支持 EF6 的某些高级映射特性。但它做得更好的是:体积更小、部署更轻、查询翻译更智能、跨数据库能力更强。
7.3 我在新项目里的选型思路
新项目不需要犹豫,直接用 EF Core。不管你是从零开始的新系统,还是老系统想重构数据访问层,EF Core 都是当前 .NET 生态的事实标准。社区支持、文档完善度、依赖生态都朝着它集中。
如果是 EF6 老项目想升级,我的建议是:如果没有不得不迁移的痛点,先别急着把全部代码一次性重写。升级 EF6 到 EF Core 不是简单地换引用、改命名空间,LINQ 查询的翻译能力差异、初始化策略变化、迁移命令不同,都会带来一系列工作。我见过不少项目在"升级 EF Core"这事上耗费几周时间,最终收益却不大。更实际的做法是:新模块用 EF Core,老模块继续跑 EF6,中间通过接口隔离,慢慢替换。
7.4 写给准备入门的人
如果你现在刚开始学 .NET 数据访问,建议直接学 EF Core。先从简单的 CRUD 入手,理解 DbContext 生命周期,再学 LINQ 查询,最后研究迁移和性能优化。不要一开始就陷入所有高级配置和底层原理,那反而会让你的学习曲线很陡。
我从手写 ADO.NET 一路走到 EF Core,最大的体会是:ORM 框架不是用来掩盖你不会 SQL 的借口,而是把你从重复劳动中解放出来,让你有精力去优化真正重要的业务逻辑和查询设计。不管在哪个平台,理解数据是怎么流动的、SQL 是怎么生成的,永远比记住某个框架的 API 重要得多。
最后分享一个我的习惯:每次写完 EF 相关代码,我会在本地跑一遍所有涉及到的 LINQ,打开日志看 SQL,确认没有意外查询、没有多余的列、没有隐藏的 N+1。这个习惯帮我排查掉了绝大多数线上问题。你也可以试试,花不了几分钟,但长期下来能给项目省下大量维护成本。