C# · #csharp#orm#entity-framework#performance

Entity Framework 6 性能优化实战

2015.11.10 C# 3 min 1.3k
// 目录 · contents

EF 6 让数据访问变得异常方便,但便利的背后隐藏了不少性能陷阱。我们项目在数据量上来之后,慢查询问题一波接一波,最终痛定思痛做了一轮系统排查和优化。这篇文章记录那次优化的主要发现。

1. N+1 查询:最常见的性能杀手

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 问题代码:典型的 N+1
var orders = db.Orders.Where(o => o.Status == OrderStatus.Pending).ToList();
foreach (var order in orders)
{
// 每次访问 order.Customer 都触发一次 SELECT — N 次额外查询!
Console.WriteLine($"{order.Id} - {order.Customer.Name}");
}

// 解决方案 1:Eager Loading(立即加载)
var orders = db.Orders
.Include(o => o.Customer)
.Include(o => o.OrderItems.Select(i => i.Product)) // 多级 Include
.Where(o => o.Status == OrderStatus.Pending)
.ToList();

// 解决方案 2:Projection(投影),只取需要的字段
var result = db.Orders
.Where(o => o.Status == OrderStatus.Pending)
.Select(o => new {
o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.OrderItems.Count()
})
.ToList();

第二种(Projection)通常比 Include 更高效,生成的 SQL 只包含需要的列,避免了传输大量无用数据。

2. 懒加载陷阱

EF 6 默认开启懒加载,导航属性在访问时才触发查询。这在循环中极其危险:

1
2
3
4
5
6
7
8
9
// 关闭全局懒加载(推荐在性能敏感场景关闭)
db.Configuration.LazyLoadingEnabled = false;

// 或者按需开启:只在特定 DbContext 作用域内使用懒加载
using (var db = new AppDbContext())
{
db.Configuration.LazyLoadingEnabled = true;
// 在这里可以用懒加载,但要小心
}

懒加载在单次访问时很方便,但一旦进入循环就成了性能噩梦。我的原则是:生产代码中关闭懒加载,用显式的 Include 或投影

3. AsNoTracking:只读查询的利器

EF 默认会跟踪查询返回的所有实体(变更追踪),以便后续 SaveChanges() 时检测修改。对于只读查询,这个机制纯属浪费:

1
2
3
4
5
6
7
8
9
10
// 默认:开启追踪,内存和CPU额外开销
var users = db.Users.Where(u => u.IsActive).ToList();

// AsNoTracking:跳过追踪,只读查询提速 30%~50%
var users = db.Users.AsNoTracking()
.Where(u => u.IsActive)
.ToList();

// 全局关闭追踪(适合只读的 DbContext)
db.Configuration.AutoDetectChangesEnabled = false;

4. 批量操作

EF 的 SaveChanges() 对每条记录生成独立的 INSERT/UPDATE/DELETE 语句,批量操作时性能极差:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 问题:插入 10000 条 = 执行 10000 次 INSERT
for (int i = 0; i < 10000; i++)
{
db.Users.Add(new User { Name = $"User{i}" });
}
db.SaveChanges(); // 极慢

// 解决方案 1:分批提交,每批 500 条
const int batchSize = 500;
for (int i = 0; i < users.Count; i++)
{
db.Users.Add(users[i]);
if (i % batchSize == 0)
{
db.SaveChanges();
// 重新创建 DbContext,清空追踪缓存
db = new AppDbContext();
}
}

// 解决方案 2:使用 EFCore.BulkExtensions(第三方库)
db.BulkInsert(users);
db.BulkUpdate(users);
db.BulkDelete(users);

实测:插入 10000 条数据,逐条 SaveChanges 约 45 秒,BulkInsert 约 1.2 秒,差距约 37 倍。

5. 正确使用索引

EF Code First 默认只对外键和主键创建索引,业务查询字段需要手动添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class User
{
public int Id { get; set; }

[Index("IX_User_Email", IsUnique = true)]
[StringLength(100)]
public string Email { get; set; }

[Index("IX_User_Status_CreatedAt", Order = 1)]
public UserStatus Status { get; set; }

[Index("IX_User_Status_CreatedAt", Order = 2)]
public DateTime CreatedAt { get; set; }
}

或者通过 Fluent API:

1
2
3
modelBuilder.Entity<User>()
.HasIndex(u => u.Email).IsUnique()
.HasIndex(u => new { u.Status, u.CreatedAt });

6. 使用 SQL 查询处理复杂场景

EF 生成的 SQL 并不总是最优的,复杂统计查询直接写 SQL 更可控:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 原始 SQL 查询(返回实体)
var users = db.Database.SqlQuery<User>(
"SELECT * FROM Users WHERE Status = @status AND Age > @age",
new SqlParameter("status", 1),
new SqlParameter("age", 18)
).ToList();

// 存储过程
var result = db.Database.SqlQuery<OrderSummary>(
"EXEC GetOrderSummary @startDate, @endDate",
new SqlParameter("startDate", startDate),
new SqlParameter("endDate", endDate)
).ToList();

踩坑记录

有一次上线后收到报警,一个报表接口响应时间从 200ms 飙到 12 秒。排查发现,开发同学在报表逻辑里不小心用了导航属性,循环里触发了 800 次额外查询。更糟糕的是,这个问题在开发环境根本发现不了——测试数据只有 20 条,生产数据是 2000 条。

从那之后我们建立了一个规范:所有涉及集合查询的代码,必须开启 EF 的 SQL 日志,在本地验证生成的 SQL 没有 N+1 问题再提交。

1
2
// 开启 SQL 日志,在 DbContext 构造函数或初始化时添加
db.Database.Log = sql => Debug.WriteLine(sql);

总结

EF 6 性能优化的几个核心原则:

  • 显式 Include 代替懒加载,避免 N+1
  • 投影(Select) 只取需要的字段,减少数据传输
  • AsNoTracking 用于所有只读查询
  • 分批提交 或第三方批量库处理大量写入
  • SQL 日志 是发现性能问题的第一步
  • 复杂查询不要强迫 EF 生成,直接写 SQL 更可控
作者 · authorzt
发布 · date2015-11-10
篇幅 · length1.3k 字 · 3 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论