你有没有过这种经历:业务报表越写越复杂,LINQ 表达式树绕得头大,Dapper 又不敢乱引,最后实在绷不住,在 EF Core 里直接塞了一段原生 SQL,结果一运行就被“列名无效”“无法映射”各种报错打懵?
我上个月就撞上这么一回。项目是 .NET 8 的 Web API,EF Core 8.0 连 SQL Server,查询要跨五张表做聚合统计。LINQ 写出来一大坨,生成八层嵌套子查询,线上跑一次 3 秒多。我索性在仓储层写原生 SQL,结果发现 EF Core 里能用来执行原生 SQL 的入口远不止一个:FromSqlRaw、FromSqlInterpolated、SqlQueryRaw……每个的映射边界还不一样,踩完坑才把整条路摸清。
这篇文章不打算讲官网文档里那些翻来覆去的基础用法,而是把实际使用过程中积累的经验整理出来,重点聊清楚三件事:EF Core 原生 SQL 的几个入口分别在什么场景用、对象映射的边界到底卡在哪、以及在三层架构下怎么安全地把原生 SQL 封装进 BaseRepository 和 BaseService。适合已经在用 EF Core,但遇到复杂查询时想把原生 SQL 用得更踏实的 .NET 开发者。
1. 先理清楚:EF Core 原生 SQL 的三条主要通道
1.1 FromSqlRaw / FromSqlInterpolated:面向实体的查询入口
FromSql 系列是在 DbSet 上直接调用的,这是它和后面 SqlQuery 最本质的区别。比如context.Blogs.FromSqlRaw("SELECT * FROM Blogs WHERE Id = {0}", id),它的返回类型必须和 DbSet 的泛型参数保持一致,也就是只能查实体类型,不能像 Dapper 那样随心所欲地返回一个匿名对象或自定义 DTO。
在实际项目里,FromSql 系列最大的价值是可以继续叠加 LINQ。EF Core 会把你的原生 SQL 当作一个子查询包起来,然后在外层追加 Where、OrderBy、Skip、Take。这意味着你可以在仓储层写一段很复杂的表连接 SQL,再在上层继续分页筛选。这个特性用过之后是真的回不去,比如下面这段代码,先通过原生 SQL 把七天内需要处理的订单捞出来,再在内存端(其实是数据库端)做排序和分页:
var id = 1024; // EF Core 8.0 推荐用法:FormattableString 自动参数化 var blogs = await context.Blogs .FromSql($"SELECT * FROM Blogs WHERE Id = {id}") .OrderBy(b => b.CreatedAt) .Take(10) .ToListAsync();FromSql 和 FromSqlInterpolated 都接收 FormattableString,插值表达式里的变量会被 EF Core 自动转成 SQL 参数,不需要手工创建 SqlParameter。FromSqlRaw 则接收原始字符串,参数用{0}占位,适合已经拼好 SQL 模板的代码。老实说 FromSql 和 FromSqlInterpolated 行为基本一致,8.0 官方更推荐直接用 FromSql,命名更简洁。
这里有一个很多新手踩过的坑:FormattableString 里插入的表名、列名不会拼进 SQL,而是被当成参数值。比如$"SELECT * FROM {tableName} WHERE Id = {id}",EF Core 会把 tableName 变成一个查询参数,最终 SQL 变成SELECT * FROM @p0 WHERE Id = @p1,数据库直接报语法错。动态表名只能用 FromSqlRaw 拼接,并且必须自己做白名单校验,这个后面第三章会详细说。
1.2 SqlQuery / SqlQueryRaw:面向标量与 DTO 的查询入口
SqlQuery 系列是 EF Core 7.0 引入的(当时叫 Query),8.0 改名成 SqlQuery。它挂在context.Database上,而不是 DbSet 上,这一点决定了它的定位:查询结果不一定要是实体类型。
最直接的用法是返回标量类型集合,比如只需要取一批 Id:
var ids = await context.Database .SqlQuery<int>($"SELECT Id FROM Blogs WHERE CreatedAt > @cutoff") .ToListAsync();更常见的场景是返回自定义 DTO。报表、统计、跨表投影这些需求,用实体映射反而别扭,因为需要额外创建一堆没有业务行为的“伪实体”。SqlQuery 直接把结果映射到 POCO,干净利落:
var rows = await context.Database .SqlQueryRaw<BlogTitleDto>( "SELECT Id, Title AS BlogTitle FROM Blogs WHERE Id = @id", new SqlParameter("@id", id)) .FirstOrDefaultAsync();注意这里的命名:带 Raw 后缀的方法接收原始 SQL 字符串和参数数组,不带 Raw 的接收 FormattableString 自动参数化。如果你写的是SqlQueryRaw,但 SQL 里用{0}占位,参数却传 SqlParameter,那可能会得到意料之外的结果,这点我建议一开始就统一约定,团队里不要两种风格混用。
SqlQuery 返回的结果默认是不跟踪的,它纯粹是“查询出来、填充对象、用完即走”,没有 ChangeTracker 那套状态管理。所以它的性能开销通常比 FromSql 小,适合只读报表。
1.3 核心区别速览:可组合、返回类型、跟踪行为
写了这么多,先把三条通道的核心区别用表格整理明白。我平时做技术评审时也喜欢直接用这个表跟同事对齐,避免大家因为 API 名字长得像就乱用:
| 维度 | FromSql / FromSqlRaw | SqlQuery / SqlQueryRaw |
|---|---|---|
| 调用位置 | DbSet 上调用 | Database 对象上调用 |
| 返回类型 | 实体类型,8.0 也支持复杂类型 | 标量类型、自定义 POCO、实体 |
| 查询跟踪 | 默认跟踪,可 AsNoTracking | 不跟踪,只读 |
| 可组合性 | 支持叠加 LINQ,SQL 会被包成子查询 | 终端查询,不建议再叠加 LINQ |
| 主键要求 | 实体必须能识别主键 | 无主键要求 |
| 典型场景 | 实体列表查询、性能优化、分段更新 | 报表 DTO、标量聚合、临时结果集 |
除了查询类 API,EF Core 还有 ExecuteSql、ExecuteSqlRaw、ExecuteSqlInterpolated 用于执行非查询 SQL(INSERT / UPDATE / DELETE / 存储过程),返回影响行数。这篇文章重点讲查询映射,就先不展开。
2. 对象映射的边界到底卡在哪
2.1 列名匹配的隐藏规则:别名和大小写
先说一个最常见的映射报错现场。你写了SELECT Id, Title, CreateTime FROM ...,返回结果给BlogDto,BlogDto 里属性叫BlogTitle、CreatedAt,结果跑出来全是 null。为什么?因为 EF Core 做对象映射时,默认规则是“SQL 返回的列名”对应“目标类型的属性名”,它不是按位置匹配的。列名对不上,属性就保持默认值。
解决办法通常是在 SQL 里起别名,不要嫌麻烦:
var rows = await context.Database .SqlQueryRaw<BlogTitleDto>( "SELECT Id, Title AS BlogTitle, CreatedAt FROM Blogs") .ToListAsync();还有大小写的坑。SQL Server 默认排序规则对大小写不敏感,CREATEtime也能匹配上CreatedAt。但如果你用的是 PostgreSQL,不带引号的标识符会被折叠成小写,实体属性是 PascalCase 的CreatedAt,查询返回的列名是createdat,两边就对不上了。同一个项目切数据库时,这类问题会集中爆发。我的建议是:凡是 SqlQuery 映射的 SQL,都主动写别名,并且保持列名和属性名完全一致,别依赖数据库的大小写容错。
2.2 实体映射的边界:缺列、主键与跟踪行为
FromSql 映射实体类型时,有一个演变过程需要知道。EF Core 7.0 之前,FromSql 要求 SQL 必须返回实体映射的所有列,少一列就报异常,而且报错信息特别硬核。7.0 之后放宽了限制,SQL 可以只返回实体属性的一部分,缺失的列会用默认值填充。
听起来很美好,但这里藏着一个大坑:如果实体是默认跟踪状态,恰好还有一列没查出来,当你调用 SaveChanges 时,缺的那一列会以默认值写回数据库,覆盖掉真实数据。这个事故我真实遇到过,虽然不是生产数据,但也足够心惊胆战。所以我的规范很简单:FromSql 缺列可以,但只允许配合 AsNoTracking 使用;任何需要后续更新保存的实体,必须把映射涉及到的列查全。
还有一个和主键相关的边界:FromSql 的结果如果被跟踪,EF Core 需要能从结果集里识别实体的主键。如果 SQL 没返回主键列,实体状态判断就会出问题。所以 FromSql 的 SQL 里,主键列一定不能漏。
2.3 DTO 映射的边界:构造函数、只读属性与可空类型
SqlQuery 映射自定义类型时,对类型的形状有隐含要求:需要无参构造函数,属性要有可写入的 setter。EF Core 物化对象时要先创建实例,再往里填属性值。如果你的 DTO 只有带参构造函数,或者全是 init-only / get-only 属性,就会遇到映射失败或者属性全为默认值的情况。
我一开始写报表 DTO 时,习惯把所有字段设成只读,试图保持对象不可变性,结果 SqlQuery 返回的每个对象属性都是 null。后来改成标准 POCO,属性带 public setter,才正常。EF Core 8.0 对复杂类型的映射支持也更好了,但复杂类型的属性嵌套映射、列命名规则又是另一套玩法,比如嵌套对象默认映射列名是Owner_Address_City这种下划线拼接,SQL 里不写对应列名就映射不上。这类边界问题,除非确实需要复杂类型建模,否则我建议优先用扁平 DTO,简单直接。
另外,SQL 返回 NUll 时,如果目标属性是非空值类型(int、DateTime、Guid),物化过程会抛异常。处理办法有两个:要么在 SQL 里用ISNULL/COALESCE兜底,要么把 DTO 属性改成可空类型。报表场景我通常两者一起用,SQL 兜底保证兼容老数据,DTO 可空属性让代码对空值更宽容。
2.4 导航属性和关系:原生 SQL 不背这个锅
很多人以为 FromSql 查出一个实体,EF Core 会自动把导航属性填好,这个理解是错的。无论是 FromSql 还是 SqlQuery,返回的结果集本质是扁平的行数据,EF Core 不会因为 SQL 里 JOIN 了别的表就自动填充导航属性。导航属性的加载需要独立查询,或者依赖 ChangeTracker 的关系修复,原生 SQL 的这一拍是空白的。
那需要关联数据怎么办?我一般是直接投影到 DTO,一次性把需要的字段都 Select 出来。比如文章表 JOIN 作者表,DTO 里直接放 AuthorName,而不是放一个 Author 导航对象然后去访问dto.Author.Name。这样做的好处是对象图扁平化,后续序列化、前端消费都省事。
如果确实想要实体导航属性,可以尝试在 FromSql 之后继续使用 Include,比如context.Blogs.FromSql(...).Include(b => b.Posts)。EF Core 会生成第二条查询去加载关联数据,但第一条 SQL 必须是可组合的,存储过程或含聚合的 SQL 在这个场景下很容易翻车。
3. 三层架构实战:在 BaseRepository 里安全地封装原生 SQL
3.1 先封装一个安全的 BaseRepository
近期在群里看到不少 .NET 8 + EF Core 的朋友在做三层架构,BaseService / BaseRepository 一套泛型基类打天下。这个思路本身没问题,但很多人的 BaseRepository 里只封装了标准的 CRUD,遇到原生 SQL 需求就不知道怎么放进去了,最后 Controller 里直接 new DbContext,三层结构形同虚设。
我的做法是在 BaseRepository 里提供两个受保护的方法,分别对应 FromSql 和 SqlQuery,这样派生仓储类既能复用,又不会把 SQL 逻辑泄露到 Service 层:
public class BaseRepository<T> where T : class { protected readonly AppDbContext Db; protected BaseRepository(AppDbContext db) { Db = db; } // 实体查询:适合查询后需要继续跟踪、修改的场景 protected async Task<List<T>> FromSqlListAsync( FormattableString sql, CancellationToken ct = default) { return await Db.Set<T>() .FromSql(sql) .AsNoTracking() .ToListAsync(ct); } // DTO/标量查询:适合报表、投影等只读场景 protected async Task<List<TDto>> SqlQueryListAsync<TDto>( string sql, params object[] parameters) where TDto : class { return await Db.Database .SqlQueryRaw<TDto>(sql, parameters) .ToListAsync(); } }注意这里 FromSqlListAsync 默认加了 AsNoTracking。原因就是前面说的,原生 SQL 查出来的实体,如果你还继续跟踪,万一 SQL 缺列,SaveChanges 时可能覆盖数据。加了 AsNoTracking 之后,查询结果就是纯粹的只读对象,安全很多。
3.2 一个真实的报表分页案例
光说不练没意思,分享一个实际项目里的报表分页需求:后台需要按时间范围查看文章列表,关联出作者名和分类名,还要支持分页,并返回总记录数。
最开始的写法是用 LINQ 加两层查询,一次查总数,一次查列表。后来觉得性能不够好,改成一条 SQL 搞定,用窗口函数COUNT(*) OVER ()在返回分页数据的同时把总数也查出来:
SELECT a.Id, a.Title, u.Name AS AuthorName, c.Name AS CategoryName, a.CreatedAt, COUNT(*) OVER () AS TotalCount FROM Articles a JOIN Users u ON a.AuthorId = u.Id JOIN Categories c ON a.CategoryId = c.Id WHERE a.CreatedAt >= @start AND a.CreatedAt <= @end ORDER BY a.CreatedAt DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY对应的 Repository 方法:
public async Task<ArticleReportResult> GetArticleReportAsync( DateTime start, DateTime end, int pageIndex, int pageSize, CancellationToken ct = default) { var offset = (pageIndex - 1) * pageSize; const string sql = """ SELECT a.Id, a.Title, u.Name AS AuthorName, c.Name AS CategoryName, a.CreatedAt, COUNT(*) OVER () AS TotalCount FROM Articles a JOIN Users u ON a.AuthorId = u.Id JOIN Categories c ON a.CategoryId = c.Id WHERE a.CreatedAt >= @start AND a.CreatedAt <= @end ORDER BY a.CreatedAt DESC OFFSET @offset ROWS FETCH NEXT @pageSize ROWS ONLY """; var parameters = new object[] { new SqlParameter("@start", start), new SqlParameter("@end", end), new SqlParameter("@offset", offset), new SqlParameter("@pageSize", pageSize) }; var items = await SqlQueryListAsync<ArticleReportDto>(sql, parameters); var total = items.FirstOrDefault()?.TotalCount ?? 0; return new ArticleReportResult(items, total); }这里有三个细节值得说。第一,SQL Server 的 OFFSET / FETCH 分页语句必须配套 ORDER BY,否则直接报错。第二,COUNT(*) OVER ()在结果集为空时不返回任何行,所以 total 要做空值兜底。第三,参数用命名 SqlParameter,SQL 文本里直接用@start,这种情况下不要再用{0}占位符,两套风格混用会乱。
ArticleReportDto 就非常简单,标注一下它的形状,保证列名和属性名能对上:
public class ArticleReportDto { public int Id { get; set; } public string Title { get; set; } public string AuthorName { get; set; } public string CategoryName { get; set; } public DateTime CreatedAt { get; set; } public int TotalCount { get; set; } }3.3 动态表名与参数化的安全边界
原生 SQL 最大的安全红线就是 SQL 注入。FromSql / FromSqlInterpolated / SqlQuery 这些基于 FormattableString 的方法,会把插值项自动参数化,这一点比较安全。真正危险的是 FromSqlRaw 和 SqlQueryRaw,它们的 SQL 是你传入的原始字符串,一旦里面有字符串拼接,风险就是 100%。
动态表名和动态排序字段是另一个容易被忽视的坑。前面提过,FormattableString 会把表名也参数化,导致 SQL 语法错误。所以动态表名只能靠拼接解决,而拼接就必须有白名单校验。我一般在代码里写一个允许表名的集合:
private static readonly HashSet<string> AllowedTables = new(StringComparer.OrdinalIgnoreCase) { "Articles", "Users", "Categories" }; public async Task<List<TDto>> QueryFromTableAsync<TDto>( string tableName, DateTime start, DateTime end) where TDto : class { if (!AllowedTables.Contains(tableName)) { throw new ArgumentException($"非法表名: {tableName}"); } var sql = $""" SELECT * FROM [{tableName}] WHERE CreatedAt >= @start AND CreatedAt <= @end """; var parameters = new object[] { new SqlParameter("@start", start), new SqlParameter("@end", end) }; return await SqlQueryListAsync<TDto>(sql, parameters); }排序字段也是同样处理,白名单里只有固定的几个列名,用户传来的排序值必须先映射再拼接。值永远走参数化,结构永远走白名单,这条铁律我写在项目文档第一页。
3.4 BaseService 层如何消费
Repository 封装好之后,Service 层调用就非常清爽。BaseService 的职责是组合多个 Repository 的查询结果,做业务校验和组装,不直接接触 DbContext,更不接触 SQL:
public class ArticleService : BaseService<Article> { private readonly ArticleRepository _articleRepository; public ArticleService(ArticleRepository articleRepository) : base(articleRepository) { _articleRepository = articleRepository; } public async Task<ArticleReportResult> GetReportAsync( DateTime start, DateTime end, int pageIndex, int pageSize, CancellationToken ct = default) { // 业务规则:时间范围最大 90 天 if (end - start > TimeSpan.FromDays(90)) { throw new BusinessException("报表时间范围不能超过 90 天"); } return await _articleRepository.GetArticleReportAsync( start, end, pageIndex, pageSize, ct); } }为什么强调三层边界?因为原生 SQL 一旦出现在 Controller 里,复用的是“那一段代码”,而不是“那一段能力”。你今天在 Controller 写了一个 SQL,明天另一个接口要复用基本只能复制粘贴,改了这段忘了那段。收进 Repository 后,所有调用方都走同一个方法,参数校验、性能监控、日志埋点都能集中在仓储层做,这才是三层架构的意义。
4. 常见问题与排查技巧实录
4.1 列名无效:最常见错的现场复盘
项目里最常遇到的报错是这个:
The required column 'CreatedAt' was not present in the results from the SQL query.看到这个错误,先别急着改代码,按顺序排查:第一步,把 SQL 复制到数据库客户端单独跑一遍,看返回的列名到底是什么。第二步,对照目标实体或 DTO 的属性名,看是否完全一致,注意大小写和下划线。第三步,检查 SQL 里是不是用了 DISTINCT 或 GROUP BY,聚合后的结果集经常会丢掉一些原表列。第四步,如果实体上有影子属性或者计算属性,EF Core 物化时也可能要求额外列,这时候要专门把对应列查出来。
举个例子,同样是查 Id 和 Title,SQL 里写成SELECT Id, Name AS Title,目标对象属性是Title,这种情况别名已经处理了,一般没问题。但如果写成SELECT Id, Name,目标属性是Title,那映射结果就是 null,而不是报错。报错往往发生在“某列真的是必要列”的时候,比如主键缺失。
我这里有个习惯:SQL 写完,先在数据库工具里用结果集的列名和目标对象属性逐一对一遍。这一步 30 秒的事,能省掉你半小时的 debug 时间。
4.2 追加 LINQ 后行为异常的真相
FromSql 支持组合 LINQ,不代表着所有 SQL 都能随意组合。EF Core 的组合机制是把你的原生 SQL 包成一个子查询,再在外层追加 WHERE / ORDER BY / OFFSET 等。如果你的原生 SQL 里已经带了 ORDER BY,组合后外层的排序会覆盖内层排序,最后结果可能会和你预期的不一致。
SqlQuery 就更要注意,它本质是终端查询,官方没有承诺支持继续组合。有些人看到Database.SqlQuery<T>返回的是 IQueryable,就在后面顺手加 Where,编译能过,但运行时的行为可能不符合预期。我的规范是:SqlQuery 的 SQL 必须把筛选、排序、分页一次写完,拿到的就是最终结果;如果还要二次筛选,在内存用 LINQ to Object 处理,不要让 EF Core 去组合。这不是性能洁癖,是明确边界,避免排查问题时认知混乱。
4.3 存储过程与表值函数的边界
EF Core 7.1 之前,FromSql 不能直接调用存储过程,运行时报错。7.1 之后支持了,但有两个默认条件要注意:一是存储过程的结果集列名要和目标类型映射匹配,二是存储过程的查询结果不能作为子查询继续组合。实际项目中,存储过程的调试难度和维护成本都更高,我倾向于用普通 SQL 代替存储过程来承载复杂查询。只有在老系统里存储过程已经封装了很重业务逻辑,短期内无法迁移时,才用 FromSql 调它。
表值函数(TVF)也是类似情况,需要在模型里用HasDbFunction注册后,才能在 FromSql 里调用。如果你在 DbContext 的 OnModelCreating 里没做过任何 TVF 配置,直接调用会报找不到函数。这块我建议项目里统一写一个 DbFunction 的配置类,把允许外部调用的 TVF 都集中注册,避免散落在各个代码文件里。
4.4 性能与跟踪:一次没必要的实体跟踪引发的事故
前面反复提到跟踪问题,这里讲一个真实性能事故。有个报表接口用 FromSqlRaw 查询实体列表,返回一万行数据,内存占用一直居高不下,接口响应时间随数据量线性恶化。排查发现罪魁祸首就是默认跟踪:EF Core 给每行实体都创建了状态快照,一万行意味着大量的字典存储和比较开销。加一个 AsNoTracking 后,内存峰值从 2GB 降到 700MB 左右,响应时间也大幅下降。
还有一个经典场景是“查询实体只为展示”。这种情况下完全没必要用 FromSql 查实体,改成 SqlQuery 直接投影 DTO,既省跟踪开销,又省字段传输。我的习惯是:能 DTO 就 DTO,能用 AsNoTracking 就 AsNoTracking,实体跟踪只留给真正要写回数据库的查询。
最后整理一个速查表,方便你排查时对号入座:
| 症状 | 可能原因 | 处理方法 |
|---|---|---|
| required column 报错 | SQL 缺列、列名不匹配、版本低于 7.0 | 补列或加别名,确认版本 |
| SqlQuery 结果属性全为 null | SQL 列名与属性名不一致 | 使用 AS 别名,保持列名一致 |
| SaveChanges 后数据被默认值覆盖 | FromSql 缺列 + 实体被跟踪 | 加 AsNoTracking,或返回全列 |
| 存储过程调用报错 | EF Core 版本低于 7.1,或列不匹配 | 升级版本,检查结果集 |
| 追加 LINQ 后排序错乱 | 组合打破了子查询的 ORDER BY | 排序在 SQL 内完成,或内存排序 |
| 动态表名拼接导致语法错误 | FormattableString 表名被参数化 | 白名单校验 + FromSqlRaw |
| SQL 中文字符显示乱码 | 连接字符串字符集配置不当 | 检查连接串,配置 utf8 等字符集 |
踩过几次坑之后,我现在写原生 SQL 的稳定心态是:先明确这段查询的结果要走实体还是 DTO,再决定用 FromSql 还是 SqlQuery;列名一律显式对齐,参数一律参数化,动态结构一律白名单;能用 LINQ 表达的绝不上原生 SQL,一旦上了原生 SQL,就让它只活在 Repository 层。希望这篇经验总结能帮你把“边界慌”变成“边界清”。