资讯动态

EF Core实战避坑:状态管理、并发冲突与性能优化指南

发布时间:2026/10/17 2:57:34 来源:尧图企业网站定制
做.NET开发绕不开两座大山MVC框架和Entity Framework。如果时间不算太长你大概率已经体会过EF既像自动挡又像手动挡的双重性格——它能帮你省掉大量数据访问层代码但一旦遇到读写分离复杂查询、并发提交、导航属性序列化这几类问题从入门到绕晕也就一顿报错的功夫。这个系列写到了第十一篇前面我们拆过MVC路由、EF参数化查询讲了DbContext生命周期也分析过延迟加载和显式加载用起来的收益和开销。这篇集中聊踩坑现场我把项目里被问得比较多的EF问题挑出来跟踪实体顺序不一致怎么处理、Include条件筛选怎么按需做、死锁和重复提交怎么定位、查询语句变成几十个关联Join该怎么办。每个问题都尽量给你能直接复现的代码和参数调整思路而不止是重启一下试试。我尽量把场景和排查思路都写具体适合刚接手EF项目、或者在写查询Controller但被各种未映射冲突重复主键弄到头疼的人。有基础的可以重点看状态管理与跟踪冲突那几段老手也可以直接翻到后面的排查流程希望能帮你少翻几次Stack Overflow。1. 整体设计思路为什么EF问题总是看着报错千奇百怪根子都差不多1.1 一个适合回忆的比喻EF Core本质是个对象关系映射器翻译成白话就是你在C#里操作对象它帮你操作数据库里的表。 很多初学者把上面这句话理解反了以为操作对象就是暂时操作一个副本于是出现修改一条记录不生效、数据被莫名覆盖、导航属性变null等疑难杂症。我做个类比你家里有一个记事本你把每天的购买记录都写在里面。EF的DbContext就是这只记事本它不只是存储数据它还记下哪一页被翻过哪行字最近被修改过哪个表格是新插进去的。这堆附加的备忘录就是EF里的状态管理。你手动改了某个实体的属性但又没执行SaveChangesDbContext会提示检测到跟踪实体发生了更改你连续两次往同一个上下文里Add同一个主键的实体它会说主键冲突。这些都不是数据库先报错而是EF在托管的边界就拦截下来了。理解这一层后面各种问题就能归拢成几类帽子没戴正状态不对、两顶帽子重复跟踪、帽子换了人主键错乱、帽子太多循环序列化。1.2 实际开发中的三种典型诉求从真实项目看大家遇到EF问题主要是下面三个地方第一是给实体加了一个字段数据库表没同步运行就报SqlException: Invalid column name。这类问题听着低级但在多人协作或者用代码先用方式开发Code First时经常出现因为大家没统一迁移规则。第二是查询的超大Join问题。一个聚合查询包含二十几个Include之后不光查询变慢SQL Server有时候还报包含的列太多已超过最大列数限制这种冷门错误。EF的自动挡把Join细节隐藏得越深实际产生的SQL就越难看这时就需要手动干预。第三是对象已被处置常见于把懒加载代理、IQueryable或者DbContext用到了Controller作用域之外。很多情况是某个方法里return一个查询结果给前端但调用端在后台线程里继续访问导航属性于是抛出ObjectDisposedException。要系统解决靠的不只是会一个API而是从状态管理加载时机上下文生命周期三个层面一起收口。下面每一节都会沿着这条主线展开。2. 核心细节解析从状态管理到数据库更新2.1 EntityState的四种状态与切换门道EF为每个被跟踪实体维护一个状态Detached、Unchanged、Modified、Added、Deleted。看起来只有五种但项目里误用集中在这几处Detached实体没有被上下文跟踪。这时修改属性直接SaveChanges是无效的因为上下文不知道你有变化。解决方式是调用Update或Attach。Modified实体已经被跟踪并且属性值被改过。如果你把整个实体传给UpdateEF会把所有列都放进UPDATE语句。Added调用Add进入的状态对应INSERT。Deleted调用Remove进入的状态对应DELETE。我这里要给个很重要的提醒很多人踩过坑当你在一个作用域里查出一个实体然后把它交给另一个方法去Update如果那个方法又连了一下同一个作用域范围的DbContext就会遇到当前实体已处于Modified状态或正在跟踪具有相同键的实体的冲突。因为你在第二位方法里再用Update等于重复戴了同一顶帽子。推荐的稳妥模式是var entity await _context.Orders.FindAsync(orderId); entity.Status 已发货; await _context.SaveChangesAsync();如果是从前端拿到的独立DTO对象想一次性更新建议这么做var entity new Order { Id dto.OrderId }; _context.Orders.Attach(entity); entity.Status dto.Status; // 手动改哪些列你就只设哪些列 _context.Entry(entity).Property(x x.Status).IsModified true; await _context.SaveChangesAsync();后面那套做法虽然代码多一点但避免了整表更新也躲开了并发冲突。你想想看如果每列都进UPDATE语句二三十个字段的表更新一列也会把所有列都带上日志和性能都难看。2.2 为什么延迟加载老是报循环引用项目一用上导航属性最经典的问题就出现了某个订单实体包含用户实体用户实体又包含他名下的订单集合。序列化的时候Json.NET从前端角度去遍历发现订单-用户-订单-用户然后就抛了 Self referencing loop detected。我早期做过一个解决方案是把导航属性全部标记成JsonIgnore结果前端很多字段又拿不到。后来发现正确思路有两个方向方向一不返回实体模型而是返回平铺的ViewModel。用投影查询不加载多余的导航属性这是最省心的方案。var result await _context.Orders .Where(o o.CustomerId customerId) .Select(o new OrderListDto { OrderNo o.OrderNo, CustomerName o.Customer.Name, TotalAmount o.TotalAmount }) .ToListAsync();方向二保留实体作为中间结果但配置序列化行为时设置ReferenceHandler.PreserveSystem.Text.Json或配置Json.NET的ReferenceLoopHandling.Ignore。这个方案简单但会带来空导航属性判断而且你无法控制哪些层级该序列化、哪些不该对深度嵌套的报表类页面并不友好。我的建议是尽量用DTO层实体只作为内部传输载体。MVC里你可以专门建一个ViewModels或者Dtos目录让Controller层和Action Return View的数据跟数据库模型解耦。这也是EF官方文档里反复强调的最佳实践之一投影是避免过度访问数据库的有力武器。2.3 并发冲突从并发更新零行到Unable to save changes很多时候你不是没改到数据而是两拨人同时改了数据EFCore的并发令牌机制会判断当前值已在修改前被其他人动过于是抛DbUpdateConcurrencyException。具体表现是这个异常抛出来后如果你不处理那么SaveChanges就中断了。我们要做的是拿到冲突条目按照业务决定客户端胜出还是数据库胜出。这里非常推荐给关键表加一个RowVersion并发令牌这样不用在每个字段上设置并发标记。配置很简单用Fluent APImodelBuilder.EntityOrder() .Property(o o.RowVersion) .IsRowVersion();表里需要加一个rowversion类型的列。每次更新时EF会把WHERE条件拼上这个版本号。一旦别人A先提交B再来更新时发现版本号对不上就会抛异常。处理策略写成这样catch (DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { var proposedValues entry.CurrentValues; var databaseValues entry.GetDatabaseValues(); foreach (var property in proposedValues.Properties) { var proposedValue proposedValues[property]; var databaseValue databaseValues[property]; // 在这边决定保留谁 } // 如果决定用数据库值覆盖客户端值 entry.OriginalValues.SetValues(databaseValues); } }这段代码的核心就是把原始值重新设成“数据库里的当前值”EF就会认为我手里这份没有过期然后重新执行保存。注意拿到databaseValues后你要看是null的写法别直接索引实体被删了会变成null。3. 实操过程与关键环节实现状态管理、Include条件筛选与超时排查3.1 快速复现并修复库存扣减被覆盖我先讲一个最典型的覆盖场景两个用户同时买了最后一个库存商品。代码是这样库存表Product实体上有Stock字段。订单服务里这样写var product await _context.Products.FindAsync(1); if (product.Stock request.Quantity) { product.Stock - request.Quantity; await _context.SaveChangesAsync(); }这个写法其实非常脆弱第一个请求查到的Stock是10第二个请求也查到了10两个都认为自己把Stock扣到9于是两个订单都成功了数据库库存只剩9但已售数量应该是2。解决方法就两句话要么用原子更新直接在SQL层扣减要么用行版本号做乐观并发控制。原子更新在EF里可以这样await _context.Database.ExecuteSqlRawAsync( UPDATE Products SET Stock Stock - {0} WHERE Id {1} AND Stock {0}, request.Quantity, productId);模拟好库存检查不再占用先按Find再按Update的窗口能很大程度上解决。但在FaceBook群控类的后台业务里我一般两种方案并行数据库操作走原子更新实体更新走RowVersion乐观并发。在这个问题上有同事踩过另一个坑他以为判断AffectedRows0就是库存不足结果发现ExecuteSqlRawAsync在SQL Server上返回的是受影响行数但EF调用SaveChanges时受影响行数返回0也可能是因为在其他地方已经把上下文标记成Unchanged了。别过度依赖这层的返回值最好在SQL里加上WHERE Stock quantity再结合返回值判断。3.2 想过滤Include出来的子集合怎么办很多人会遇到这个问题我要查某个订单头也要查他的子订单但只想查子订单里状态是成功的记录。如果直接开Include(o o.Items)再把不需要的记录用Where(z z.Status 成功)滤掉会出错——因为Include的结果集已经把它拉进来了你在内存里过滤只是前端展示层面的事儿对数据库查询和性能没有帮助。EF Core 5.0之前解决办法是先去查询子表然后用导航属性分离集合。EF Core 5.0之后提供了过滤Include按官方写var orders await _context.Orders .Where(o o.Id orderId) .Include(o o.Items.Where(i i.Status 成功)) .ToListAsync();在MVC的列表页里我一般给这个查询再加个AsSplitQuery()让它走多个查询避免一个巨大的Join把子表数据撑爆具体到SQL Server里主要避免笛卡尔爆炸。有几点要注意过滤Include不能同时对同一个导航集合做两次过滤会抛错误你得拆成两个Include或者用临时表过渡。过滤Include只影响该导航集合的内容不影响父实体主查询。你要是对Items还用了ThenInclude再嵌套过滤条件会带到更深层。3.3 大数量的删除为什么RemoveRange慢得可怕批量删除场景很常见。很多人写的代码是var list await _context.Orders.Where(o o.CreateTime endDate).ToListAsync(); _context.Orders.RemoveRange(list); await _context.SaveChangesAsync();这样没问题但如果list有10万条EF会把每条实体都跟踪然后逐条生成DELETE语句最后还可能因为参数化限制报错。得不偿失。EF Core 7.0之后支持ExecuteDelete直接由数据库执行批量删除速度快得多await _context.Orders .Where(o o.CreateTime endDate) .ExecuteDeleteAsync();同样的道理还有ExecuteUpdateawait _context.Products .Where(p p.CategoryId 100) .ExecuteUpdateAsync(setters setters.SetProperty(p p.IsActive, false));这两类操作不会加载实体到内存也不会触发SaveChanges流程内部的变更追踪。所以你得清楚用它们的时候你没法通过遍历那些实体来做一些额外的业务逻辑比如写日志、做缓存失效。如果必须做就分批处理取Id列表每500条一批执行删除。3.4 超时问题的排查思路以前我接手过一个报表查询传入日期范围后要几分钟才返回后来通过捕获SQL发现它把几十个表都Join了一下其中有两张表几十GB大死锁严重。这里要介绍一个超实用的手段开启SQL日志你可以在Startup里这样调optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information) .EnableSensitiveDataLogging() .EnableDetailedErrors();关键是在开发环境打开不要在生产环境打开因为这个会输出SQL参数值有泄露风险。拿到SQL之后去数据库工具里面跑一遍执行计划基本能看到扫的是哪张表哪个索引缺失。EF本身不会帮你创建索引但你在模型配置里可以用.HasIndex()去补充比如modelBuilder.EntityOrder() .HasIndex(o new { o.CustomerId, o.CreateTime });索引不是越多越好查询走哪条索引要结合执行计划判断。很多EF项目慢不是因为EF不行而是生成的SQL走了全表扫描你连个非聚集索引都没建。说白了EF只是把SQL生成出来要不要建索引还是数据库自己的事儿。3.5 文化差异时区、日期与decimal的“隐形转换”实体属性是DateTime数据库是datetime或datetime2传参前如果没统一EF Core按Local Kind和Unspecified Kind的处理方式不一样查询条件可能出现无视索引的情况。在涉及多个时区或定时任务的项目里一定要把入库时间存成UTC到显示层再转本地时间。decimal字段映射到SQL Server时默认的精度是18, 2。你存9999.99没问题但存999999.99可能被截断或报错。建议用Fluent API显式配置比如modelBuilder.EntityOrder() .Property(o o.TotalAmount) .HasPrecision(18, 6);另一个容易忽略的点是字符串排序和索引问题如果数据库排序规则是Chinese_PRC_CI_AS查询中如果你用了Linq的String.ContainsSQL Server很难把这个转成索引友好的Like查询。有条件建议改成索引前缀匹配或者使用索引列直接比较。4. 进阶场景与规避误区Include关联、正确配置上下文生命周期4.1 为什么把实体放到别的层就丢失了我见过不少人写了这样一段代码public Order GetOrder(int id) { using (var context new MyDbContext()) { return context.Orders.Find(id); } }外面调用这个方法后又去访问order.Items或order.Customer结果就是ObjectDisposedException因为延迟加载需要打开的DbContext支持而using块已经把DbContext释放了。要修有两个方向第一改成显式加载var order await _context.Orders.FindAsync(id); if (!order.Items.Any()) { await _context.Entry(order).Collection(o o.Items).LoadAsync(); }第二服务层直接返回查询好的数据用完即走。我比较推荐后者因为几乎所有WebApi返回前端的数据都应该是完整的、一次性加载好的。你要是用了延迟加载就是给自己挖坑每个导航属性访问都触发额外查询N1问题就来了。现在流行叫做禁用延迟加载代理在EF Core里其实不是默认开启等于你在配置里不写UseLazyLoadingProxies就默认关闭。但如果项目历史代码里有某些地方还依赖延迟加载你得评估一下。4.2 上下文生命周期在MVC里的标准配置有不少老项目是把DbContext放进using块里然后在Controller里new一个。这种做法最大的麻烦是你想在多个Action方法或者Service之间共享同一个跟踪状态追踪实体在不同地方出现时状态乱掉。现在主流做法是依赖注入在Program.cs或者Startup.cs中注册builder.Services.AddDbContextMyDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(DefaultConnection)));默认的生命周期是Scoped即每个HTTP请求一个DbContext实例。这个比较符合MVC的场景在同一个请求内你想让仓储、服务都能互相配合用同一个上下文的状态跨请求则彼此隔离不会出现一个请求修改了另一个请求的未保存数据。在多线程后台任务里如果你要用同一个DbContext实例得小心。它内部有状态管理不是线程安全的你不能让两个线程同时往里塞查询。合理的做法是每个后台任务开一个自己的作用域或者自己new一个上下文。4.3 数据迁移相关的几个疑难杂症很多项目采用Code First团队里每人拉代码之后都会执行Update-Database。经常遇到迁移文件冲突或Pending model changes本质是模型快照不一致。这里有一个最安全也最枯燥的规矩永远只在一个分支上创建迁移。提交代码时包括迁移文件Migrations文件夹里面的.cs和ModelSnapshot.cs一起提交。如果跟别人冲突不要在同一迁移文件里手动合并删除本地的未应用迁移重新生成一个。因为EF Core的迁移本质是脚本序列如果两个人各自生成一个AddOrderId的迁移合并文件就会导致主键或者列重复添加的冲突。正确解法是本地将数据库Drop或者还原到上一个版本删除冲突迁移再重新Add-Migration让生成器自己计算一个干净的变化脚本。还有一类问题是数据库已经有钱模型现在想加字段用迁移新增字段后老数据默认给null或默认值。如果你在模型里把它配成IsRequired()但数据库列InitialValue没填插入老数据时会报错。可以这样考虑加字段时给你一个明确的方向要么允许空值要么指定默认值。5. 我的排错清单和实操习惯5.1 问题排查优先顺序把实际问题汇总后比较容易形成一套诊断路径。我之前做过一个速查每次遇到EF异常先不看堆栈第二行而是先按下面的顺序走如果SQL执行到中途卡死或慢先打开SQL日志确认Generated SQL是否符合预期尤其是有没有多表笛卡尔积。如果抛并发冲突看RowVersion字段有没有值再看异常Entires条数确认是不是刚才从View传值把字段清掉了。如果报Invalid column name先把数据库里实际列跟实体属性列名拉出来比对很多时候是因为缓存了旧的EDM元数据重启IIS没用的因为数据库没更新或迁移没执行。如果报序列化循环直接看Controller的返回值类型与实体属性导航。如果报ObjectDisposedException看这个DbContext是不是在using里被释放然后看是不是开启了延迟加载最后看异步连续性是否丢上下文。5.2 用什么工具看EF生成的SQL日常Debug我习惯用两条路开发环境用LogTo直接输出到控制台窗口能看到查询的参数和耗时。线上环境用数据库工具比如SQL Server Profiler或者扩展事件捕获EF生成的SQL。对重点复杂查询我会把捕获的SQL贴到数据库执行看执行计划里的索引seek/scan开销。这一步是很多EF新人最不爱做但最有效的。另外注意如果你开启了EnableSensitiveDataLogging控制台会打印实际参数值这在开发环境还好生产环境就关掉因为日志里会暴露真实用户ID、订单号等信息合规安全上很容易踩雷。5.3 关于上下文级别全局查询过滤器的坑全局查询过滤器是EF Core一个很有用的功能比如在多租户场景下自动过滤某个租户IDbuilder.EntityOrder().HasQueryFilter(o o.TenantId _tenantId);用起来方便但坑也大你用IgnoreQueryFilters()才能绕过但一旦绕过数据范围就不受控。对所有查这个表的查询都附加了条件如果是后台报表页误用了全局过滤器会查不到别的租户的数据。关联查询也会自动应用过滤器有时候你要的是不限制某张关联表却会被它悄悄过滤掉结果就是总数对不上。所以我一般建议如果只是业务逻辑场景需要过滤不如在查询时显式加Where。全局查询过滤器更适合平台级别的强制隔离不太适合业务表。6. 最近一次项目里踩坑的复盘6.1 Migration生成成功但执行失败的复盘最近在一个老项目里我加了一张联系记录表然后用Add-Migration生成一堆变更脚本。当时整体是没跑数据库工具的结果执行Update-Database时报“列已存在”。我查了一下是某位同事早期手动改了数据库表结构但没同步到模型快照导致生成的迁移脚本里重复添加了某一列。复盘下来正确做法是别手动改数据库表结构要走迁移。但如果已经手动改了需要对比模型与数据库之间的一致性问题用EF Core工具里我们能做的无非是生成一个空迁移然后手动把AddColumn和DropColumn的方向对齐。6.2 “Include进去的集合怎么还是空”的复盘另一个Case是EF Core的拆分查询与配置的问题。当时有个实体包含一个导航集合我用Include加载通过AsSplitQuery把查询拆成两条但发现第二条查出来的集合没合并进实体里数据全空。检查后发现是因为我在查询里加了Distinct或OrderBy这样的操作导致EF无法确认导航属性与外键的关联关系合并失败。其实解决办法不复杂把拆分的操作放到后半段或者把分页等动作放到主查询之后再做。这提醒我们EF Core对查询的可翻译性有既定要求不是所有LINQ操作符都能被翻译成它期望的SQL结构。7. 常用命令行速查和配置建议如果你还是习惯用命令行方式我会把这些命令存成一个笔记dotnet ef migrations add AddOrderTable // 生成迁移 dotnet ef database update // 应用迁移到数据库 dotnet ef migrations script // 生成SQL脚本用于发布环境 dotnet ef migrations remove // 移除上一次迁移前提还没应用发布生产环境的时候我不建议直接在服务器上跑Update-Database那样有风险。更稳的做法是生成一个SQL脚本由DBA审核之后再执行dotnet ef migrations script 上一个版本 目标版本 -o upgrade.sql再配合幂等脚本dotnet ef migrations script --idempotent -o upgrade.sql这个脚本会判断每个迁移是否已应用不会重复执行适合部署自动化流水线。8. 关于EF版本差异的取舍EF Core 6之后新增了Temporal Table的影子表支持EF Core 7之后有了ExecuteDelete/ExecuteUpdateEF Core 8之后对复杂查询的可翻译性大幅提升也新增了原始SQL查询的更多选项。我在新项目里会直接用最新长期支持版但老项目如果还在.NET Core 3.1上那升级压力比较大通常会先固化使用习惯不追求花哨特性。如果你还在用EF6非Core维护老框架有很多查询语法和跟踪行为跟EF Core并不完全一致比如EF6对LazyLoading默认开启EF Core默认关闭EF6里没有过滤Include你只能用分离查询EF6里没有拆分查询的选项。要是你从EF6迁到EF Core最重要的一步是把所有依赖延迟加载的地方改成显式加载或投影加载不然你会有一堆System.ObjectDisposedException。9. 实际开发中值得长期关注的习惯做EF项目时间长了有几个习惯给我省了很多麻烦第一每个实体都加一个主键Id和一些审计字段CreateTime、UpdateTime、CreatorId。这样一来后续排查“这行是谁改的”时不用去反查日志。第二所有对外输出的模型用DTO不直接扔实体出去。你会少碰到很多序列化循环、多余字段暴露、以及前后端字段改名连带一堆SQL问题。第三每个查询尽量写分页就算数据量不大也写Take。避免哪一天某个列表数据暴涨把前端页面卡死。第四数据库变动尽量避免用迁移解决一切。如果只是加索引、清数据这些操作建议走独立脚本而不是迁移迁移更适合结构化变更。第五注意每个请求的作用域与异常处理。不要为了图省事把DbContext注册成Singleton这样会导致多线程共享实例和状态跨请求污染。这些坑我当年踩过后来才发现官方文档一直强调Scoped生命周期。10. 排查问题时的执行计划视角再返回去补一个实用技能看执行计划。EF Core生成SQL后你最容易遇到的有三种执行计划问题索引缺失SQLServer执行计划里会出现绿色提示缺少索引这时你只要按提示建索引就行但要注意索引不该建太多尤其是更新频繁的表。键查找有时候你查询的列不在索引覆盖范围内SQL Server就会先通过索引找到主键然后再回表查一次。这可能让查询变慢加到覆盖索引里能解决。隐式转换字符串列跟数字参数比较或者DateTime参数跟varchar列比较则会触发隐式转换导致索引失效。这个要看连接参数的类型。这个和EF本身虽然没什么关系但EF隐藏了很多SQL细节反而让我们更需要具备这种解读执行计划的能力。很多所谓EF慢查询最后都是索引缺失或隐式转换我在这上面花的时间比调EF配置多得多。EF只是个工具别神话它也别妖魔化它。出现问题的本质往往是它对开发者的数据库敏感度要求很低但你要想在生产环境稳定运行就得主动提升自己对SQL和索引的敏感度。最后分享一个个人习惯我每次在程序里看到EF报错不急着改代码先去看它生成的SQL是什么样跟预期差多少。执行计划看三分钟比盲改代码三小时有用。这个习惯靠的是平时对各类查询的积累熟练之后大部分EF常见问题你闭着眼睛都能猜个八九不离十。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑