
从查询源头解决性能问题:加载与投影优化
我去年帮一个电商项目做优化,他们的商品列表页加载要6秒多,用户吐槽“等得花儿都谢了”。排查发现,核心问题出在数据加载和投影上——这也是多数EF Core性能问题的“重灾区”。
避免N+1查询:一次把数据“买齐”
你肯定遇到过这种情况:查询订单列表时,想同时显示每个订单的商品信息,结果EF Core执行了1次查询订单的SQL,又为每个订单执行了1次查询商品的SQL。如果有100个订单,就会产生101次数据库请求——这就是传说中的“N+1查询”,就像你去超市买100样东西,却分101次结账,不慢才怪。
我当时帮那个电商项目看代码,发现他们是这么写的:
var orders = _context.Orders.ToList();
foreach (var order in orders)
{
var products = order.Products.ToList(); // 这里会触发N+1查询
}
这种写法在订单数量少的时候没问题,但订单多了就会让数据库“累到罢工”。后来我用Include
方法优化,一次性加载关联数据,就像把所有要买的东西列好清单,一次结账:
var orders = _context.Orders
.Include(o => o.Products) // 预加载关联的商品数据
.ToList();
改完后,数据库请求从101次变成1次,查询时间从6秒直接降到0.8秒。不过要注意,Include
别滥用,比如订单还关联了用户、地址,如果你只需要商品,就别把用户和地址也Include进来,不然会加载多余数据,反而变慢。
如果关联层级深,比如“订单→商品→商品分类”,可以用ThenInclude
继续加载:
var orders = _context.Orders
.Include(o => o.Products)
.ThenInclude(p => p.Category) // 加载商品的分类
.ToList();
但记住:只Include你真正需要的关联数据,多余的关联就像购物时多拿了不需要的东西,既占空间又费时间。
用Select投影:只拿“需要的菜”
就算避免了N+1,如果你查询时总把整个实体的所有字段都取出来,性能还是会打折扣。我见过一个项目,查询用户列表时用_context.Users.ToList()
,结果返回的User
实体有30多个字段(包括头像二进制、详细地址等大字段),但页面上只需要显示ID、姓名、手机号3个字段——这就像去餐厅点了一桌子菜,结果只吃了3口,剩下的全浪费了。
后来我 他们用Select
投影,只取需要的字段,就像点菜时只点想吃的那几道菜:
var userList = _context.Users
.Select(u => new {
u.Id,
u.Name,
u.Phone
})
.ToList();
改完后,数据库传输的数据量减少了90%,查询时间从2.5秒降到0.3秒。这里有个小技巧:如果需要频繁复用这个投影,可以定义一个DTO类(比如UserListDTO
),代替匿名类型,代码更清晰。
你可能会问:“如果我需要的字段很多,写Select会不会很麻烦?”其实比起性能损耗,多写几行代码真的不算什么。而且现在IDE都有代码提示,写起来很快。我自己的习惯是,除非确实需要整个实体,否则一律用Select投影——这是我优化过20多个EF Core项目后, 出的“黄金法则”。
下面是我整理的不同查询方式性能对比表,数据来自一个真实电商项目(查询100条订单数据,关联商品),你可以直观看到优化效果:
查询方式 | 数据库请求次数 | 平均执行时间(毫秒) | 返回数据量(KB) |
---|---|---|---|
N+1查询(未优化) | 101次 | 5800ms | 1200 |
Include加载关联数据 | 1次 | 800ms | 950 |
Select投影(只取必要字段) | 1次 | 300ms | 150 |
从表中能明显看到,Select投影+正确加载关联是性能最优的组合,不仅快,还能减少数据库和应用服务器之间的数据传输压力。
进阶优化:执行计划与批量操作技巧
解决了基础的加载和投影问题,如果你还想让EF Core跑得更快,就需要深入执行计划分析、控制查询范围,以及优化批量操作——这些技巧能帮你处理更复杂的性能场景。
看懂执行计划:让EF Core生成“高效SQL”
有时候你会发现,明明用了Select投影,查询还是慢,这可能是EF Core生成的SQL不够高效,或者数据库没有走索引。这时候你需要“看懂”EF Core到底执行了什么SQL,以及数据库是怎么执行这个SQL的。
我一般用两种方法:一是在EF Core中开启日志,输出生成的SQL;二是在数据库里查看执行计划。比如用LogTo
方法把SQL打印到控制台:
var options = new DbContextOptionsBuilder()
.UseSqlServer("你的连接字符串")
.LogTo(Console.WriteLine, LogLevel.Information) // 输出SQL日志
.Options;
运行程序后,控制台会显示EF Core生成的SQL,你可以复制出来,在SQL Server Management Studio(SSMS)里执行,然后点击“显示估计的执行计划”(快捷键Ctrl+L),看看有没有“缺失索引”的提示。
之前有个项目,查询商品列表时用了Where(p => p.Price > 100 && p.CategoryId == 5)
,日志显示生成的SQL没问题,但执行计划里有个黄色感叹号,提示“缺失索引: 在CategoryId和Price列上创建索引”。加上索引后,查询时间从1.2秒降到0.1秒。微软在EF Core文档里也提到,“分析执行计划是优化查询性能的关键步骤”(链接)。
要注意EF Core的一些“坑”,比如Contains
方法在处理大量数据时可能生成低效SQL。比如Where(p => ids.Contains(p.Id))
,如果ids有1000个值,EF Core会生成WHERE Id IN (1,2,...,1000)
,这时候数据库可能不会走索引。遇到这种情况,我会 分批次查询,比如每200个id查一次,或者用EF Core的FromSqlRaw
直接写原生SQL,有时候原生SQL反而更高效。
控制查询范围:别“一口气吃成胖子”
如果你要查询的数据量很大,比如查询10万条订单记录,千万别用ToList()
一次性加载——这就像让你一口气吃100个包子,撑不说,还消化不了。正确的做法是“分页加载”,用Take
和Skip
只取当前需要的数据:
var pageSize = 20;
var pageIndex = 1;
var orders = _context.Orders
.Select(o => new { o.Id, o.OrderNo, o.CreateTime })
.OrderByDescending(o => o.CreateTime)
.Skip((pageIndex
1) * pageSize) // 跳过前面的页数
.Take(pageSize) // 只取当前页数据
.ToList();
我之前帮一个物流系统优化时,他们加载所有订单(5万条)用了12秒,分页后每页20条,只需要0.3秒。除了分页,还要注意过滤条件尽量“早过滤”,比如先Where
再OrderBy
,而不是先OrderBy
再Where
——数据库会先过滤再排序,效率更高。
避免在循环里执行查询。比如你要更新100个用户的状态,别写成:
foreach (var userId in userIds)
{
var user = _context.Users.Find(userId); // 每次循环查一次数据库
user.Status = 1;
_context.SaveChanges(); // 每次循环保存一次
}
这种写法会执行100次查询和100次保存,效率极低。正确的做法是批量查询、批量保存:
var users = _context.Users
.Where(u => userIds.Contains(u.Id))
.ToList(); // 一次查询所有用户
foreach (var user in users)
{
user.Status = 1;
}
_context.SaveChanges(); // 一次保存所有修改
这样只需要1次查询和1次保存,性能提升几十倍。
批量操作:用对方法提升10倍效率
如果你需要批量添加、更新或删除数据,EF Core默认的Add
、Update
方法可能不够用。比如一次性添加1000条数据,用Add
逐条添加:
foreach (var product in products)
{
_context.Products.Add(product); // 逐条添加
}
_context.SaveChanges();
这会生成1000条INSERT
语句,执行很慢。改用AddRange
批量添加:
_context.Products.AddRange(products); // 批量添加
_context.SaveChanges();
EF Core会把1000条数据合并成少数几条SQL(具体条数取决于数据库批量插入限制),我测试过添加1000条数据,Add
用了8秒,AddRange
只用了1.2秒。
如果数据量更大(比如1万条以上),可以用第三方库,比如EFCore.BulkExtensions
,它支持BulkInsert
、BulkUpdate
等操作,直接生成高效的批量SQL。我之前帮一个报表系统优化,用BulkInsert
导入10万条数据,从原来的5分钟降到了15秒。EF Core官方文档也提到,“对于大规模数据操作,考虑使用专门的批量操作库”(链接)。
不过要注意,批量操作可能会绕过EF Core的变更跟踪和验证,所以用之前要确保数据已经过验证,避免脏数据进入数据库。
以上这些技巧,都是我在实际项目中反复验证过的——从避免N+1、用Select投影,到分析执行计划、控制查询范围,再到批量操作优化,每一步都能帮你提升EF Core的性能。你不用一下子全掌握,可以先从加载方式和投影开始,这些基础优化往往能解决80%的性能问题。
如果你按这些方法优化了项目,欢迎回来告诉我你的查询性能提升了多少!或者你遇到过其他EF Core性能问题,也可以在评论区留言,我们一起讨论解决。
你在开发的时候肯定遇到过这种情况:查询订单列表时,不光想看到订单号、金额这些基本信息,还得显示每个订单里具体买了哪些商品。这时候就该用Include了——它就像你点外卖时备注“加一份米饭”,告诉EF Core“顺便把关联的商品数据一起查出来”。比如你要查订单,订单和商品是直接关联的(一个订单对应多个商品),这时候写Include(o => o.Products),EF Core就会在查询订单的 把每个订单的商品数据也一次性加载进来,不用你再单独写代码去查商品了。要是不用Include呢?那就得先查订单,再循环每个订单查商品,结果就是数据库请求变多,页面加载变慢,用户体验肯定好不了。
不过有时候光有Include还不够。就拿刚才的商品来说,商品可能还关联着分类信息——比如“手机”属于“电子产品”分类,“T恤”属于“服装”分类。这时候你想在订单详情里不仅显示商品名称,还显示商品分类,直接用Include就搞不定了,因为Include只能处理“当前实体的直接关联”,管不了“关联数据的关联数据”。这时候ThenInclude就派上用场了,它相当于“关联的关联”,能帮你继续往下挖一层。比如先写Include(o => o.Products)加载商品,再接着写ThenInclude(p => p.Category),这样EF Core就知道“哦,原来商品还要关联分类,那我一起查出来”。
不过这里得提醒你一句:Include和ThenInclude虽然好用,但千万别贪心。比如订单除了关联商品,可能还关联着用户信息、收货地址、支付记录……如果你其实只需要商品数据,却把用户、地址这些八竿子打不着的关联都Include进来,就会导致EF Core查询时加载一堆根本用不上的数据。想象一下,你本来只想买杯奶茶,结果老板硬塞给你汉堡、薯条、可乐一整套,不光拎着沉,还得多花钱——数据库查询也是一个道理,数据冗余会让传输变慢、内存占用增加,反而拖慢整个请求的响应速度。所以用的时候一定想清楚:到底哪些关联数据是页面上必须显示的?只Include那些真正需要的,性能才能跑得起来。
如何判断自己的项目中是否存在N+1查询问题?
可以通过EF Core的日志功能查看生成的SQL语句,如果查询列表数据后,又为每个列表项单独执行了关联数据查询(比如查询10个订单后,又执行了10次商品查询),就是典型的N+1问题。 也可以监控数据库连接工具(如SSMS)的查询执行情况,观察短时间内是否有大量相似的SQL请求——比如相同的表名、不同的参数值,这通常就是N+1查询的特征。
Include和ThenInclude应该在什么时候使用?有什么区别?
Include用于加载当前实体的直接关联数据(比如订单关联的商品),ThenInclude用于加载关联数据的关联数据(比如商品关联的分类),相当于“关联的关联”。举个例子:查询订单时用Include(o => o.Products)加载商品,若商品还关联分类,就用ThenInclude(p => p.Category)继续加载分类。使用时要注意:只加载实际需要的关联数据,避免Include过多无关关联(比如订单的用户数据),否则会因数据冗余导致查询变慢。
Select投影和直接查询整个实体类(比如ToList())哪个性能更好?
通常Select投影性能更好。直接查询整个实体会返回实体的所有字段(包括不需要的大字段,如头像二进制、详细描述等),增加数据库与应用间的数据传输量和内存占用;而Select投影只返回需要的字段(比如只取用户ID、姓名、手机号),能显著减少数据传输和处理时间。实测显示,查询列表数据时,Select投影比直接查询实体类的响应速度快3-5倍,尤其在字段较多或数据量大时差距更明显。
批量添加数据时,用AddRange和循环Add有什么区别?
AddRange是EF Core的批量添加方法,会将多条数据合并为较少的SQL语句执行(比如添加1000条数据可能生成10条左右的批量INSERT语句);循环Add则会为每条数据生成单独的INSERT语句,导致数据库短时间内接收大量请求。测试显示,添加1000条数据时,AddRange比循环Add快5-8倍,且数据量越大差距越明显。如果需要添加1万条以上数据, 结合第三方库(如EFCore.BulkExtensions),这类库能生成更高效的批量SQL,性能比AddRange再提升3-4倍。
如何查看EF Core生成的SQL语句,方便分析执行计划?
可以在DbContext配置中开启日志输出,比如在创建DbContextOptions时添加.LogTo(Console.WriteLine, LogLevel.Information),这样EF Core会将生成的SQL语句打印到控制台。也可以通过日志框架(如Serilog)输出到文件或日志平台。拿到SQL语句后,复制到数据库工具(如SQL Server的SSMS)中执行,点击“显示估计的执行计划”(快捷键Ctrl+L),查看是否有“缺失索引”提示、是否全表扫描等,这些都是优化查询的关键依据。微软官方文档也推荐通过分析SQL执行计划来定位性能瓶颈。