线上一个列表接口响应时间从两百毫秒慢慢涨到快两秒,一开始怀疑是数据量涨上去了,扩容加索引都试了效果不明显,最后打开 EF Core 的日志才发现是代码本身的问题,跟数据量关系不大,纯粹是查询方式写得有问题。记几个这次连带查出来的坑。

最典型的是 N+1 查询,代码长得人畜无害:

1
2
3
4
5
6
var orders = await _context.Orders.Where(o => o.Status == "pending").ToListAsync();
foreach (var order in orders)
{
var customer = await _context.Customers.FindAsync(order.CustomerId); // 每次循环都单独查一次
order.CustomerName = customer.Name;
}

订单列表一次查几百条,这个循环就对数据库发了几百次查询,日志开出来一看密密麻麻全是 SELECT * FROM Customers WHERE Id = @id,慢是必然的。改成 Include 一次性联查就好了:

1
2
3
4
var orders = await _context.Orders
.Where(o => o.Status == "pending")
.Include(o => o.Customer)
.ToListAsync();

这个坑属于老生常谈,网上一搜一大把,但真正在自己项目里犯这个错误的时候是完全无感的,因为本地测试数据就十几条,循环里查一百次和查十次数据库时间上根本感觉不出差别,量小的时候完全暴露不出来,等到生产环境订单量涨起来就现原形了。

第二个是不必要的 Include 把整个对象图都拉出来了,明明只要显示订单号和客户姓名两个字段:

1
2
3
4
5
6
7
8
var orders = await _context.Orders
.Include(o => o.Customer)
.ThenInclude(c => c.Addresses)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ToListAsync();

var result = orders.Select(o => new { o.Id, CustomerName = o.Customer.Name });

这段代码把客户的地址、订单明细、明细对应的商品全都查出来了,就为了最后只用两个字段,属于典型的”先查全量再筛选”,白白浪费了大量数据库和网络开销。EF Core 用久了容易养成”要用什么就 Include 什么关联表”的肌肉记忆,但真正该做的是用 Select 投影直接告诉数据库只要哪几列:

1
2
3
var result = await _context.Orders
.Select(o => new { o.Id, CustomerName = o.Customer.Name })
.ToListAsync();

这样 EF Core 生成的 SQL 会自动做成一个精简的 JOIN,只取需要的字段,不会把整张关联表都搬过来。

还有一个隐蔽的地方是 AsNoTracking 没用上。默认情况下 EF Core 会给查出来的每个实体做变更跟踪,方便后续调用 SaveChanges 的时候知道哪些字段改了。但纯读取、不做任何修改的查询场景(这种接口在系统里其实占大多数),这层跟踪完全是浪费,尤其是查询结果集比较大的时候,跟踪的开销会跟着水涨船高:

1
2
3
4
var orders = await _context.Orders
.AsNoTracking()
.Where(o => o.Status == "pending")
.ToListAsync();

这次排查下来这个改动带来的提升比想象中明显,纯只读的查询接口基本上都应该默认带上 AsNoTracking,除非确实后面要改这些实体再存回去。

最后一个是分页写法的问题,团队里有段代码是先把整个表查出来再在内存里分页:

1
2
var all = await _context.Orders.ToListAsync();
var page = all.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToList();

这种写法在数据量小的时候完全看不出毛病,表一大就是灾难,相当于每次分页请求都要把全表数据从数据库搬到应用内存里,再在内存里做分页,数据库那边一点忙都没帮上。Skip/Take 必须写在 IQueryable 链路里,让它随查询一起被翻译成 SQL 的 OFFSET/FETCH,而不是等 ToListAsync() 把数据都拉到内存之后才分页:

1
2
3
4
5
6
var page = await _context.Orders
.Where(o => o.Status == "pending")
.OrderBy(o => o.CreatedAt)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync();

这几个坑排查思路都差不多,开着 EF Core 的日志(.LogTo(Console.WriteLine, LogLevel.Information) 或者接 Serilog)看它实际生成的 SQL,很多问题一眼就能看出来,比对着 LINQ 代码猜靠谱得多,毕竟 LINQ 写起来顺眼不代表翻译出来的 SQL 就高效。