Skip to content

延迟加载与显式加载 ​

目录 ​


延迟加载 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);
}

总结 ​

核心要点 ​

  1. 延迟加载: 方便但危险,容易导致 N+1 查询
  2. 显式加载: 完全控制,适合条件加载
  3. 预加载: 大多数情况下的最佳选择
  4. 投影查询: 性能最优,推荐用于只读场景
  5. 监控: 始终关注生成的 SQL 查询

最佳实践 ​

场景推荐方式原因
Web APIInclude / 投影避免 N+1,性能好
报表查询投影查询最小化数据传输
条件加载显式加载灵活控制
后台任务Include + SplitQuery平衡性能和复杂度
交互式应用混合使用根据场景选择

性能排序 ​

投影查询 > 预加载(Include) > 显式加载 > 延迟加载
   🚀          🚀              🚗            🐌
 最快         很快            中等           最慢

下一步 ​

基于 MIT 许可发布