EF Core 查询优化与事务实践

系统讲解 EF Core 中的查询优化、跟踪与无跟踪、投影、分页、事务与常见性能问题。

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();

如果接口只是用户列表,只需要:

  • Id
  • Name
  • Email

那整实体查询通常是浪费。

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 查询的典型表现是:

  1. 先查出一批主数据
  2. 再对每条数据额外发起一次查询

例如:

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

第十五章 排查性能问题时先看什么

如果一个接口很慢,可以先检查:

  1. 是否查了过多字段
  2. 是否用了过多 Include
  3. 是否忘了 AsNoTracking
  4. 是否存在 N+1 查询
  5. 是否先 ToList() 再内存过滤
  6. SQL 最终长什么样

不要一上来就怀疑数据库或服务器,先看查询方式本身是否合理。


第十六章 初学者最容易踩的坑

16.1 列表查询默认整实体 + 跟踪

这通常不是最优选择。

16.2 不分页直接查全表

数据量一上来会立刻出问题。

16.3 Include 用上瘾

用起来方便,但代价常常被低估。

16.4 一个业务里多步写入却不考虑事务

最后会得到部分成功的数据脏状态。

16.5 查询慢时只盯着 C# 代码,不看 SQL

很多问题最终都体现在 SQL 层。


第十七章 入门阶段建议掌握到什么程度

建议至少掌握:

  1. 跟踪查询和无跟踪查询的区别
  2. 投影查询优于整实体查询的思路
  3. 分页查询的标准写法
  4. Include 的作用和风险
  5. N+1 查询问题的基本识别能力
  6. 事务边界的基本认知

延伸阅读

  • 如果你还没掌握实体建模和基础 CRUD,先阅读 EF Core 基础入门
  • 如果你想把 LINQ 查询能力补得更扎实,继续阅读 C# 集合与 LINQ 实战
  • 如果你要把数据库异常纳入接口治理,继续阅读 ASP.NET Core 统一异常处理与统一返回

本文小结

EF Core 查询优化和事务实践的核心,不是记住多少条技巧,而是建立两种意识:

  • 查询意识:只拿需要的数据,用合理方式拿
  • 一致性意识:多步写操作要考虑事务和并发

这两种意识一旦形成,你写出来的数据访问代码就会比“只会 CRUD”稳很多。