EF Core 查询优化与事务实践
学会 EF Core 基础 CRUD 之后,下一步最容易出问题的地方通常不是“会不会写”,而是“这样写性能和一致性是否可靠”。
很多项目前期体量小,看不出问题,但一旦数据量上来,就会逐步暴露:
- 接口越来越慢
- 查询返回数据过大
Include滥用- 并发下数据被覆盖
- 多步操作失败后出现部分成功
这篇文章的重点,就是把 EF Core 从“能用”推进到“相对稳妥地用”。
第一章 为什么基础 CRUD 之后一定要学查询优化
EF Core 的便利,很容易让开发者产生一种错觉:既然代码写起来不复杂,那性能应该也不会差太多。
但真实情况是,下面这些写法差异非常大:
- 查整实体 vs 查需要字段
- 跟踪查询 vs 无跟踪查询
- 单次合理查询 vs 多次重复查询
- 适度关联加载 vs 无脑
Include
所以真正重要的不是“能查出来”,而是“查得合理”。
第二章 查询时先建立一个核心原则
查询优化的第一原则可以总结成一句话:
只查询当前场景真正需要的数据。
这句话听起来很简单,但它会直接影响:
- 返回字段数量
- SQL 复杂度
- 内存占用
- 序列化开销
- 网络传输成本
第三章 跟踪查询和无跟踪查询
3.1 跟踪查询
默认情况下,EF Core 会跟踪查询出的实体。
var users = await dbContext.Users.ToListAsync();
这意味着上下文会记录这些实体的状态变化,以便后续 SaveChangesAsync() 时生成更新语句。
3.2 无跟踪查询
如果只是读,不打算修改实体,更推荐使用:
var users = await dbContext.Users
.AsNoTracking()
.ToListAsync();
3.3 什么时候用 AsNoTracking
适合:
- 列表页
- 只读详情页
- 统计查询
- 导出查询
不太适合:
- 你查询完后还要直接修改并保存
3.4 为什么它能提高性能
因为少了状态跟踪开销。
当数据量大时,这个差异会越来越明显。
第四章 投影查询优先于整实体查询
这是最常见也最实用的优化之一。
4.1 不推荐的写法
var users = await dbContext.Users.ToListAsync();
如果接口只是用户列表,只需要:
IdNameEmail
那整实体查询通常是浪费。
4.2 更推荐的写法
var users = await dbContext.Users
.AsNoTracking()
.Select(x => new UserListItem
{
Id = x.Id,
Name = x.Name,
Email = x.Email
})
.ToListAsync();
public class UserListItem
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
4.3 为什么投影更好
因为它通常意味着:
- 只查必要列
- 返回对象更轻
- 避免不必要的跟踪
- 接口 DTO 更明确
第五章 分页查询怎么写才靠谱
列表接口几乎都要分页。
5.1 基础写法
int page = 1;
int pageSize = 20;
var query = dbContext.Users.AsNoTracking();
var total = await query.CountAsync();
var items = await query
.OrderBy(x => x.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new UserListItem
{
Id = x.Id,
Name = x.Name,
Email = x.Email
})
.ToListAsync();
5.2 为什么一定要有明确排序
分页如果没有稳定排序,结果可能不稳定,尤其是数据变动时。
最常见的做法是:
- 按主键
- 按创建时间
- 按业务排序字段
第六章 Include 怎么用才不过度
6.1 一个基础例子
var orders = await dbContext.Orders
.Include(x => x.Items)
.ToListAsync();
这会把订单及其明细一起加载。
6.2 什么时候适合 Include
适合:
- 当前接口确实需要关联实体
- 单次查询返回量可控
6.3 什么时候要谨慎
如果是列表查询,而且每条记录都有很多关联数据,那么无脑 Include 很可能导致:
- SQL 复杂
- 数据膨胀
- 网络传输增大
- 序列化慢
6.4 一个更稳妥的思路
列表接口优先考虑:
- 只查主表必要字段
- 真要展示关联内容时再做投影或单独查询
第七章 避免 N+1 查询问题
N+1 查询的典型表现是:
- 先查出一批主数据
- 再对每条数据额外发起一次查询
例如:
var orders = await dbContext.Orders.ToListAsync();
foreach (var order in orders)
{
order.Items = await dbContext.OrderItems
.Where(x => x.OrderId == order.Id)
.ToListAsync();
}
如果有 100 条订单,这段代码就可能执行 101 次数据库查询。
7.1 为什么它危险
数据量小的时候不明显,线上数据一大性能会迅速恶化。
7.2 如何改进
常见思路:
- 合理使用
Include - 用一次投影查询拿到需要的数据
- 分批查后在内存做映射
第八章 查询时要不要拆成多步
有时单条复杂查询不一定最好,有时多步查询也不一定更差。
真正要看的不是“是不是只发一条 SQL”,而是:
- SQL 是否足够清晰
- 数据量是否可控
- 总体性能是否合理
所以优化不是死背规则,而是要结合场景判断。
第九章 更新时如何减少误更新风险
9.1 推荐:查出实体后明确赋值
var user = await dbContext.Users.FindAsync(id);
if (user == null)
{
return;
}
user.Name = request.Name;
user.Email = request.Email;
await dbContext.SaveChangesAsync();
9.2 不推荐:前端传什么就整体覆盖什么
这种做法容易导致:
- 审计字段被覆盖
- 非可编辑字段被误改
- 并发时旧数据覆盖新数据
第十章 批量操作时要特别谨慎
如果一次要处理很多数据,重点要关注:
- 查询数量
- 事务范围
- 数据库锁
- 日志量
- 是否需要分批
很多时候“代码很短”,不代表执行代价小。
第十一章 事务什么时候需要主动关注
很多简单场景下,一次 SaveChangesAsync() 本身就足够。但如果你的业务涉及多个步骤,必须一起成功或一起失败,就要显式考虑事务。
典型场景:
- 创建订单 + 扣库存
- 转账扣款 + 加款 + 记流水
- 创建主记录 + 创建明细
第十二章 一个典型事务示例
using var transaction = await dbContext.Database.BeginTransactionAsync();
try
{
var order = new Order
{
OrderNo = "NO1001"
};
dbContext.Orders.Add(order);
await dbContext.SaveChangesAsync();
dbContext.OrderItems.Add(new OrderItem
{
OrderId = order.Id,
ProductName = "Keyboard"
});
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
12.1 这段代码说明了什么
- 多步数据库写入必须作为一个整体考虑
- 失败时要回滚
- 不应该出现“主表成功、明细失败”的半成功状态
第十三章 并发更新要有意识
真实业务里,多个请求可能同时修改同一条数据。
如果没有并发控制意识,就可能出现:
- 后提交的旧数据覆盖先提交的新数据
13.1 入门阶段先记住的原则
- 更新时明确字段边界
- 不要整对象盲目覆盖
- 关键业务要考虑并发冲突
更深入时可以继续学习乐观并发控制。
第十四章 什么时候考虑混合使用 Dapper
EF Core 很适合大部分业务 CRUD,但并不意味着它要独自承担所有数据库访问。
更适合考虑 Dapper 的场景:
- 特别复杂的 SQL
- 报表统计
- 极度关注 SQL 细节
- 需要手写高性能查询
一个常见做法是:
- 业务 CRUD 主要用
EF Core - 特殊复杂查询用
Dapper
第十五章 排查性能问题时先看什么
如果一个接口很慢,可以先检查:
- 是否查了过多字段
- 是否用了过多
Include - 是否忘了
AsNoTracking - 是否存在 N+1 查询
- 是否先
ToList()再内存过滤 - SQL 最终长什么样
不要一上来就怀疑数据库或服务器,先看查询方式本身是否合理。
第十六章 初学者最容易踩的坑
16.1 列表查询默认整实体 + 跟踪
这通常不是最优选择。
16.2 不分页直接查全表
数据量一上来会立刻出问题。
16.3 Include 用上瘾
用起来方便,但代价常常被低估。
16.4 一个业务里多步写入却不考虑事务
最后会得到部分成功的数据脏状态。
16.5 查询慢时只盯着 C# 代码,不看 SQL
很多问题最终都体现在 SQL 层。
第十七章 入门阶段建议掌握到什么程度
建议至少掌握:
- 跟踪查询和无跟踪查询的区别
- 投影查询优于整实体查询的思路
- 分页查询的标准写法
Include的作用和风险- N+1 查询问题的基本识别能力
- 事务边界的基本认知
延伸阅读
- 如果你还没掌握实体建模和基础 CRUD,先阅读
EF Core 基础入门 - 如果你想把
LINQ查询能力补得更扎实,继续阅读C# 集合与 LINQ 实战 - 如果你要把数据库异常纳入接口治理,继续阅读
ASP.NET Core 统一异常处理与统一返回
本文小结
EF Core 查询优化和事务实践的核心,不是记住多少条技巧,而是建立两种意识:
- 查询意识:只拿需要的数据,用合理方式拿
- 一致性意识:多步写操作要考虑事务和并发
这两种意识一旦形成,你写出来的数据访问代码就会比“只会 CRUD”稳很多。