Appearance
延迟加载与显式加载
目录
延迟加载 Lazy Loading
概念理解
延迟加载(Lazy Loading): 在首次访问导航属性时,才自动从数据库加载相关数据。
csharp
// 启用延迟加载后
var customer = context.Customers.Find(1); // 只加载 Customer
// 首次访问 Orders 时,自动执行 SQL 查询
var orders = customer.Orders; // ← 这里触发数据库查询工作原理
mermaid
graph LR
A[访问导航属性] --> B{数据已加载?}
B -->|否| C[执行 SQL 查询]
B -->|是| D[返回缓存数据]
C --> E[填充导航属性]
E --> D
D --> F[返回结果]配置延迟加载
方法 1: 使用代理(推荐)
csharp
// Program.cs (.NET 8/9/10)
using Microsoft.EntityFrameworkCore.Proxies;
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseLazyLoadingProxies() // 启用代理
.UseSqlServer(connectionString);
});
// 实体类: 导航属性必须是 virtual
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
// ⚠️ 必须是 virtual 才能启用延迟加载
public virtual ICollection<Order> Orders { get; set; }
public virtual Address ShippingAddress { get; set; }
}
public class Order
{
public int Id { get; set; }
public DateTime OrderDate { get; set; }
public virtual Customer Customer { get; set; }
public virtual ICollection<OrderItem> Items { get; set; }
}原理: EF Core 在运行时创建派生类的代理,重写 virtual 属性以实现懒加载。
方法 2: ILazyLoader(无代理)
csharp
// 适用于不能使用 virtual 的场景(如 DDD)
public class Customer
{
private readonly ILazyLoader _lazyLoader;
private ICollection<Order> _orders;
public Customer() { }
public Customer(ILazyLoader lazyLoader)
{
_lazyLoader = lazyLoader;
}
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Order> Orders
{
get => _lazyLoader?.Load(this, ref _orders);
set => _orders = value;
}
}
// 注册
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
// 不需要 UseLazyLoadingProxies()
});延迟加载示例
基础用法
csharp
// 获取客户(不加载订单)
var customer = await context.Customers.FindAsync(1);
Console.WriteLine(customer.Name); // OK
// 首次访问 Orders,触发查询
var orders = customer.Orders; // SELECT * FROM Orders WHERE CustomerId = 1
// 再次访问,不再查询(已缓存)
var orders2 = customer.Orders; // 无 SQL
// 遍历订单
foreach (var order in customer.Orders) // 只在首次访问时查询
{
Console.WriteLine(order.OrderDate);
}嵌套延迟加载
csharp
var customer = await context.Customers.FindAsync(1);
// 访问订单
var orders = customer.Orders; // 查询 1: Orders
foreach (var order in orders)
{
// 访问每个订单项 ← N+1 查询问题!
var items = order.Items; // 查询 N: OrderItems
foreach (var item in items)
{
// 访问产品信息 ← 更严重的 N+1!
var product = item.Product; // 查询 N*M: Products
Console.WriteLine(product.Name);
}
}
// 总查询次数: 1 + N + N*M
// 如果有 10 个订单,每个订单 5 个商品: 1 + 10 + 50 = 61 次查询!检测延迟加载查询
csharp
// 启用详细日志,查看延迟加载触发的查询
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseLazyLoadingProxies()
.UseSqlServer(connectionString)
.LogTo(Console.WriteLine, LogLevel.Information); // 记录所有 SQL
});
// 输出示例:
// info: 14:30:00.001 Executed DbCommand (2ms) [SELECT TOP(1) ... FROM Customers]
// info: 14:30:00.050 Executed DbCommand (3ms) [SELECT ... FROM Orders WHERE CustomerId = 1]
// info: 14:30:00.060 Executed DbCommand (1ms) [SELECT ... FROM OrderItems WHERE OrderId = 101]
// ← 可以看到多次查询常见问题
问题 1: ObjectDisposedException
csharp
// ❌ 错误: DbContext 释放后访问导航属性
Customer GetCustomer()
{
using var context = new AppDbContext();
return context.Customers.Find(1);
}
var customer = GetCustomer();
var orders = customer.Orders; // 💥 ObjectDisposedException!
// ✅ 解决 1: Include 预加载
Customer GetCustomerWithOrders()
{
using var context = new AppDbContext();
return context.Customers
.Include(c => c.Orders)
.FirstOrDefault(c => c.Id == 1);
}
// ✅ 解决 2: 延长 DbContext 生命周期
async Task<Customer> GetCustomerAsync(AppDbContext context)
{
var customer = await context.Customers.FindAsync(1);
await context.Entry(customer).Collection(c => c.Orders).LoadAsync();
return customer;
}问题 2: 序列化循环引用
csharp
// ❌ 错误: JSON 序列化时触发延迟加载,导致循环引用
var customer = await context.Customers.FindAsync(1);
return Results.Json(customer);
// 💥 StackOverflowException: Customer → Orders → Customer → ...
// ✅ 解决 1: 使用 DTO
var customer = await context.Customers.FindAsync(1);
var dto = new CustomerDto
{
Id = customer.Id,
Name = customer.Name,
OrderCount = customer.Orders.Count // 仅加载需要的数据
};
return Results.Json(dto);
// ✅ 解决 2: 禁用延迟加载
context.ChangeTracker.LazyLoadingEnabled = false;问题 3: 意外的 N+1 查询
csharp
// ❌ 错误: 循环中触发延迟加载
var customers = await context.Customers.ToListAsync();
foreach (var customer in customers)
{
Console.WriteLine(customer.Orders.Count); // N+1 查询!
}
// ✅ 解决: Include 或投影查询
var customers = await context.Customers
.Include(c => c.Orders)
.ToListAsync();
// 或者
var customerData = await context.Customers
.Select(c => new
{
c.Name,
OrderCount = c.Orders.Count
})
.ToListAsync(); // 单次查询显式加载 Explicit Loading
概念理解
显式加载(Explicit Loading): 手动调用 API 来加载导航属性,精确控制何时加载数据。
csharp
var customer = await context.Customers.FindAsync(1);
// 显式加载订单
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync(); // ← 明确指定何时加载
// 现在可以访问订单
var orders = customer.Orders; // 无额外查询优势
✅ 完全控制: 明确知道何时执行查询
✅ 避免意外查询: 不会像延迟加载那样自动触发
✅ 灵活过滤: 可以在加载时应用过滤条件
✅ 无需 virtual: 不需要将属性标记为 virtual
基本用法
加载集合导航属性
csharp
// 获取客户
var customer = await context.Customers.FindAsync(1);
// 显式加载订单集合
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
// 访问订单(已加载,无额外查询)
foreach (var order in customer.Orders)
{
Console.WriteLine(order.OrderDate);
}加载引用导航属性
csharp
// 获取订单
var order = await context.Orders.FindAsync(101);
// 显式加载客户(引用)
await context.Entry(order)
.Reference(o => o.Customer)
.LoadAsync();
// 访问客户
Console.WriteLine(order.Customer.Name);高级用法
1. 过滤加载
csharp
// 只加载最近的订单
await context.Entry(customer)
.Collection(c => c.Orders)
.Query(q => q.Where(o => o.OrderDate >= DateTime.Today.AddDays(-30))
.OrderByDescending(o => o.OrderDate))
.LoadAsync();
// 统计数量(不加载完整对象)
var recentOrderCount = await context.Entry(customer)
.Collection(c => c.Orders)
.Query()
.Where(o => o.OrderDate >= DateTime.Today.AddDays(-30))
.CountAsync();2. 分页加载
csharp
public async Task<List<Order>> GetCustomerOrdersAsync(
int customerId,
int pageNumber,
int pageSize)
{
var customer = await context.Customers.FindAsync(customerId);
// 分页加载订单
var orders = await context.Entry(customer)
.Collection(c => c.Orders)
.Query()
.OrderByDescending(o => o.OrderDate)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return orders;
}3. 条件加载
csharp
var customer = await context.Customers.FindAsync(1);
// 根据条件决定是否加载
if (customer.IsVip)
{
// VIP 客户: 加载所有历史订单
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
}
else
{
// 普通客户: 仅加载最近订单
await context.Entry(customer)
.Collection(c => c.Orders)
.Query(q => q.Where(o => o.OrderDate >= DateTime.Today.AddMonths(-3)))
.LoadAsync();
}4. 多级显式加载
csharp
var order = await context.Orders.FindAsync(101);
// 加载客户
await context.Entry(order)
.Reference(o => o.Customer)
.LoadAsync();
// 加载订单项
await context.Entry(order)
.Collection(o => o.Items)
.LoadAsync();
// 加载每个订单项的产品
foreach (var item in order.Items)
{
await context.Entry(item)
.Reference(i => i.Product)
.LoadAsync();
}检查是否已加载
csharp
var customer = await context.Customers.FindAsync(1);
// 检查集合是否已加载
bool ordersLoaded = context.Entry(customer)
.Collection(c => c.Orders)
.IsLoaded; // false
// 加载订单
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
ordersLoaded = context.Entry(customer)
.Collection(c => c.Orders)
.IsLoaded; // true
// 检查引用是否已加载
var order = await context.Orders.FindAsync(101);
bool customerLoaded = context.Entry(order)
.Reference(o => o.Customer)
.IsLoaded; // false三种加载方式对比
对比表
| 特性 | 预加载(Eager) | 延迟加载(Lazy) | 显式加载(Explicit) |
|---|---|---|---|
| 语法 | Include() | 自动触发 | Entry().Collection().Load() |
| 控制权 | 完全控制 | 自动 | 手动控制 |
| 性能可预测性 | ✅ 高 | ❌ 低 | ✅ 高 |
| N+1 风险 | ❌ 无 | ⚠️ 高 | ❌ 无 |
| 需要 virtual | ❌ 否 | ✅ 是 | ❌ 否 |
| 适用场景 | 确定需要关联数据 | 按需访问 | 条件加载 |
| 查询次数 | 1 次(或 SplitQuery) | 可能多次 | 精确控制 |
| 内存占用 | 可能较高 | 按需加载 | 按需加载 |
| 推荐程度 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
选择指南
mermaid
graph TD
A[需要加载关联数据?] --> B{是否确定需要?}
B -->|是,总是需要| C[预加载 Include]
B -->|不确定| D{访问频率?}
D -->|偶尔访问| E[延迟加载 Lazy]
D -->|条件访问| F[显式加载 Explicit]
C --> G{是否有 JOIN 爆炸?}
G -->|是| H[AsSplitQuery]
G -->|否| I[直接 Include]代码对比示例
csharp
// 场景: 获取客户及其订单
// ✅ 预加载: 适合大多数场景
var customer1 = await context.Customers
.Include(c => c.Orders)
.FirstOrDefaultAsync(c => c.Id == 1);
// ⚠️ 延迟加载: 小心 N+1
var customer2 = await context.Customers.FindAsync(1);
var orders = customer2.Orders; // 触发查询
// ✅ 显式加载: 条件加载
var customer3 = await context.Customers.FindAsync(1);
if (someCondition)
{
await context.Entry(customer3)
.Collection(c => c.Orders)
.LoadAsync();
}性能分析与优化
N+1 查询问题详解
问题演示
csharp
// ❌ 糟糕的模式: N+1 查询
var customers = await context.Customers.ToListAsync(); // 查询 1
foreach (var customer in customers)
{
// 每次访问都触发查询
var orders = customer.Orders; // 查询 N
foreach (var order in orders)
{
var items = order.Items; // 查询 N*M
}
}
// 总查询数: 1 + N + N*M
// 100 客户 × 5 订单 × 3 商品 = 1 + 100 + 500 = 601 次查询!解决方案 1: Include
csharp
// ✅ 预加载
var customers = await context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.ToListAsync();
// 查询数: 1 (或使用 AsSplitQuery 拆分为 3 次)解决方案 2: 投影查询
csharp
// ✅✅ 最佳: 只查询需要的数据
var customerData = await context.Customers
.Select(c => new
{
c.Name,
OrderCount = c.Orders.Count,
TotalSpent = c.Orders.Sum(o => o.Total)
})
.ToListAsync();
// 查询数: 1
// 数据传输: 最小化性能基准测试
csharp
// 测试数据: 1000 个客户,每个客户 5 个订单
public class LoadingPerformanceTest
{
private readonly AppDbContext _context;
public async Task RunTestsAsync()
{
// 测试 1: 延迟加载(最差)
var sw1 = Stopwatch.StartNew();
await TestLazyLoadingAsync();
sw1.Stop();
Console.WriteLine($"延迟加载: {sw1.ElapsedMilliseconds}ms");
// 结果: ~5000ms (5001 次查询)
// 测试 2: 预加载(好)
var sw2 = Stopwatch.StartNew();
await TestEagerLoadingAsync();
sw2.Stop();
Console.WriteLine($"预加载: {sw2.ElapsedMilliseconds}ms");
// 结果: ~150ms (1 次查询)
// 测试 3: 投影查询(最好)
var sw3 = Stopwatch.StartNew();
await TestProjectionAsync();
sw3.Stop();
Console.WriteLine($"投影查询: {sw3.ElapsedMilliseconds}ms");
// 结果: ~80ms (1 次查询,传输数据更少)
}
private async Task TestLazyLoadingAsync()
{
var customers = await _context.Customers.ToListAsync();
foreach (var customer in customers)
{
var orders = customer.Orders; // N+1
}
}
private async Task TestEagerLoadingAsync()
{
var customers = await _context.Customers
.Include(c => c.Orders)
.ToListAsync();
}
private async Task TestProjectionAsync()
{
var data = await _context.Customers
.Select(c => new { c.Name, OrderCount = c.Orders.Count })
.ToListAsync();
}
}
// 输出:
// 延迟加载: 5234ms ← 极慢!
// 预加载: 142ms ← 快 37 倍
// 投影查询: 78ms ← 快 67 倍优化建议
1. 默认使用 Include
csharp
// ✅ 推荐模式
var orders = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ToListAsync();2. 大数据量用投影
csharp
// ✅ 最佳实践
var orderSummaries = await context.Orders
.Select(o => new OrderSummaryDto
{
OrderId = o.Id,
CustomerName = o.Customer.Name,
ItemCount = o.Items.Count,
Total = o.TotalAmount
})
.ToListAsync();3. 条件加载用显式
csharp
// ✅ 合理搭配
var customer = await context.Customers.FindAsync(id);
if (includeOrders)
{
await context.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
}4. 避免深层嵌套
csharp
// ❌ 避免: 三层以上 Include
var order = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.ThenInclude(i => i.Product)
.ThenInclude(p => p.Category) // 太深了!
.FirstOrDefaultAsync(o => o.Id == id);
// ✅ 改为: 分别查询
var order = await context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
var products = await context.Products
.Where(p => order.Items.Select(i => i.ProductId).Contains(p.Id))
.Include(p => p.Category)
.ToListAsync();.NET 8/9/10 新特性
.NET 8: 改进的查询性能
csharp
// .NET 8 优化了复杂 Include 查询的性能
// 对于深层嵌套,查询生成速度提升 30-40%
var customers = await context.Customers
.Include(c => c.Orders)
.ThenInclude(o => o.Items)
.AsSplitQuery() // .NET 5+ 引入,.NET 8 进一步优化
.ToListAsync();.NET 9: 增强的诊断信息
csharp
// .NET 9 提供更详细的延迟加载警告
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseLazyLoadingProxies()
.UseSqlServer(connectionString)
.EnableSensitiveDataLogging() // .NET 9 增强
.LogTo(Console.WriteLine, LogLevel.Warning);
});
// 当检测到 N+1 查询时,会输出警告:
// warn: Possible N+1 query detected for 'Customer.Orders'.NET 10: 智能加载(预览)
预计特性:
- 自动检测并优化 N+1 查询
- 基于访问模式的智能预加载
- 更细粒度的加载策略配置
实际应用场景
场景 1: API 端点
csharp
// Minimal API (.NET 8)
app.MapGet("/api/customers/{id}", async (int id, AppDbContext db, bool includeOrders) =>
{
var customer = await db.Customers.FindAsync(id);
if (customer == null)
return Results.NotFound();
// 条件加载
if (includeOrders)
{
await db.Entry(customer)
.Collection(c => c.Orders)
.LoadAsync();
}
return Results.Ok(customer);
});场景 2: 批量报表
csharp
// 销售报表(投影查询)
var salesReport = await context.Customers
.Select(c => new SalesReportDto
{
CustomerId = c.Id,
CustomerName = c.Name,
TotalOrders = c.Orders.Count,
TotalSpent = c.Orders.Sum(o => o.TotalAmount),
LastOrderDate = c.Orders.Max(o => o.OrderDate),
TopProducts = c.Orders
.SelectMany(o => o.Items)
.GroupBy(i => i.Product.Name)
.Select(g => new ProductSalesDto
{
ProductName = g.Key,
Quantity = g.Sum(i => i.Quantity)
})
.OrderByDescending(p => p.Quantity)
.Take(5)
.ToList()
})
.ToListAsync();场景 3: 审计日志
csharp
// 显式加载审计信息
public async Task LogOrderChangeAsync(int orderId)
{
var order = await context.Orders.FindAsync(orderId);
// 加载关联数据用于审计
await context.Entry(order)
.Reference(o => o.Customer)
.LoadAsync();
await context.Entry(order)
.Collection(o => o.Items)
.LoadAsync();
foreach (var item in order.Items)
{
await context.Entry(item)
.Reference(i => i.Product)
.LoadAsync();
}
// 记录审计日志
_auditLogger.LogChange(order);
}总结
核心要点
- 延迟加载: 方便但危险,容易导致 N+1 查询
- 显式加载: 完全控制,适合条件加载
- 预加载: 大多数情况下的最佳选择
- 投影查询: 性能最优,推荐用于只读场景
- 监控: 始终关注生成的 SQL 查询
最佳实践
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| Web API | Include / 投影 | 避免 N+1,性能好 |
| 报表查询 | 投影查询 | 最小化数据传输 |
| 条件加载 | 显式加载 | 灵活控制 |
| 后台任务 | Include + SplitQuery | 平衡性能和复杂度 |
| 交互式应用 | 混合使用 | 根据场景选择 |
性能排序
投影查询 > 预加载(Include) > 显式加载 > 延迟加载
🚀 🚀 🚗 🐌
最快 很快 中等 最慢