☰
EF Core模型优化实战:根治查询慢与SQL怪异
2026/9/29 15:18:44 网站建设 项目流程

1. 项目思路拆解:EF Core模型优化到底在优化什么

先聊一个常见但又特别容易被忽视的问题:很多同学拿到EF Core项目第一反应是去优化查询、加缓存、上读写分离,但真正体验过几次就会发现,查询慢、内存涨、SQL怪异,根子往往出在模型本身。模型是EF Core所有操作的起点——映射规则、关系形态、列类型、索引、级联行为、并发控制,全是模型层决定的。模型没理顺,后面写多少高级查询都是在错误的地基上盖楼。

这个项目的核心目标就是在不换数据库、不改业务逻辑的前提下,对EF Core的实体模型做一轮系统性的优化。这里说的“优化”不是把几十张表全部重写,而是抓住几个最影响性能和稳定性的点:命名约定与显式配置的平衡、查询路径上的索引和拆分策略、关系配置与级联行为、并发控制字段的落位,以及避免那些藏在背后的隐式客户端评估。完成之后最直观的变化是:同样的业务代码,生成的SQL明显干净了,重复查询少了,内存中的对象数量下降了一个量级,慢查询日志里的超时记录基本消失。

这篇文章适合正在用EF Core做实际项目的开发人员,不管你是刚把项目从EF6迁移过来,还是已经在EF Core上跑了两三年但总感觉性能不温不火,都能从里面找到可以落地的东西。我会把自己在真实项目里踩过的坑、反复验证过的配置方式、以及一些文档里不会明说的细节全部写出来,争取让看完的同学能直接对照自己的模型做一次体检。

2. 模型优化的起点:约定对了,后面所有环节都省心

2.1 别急着写Fluent API,先理清业务实体关系

在实际动手优化模型之前,我强烈建议先做一件事:把项目里所有的实体类拉出来,画一张实体关系草图,确认每对关系到底是一对一、一对多还是多对多,以及导航属性是否需要双向。这一条看似和性能无关,但实际上决定了后续所有配置的走向。

为什么这么说?因为EF Core的映射约定(Convention)会自动根据导航属性的形态推导出关系。比如A类里有一个ICollection<B> Bs,B类里有一个A A,EF Core会自动认为这是一对多关系,并默认在B表上生成外键列AId。如果业务语义其实是多对多,而你又没有中间实体,EF Core会自动帮你在数据库里建一张关联表,命名往往是ABs或AB之类。这样做的风险在于:一旦约定推导出的关系和表结构设计与业务预期不一致,模型层的“小偏差”会在查询阶段放大成“大问题”——最典型的就是多了一次意想不到的JOIN,或者本应该在一张表上完成的过滤被拆成了多次往返。

我在一个订单项目里就遇到过这样的情况:订单和产品明明是简单的多对多关系,业务上只需要知道“某订单包含哪些产品”,但团队里有人直接写了两个集合导航属性,没有定义中间实体。EF Core生成的关联表确实能用,但后续做分页查询、按产品维度统计订单时,SQL全部绕到了关联表上,联合索引又没建,查询响应直接翻倍。后来抽时间把中间实体OrderProduct显式建模,配合复合主键和两个索引,情况才彻底改善。

所以我的建议是:先理清关系,再动手改代码。这一步不涉及任何模型配置,纯粹是业务梳理,但回报极高,能帮你省下后面大量调试SQL的时间。

2.2 主键、外键和导航属性的约定陷阱

理清关系之后,第二步是检查实体里主键、外键和导航属性的命名与配置。这同样是最容易被忽略、又最容易引发查询异常的环节。

先说主键。EF Core默认会把名为Id或类名Id的属性识别为主键,并且默认按ValueGeneratedOnAdd处理。这是对的,但有几个坑:

  • 如果业务主键本身是自然键(比如身份证号、订单号),而你希望它作为主键,那必须显式配置HasKey,并且仔细考虑是否真的需要数据库自增。用自然键做主键,后续插入时EF Core会认为主键已存在,不再生成自增逻辑;如果两边没对齐,插入时会主键冲突,查询时又会莫名多出一条WHERE Id = 0的条件。
  • 外键命名也有约定:当A依赖B时,EF Core默认寻找BId或B类名Id作为外键,如果找不到再自动创建一个。自动创建的外键列往往和业务语义不一致,比如B对象里根本没有对应的业务字段,但数据库里突然多了一列。这样的“隐形外键”对排查问题极其不利。

再说导航属性。不要小看导航属性,它的存在直接决定EF Core是生成内连接还是左连接,也决定删除时是否会发生额外的级联操作。我的经验是:能不暴露的导航属性就尽量不暴露,用IQueryable替代集合导航。比如一个实体需要知道“这个订单的所有明细”,但在业务中很少一次性把明细全部加载,那就不该在模型里放一个ICollection<OrderItem>,而应该在仓储层通过DbContext.OrderItems.Where(x => x.OrderId == order.Id)来查询。这样做的直接好处是:查询时不会默认带上这个关联关系,避免生成多余的JOIN,也减少了模型跟踪实体的压力。

如果必须保留导航属性,也建议检查一下是否配置了IsRequired。在EF Core 5.0之后,可空导航属性和必需导航属性的处理方式有差异。一个简单的经验:一端是主体(被依赖方)的导航属性,比如OrderItem.Order设为可空(Order?),另一端Order.OrderItems设为必需集合。这样可以避免错误的级联删除和外键非空约束,也能让EF Core正确生成LEFT JOIN而不是INNER JOIN,防止查询结果被无意中过滤掉。

2.3 显式配置的优先级与取舍

约定虽好,但实际业务规则总是会突破约定,所以显式配置是模型优化的重要一环。这里我想专门说一个原则:能用Fluent API解决的问题,就不要依赖数据注解,更不要依赖约定。不是说数据注解不好,而是Fluent API的集中配置在大型项目里更可控、更容易维护、也更方便整体审查。

在实际项目中,我至少会在OnModelCreating里显式完成下面几类配置:

配置项推荐方式原因
表名、列名ToTable、HasColumnName避免默认命名与业务命名的出入
列类型、长度、精度HasColumnType、HasMaxLength避免数据库自动推断类型带来的低效存储
索引HasIndex、IsUnique、IsDescending直接影响查询性能
关系HasOne、HasMany、WithOne、WithMany避免约定推导错误
级联行为OnDelete(DeleteBehavior.Restrict)控制删除时的行为
并发控制IsRowVersion、IsConcurrencyToken避免并发更新覆盖问题
查询过滤HasQueryFilter实现软删除或租户隔离

有人会觉得这些配置太繁琐,但其实每一条都有明确的目的。比如HasColumnType看起来只是指定一个数据库类型,但如果你让EF Core自动推断,它可能会为字符串列生成nvarchar(max),为布尔列生成bit长度1。这种默认行为带来的问题是存储开销大、索引失效、查询时发生隐式转换。而你一旦把列类型明确为varchar(50)或者decimal(18, 2),数据库的索引利用率和查询性能都会明显改善。

还有一个细节:尽量不用HasDefaultValueSql做软删除标记,也不要把HasQueryFilter和索引配置混在一起想当然。比如你配置了HasQueryFilter(x => !x.IsDeleted),EF Core会在每次查询时自动追加WHERE IsDeleted = 0,但没有索引支持的话,全表扫描会非常吃力。这时候就该配合HasIndex(x => x.IsDeleted),或者设计一个更精确的过滤条件。

3. 查询加速三板斧:从表达式到执行计划

3.1 拆分查询与单表查询的实际取舍

先说一个很多项目里都存在的问题:一个接口里为了省几次往返,把实体相关的所有数据一次性Include出来,结果EF Core生成了一个六七张表JOIN的超级查询,数据量大一点就卡死。Include本身不是错,错在用它覆盖了所有场景。

EF Core 5.0之后引入了AsSplitQuery,它可以避免JOIN结果集的笛卡尔爆炸。但AsSplitQuery并不是银弹,它会为每一层关系生成额外的查询语句,也就是变成多个SQL分次执行。假设订单和明细,再关联产品,如果使用Include+AsSplitQuery,会生成两条查询:一条查订单,一条通过INNER JOIN把订单明细和产品一起查出来。第二个查询的过滤条件会自动带上上一查询的订单ID,通过参数化的形式拼接。

这个特性最适合一对多、多对多且数据量大的场景。但如果你的关系是层层单值(比如订单只有一个客户,客户只有一个区域),那普通的JOIN反而更高效。至于嵌套多层的ThenInclude,就要特别小心:每一层都会增加一次数据库往返,而如果业务并不需要拿全这些数据,只是偶尔用一两个字段,那还不如拆成单独的查询,再用内存组装。

我的建议是:先默认关闭Include,只加载当前业务确实需要的数据。等性能瓶颈明确出现在“需要一次性取多张表数据”的场景时,再用AsSplitQuery做定向优化。不要在还没确定瓶颈的情况下,把所有查询都改成拆分模式,那样只会无谓增加网络往返次数。

3.2 NoTracking与投影的实战细节

模型优化里最容易被忽略、但见效最快的一招,就是在查询时明确告诉EF Core是否跟踪实体。默认情况下EF Core会把查询出来的实体放入变更跟踪器,以便后续调用SaveChanges时对比差异。这个功能的代价是:每个实体的状态快照、原始值、当前值、关系快照都会被记录下来,内存开销和GC压力都会随之上涨。

一个我知道的典型案例:接口里读取1000条订单,明明只是返回给前端展示,但EF Core默认模式下把这1000个对象全部跟踪了。结果每次请求产生几万次属性赋值和快照操作,内存不断上涨,GC频繁触发。后来我对所有只读查询统一加了AsNoTracking(),内存占用直接下降超过一半,P95响应时间也从1.2秒降到了0.6秒。

但AsNoTracking有一个需要特别注意的地方:如果你在同一个DbContext里先执行一条NoTracking查询拿到一个实体,然后修改它的属性,再调用SaveChanges,EF Core不会自动知道这个实体需要更新。更隐蔽的问题是:当你用AsNoTracking查询出一个实体,然后又用同一DbContext查询同一个实体(默认跟踪),此时内存中会出现两个实例,一个被跟踪、一个不被跟踪,后续如果对其中一个做更新操作,另一个的状态会形成不一致。所以在使用NoTracking时,尽量保持整个接口链路一致。

投影(Select)则是另一个层面的优化。建议在查询时尽量使用投影,而不是先查询实体再Select属性。例如:

var result = await db.Orders .Where(o => o.Status == OrderStatus.Paid) .Select(o => new { o.Id, o.OrderNo, CustomerName = o.Customer.Name }) .ToListAsync();

这段代码只加载需要的列,而且Customer这里的JOIN是EF Core自动翻译生成的,不会把整张Customer表的数据加载进来。如果你写成:

var orders = await db.Orders .Include(o => o.Customer) .Where(o => o.Status == OrderStatus.Paid) .ToListAsync(); var result = orders.Select(o => new { o.Id, o.OrderNo, o.Customer.Name });

EF Core会加载Orders的全部列和Customers的全部列,然后再在内存里投影。数据字段少时差别不大,但字段一多、表一宽,这个差距就是几十倍的。

3.3 编译查询、索引与SQL生成的实际联动

EF Core在5.0/6.0之后,默认会对常用查询做缓存,但缓存的主要目的是复用表达式树解析后的命令树,而不是复用数据库执行计划。对于高并发、高频且参数固定的查询,可以进一步使用编译查询,也就是EF.CompileQuery或EF.CompileAsyncQuery。

比如:

private static readonly Func<AppDbContext, int, Task<List<Order>>> GetOrdersByStatus = EF.CompileAsyncQuery((AppDbContext db, int status) => db.Orders.Where(o => o.Status == status).ToListAsync());

编译查询的意思是:将Lambda表达式树一次性编译成委托,后续每次调用时直接执行委托,避免重复解析表达式树的开销。在这里我不建议对低频查询使用编译查询——它的优化收益只体现在高频、短小的查询上。如果一条查询本来就执行得很快,只是调用频率不高,那编译查询的收益就几乎为零,反而增加了代码复杂度。

光有编译查询还不够,索引配置必须同步跟上。实际项目中,我通常会对以下字段建立索引:

  • 外键列(比如Order.CustomerId)
  • 经常出现在Where条件中的列
  • 经常出现在OrderBy、GroupBy中的列
  • 复合条件中组合使用的列,尤其是Status + CreateTime这类场景

配置索引的Fluent API方式:

modelBuilder.Entity<Order>() .HasIndex(o => new { o.Status, o.CreateTime }) .HasFilter("[Status] = 2") .IsDescending(false, true);

注意HasFilter是SQL Server的过滤索引,在EF Core 5.0之后可以直接配置。对于包含大量历史数据但只查询有效状态的表,这种过滤索引的效率远高于普通索引。一个细节:如果你在查询中使用了HasQueryFilter过滤软删除数据,那索引配置里也应该带上同样的过滤条件,否则索引可能无法被查询优化器选中。

还有一点容易被忽略:字符串列的长度和排序规则影响索引利用率。如果你的实体属性是string类型且没有指定最大长度,EF Core在SQL Server上默认生成nvarchar(max),这种列不能建索引(除非用HASH或全文索引)。所以,一旦某个字符串字段需要被查询或排序,必须给它配置HasMaxLength(50)或类似值。这也是模型层对查询层最直接的帮助之一。

4. 关系、级联与数据一致性设计,为了性能要硬下心

4.1 删除级联的默认行为必须根据业务自定义

EF Core默认的级联删除策略是Cascade,也就是删除主表记录时自动删除所有关联子表记录。这在数据库层面看起来方便,但放在真实业务里,很容易变成一场灾难。我见过一个项目,删除组织时连带着把该组织下所有用户、所有订单、所有附件全部级联删除,等到发现问题时数据已经没了,只能靠备份恢复。

所以模型优化时,一定要检查每个一对多关系的DeleteBehavior。实际配置建议是:

modelBuilder.Entity<Order>() .HasMany(o => o.OrderItems) .WithOne(i => i.Order) .OnDelete(DeleteBehavior.Restrict);

Restrict意味着父记录存在关联子记录时,禁止删除父记录;SetNull则把外键置空,但前提是外键列可空。在这两者之间,我一般更推荐Restrict,因为它不会产生“孤儿数据”。如果业务确实需要删除父级时同步清理子级,那也要确保删除的范围在预期内,并且删除操作发生在一个事务里,避免一半成功一半失败。

还有一点值得注意:EF Core的级联行为配置只影响数据库层面的外键约束和EF Core生成的删除SQL。如果数据库里已经有外键约束,且约束行为是默认的CASCADE,那即使EF Core配置为Restrict,数据库层面的约束依然会优先执行,导致EF Core生成的删除SQL与实际行为不一致。所以调整EF Core模型的同时,数据库本身的约束定义也需要同步检查。

4.2 并发控制字段的配置与使用

高并发场景下,模型的并发控制设计尤为关键。EF Core的并发控制主要有两种方式:

  • 使用自增版本号:对应配置IsRowVersion(),SQL Server会生成rowversion列,每次更新时自动递增。
  • 使用自定义并发令牌:对应配置IsConcurrencyToken(),通常用在可空时间戳或自定义状态字段上。

实际项目中,我更推荐用IsRowVersion()来处理大部分并发需求。它的优势是数据库自动维护,不需要应用层关心递增逻辑,天然适合乐观并发。配置方法:

modelBuilder.Entity<Order>() .Property(o => o.RowVersion) .IsRowVersion();

使用起来就是正常的Update操作。EF Core在生成更新SQL时,会自动在WHERE条件里带上RowVersion = 原值,如果更新时版本号已经不匹配,SaveChanges会抛出DbUpdateConcurrencyException,应用层根据这个异常做重试或提示即可。

这里有一个常见的坑:如果实体上同时有多个并发令牌属性,EF Core会把这几个属性全部放入WHERE条件。这个做法没问题,但会让更新SQL变得复杂,并且容易在排查时混淆。所以我的建议是:一个实体只保留一个并发控制属性,不要叠加。

在低冲突业务里,也可以考虑逻辑删除配合IsConcurrencyToken,比如用UpdatedAt字段作为并发令牌。更新时EF Core会把旧的时间和当前时间对比,不一致说明数据已被他人修改。这种方式在读取时性能更好,因为不用额外维护rowversion列,但需要保证时间字段的精度足够高(数据库端用datetime2(3)以上),否则两个并发请求可能拿到相同的时间值,导致乐观并发失效。

4.3 避免隐式客户端评估,把表达式推给数据库

EF Core可以执行复杂的表达式,但它有一个不太友好的特性:如果查询表达式中有一部分无法翻译成SQL,EF Core会尝试把数据拉回客户端,在内存中进行剩余操作。这个行为从功能角度看是“人性化”的,但从性能角度看却是灾难级的。

一个最容易踩的坑是:

var list = await db.Orders .Where(o => o.Status == OrderStatus.Paid) .Select(o => new { o.Id, Amount = o.Amount * (decimal)Math.Pow(1.1, DateTime.Now.Year - o.CreateTime.Year) }) .ToListAsync();

这是在客户端计算的经典例子。看起来只是普通的C#表达式,但EF Core无法准确翻译Math.Pow和DateTime.Now.Year的组合,于是把它放到了客户端执行。如果Orders有十万条,EF Core会先把所有字段拉回内存再计算,性能自然崩盘。

正确做法是把它改写成可以在数据库端执行的形式,比如用EF.Functions.DateDiffYear()来计算年份差,再用Math.Pow表达式时把它全部放到Select的投影中,让EF Core能翻译成SQL函数调用。这里有一个判断逻辑:如果表达式中用到了非EF Core可翻译的静态方法、循环、条件分支或复杂的字符串操作,那么极大概率会被拉到客户端执行。如果查询的结果集不大(比如只是几十上百条),这种方式可以接受;但如果涉及全表扫描、大分页,就必须避免。

另一个类似的坑是First()、Single()、Last()方法。EF Core只支持First()、Single()翻译为SQL,Last()默认不支持,因为它涉及到无序集合的顺序问题,EF Core无法保证数据库返回的顺序。所以如果业务确实需要最后一条记录,要么先OrderByDescending,要么直接在数据库端用ORDER BY加TOP 1。这些都是典型的模型层/查询层错配问题。

5. 常见问题与排查技巧实录

5.1 最容易出问题的隐蔽角落:类型映射与SQL转换

我在多个项目里见过一种很典型的现象:模型配置看起来完全正确,但生成的SQL查询结果就是不正确,或者在特定数据量下性能骤降。排查到最后,往往不是业务逻辑问题,而是类型映射不一致。

举一个具体的案例:数据库里的CreateTime列是datetime类型,精度只到秒级。EF Core默认映射为DateTime,没问题。但如果你在代码里把查询的筛选条件写成o.CreateTime >= startDate,而startDate带上了毫秒,那EF Core生成的SQL会在查询参数中带上毫秒值。由于datetime不支持毫秒精度,SQL Server会自动进行隐式转换或截断,如果数据边界正好卡在毫秒级,查询结果就可能跟预期不一致。

模型层面的解决办法是把这类字段统一映射为datetime2:

modelBuilder.Entity<Order>() .Property(o => o.CreateTime) .HasColumnType("datetime2(3)");

这样代码里传什么精度,数据库端就能精确匹配。这是模型优化中很直观但经常被忽略的一环。

还有一个小坑:decimal列如果配置为decimal(18, 2),而你在代码中传参时使用了decimal,EF Core会正确生成参数,但如果你在查询中使用了Math.Round或者对decimal做乘法,EF Core可能生成一个精度更高的小数列,最终与表结构精度不匹配,导致查询意外无法走索引。这类问题排查起来非常隐蔽,建议在模型配置时对金额、数量等字段统一明确精度和位数,不要依赖默认值。

5.2 性能排查的系统化方法

当你感觉模型优化到一定程度,但某些查询还是慢,不要直接怀疑优化方向错误,而是应该系统地排查。我的排查顺序是这样的:

  1. 先看DbContext注册的生命周期。如果注册成Singleton,那所有请求共用一个上下文,EF Core的查询缓存可能会串数据,这是致命的。推荐注册为Scoped,每次请求一个上下文。
  2. 再看生成的SQL。用LogTo(Console.WriteLine, LogLevel.Information)把SQL打印出来,直接观察是否有额外的JOIN、隐式转换、多余的ORDER BY、COUNT(*)这类操作。
  3. 确认实体跟踪数量。在DbContext的ChangeTracker事件里统计实体个数,或者用ChangeTracker.DebugView.LongView快速查看跟踪情况。如果只读查询里跟踪数量远超预期,就要补上AsNoTracking。
  4. 最后看索引使用情况。在SQL Server里用SET STATISTICS IO ON和SET STATISTICS TIME ON来分析查询执行计划,重点观察是否存在表扫描和索引查找差异。

下面是一个典型排查记录,来自一次真实项目的模型优化:

优化前SQL: SELECT [o].[Id], [o].[OrderNo], [c].[Name] FROM [Orders] AS [o] LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] WHERE [o].[Status] = 1 AND [o].[CreateTime] >= @p 优化后SQL: SELECT [o].[Id], [o].[OrderNo], [c].[Name] FROM [Orders] AS [o] INNER JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[Id] WHERE [o].[Status] = 1 AND [o].[CreateTime] >= @p

差别在LEFT JOIN改成了INNER JOIN。这是因为模型里配置了IsRequired,EF Core知道Customer一定存在,所以自动使用内连接。内连接不仅减少了匹配行数,也让SQL Server优化器更容易选择索引连接策略。这就是模型配置直接影响执行计划的一个典型案例。

5.3 实战踩坑清单

最后整理一份我实际踩过的坑,每条都是真实项目里的教训,适合打印出来贴在工位旁边:

编号坑点原因解决方式
1实体属性全用public int?可空类型,导致SQL条件判断不稳定可空属性会影响EF Core的SQL生成明确业务是否允许为空,不允许的字段全部改为非空
2表名和列名使用默认命名,数据库迁移后出现列顺序混乱数据库端列顺序由末次添加决定显式配置HasColumnName和ToTable
3一对多关系没有配置IsRequired,查询生成LEFT JOIN,数据量一大就慢LEFT JOIN对索引要求更高明确关系方向,配置IsRequired
4软删除使用HasQueryFilter但没有索引全表扫描浪费大量IO为过滤条件建索引
5更新大对象时加载整个实体再修改大字段加载浪费内存和网络使用投影单独加载需要修改的字段
6同一个DbContext里先查询再Include,导致重复查询查询顺序影响执行计划尽可能一次性构建完整查询
7使用DateTime本地时间与数据库UTC时间混合时区混淆会产生错误数据统一用UTC,只在展示层转本地时间
8不配置OnDelete,让级联默认值生效可能会误删数据按业务逐个检查DeleteBehavior

我在重构一个报表模块时,就把上面这些坑几乎全部踩了一遍。当时那个模块要查出所有未完成订单及其客户名称,一开始用了Include(x => x.Customer),查询生成的是LEFT JOIN,慢会话把数据库连接池都拖满了。后改为投影+AsNoTracking+内连接约束,查询秒级返回,数据库压力也降了下来。整个过程没有改一行业务逻辑,纯粹是模型层和查询层的调整。

另外一个容易被忽略的问题是查询使用的表达式中是否包含了无法翻译的成员。比如在Where里使用o.CreateTime.Month == 1,EF Core其实是可以翻译成DATEPART(month, CreateTime) = 1的。但如果你写的是o.CreateTime.ToString("yyyy-MM"),这就没法翻译了,EF Core会把所有行的CreateTime拉到客户端再转换。所以我建议在使用日期、字符串函数时,尽量先查一下EF Core支持哪些函数,或者直接用EF.Functions体系。

6. 后续还能怎么扩展

模型优化不是一次性工作,它是随着业务迭代不断调整的过程。我个人在实际项目里的一个习惯是:每次数据库表结构变更时,同时检查EF Core模型与数据库结构是否仍然匹配,用dotnet ef migrations add生成的迁移脚本做一次对比审查。如果模型层出现大量重复配置,说明设计已经在退化,就该考虑抽取基类或者引入强类型配置类。

对于还在用EF Core 3.1或更老版本的项目,建议尽快计划升级到EF Core 6.0以上。原因不只是性能优化,更重要的是很多模型层控制能力(比如HasFilter过滤索引、UseCollation排序规则、更精细的DeleteBehavior映射)都是5.0之后才完整的。升级过程中模型的OnModelCreating配置会有一些细微差异,但基本都是兼容性调整,成本可控。

另外一点个人经验:模型优化要和DbCommandInterceptor配合使用。通过对所有命令执行时间、SQL文本的拦截和记录,可以在环境上快速定位哪些查询是由实体加载触发的、哪些是手动SQL触发的,避免上线后才发现“某个接口特别慢但查不到原因”的问题。

这次模型优化之后,我最直观的感受是:优化模型其实是在优化数据进出的通道,而不是在优化数据本身。通道理顺了,业务代码可以写得更加直观,查询也更有可预测性。这一点比省下几次数据库往返更有价值。

如果你也正在做EF Core项目的性能调优,建议从模型层开始,先做一轮关系梳理和配置审查,再动手改查询。磨刀不误砍柴工,这套流程走下来,你会对EF Core的行为模式理解得更加透彻。

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

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

立即咨询