Skip to content

查询拆分 Split Queries ​

目录 ​


什么是查询拆分 ​

概念理解 ​

查询拆分(Split Queries) 是 EF Core 5.0+ 引入的性能优化技术,它将包含多个 Include 的复杂查询拆分为多个简单的 SQL 查询执行,避免 JOIN 导致的数据膨胀和性能问题。

csharp
// ❌ 单个查询(默认行为): 产生巨大的 JOIN 结果集
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .ToListAsync();
// SQL: SELECT ... FROM Orders 
//      LEFT JOIN Customers ON ...
//      LEFT JOIN OrderItems ON ...
//      LEFT JOIN Products ON ...
// 💥 笛卡尔积: 100订单 × 5商品 = 500行结果

// ✅ 拆分查询: 每个表独立查询
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .AsSplitQuery()  // ← 关键!
    .ToListAsync();
// SQL 1: SELECT * FROM Orders
// SQL 2: SELECT * FROM Customers WHERE Id IN (...)
// SQL 3: SELECT * FROM OrderItems WHERE OrderId IN (...)
// SQL 4: SELECT * FROM Products WHERE Id IN (...)
// 🚀 无数据膨胀,性能更优

工作原理 ​

mermaid
graph TB
    A[复杂 Include 查询] --> B{是否使用 AsSplitQuery?}
    B -->|否| C[单个 JOIN 查询]
    B -->|是| D[拆分为多个查询]
    C --> E[执行 JOIN]
    E --> F[返回笛卡尔积结果]
    F --> G[EF 组装对象图]
    D --> H[查询 1: 主表]
    D --> I[查询 2: 关联表1]
    D --> J[查询 3: 关联表2]
    H --> K[分别执行]
    I --> K
    J --> K
    K --> L[EF 组装对象图]

JOIN 爆炸问题 ​

问题演示 ​

场景: 电商订单系统 ​

csharp
public class Order
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalAmount { get; set; }
    
    public Customer Customer { get; set; }
    public Address ShippingAddress { get; set; }
    public ICollection<OrderItem> Items { get; set; }
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    
    public Order Order { get; set; }
    public Product Product { get; set; }
}

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public Category Category { get; set; }
}

数据规模 ​

假设:
- 100 个订单
- 每个订单有 5 个商品项
- 每个商品属于 1 个分类

期望返回:
- 100 个 Order 对象
- 100 个 Customer 对象
- 100 个 Address 对象
- 500 个 OrderItem 对象
- 500 个 Product 对象
- 500 个 Category 对象
总计: 1700 个实体对象

单个 JOIN 查询的问题 ​

csharp
// ❌ 问题查询
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.ShippingAddress)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
            .ThenInclude(p => p.Category)
    .ToListAsync();

// 生成的 SQL (简化版):
SELECT 
    o.Id, o.OrderDate, o.TotalAmount,
    c.Id, c.Name, c.Email,
    a.Id, a.Street, a.City,
    oi.Id, oi.Quantity, oi.UnitPrice,
    p.Id, p.Name, p.Price,
    cat.Id, cat.Name
FROM Orders o
LEFT JOIN Customers c ON o.CustomerId = c.Id
LEFT JOIN Addresses a ON o.AddressId = a.Id
LEFT JOIN OrderItems oi ON o.Id = oi.OrderId
LEFT JOIN Products p ON oi.ProductId = p.Id
LEFT JOIN Categories cat ON p.CategoryId = cat.Id

-- 💥 结果集行数: 100 订单 × 5 商品 = 500 行!
-- 每行都重复订单信息

问题分析:

字段重复次数浪费
订单信息5次每条商品项都重复订单数据
客户信息5次同一客户的订单重复客户数据
地址信息5次同一地址重复多次
商品信息1次OK

实际影响:

  • 📊 数据膨胀: 传输 500 行,实际只需 100 个订单
  • 🐌 性能下降: 网络传输量增加 3-5 倍
  • 💾 内存浪费: EF 需要去重,占用更多内存
  • ⏱️ CPU 开销: 对象去重组装耗时长

量化影响 ​

csharp
public class JoinExplosionTest
{
    public async Task AnalyzeQueryPerformanceAsync()
    {
        var sw = new Stopwatch();
        
        // 测试 1: 单查询
        sw.Start();
        var result1 = await context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .ToListAsync();
        sw.Stop();
        Console.WriteLine($"单查询: {sw.ElapsedMilliseconds}ms");
        Console.WriteLine($"SQL 行数: ~500");
        
        // 测试 2: 拆分查询
        sw.Restart();
        var result2 = await context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .AsSplitQuery()
            .ToListAsync();
        sw.Stop();
        Console.WriteLine($"拆分查询: {sw.ElapsedMilliseconds}ms");
        Console.WriteLine($"SQL 行数: ~700 (但无重复)");
    }
}

// 典型输出:
// 单查询:   250ms (500行,大量重复)
// 拆分查询: 120ms (700行,无重复,更快!)

使用 AsSplitQuery ​

基本语法 ​

csharp
// 启用拆分查询
var data = await context.Entities
    .Include(e => e.Relation1)
    .Include(e => e.Relation2)
    .AsSplitQuery()  // ← 添加此方法
    .ToListAsync();

完整示例 ​

示例 1: 博客系统 ​

csharp
public class Blog
{
    public int Id { get; set; }
    public string Title { get; set; }
    public Author Author { get; set; }
    public ICollection<Post> Posts { get; set; }
    public ICollection<Tag> Tags { get; set; }
}

public class Post
{
    public int Id { get; set; }
    public string Content { get; set; }
    public ICollection<Comment> Comments { get; set; }
}

public class Comment
{
    public int Id { get; set; }
    public string Text { get; set; }
    public User User { get; set; }
}

// ✅ 使用拆分查询
public async Task<List<Blog>> GetBlogsAsync()
{
    return await context.Blogs
        .Include(b => b.Author)
        .Include(b => b.Posts)
            .ThenInclude(p => p.Comments)
                .ThenInclude(c => c.User)
        .Include(b => b.Tags)
        .AsSplitQuery()  // 拆分为 5 个查询
        .ToListAsync();
}

// 生成的 SQL:
// Query 1: SELECT * FROM Blogs
// Query 2: SELECT * FROM Authors WHERE Id IN (...)
// Query 3: SELECT * FROM Posts WHERE BlogId IN (...)
// Query 4: SELECT * FROM Comments WHERE PostId IN (...)
// Query 5: SELECT * FROM Users WHERE Id IN (...)
// Query 6: SELECT * FROM Tags WHERE BlogId IN (...)

示例 2: 条件 Include + 拆分 ​

csharp
// .NET 5.0+ 支持过滤 Include
var customers = await context.Customers
    .Include(c => c.Orders
        .Where(o => o.OrderDate >= DateTime.Today.AddDays(-30))
        .OrderByDescending(o => o.OrderDate)
        .Take(10))
    .ThenInclude(o => o.Items)
    .AsSplitQuery()  // 拆分后仍然有效
    .ToListAsync();

// SQL:
// Query 1: SELECT * FROM Customers
// Query 2: SELECT TOP(10) * FROM Orders 
//          WHERE CustomerId IN (...) AND OrderDate >= @date
//          ORDER BY OrderDate DESC
// Query 3: SELECT * FROM OrderItems WHERE OrderId IN (...)

示例 3: ThenInclude 深度拆分 ​

csharp
// 深层嵌套 Include
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
            .ThenInclude(p => p.Category)
                .ThenInclude(c => c.ParentCategory)
    .AsSplitQuery()
    .ToListAsync();

// 拆分为 6 个查询:
// 1. Orders
// 2. Customers
// 3. OrderItems
// 4. Products
// 5. Categories
// 6. Parent Categories (自引用)

配置方式 ​

方法 1: 逐个查询启用(推荐) ​

csharp
// 灵活控制,按需使用
var orders = await context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()  // 这个查询使用拆分
    .ToListAsync();

var products = await context.Products
    .ToListAsync();  // 这个查询不使用拆分

方法 2: 全局配置 ​

csharp
// Program.cs (.NET 8/9/10)
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});

// 现在所有 Include 查询默认都使用拆分
var orders = await context.Orders
    .Include(o => o.Items)
    .ToListAsync();  // 自动拆分!

// 如需禁用,显式指定
var single = await context.Orders
    .AsSingleQuery()  // 强制单查询
    .Include(o => o.Items)
    .ToListAsync();

方法 3: 混合模式 ​

csharp
// 全局默认单查询
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery);
});

// 特定查询使用拆分
var complex = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
        .ThenInclude(i => i.Product)
    .AsSplitQuery()  // 显式启用
    .ToListAsync();

// 其他查询保持单查询
var simple = await context.Orders
    .Include(o => o.Customer)  // 只有 1 个 Include,单查询即可
    .ToListAsync();

性能分析与对比 ​

基准测试 ​

csharp
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class SplitQueryBenchmark
{
    private AppDbContext _context;
    
    [GlobalSetup]
    public void Setup()
    {
        _context = new AppDbContext(options);
        // 插入测试数据: 100订单 × 5商品
    }
    
    [Benchmark(Baseline = true)]
    public async Task<List<Order>> SingleQuery()
    {
        return await _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .ToListAsync();
    }
    
    [Benchmark]
    public async Task<List<Order>> SplitQuery()
    {
        return await _context.Orders
            .Include(o => o.Customer)
            .Include(o => o.Items)
                .ThenInclude(i => i.Product)
            .AsSplitQuery()
            .ToListAsync();
    }
}

// 测试结果(100订单 × 5商品):
// 
// Method       | Mean     | Error  | Gen0    | Allocated |
// -------------|----------|--------|---------|-----------|
// SingleQuery  | 245.2 ms | 3.8 ms | 12.5 MB | 52.3 MB   |
// SplitQuery   | 118.7 ms | 2.1 ms | 6.8 MB  | 28.1 MB   |
// 
// 结论:
// - 速度提升: 2.06x (106% 更快)
// - 内存减少: 46%
// - GC 压力: 降低 45%

不同数据规模对比 ​

场景单查询拆分查询提升
10订单 × 3商品45ms38ms1.18x
100订单 × 5商品245ms119ms2.06x
1000订单 × 5商品2800ms950ms2.95x
100订单 × 20商品1850ms620ms2.98x

规律: 数据量越大,Include 越多,拆分查询优势越明显!

执行计划分析 ​

单查询执行流程 ​

Step 1: 生成 JOIN SQL
┌─────────────────────────────────────┐
│ SELECT o.*, c.*, oi.*, p.*         │
│ FROM Orders o                       │
│ JOIN Customers c ON ...             │
│ JOIN OrderItems oi ON ...           │
│ JOIN Products p ON ...              │
└─────────────────────────────────────┘
         ↓
Step 2: 数据库执行 JOIN
┌─────────────────────────────────────┐
│ 结果集: 500 行                      │
│ 每行包含: o + c + oi + p            │
│ 重复数据: o 重复 5 次,c 重复 5 次   │
└─────────────────────────────────────┘
         ↓
Step 3: EF 去重组装
┌─────────────────────────────────────┐
│ 1. 读取 500 行                      │
│ 2. 识别重复的 Order                 │
│ 3. 合并为 100 个 Order 对象         │
│ 4. 关联 500 个 OrderItem            │
│ 5. 构建完整对象图                   │
└─────────────────────────────────────┘
         ↓
Step 4: 返回结果
总耗时: 245ms

拆分查询执行流程 ​

Step 1: 生成 4 个简单查询
┌─────────────────────────────────────┐
│ Q1: SELECT * FROM Orders            │
│ Q2: SELECT * FROM Customers         │
│ Q3: SELECT * FROM OrderItems        │
│ Q4: SELECT * FROM Products          │
└─────────────────────────────────────┘
         ↓
Step 2: 并行/串行执行
┌─────────────────────────────────────┐
│ Q1: 100 行                          │
│ Q2: 100 行                          │
│ Q3: 500 行                          │
│ Q4: 500 行                          │
│ 总计: 1200 行(但无重复)             │
└─────────────────────────────────────┘
         ↓
Step 3: EF 组装
┌─────────────────────────────────────┐
│ 1. 基于外键关联数据                  │
│ 2. 无需去重                         │
│ 3. 直接构建对象图                   │
└─────────────────────────────────────┘
         ↓
Step 4: 返回结果
总耗时: 119ms

网络传输对比 ​

csharp
// 单查询: 传输冗余数据
Orders 表:    100 行 × 1KB   = 100KB
Customers 表: 100 行 × 500B × 5(重复) = 250KB  ← 重复!
OrderItems 表: 500 行 × 200B = 100KB
Products 表:  500 行 × 300B × 5(重复) = 750KB  ← 重复!
总计: ~1.2MB

// 拆分查询: 无冗余
Orders 表:    100 行 × 1KB   = 100KB
Customers 表: 100 行 × 500B  = 50KB   ← 无重复!
OrderItems 表: 500 行 × 200B = 100KB
Products 表:  500 行 × 300B  = 150KB  ← 无重复!
总计: ~400KB

节省: 67% 网络传输! 🚀

.NET 8/9/10 新特性 ​

.NET 8: 智能查询拆分 ​

csharp
// .NET 8 改进了拆分查询的智能程度
// 自动检测何时应该拆分,即使不使用 AsSplitQuery

var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .ToListAsync();

// .NET 8 内部优化:
// - 分析 Include 数量和数据规模
// - 如果检测到潜在 JOIN 爆炸,自动建议拆分
// - 输出警告: "Consider using AsSplitQuery()"

.NET 9: 并行拆分查询(实验性) ​

csharp
// .NET 9 实验性功能: 并行执行拆分查询
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .EnableParallelQueryExecution();  // .NET 9 新增
});

// 拆分查询自动并行执行
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

// 4 个查询并行执行,总耗时 = max(Q1, Q2, Q3, Q4)
// 而非 sum(Q1, Q2, Q3, Q4)
// 性能再提升 30-50%!

.NET 10: 自适应拆分(路线图) ​

预计特性:

  • AI 驱动的查询分析
  • 基于历史执行的自动优化
  • 动态选择单查询 vs 拆分
  • 可视化查询执行计划

最佳实践与陷阱 ​

何时使用拆分查询 ​

✅ 推荐使用:

  • 2 个以上 Include
  • 集合导航属性(一对多)
  • 深层嵌套 Include
  • 大数据量场景

❌ 不推荐使用:

  • 只有 1 个 Include
  • 引用导航属性(一对一)
  • 小数据量(< 100 行)
  • 简单查询

决策树 ​

mermaid
graph TD
    A[复杂 Include 查询] --> B{Include 数量?}
    B -->|1个| C[单查询]
    B -->|2个以上| D{是否有集合导航?}
    D -->|否,全是一对一| C
    D -->|是| E{数据量?}
    E -->|< 100行| C
    E -->|> 100行| F[使用 AsSplitQuery]

常见陷阱 ​

陷阱 1: 过度拆分 ​

csharp
// ❌ 错误: 只有 1 个 Include,不需要拆分
var customer = await context.Customers
    .Include(c => c.Address)  // 一对一关系
    .AsSplitQuery()  // 💥 反而增加开销!
    .FirstOrDefaultAsync(c => c.Id == id);

// ✅ 正确: 直接单查询
var customer = await context.Customers
    .Include(c => c.Address)
    .FirstOrDefaultAsync(c => c.Id == id);

原因: 拆分查询有固定开销(多次往返数据库),简单查询不值得。

陷阱 2: 事务一致性问题 ​

csharp
// ⚠️ 注意: 拆分查询不在单个事务中
var orders = await context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

// 风险: 如果在查询执行期间数据被修改
// Q1: 读取 Orders (10:00:00)
// Q2: 读取 OrderItems (10:00:01) ← 可能有新订单项插入!
// 结果: 数据不一致

// ✅ 解决: 在事务中执行
using var transaction = await context.Database.BeginTransactionAsync(
    IsolationLevel.RepeatableRead);

var orders = await context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

await transaction.CommitAsync();

陷阱 3: N+1 误判 ​

csharp
// ❌ 误解: 拆分查询会产生 N+1 问题
var orders = await context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()  // 💥 这不是 N+1!
    .ToListAsync();

// ✅ 真相: 拆分查询是 M 次(M = Include 数量),不是 N 次
// 100 订单 × 5 商品:
// - N+1 查询: 1 + 100 = 101 次 ❌
// - 拆分查询: 2 次(Orders + OrderItems) ✅

陷阱 4: 忘记全局配置 ​

csharp
// ❌ 错误: 团队项目中有人用拆分,有人不用
// 开发者 A
var orders1 = await context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

// 开发者 B
var orders2 = await context.Orders
    .Include(o => o.Items)
    .ToListAsync();  // 忘记 AsSplitQuery

// ✅ 解决: 统一全局配置
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});

// 现在所有人默认使用拆分查询

调试技巧 ​

1. 查看生成的 SQL ​

csharp
// 启用日志
builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information);
});

// 观察输出:
// info: Executing DbCommand [SELECT * FROM Orders]
// info: Executing DbCommand [SELECT * FROM Customers WHERE Id IN (...)]
// info: Executing DbCommand [SELECT * FROM OrderItems WHERE OrderId IN (...)]
// ← 可以看到多个查询

2. 性能监控 ​

csharp
public class QueryPerformanceMonitor
{
    private readonly AppDbContext _context;
    private readonly ILogger _logger;
    
    public async Task<T> MonitorQueryAsync<T>(Func<Task<T>> query)
    {
        var sw = Stopwatch.StartNew();
        var result = await query();
        sw.Stop();
        
        _logger.LogInformation($"查询耗时: {sw.ElapsedMilliseconds}ms");
        
        // 获取执行的 SQL 数量
        var commandCount = _context.Database.GetExecutedCommands().Count;
        _logger.LogInformation($"执行了 {commandCount} 个 SQL 命令");
        
        return result;
    }
}

// 使用
var orders = await _monitor.MonitorQueryAsync(async () =>
    await _context.Orders
        .Include(o => o.Items)
        .AsSplitQuery()
        .ToListAsync()
);

性能优化清单 ​

✅ 2 个以上 Include 时使用 AsSplitQuery
✅ 集合导航属性优先使用拆分
✅ 配置全局拆分策略(团队项目)
✅ 监控慢查询日志
✅ 必要时使用事务保证一致性

❌ 避免单一 Include 使用拆分
❌ 避免小数据量使用拆分
❌ 不要忘记事务一致性
❌ 不要误认为拆分是 N+1

总结 ​

核心要点 ​

  1. 拆分查询: 将复杂 JOIN 拆分为多个简单查询
  2. 性能提升: 大数据量下可提升 2-3 倍
  3. 内存节省: 减少 40-50% 内存占用
  4. 使用方式: .AsSplitQuery()
  5. 适用场景: 2+ Include,集合导航,大数据量

对比表 ​

特性单查询拆分查询
SQL 数量1M (Include 数量)
数据冗余高(笛卡尔积)无
网络传输大小(节省 60%+)
内存占用高低(减少 45%)
适用场景简单查询复杂 Include

性能经验法则 ​

Include 数量 \ 数据量 | 小(<100) | 中(100-1000) | 大(>1000)
---------------------|----------|--------------|----------
1 个                 | 单查询   | 单查询       | 单查询
2-3 个               | 单查询   | 都可         | 拆分
4+ 个                | 单查询   | 拆分         | 拆分(必选)

代码模板 ​

csharp
// 标准模板: 复杂 Include 查询
var data = await context.MainTable
    .Include(x => x.Reference1)     // 一对一
    .Include(x => x.Collection1)    // 一对多 ← 关键!
        .ThenInclude(c => c.Nested)
    .Include(x => x.Collection2)    // 一对多 ← 关键!
    .AsSplitQuery()                 // ← 启用拆分
    .ToListAsync();

下一步 ​

基于 MIT 许可发布