1. 动手之前,先想清楚EF框架的版本与项目匹配问题
1.1 同样是EF,EF6和EF Core是两套东西
第一次在Visual Studio里接触EF框架的人,打开NuGet包管理器时大概率会愣一下:搜EntityFramework出来一大排,EntityFramework 6.x、Microsoft.EntityFrameworkCore、Microsoft.EntityFrameworkCore.SqlServer,到底装哪个?我在各种项目里来回切换过,结论其实很直接:如果项目是.NET Framework体系(常见于传统WinForms、WPF、老Web项目、很多工控上位机),老老实实用EF6;如果项目是.NET Core或.NET 5以上的现代体系,用EF Core。
这两者的区别不只是版本号,整个底层设计都换了。EF6是.NET Framework时代成长起来的重量级ORM,功能高度集成,配置文件多,对老项目支持好,尤其是像WinForms上位机这类需要快速对接本地数据库、内网部署的场景,EF6非常稳。EF Core是微软后来针对跨平台和现代架构重写的产物,模块化、体积轻、支持依赖注入、原生异步,性能也优化了不少,EF Core 8/9已经做到了很多以前EF6做不到的事。
判断方式极简单:在Visual Studio里新建项目时,目标框架那栏如果是.NET 6/7/8/9,就选EF Core;如果是.NET Framework 4.6.1/4.7.2/4.8,选EF6。用错版本最典型的后果是:你明明在EF Core项目里装了EF6的包,运行时报一堆奇怪的程序集加载失败。这不是代码问题,是选型问题,越早理解越省事。
1.2 Visual Studio版本与EF框架的兼容关系
Visual Studio本身只是个IDE,它对EF框架的使用影响主要在两点:一是新建项目时决定你能选什么目标框架,二是NuGet包管理器帮你拉依赖。VS2019、VS2022、VS2026这些版本,对EF6和EF Core都能正常支持,没有说哪个VS版本必须配哪个EF版本。
真正会产生影响的是你的项目目标框架。比如EF Core 8官方要求net8.0,如果你的VS里装的是老版.NET SDK,新建项目时只能选net6.0,那强行装EF Core 8包就会报NU1202(不兼容的包依赖)。解决方案也直接:要么降低EF Core版本,比如装6.x或7.x;要么升级项目目标框架到net8.0。
我个人的建议是,新机器新项目直接上VS2022以上的版本,并安装标准的工作负载。在Visual Studio Installer里勾选"ASP.NET和Web开发"和".NET桌面开发",前者覆盖Web API、MVC项目,后者覆盖WinForms、WPF上位机项目。安装完成后在项目属性里确认目标框架,保证和要用的EF版本匹配,后面基本不会在环境层面出幺蛾子。
2. 从建项目到装好EF包:NuGet环节的几个关键细节
2.1 项目骨架怎么搭最省心
很多初学者一上来就想着分层架构、仓储模式、依赖注入,其实在Visual Studio里第一步尝试EF,建一个简单的控制台项目或ASP.NET Core Web API项目就够用了。控制台项目方便你打断点调试,Web API项目更容易模拟真实场景。我个人推荐第一次练手用ASP.NET Core Web API加EF Core,因为后续写接口、注入DbContext都顺路。
创建项目时注意命名空间规范,比如解决方案名EFDemo,项目名EFDemo.Api。类库要不要单独拆出来?初期不用拆,把实体类和DbContext直接放在主项目下,等运行通了一个完整CRUD流程,再考虑拆分。为什么?因为EF框架的调试难度主要集中在数据库连接和模型映射上,项目结构越简单,你越容易定位问题。
2.2 NuGet安装:EF6和EF Core各自的正确姿势
在Visual Studio里装EF包有两条路,一条是图形界面:右键项目选"管理NuGet程序包",浏览页搜索包名,选对应版本安装;另一条是包管理器控制台(工具- NuGet包管理器 - 程序包管理器控制台),输命令。
EF6只需要一个包:
Install-Package EntityFramework这个包会把EF6的运行时、设计时工具全部带上,不用再装别的。如果你用的是SQL Server家族数据库,这个包就够。EF Core则稍有讲究,通常不需要单独装Microsoft.EntityFrameworkCore,而是直接装对应的数据库提供程序包,它会通过依赖把核心包带进来:
Install-Package Microsoft.EntityFrameworkCore.SqlServer如果你用的是SQLite、PostgreSQL、MySQL,则分别装Microsoft.EntityFrameworkCore.Sqlite、Npgsql.EntityFrameworkCore.PostgreSQL、Pomelo.EntityFrameworkCore.MySql。很多人装了主包忘了提供程序包,结果运行时报"没有找到数据库提供程序"的错误,根源就在这里。EF Core的设计哲学就是你用哪个库就装哪个包,其余的都当传递依赖自动处理。
2.3 安装中常年见到的三类异常
第一类是版本冲突。表现是安装进度条走了半天,最后报NU1107或NU1608之类依赖冲突错误。处理思路不是瞎试版本,而是看项目目标框架和包版本是否匹配,比如net6.0项目强行装EF Core 8就是典型冲突,这种情况要么升目标框架,要么降EF版本到6.0.x。
第二类是下载卡在0B或进度条一直不动。这在Visual Studio里相当常见,原因是NuGet默认源访问慢或网络被限制。处理方法是在NuGet包管理器右上角的源设置里,把包源切换到国内可用的镜像源。
第三类是Packages.config和PackageReference混用造成的重复引用。老项目用packages.config,新项目用PackageReference,如果同一个项目里两种引用方式都存在,编译时会看到大量重复引用警告,甚至导致程序集版本错乱。建议在项目文件里把packages.config删掉,统一改成PackageReference,或者通过VS的迁移功能一键转换。
3. Code First建模:从实体类到数据库诞生的完整链路
3.1 实体类定义:别只写属性,主键和外键约定要看懂
EF框架的三种建模方式——Code First、Database First、Model First——我强烈建议新项目用Code First,也就是先写C#类,再由EF帮你创建数据库表。这种方式的可读性和维护性都是最好的,代码即模型,配合迁移机制,数据库结构的演进就是代码变更的历史记录。
下面是一个典型的订单场景,包含商品和分类两个表,外加订单明细:
public class Category { public int Id { get; set; } public string Name { get; set; } = string.Empty; public ICollection<Product> Products { get; set; } = new List<Product>(); } public class Product { public int Id { get; set; } public string Name { get; set; } = string.Empty; public decimal Price { get; set; } public int CategoryId { get; set; } public Category? Category { get; set; } }这里有几个约定值得解释。主键:属性取名Id或以类名+Id结尾,比如CategoryId,EF会自动识别为主键,不需要额外标注。外键:Product里的CategoryId会被识别为外键,同时定义了一个Category导航属性,EF就能自动建立两个表的关系。字符串类型默认映射为nvarchar(max),这在实际项目里通常不够严谨,需要用Data Annotation特性或Fluent API限定长度。
public class Product { public int Id { get; set; } [MaxLength(100)] public string Name { get; set; } = string.Empty; [Column(TypeName = "decimal(18,2)")] public decimal Price { get; set; } }3.2 DbContext:EF框架的交通枢纽
DbContext是EF框架的使用核心。它负责管理实体对象、查询数据库、保存变更,有点像数据库连接和实体集合之间的总调度室。一个简洁的DbContext这样写:
public class AppDbContext : DbContext { public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } public DbSet<Product> Products => Set<Product>(); public DbSet<Category> Categories => Set<Category>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity<Product>(entity => { entity.Property(p => p.Price).HasPrecision(18, 2); entity.HasOne(p => p.Category) .WithMany(c => c.Products) .HasForeignKey(p => p.CategoryId); }); } }我建议表结构复杂时优先使用Fluent API(就是OnModelCreating里的写法),因为Data Annotation把规则散落在各个实体属性上,表关系一多看起来非常乱,而Fluent API集中管理,别人接手时一眼能看到所有映射规则。下面这几种配置在真实项目中出现频率极高:限制字符串长度、设置decimal精度、建立唯一索引。
modelBuilder.Entity<Product>(entity => { entity.HasIndex(p => p.Name).IsUnique(); });3.3 连接字符串的两种配置位置
连接字符串告诉EF去哪里找数据库。EF6通常写在App.config或Web.config的 节:
<connectionStrings> <add name="AppDbContext" connectionString="Server=.;Database=EFDemoDb;Trusted_Connection=True;TrustServerCertificate=True;" providerName="System.Data.SqlClient" /> </connectionStrings>EF Core则写appsettings.json:
{ "ConnectionStrings": { "DefaultConnection": "Server=.;Database=EFDemoDb;Trusted_Connection=True;TrustServerCertificate=True;" } }注意EF6的配置里多了providerName="System.Data.SqlClient",而EF Core不需要。很多人在EF Core项目里照抄EF6的配置,结果报"关键字不受支持"的错,就是这个原因。另外本地开发建议用默认实例加Windows身份验证,真到部署环境再切换成SQL账号连接串。
3.4 数据库生成:执行迁移命令的那几步
写好实体和DbContext后,要让Visual Studio帮我们生成数据库,步骤如下:
首先在包管理器控制台里把默认项目选成DbContext所在的项目。然后输入Enable-Migrations(EF6的旧命令),EF Core则不用这一步,直接从Add-Migration开始。依次执行:
Add-Migration Init Update-Database -VerboseAdd-Migration会扫描模型变化并生成一个迁移类,里面写了Up和Down方法;Update-Database把迁移应用到数据库,-Verbose可以打印实际执行的SQL,我强烈建议带上这个参数,能直观看到EF是怎么建表的。
如果数据库不存在,EF会自行创建。第一次跑通这套流程后,你会发现自己建库的速度比在SSMS里手写SQL快得多。而且因为有了迁移脚本,后续无论换机器还是换同事,一条Update-Database就能把库结构还原出来。
4. 日常CRUD之外,DbContext真正值得花时间研究的几个点
4.1 SaveChanges是分水岭:增删改都要靠它落库
EF框架的增删改查看着简单,实际用起来有几个重要的心理预期。先说增删改,一个完整的插入流程如下:
using var db = new AppDbContext(GetOptions()); var category = new Category { Name = "饮料" }; db.Categories.Add(category); await db.SaveChangesAsync();关键在于后面这行SaveChangesAsync。Add方法只是把实体标记为Added状态,对象图还停留在内存里,只有调了SaveChanges,EF才会真正生成INSERT语句发往数据库。即使你Add了十个、一百个实体,都是同一行SaveChanges统一提交。
删除和更新也是同一个套路:先查出实体,要么Remove标记删除,要么改属性等SaveChanges统一Update。
var product = await db.Products.FindAsync(id); if (product != null) { product.Price = 9.9m; await db.SaveChangesAsync(); }很多新手在这里犯迷糊,改完属性以为立刻生效,切到数据库一看没变化,来来回回排查半天,其实只是少了一句SaveChanges。
4.2 IQueryable的延迟执行:原来查询并没有真的查询
EF框架的查询有个反直觉的地方。你用db.Products.Where(p => p.Price > 10)并不会立刻执行SQL,它只是构建了一个IQueryable表达式树。真正打开数据库连接、跑SQL的地方是遍历结果的那一刻,比如ToListAsync、FirstOrDefaultAsync。
这个特性带来的好处是你可以在分页、过滤条件不确定的场景下逐步叠加查询条件,最后再一次性执行。但副作用也明显:如果你在循环里遍历某个IQueryable,结果每遍历一次就执行一次SQL,性能立马崩盘。
导航属性也有类似陷阱。只查询Product列表时,Category是空的,因为EF默认不加载关联对象。要一次性把关联数据取出来,用Include:
var products = await db.Products .Include(p => p.Category) .Where(p => p.CategoryId == 1) .ToListAsync();这样生成的SQL会带JOIN,一次取回关联数据。如果不加Include,循环里又访问p.Category.Name,就会造成所谓的N+1查询问题——主表查一次,每个子对象再查一次,数据量大时性能极其难看。判断这个问题的办法很简单:把EF生成的SQL打印出来,看LOG里的SELECT条数,几条就是几个查询。
4.3 只读场景加AsNoTracking,批量插入关掉自动检测
EF框架默认会跟踪查询出来的每一个实体。这意味着当你查询1万条数据时,DbContext内部的状态管理器就咬着这1万条实体的快照不放,内存占用和变更检测的开销都不小。对于纯展示、不修改数据的查询,加一个AsNoTracking就能绕开跟踪机制:
var categories = await db.Categories .AsNoTracking() .ToListAsync();注意用了AsNoTracking之后,查询出来的实体就脱离DbContext管理了,对它做修改再SaveChanges是无效的。所以这个优化只对只读场景使用。
批量插入的另一个隐藏性能杀手是AutoDetectChangesEnabled。EF每次SaveChanges之前都会自动探测所有实体有没有变化,如果你的循环里逐条Add,实际开销会随实体数量指数级上升。大量导入时先关掉检测:
db.ChangeTracker.AutoDetectChangesEnabled = false; for (int i = 0; i < 10000; i++) { db.Products.Add(new Product { Name = "商品" + i }); } await db.SaveChangesAsync(); db.ChangeTracker.AutoDetectChangesEnabled = true;真实项目中我见过有人循环Add一万条数据,足足跑了30多秒,关掉自动检测后压到2秒左右,效果非常明显。
4.4 事务和并发:不是高端技巧,是保命技能
多个表要么同时成功要么同时失败,这种场景必须用事务。EF6里用Database.BeginTransaction,EF Core里同样支持:
await using var transaction = await db.Database.BeginTransactionAsync(); try { db.Categories.Add(new Category { Name = "日用" }); await db.SaveChangesAsync(); db.Products.Add(new Product { Name = "纸巾", Price = 3.5m }); await db.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }并发控制方面,最简单可靠的方式是给表加rowversion并发字段。SQL Server里定义一列byte[]类型的Version或Timestamp,EF自动将其映射为rowversion。当两个用户同时修改同一条记录时,后提交的一方会抛出DbUpdateConcurrencyException,你就知道这条数据被别人动过了,可以提示用户重新加载数据。
5. 数据库结构变更以后:迁移机制的正确打开方式
5.1 别肝数据库表,迁移才是正路
项目迭代到中期,实体类必然会加字段、改长度、加索引。如果你直接去SQL Server Management Studio里手改表结构,代码倒是能跑一阵,但EF框架内部维护着一个模型快照(ModelSnapshot),只要你改了实体,EF就知道模型和数据库对不上,运行时直接给你抛"模型已更改"的异常。
正确做法永远走迁移。每改一次模型,执行一次Add-Migration,再Update-Database。比如给Product加一个Stock字段:
public int Stock { get; set; }然后:
Add-Migration AddStockToProduct Update-Database这样EF会生成一个包含ALTER TABLE语句的迁移文件,数据库结构就跟着代码走了。
5.2 Add-Migration生成的文件里藏着什么
迁移文件分三部分:Up方法写的是正向迁移的变更,Down方法写的是回退。比如加了Stock字段,Down方法里就会删掉这一列。这意味着你的数据库结构是可以向前向后任意迁移的,团队里任何一个人拉代码后执行Update-Database,就能得到和开发环境一致的数据库结构。
不过有一点要注意,迁移文件的顺序很重要,EF按时间顺序应用迁移,不要随便删改已经应用过的迁移文件。如果迁移还没上线,你想改模型重新生成,可以直接删掉该迁移和数据库里对应的版本记录再重来;如果已经部署到生产了,那就只能继续追加新迁移,不能回头改旧的,这是原则。
5.3 生产环境更新:用脚本而不是直接连库执行
生产环境的数据库一般不会允许你直接从Visual Studio连上去Update-Database,正规一点的操作是导出SQL脚本交给DBA审核执行:
Update-Database -Script -SourceMigration Init -TargetMigration Latest这个命令会把从Init迁移到最新版本的所有SQL脚本生成出来,不连接数据库。也可以简写成:
Update-Database -Script在有迁移历史记录的库上,它会输出增量变更脚本。线下系统我还会配合使用EnsureCreated和Migrate的区别说明:EnsureCreated适合一次性创建数据库的极简场景,比如本地demo,它完全不记录迁移历史,后面加字段也不会自动更新;而Migrate则是正式项目应该使用的初始化方式,它会应用所有迁移并维护版本表。生产环境务必用Migrate或脚本更新,而不是EnsureCreated。
6. 实战里我和EF框架交过手的几个经典问题
6.1 "自数据库创建后已更改"这个报错的真相
这是EF6里最著名的报错之一:自数据库创建后,模型是否有更改。触发原因基本就是数据库表结构和模型不一致,而EF6默认用一张EdmMetadata表记录模型哈希值,一旦哈希对不上就直接拒绝干活。解决方案不是禁用检查,而是重新走一次迁移,让模型和库对齐。如果是在EF Core里,同样的问题表现稍有不同,但排查思路一致:先对比实体类和数据库表,找出新增的字段或移除的列,再补一条迁移。
6.2 "未能加载文件或程序集EntityFramework"怎么查
这类报错大多出现在EF6项目里,项目能编译,但一运行就挂。大概率是NuGet包没装全,或者配置文件缺少entityFramework节。EF6默认会在App.config/Web.config里写入这样一段:
<entityFramework> <defaultConnectionFactory type="System.Data.Entity.Infrastructure.LocalDbConnectionFactory, EntityFramework" /> </entityFramework>如果没有这段,并且你的项目中也没显式指定连接工厂,运行时就可能找不到程序集。最稳妥的修复方式:卸载EntityFramework包,重新安装,让系统自动生成配置。如果是EF Core项目,更有可能是数据库提供程序包没装,比如你只装了Microsoft.EntityFrameworkCore却用了SqlServer连接,运行时报"Unable to find provider"。
6.3 连接字符串没生效,数据库连到了奇怪的地方
我见过很多次这种情况:明明在appsettings.json里写了连到生产库的字符串,程序却连到了本地LocalDB。排查思路很直接,先看Program.cs里UseSqlServer是否显式传入了连接字符串名称,比如:
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));注意CreateDbContext或Program.cs里注入时用的字符串名称,必须要和配置文件里的Key完全一致,大小写也得对。另外EF Core在设计时迁移(比如Add-Migration)时会尝试读取连接串,如果读取不到或名称不对,它会默认选择一个错误的数据源。最简单粗暴的验证方法是在DbContext的OnConfiguring里临时写一个固定的连接串跑通流程,确认问题出在配置读取后,再改回依赖注入的写法。
6.4 导航属性明明写了virtual,懒加载还是不生效
EF6里使用懒加载有两个条件:导航属性必须标记为virtual,并且DbContext配置了LazyLoadingEnabled。EF Core从3.0开始,默认就关闭了懒加载,需要额外安装Microsoft.EntityFrameworkCore.Proxies包并显式开启UseLazyLoadingProxies。
optionsBuilder.UseLazyLoadingProxies() .UseSqlServer(connectionString);但我的实际建议是,新项目不要依赖懒加载。懒加载虽然在访问属性时自动加载数据很爽,但实际上是在隐藏查询行为,稍不留神就产生N+1查询问题。与其等坑出现再优化,不如从一开始就用Include或Select显式加载你真正需要的数据。关闭懒加载之后,代码逻辑反而清晰,因为每次数据库访问都在你能看见的地方。
6.5 批量插入几千条数据,越插越慢
除了前面提过的AutoDetectChangesEnabled,执行批量插入时还有一个隐藏开销:每次Add后DbContext会实时计算关系修复操作,逐条Add上几千条数据,这部分消耗同样不小。除了关掉自动检测,更彻底的办法是改用EF Core 7及以上版本引入的ExecuteDelete和ExecuteUpdate这类批处理API。比如批量更新价格:
await db.Products .Where(p => p.CategoryId == 1) .ExecuteUpdateAsync(setters => setters.SetProperty(p => p.Price, 9.9m));这种操作绕过DbContext的跟踪机制,直接生成一条UPDATE语句发到数据库,不加载实体也不逐条SaveChanges,几十万条数据的更新也只要几秒钟。
6.6 迁移历史表不一致导致Update-Database失败
团队协作时,迁移版本和数据库里实际已应用的版本对不上,是Update-Database报错的高发区。检查对策分两步:第一步在SQL Server里看__EFMigrationsHistory表,里面记录了所有已执行的迁移ID;第二步在项目迁移文件夹里对比迁移文件,找出本地有但数据库没记录的那个版本,确认是别人提交的新迁移就继续执行,如果是自己本地生成的重复迁移,就要考虑删除或者手动补一条History表记录。
这里有个容易误操作的点:手动往__EFMigrationsHistory表插记录来"欺骗"系统,认为某个迁移已经执行过。偶尔遇到紧急情况可以临时用,但事后必须补上对应的表结构变更,否则生产环境迟早暴雷。我个人的原则是,迁移历史必须和数据库结构完全一致,宁可重来也绝不滥改历史表。
最后想提醒一句,EF框架用到后面会发现,真正决定你用得顺不顺手的,其实不是ORM本身,而是你对数据库的尊重程度。该建的索引要建,该收敛的查询一定要收敛,该写迁移就写迁移,别拿手改数据库当捷径。把这套习惯养成了,无论项目换到EF6还是EF Core,你都能比绝大多数人少踩一半的坑。