PAYATHON 2026

如何让 EF Core 以指定方式生成查询?

技术老齐

结论

EF Core 没有通用且稳定的 API,能要求查询翻译器“必须生成 OUTER APPLY”“禁止使用 ROW_NUMBER()”,或复刻某个旧版本的 SQL shape。

LINQ 查询描述的是结果语义,而不是 SQL 模板。只要结果等价,EF Core 及其数据库 provider 就可能在不同版本中采用不同的翻译方式。修改 LINQ 表达式或加入 p.PartnerId - 0,即使能让当前版本生成预期的 SQL,也不能作为长期契约。

如果查询必须保持特定的 SQL 结构,可以为这条性能敏感路径直接编写 SQL,或将查询封装成数据库对象。EF Core 仍可用于其他查询,只需对少数热点查询显式控制 SQL。

为什么没有通用的 SQL shape hint

EF Core 的查询过程大致分为三层:

  1. LINQ 表达式描述所需结果。
  2. EF Core 和 provider 将表达式转换为关系查询。
  3. 数据库优化器决定最终的执行计划。

FirstOrDefault()、navigation property 和 correlated subquery 都可能经过规范化、消除或改写。下面两段 LINQ 写法不同,但翻译器可能认为它们语义相同:

p.Addresses
    .Where(a => a.AddressTypeId == 'P')
    .FirstOrDefault()
p.Addresses
    .Where(a =>
        a.PartnerId == p.PartnerId &&
        a.AddressTypeId == 'P')
    .FirstOrDefault()

navigation property 已经表达了 a.PartnerId == p.PartnerId 这层关系,因此翻译器可以删除这个显式条件。

a.PartnerId == p.PartnerId - 0 之所以能改变 SQL,是因为当前翻译器没有化简这个表达式。后续的 EF Core 版本、provider 更新或查询中其他表达式的变化,都可能让这种写法失效。它也会使代码的业务意图变得含糊,不适合作为正式方案。

先确认问题是否真的来自 SQL 结构

SQL 文本的形状不能直接说明数据库会怎样执行。SQL Server 未必逐行执行 correlated subquery,也未必会物化并编号 view 的全部结果。是否存在全表扫描、排序、spool 或 predicate pushdown,应以实际执行计划为准。

可以先做以下检查:

  1. 使用 ToQueryString() 保存 EF Core 生成的 SQL。
  2. 使用接近生产环境的数据量查看 Actual Execution Plan。
  3. 配合 SET STATISTICS IO, TIME ON,比较逻辑读取、CPU 时间和实际行数。
  4. 检查 view 展开后的计划,确认估算行数与实际行数是否严重偏离。
  5. 检查筛选列、关联列和排序列是否有合适的索引支持。

例如,业务规则是“每个 Partner 取 RowId 最小的 P 类型地址”时,索引通常要围绕 PartnerId、AddressTypeId 和 RowId 设计,并根据需要包含 City。具体的列顺序仍取决于实际谓词、数据分布和执行计划。

FirstOrDefault() 如果没有搭配 OrderBy(),“第一条”就没有确定含义。LINQ 和手写 SQL 都应明确排序规则:

var query =
    from p in context.Partners
    select new
    {
        p.Name,
        City = p.Addresses
            .Where(a => a.AddressTypeId == 'P')
            .OrderBy(a => a.RowId)
            .Select(a => a.City)
            .FirstOrDefault()
    };

这种写法明确了查询语义,但不能保证 EF Core 一定生成 OUTER APPLY。

需要固定 SQL 时使用 FromSql

必须使用 OUTER APPLY 时,可以把查询结果映射到 keyless entity type:

public sealed class PartnerCityRow
{
    public string Name { get; set; } = null!;
    public string? City { get; set; }
}

在模型中配置:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<PartnerCityRow>().HasNoKey();
}

然后直接编写所需的 SQL:

var rows = await context.Set<PartnerCityRow>()
    .FromSqlInterpolated($"""
        SELECT
            p.Name,
            selectedAddress.City
        FROM dbo.Partner AS p
        OUTER APPLY
        (
            SELECT TOP (1)
                a.City
            FROM dbo.Address AS a
            WHERE a.PartnerId = p.PartnerId
              AND a.AddressTypeId = 'P'
            ORDER BY a.RowId
        ) AS selectedAddress
        WHERE p.Status = {status}
        """)
    .AsNoTracking()
    .ToListAsync();

参数应通过 FromSqlInterpolated 传入,不要拼接字符串。影响性能的外层过滤条件也可以放进原始 SQL。继续在 FromSql 结果上组合 LINQ 时,EF Core 通常会在外面再包一层查询,最终的 SQL 形状仍可能变化。

还可以把查询封装在数据库中

如果这段 SQL 会被多处调用,或需要交给 DBA 独立调优,可以封装成:

  • view;
  • table-valued function;
  • stored procedure;
  • 其他由数据库版本控制的查询对象。

EF Core 可以映射 view 或 table-valued function,也能读取存储过程的结果。不过,存储过程通常无法像普通的可组合 SQL 那样继续追加任意 LINQ 运算,因此过滤和排序往往要通过参数传入。

这样可以把 SQL shape、索引和数据库端调优集中在数据库层,但也会增加数据库对象的部署和版本管理工作。

EF Core 提供了哪些相关控制能力

EF Core 提供了一些受支持的查询控制 API,但每个 API 都只控制特定行为,不能用来任意指定 SQL shape:

  • AsSplitQuery() 和 AsSingleQuery() 控制包含多个相关集合时使用拆分查询还是单条查询。
  • AsNoTracking() 控制实体跟踪,不控制连接方式。
  • TagWith() 为 SQL 添加诊断注释,不会强制执行计划。
  • EF.Functions 和映射的数据库函数可以表达特定的数据库操作,但通常无法指定整个查询的连接结构。
  • FromSql、FromSqlInterpolated 和 FromSqlRaw 用于直接控制 SQL。

数据库 provider 可能还会提供额外能力。这类能力通常是 provider-specific,也不能保证 OUTER APPLY、窗口函数或其他 SQL 结构在后续版本中保持不变。

是否应该扩展查询翻译器

EF Core 提供自定义方法翻译、表达式转换和服务替换等扩展点,也可以通过 command interceptor 在命令执行前处理 SQL。要重写整个查询结构,通常需要依赖 provider 或查询管线的内部实现。

这类代码维护成本较高:

  • EF Core 升级后需要重新验证;
  • provider 更新可能改变表达式树或 SQL 生成过程;
  • 按字符串改写 SQL 容易破坏参数、别名和嵌套查询;
  • 很难覆盖所有查询组合。

如果大量查询都需要同一种转换,而且团队愿意承担版本适配和回归测试成本,可以考虑自定义 translator。对于单条热点查询,直接编写 SQL 通常更简单。

推荐的工程处理方式

EF Core 可以继续负责大多数 CRUD、关系映射、事务和普通查询。只有经过执行计划确认存在问题的查询,才需要改用显式 SQL。

升级 EF Core 或数据库 provider 时,还可以为关键查询增加回归检查:

var sql = query.ToQueryString();

生成的 SQL 可以保存下来审查,但不宜对空格、别名等文本细节做完整字符串断言,因为这类断言很脆弱。更实用的做法是检查关键结构,同时结合真实数据上的性能基准和执行计划。

LINQ 适合表达查询语义。需要保证 SQL shape 时,应在对应查询中显式接管 SQL,而不要把 LINQ 翻译结果当成固定的 SQL 模板。

备注:内容仅供参考。