线上一个列表接口响应时间从两百毫秒慢慢涨到快两秒,一开始怀疑是数据量涨上去了,扩容加索引都试了效果不明显,最后打开 EF Core 的日志才发现是代码本身的问题,跟数据量关系不大,纯粹是查询方式写得有问题。记几个这次连带查出来的坑。
最典型的是 N+1 查询,代码长得人畜无害:
1 | var orders = await _context.Orders.Where(o => o.Status == "pending").ToListAsync(); |
订单列表一次查几百条,这个循环就对数据库发了几百次查询,日志开出来一看密密麻麻全是 SELECT * FROM Customers WHERE Id = @id,慢是必然的。改成 Include 一次性联查就好了:
1 | var orders = await _context.Orders |
这个坑属于老生常谈,网上一搜一大把,但真正在自己项目里犯这个错误的时候是完全无感的,因为本地测试数据就十几条,循环里查一百次和查十次数据库时间上根本感觉不出差别,量小的时候完全暴露不出来,等到生产环境订单量涨起来就现原形了。
第二个是不必要的 Include 把整个对象图都拉出来了,明明只要显示订单号和客户姓名两个字段:
1 | var orders = await _context.Orders |
这段代码把客户的地址、订单明细、明细对应的商品全都查出来了,就为了最后只用两个字段,属于典型的”先查全量再筛选”,白白浪费了大量数据库和网络开销。EF Core 用久了容易养成”要用什么就 Include 什么关联表”的肌肉记忆,但真正该做的是用 Select 投影直接告诉数据库只要哪几列:
1 | var result = await _context.Orders |
这样 EF Core 生成的 SQL 会自动做成一个精简的 JOIN,只取需要的字段,不会把整张关联表都搬过来。
还有一个隐蔽的地方是 AsNoTracking 没用上。默认情况下 EF Core 会给查出来的每个实体做变更跟踪,方便后续调用 SaveChanges 的时候知道哪些字段改了。但纯读取、不做任何修改的查询场景(这种接口在系统里其实占大多数),这层跟踪完全是浪费,尤其是查询结果集比较大的时候,跟踪的开销会跟着水涨船高:
1 | var orders = await _context.Orders |
这次排查下来这个改动带来的提升比想象中明显,纯只读的查询接口基本上都应该默认带上 AsNoTracking,除非确实后面要改这些实体再存回去。
最后一个是分页写法的问题,团队里有段代码是先把整个表查出来再在内存里分页:
1 | var all = await _context.Orders.ToListAsync(); |
这种写法在数据量小的时候完全看不出毛病,表一大就是灾难,相当于每次分页请求都要把全表数据从数据库搬到应用内存里,再在内存里做分页,数据库那边一点忙都没帮上。Skip/Take 必须写在 IQueryable 链路里,让它随查询一起被翻译成 SQL 的 OFFSET/FETCH,而不是等 ToListAsync() 把数据都拉到内存之后才分页:
1 | var page = await _context.Orders |
这几个坑排查思路都差不多,开着 EF Core 的日志(.LogTo(Console.WriteLine, LogLevel.Information) 或者接 Serilog)看它实际生成的 SQL,很多问题一眼就能看出来,比对着 LINQ 代码猜靠谱得多,毕竟 LINQ 写起来顺眼不代表翻译出来的 SQL 就高效。