ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SqlSugar 6导航查询实战:Includes与Mapper选择与性能优化之道

2026/9/17 5:21:16 拓冰建站 浏览量
SqlSugar 6导航查询实战:Includes与Mapper选择与性能优化之道 做.NET后端开发的应该没人不认识SqlSugar吧。这个国产ORM框架用起来确实省心特别是从Dapper那套手写映射切换到SqlSugar之后整个数据访问层都清爽了不少。今天想聊聊SqlSugar 6.x里的导航查询这是一个我很早就想写的话题因为很多新手在这里容易绕晕甚至用了很久也只是把SqlSugar当“高级Dapper”用明明可以用导航查询一把梭结果还在手写Join和循环补数据。这篇博客就结合我自己的实际项目经验把SqlSugar 6里导航查询的配置方式、两种核心实现路径、分页和性能优化玩法以及一箩筐的坑全部分享出来。适合正在用SqlSugar做后台管理系统、API服务尤其是涉及多表关联查询的.NET开发者。1. 导航查询到底解决什么问题1.1 没有导航查询时你的代码会有多痛先别急着谈方法我们想想没有导航查询是什么状态。比如查询学生列表要同时带出班级名称和这个学生借过的书你大概率要写var students db.QueryableStudent().ToList(); foreach (var s in students) { s.ClassInfo db.QueryableClassInfo().First(c c.Id s.ClassId); s.Books db.QueryableBook().Where(b b.StudentId s.Id).ToList(); }这段代码在数据量小的时候看不出毛病等学生到了几百人这个循环就会变成几百次甚至上千次数据库往返也就是传说中的N1查询。更麻烦的是你还要自己维护集合属性初始化一不小心漏掉某个关联表前端就甩锅说“接口字段缺失”。即使你选择写Join那在查询对象和实体映射之间还得动用AutoMapper或者手写Select麻烦不说多对多场景下还会产生重复行。导航查询的价值就在这里你只需要在实体上定义好关联属性然后在查询时告诉SqlSugar“我要把哪个导航属性也查出来”框架会在底层生成高效的SQL一次性把主表和关联数据都捞回来并完成对象图的组装。开发效率高代码也直观。1.2 SqlSugar导航查询的两种实现路径SqlSugar 6里导航查询并不是只有一种套路我把它理解成两条路实时导航查询和非实时导航查询。实时导航查询用的是Includes方法它是在查询语句中直接“带上”导航属性框架会解析实体之间的关系自动生成Join或者子查询然后把结果列表和关联数据一起返回。这种方式的优点是写法直接、嵌套灵活比如Includes(s s.ClassInfo)这种一眼就能看出要查什么。缺点是如果一次性展开多个集合导航生成的SQL可能会比较“胖”尤其是在分页场景下容易让分页偏移变得不准。非实时导航查询用的是Mapper方法它更像是“先查主表再批量查子表最后在内存里组装”。这种方式的特点是性能可控特别是在分页查询和“查部分字段”的场景下使用效果非常明显。官方有时候也管它叫“分页导航查询”或者“非实时导航”。两条路没有绝对优劣后面我会结合具体场景说清楚什么时候用哪个。简单讲单条详情页、嵌套层级不深用Includes列表分页、多集合导航、大宽表场景用Mapper。2. 实体建模与导航属性的几种关系2.1 一对一、一对多、多对多的实体配置导航查询的基础是实体关系建模实体上必须有对应的导航属性。这里我用一个最常见的“班级-学生-课程”模型来演示。// 班级 public class ClassInfo { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } [Navigate(NavigateType.OneToMany, nameof(Student.ClassId))] public ListStudent Students { get; set; } } // 学生 public class Student { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } public int ClassId { get; set; } [Navigate(NavigateType.ManyToOne, nameof(ClassId))] public ClassInfo ClassInfo { get; set; } [Navigate(NavigateType.OneToMany, nameof(Book.StudentId))] public ListBook Books { get; set; } [Navigate(NavigateType.ManyToMany, typeof(StudentCourse))] public ListCourse Courses { get; set; } } // 书籍 public class Book { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } public int StudentId { get; set; } } // 课程 public class Course { [SugarColumn(IsPrimaryKey true, IsIdentity true)] public int Id { get; set; } public string Name { get; set; } } // 学生-课程中间表 public class StudentCourse { public int StudentId { get; set; } public int CourseId { get; set; } }这里有几个关键点。[Navigate]特性里的OneToMany和ManyToOne要搞清楚ListT属性通常是OneToMany单个实体属性通常是ManyToOne。多对多关系需要额外指定一个中间实体类型例如StudentCourse框架会通过它去关联两张主表。实体上如果没写[Navigate]特性SqlSugar会尝试根据外键命名约定自动识别导航关系但一旦关系稍微绕一点自动识别就会失效。我建议从一开始就显式标注省得后续排查“为什么导航属性查出来是空”。2.2 外键与导航属性的关联约定外键是导航查询的桥梁。SqlSugar中ManyToOne关系一般通过“本表外键字段”关联“目标表主键”。比如Student.ClassId对应ClassInfo.Id在[Navigate]里直接写nameof(ClassId)框架就能建立起映射。一个容易踩坑的细节是外键类型必须一致。ClassId是int那ClassInfo.Id也必须是int如果你用了long会有隐式转换但有时候SqlSugar对类型匹配的校验很严格干脆要求两端完全一致。另外如果是可空外键比如int?关联查询时要注意空值处理通常SqlSugar生成LEFT JOIN后空外键会自然产生null导航属性不会抛异常但如果你在后续逻辑里直接访问该导航属性还是会空引用。对于OneToMany集合导航比如班级里的Students需要在[Navigate]里指定子表的外键属性名nameof(Student.ClassId)。这里我建议都传这个参数不要偷懒只写[Navigate]否则框架可能会根据类型去猜测猜测失败后映射就静默失效。2.3 多级导航组合导航属性可以嵌套比如查询学生时同时把学生所属班级的班主任信息查出来。只要在Includes里一层一层点下去var list db.QueryableStudent() .Includes(s s.ClassInfo.Teacher) .ToList();这里Teacher是ClassInfo上的另一个导航属性。SqlSugar会解析整条表达式链生成对应的多表关联SQL。这种多级导航在详情页特别实用能一口气把深层对象图加载完避免前端多次请求。不过要注意多级导航层级过深会导致SQL性能下降。我一般控制在两级以内最多三级再深就考虑用多个查询在服务端组装或者直接用聚合根模式拆业务了。3. 实时导航查询Includes的完整用法3.1 基础Includes查询与生成的SQL平时最常用的就是Includes。比如查询所有学生并带出班级信息代码很简单var students db.QueryableStudent() .Includes(s s.ClassInfo) .ToList();SqlSugar会把你传给Includes的表达式解析成关联关系生成的SQL大概是这样的伪SQLSELECT s.*, c.* FROM Student s LEFT JOIN ClassInfo c ON s.ClassId c.Id它并不是像EF Core那样默认使用子查询或额外查询而是走了Join。这里“左边的表”是主表右边是根据导航关系Join出来的表。这个设计的好处是关联数据能按一行行的方式返回避免额外查询但坏处是如果导航的是集合那主表记录会因Join而重复。所以如果在查询班级列表时想带出学生集合你可能会写成var classes db.QueryableClassInfo() .Includes(c c.Students) .ToList();此时生成的SQL会有LEFT JOIN并且班级的每一条数据可能因为多个学生而重复。SqlSugar在组装时会把同一个班级的记录合并成一条所以你在C#侧拿到的是正确的“班级含多个学生”结构。但如果你用了Select(u new DTO)或者做了分页就很容易翻车后面我会讲到。3.2 多导航与条件过滤一次查询带多个导航属性可以用多次Includes也可以把它们链在一起var students db.QueryableStudent() .Includes(s s.ClassInfo) .Includes(s s.Books) .ToList();注意一次查询里不管是单个导航还是多个导航都只查一次数据库这个和手写循环补齐有本质区别。如果只想加载符合条件的子集合比如只加载学生“未删除”的书籍可以在Includes表达式里加Wherevar students db.QueryableStudent() .Includes(s s.Books.Where(b b.IsDelete false)) .ToList();这个写法在SqlSugar 6里是支持的也是我很喜欢的一个功能。它生成的SQL会在Join子表时带上条件而不是把全量数据捞回来后C#侧再过滤。这一点做得比很多ORM都顺手。不过要小心这种过滤只影响导航集合并不会影响主表Student的条数。也就是说如果某个学生只有一本已删除的书学生依然会返回只是Books集合为空。3.3 多级集合导航与数据合并的坑多级导航里最典型的是“班级 - 学生 - 书籍”这种两级集合。你想一次查询班级并且带出班级里所有学生以及他们各自的书var classes db.QueryableClassInfo() .Includes(c c.Students) .ThenInclude(s s.Books) // 注意SqlSugar可能不支持 ThenInclude .ToList();这里我要特别提醒SqlSugar和EF Core不同它一般不提供ThenInclude扩展。多级集合导航在SqlSugar里的写法仍然是靠嵌套表达式或者直接在Includes里逐层展开var classes db.QueryableClassInfo() .Includes(c c.Students.Select(s s.Books)) .ToList();实测下来Includes(c c.Students.Select(s s.Books))这种写法在SqlSugar 6里是可以工作的框架会递归解析导航链。但生成的SQL会涉及多次LEFT JOIN中间表的数据会严重重复。当数据量变大后SqlSugar的拆分逻辑会带来一定的CPU和内存开销。所以这种两级集合导航建议优先考虑Mapper方案别硬用Includes硬扛。4. Mapper映射分页与性能场景下的首选4.1 为什么要用Mapper前面说了Includes在集合导航和多级导航场景下会产生重复数据。更麻烦的是分页。假设你要做学生列表分页每页20条同时带出每个学生的书籍集合。如果用Includes底层SQL可能是让学生表和书籍表Join这样学生“张三”可能因为有三本书而出现三行此时你写ToPageList时框架统计的总数可能按物理行来算结果就是总数不对、数据错乱。Mapper就是为这种场景设计的。它先把主表数据查出来比如一次性查出第1页的20个学生然后收集这些学生的Id再发一条WHERE StudentId IN (...)的SQL去查书籍最后在内存中把书籍集合赋值到对应的学生对象上。从执行层面看总共2次SQL主表查询、子表查询而Includes是1次SQLJoin。但Mapper胜在返回结果结构清晰分页不会错乱。4.2 Mapper的基本使用与分页配合看一下代码var pageIndex 1; var pageSize 20; var total 0; var students db.QueryableStudent() .Select(s new Student { Id s.Id, Name s.Name, ClassId s.ClassId }) .ToPageList(pageIndex, pageSize, ref total); students db.QueryableStudent() .Where(s students.Select(st st.Id).Contains(s.Id)) .Mapper(x x.ClassInfo, x x.ClassId, x x.Id) // 映射单个导航 .Mapper(x x.Books, x x.Id, x x.StudentId) // 映射集合导航 .ToList();先别急这个写法分两步第一步用Select查出了分页用的基础字段第二步再根据分页结果去补全导航属性。Mapper的第一个参数是实体上的导航属性表达式第二个参数是“主表侧”的关联键第三个参数是“子表侧”的关联键。比如Mapper(x x.ClassInfo, x x.ClassId, x x.Id)意思是用Student.ClassId去匹配ClassInfo.Id然后把命中的ClassInfo对象赋给x.ClassInfo。再比如Mapper(x x.Books, x x.Id, x x.StudentId)意思是用Student.Id去匹配Book.StudentId然后组装成ListBook赋给x.Books。注意第二步Mapper查询的对象是可以不先Where的直接对实体QueryableStudent调用Mapper也可以。但要注意它不会自动限制在第一步的那20个学生而是会作用于Mapper调用之前的所有查询条件。所以正确做法是先借助第一步的Id集合做过滤再调用Mapper这样SqlSugar就只会查询这20个学生对应的导航数据。4.3 Mapper查询与DTO选择的配合细节很多时候列表接口并不需要返回整个实体字段这时候可以配合Select生成DTO或者部分字段映射。比如var list db.QueryableStudent() .Select(s new StudentDto { Id s.Id, Name s.Name, ClassName s.ClassInfo.Name, // 也许这里已经通过join映射了但如果导航没加载会失效 }) .Mapper(s s.Books, s s.Id, s s.StudentId) .ToList();我建议你在用Mapper时如果目标对象是StudentDto那么就需要在StudentDto上也定义Books集合属性和ClassInfo导航属性并且这两个属性都得允许赋值。如果不想直接暴露实体可以定义成DTO的导航属性类型可以是ListBookDto但Mapper对DTO的实体识别能力会比实体类弱一点所以复杂场景下我通常是先查实体再AutoMapper转DTO。还有一个小坑实体的集合导航属性如果没有初始化比如public ListBook Books { get; set; }那么Mapper在组装前如果检测到该属性为null可能会自己创建实例后赋值也可能因为属性没有setter而失败。我习惯写成public ListBook Books { get; set; } new ListBook();这样即便查不到数据返回的对象也不会是null前端少很多空判断。5. 条件筛选、排序分页与导航查询的组合技巧5.1 按子表条件过滤父表导航查询最常见的误区是把过滤条件和关联查询混在一起。比如想查“借过书的学生”新手会写db.QueryableStudent() .Includes(s s.Books.Where(b b.Id 0)) .ToList();这只是把所有学生的书都加载出来而且Where写在Includes里不会直接过滤主表。正确的做法是用Any或Count这类导航属性语法var students db.QueryableStudent() .Where(s s.Books.Any(b b.Id 0)) .ToList();SqlSugar会把Any翻译成EXISTS (SELECT 1 FROM Book WHERE StudentId Student.Id AND Id 0)这样既不会产生数据重复又能精确过滤父表。这个写法在EF Core和SqlSugar里几乎是通用的值得记住。5.2 对导航集合内容排序有时候需要按子表的某个字段对主表排序。比如查班级列表希望“按班级里最近一次创建的学生时间倒序排列”。这个需求用Includes是不合理的因为排序发生在Join之前还是之后会直接影响结果。SqlSugar里可以这样写var classes db.QueryableClassInfo() .OrderBy(c c.Students.OrderByDescending(s s.CreateTime).Select(s s.CreateTime).FirstOrDefault()) .ToList();翻译成SQL大概是一个相关子查询。这种写法能跑但性能Shale一般。数据量大时建议子查询单独处理或者先在SQL层面做好计算字段再映射。排序这块我的经验是尽量别把集合导航塞进OrderBy它能用但可读性差维护起来也费劲。5.3 分页最佳实践结合前面讲的我一般遇到“列表页 集合导航”时采取的套路是先单独查询主表分页得到当前页Id列表。再用QueryableT().Where(x ids.Contains(x.Id)).Mapper(...)补齐导航。最后如果需要DTO再AutoMapper转换。这样就把分页、查导航、转换DTO三件事解耦了。在写接口时性能比较稳定也不会因为业务调整导致SQL结构大变。6. 常见问题与性能排查6.1 为什么用了Includes后查询变慢很多同学反馈用Includes之后SQL变得特别慢。大概率是因为一次性Join了多个集合导航产生了笛卡尔积。比如班级表Join学生表再Join书籍表如果平均每个班100个学生每个学生10本书那数据库返回的临时行数就是班级数×100×10传输和组装的成本都很高。我的排查办法是开启SqlSugar的SQL日志先看生成的SQL语句。如果发现一个主查询最后返回的行数有“爆炸式”增长就考虑拆成Mapper或多条查询。另外还要看有没有多余的order by某些场景下OrderBy加在Join结果上会让排序内存压力变大。6.2 外键配置不对导致导航结果为空导航查出来是null或者空集合绝大多数情况是外键对不上。我踩过最深的坑是两个实体都用了Guid类型主键看起来字段名和类型都对但一个Guid是“主键”标识另一个Guid是普通字段SqlSugar在识别时没问题问题出在数据本身的格式大小写不同Guid存储为字符串时大小写不一致导致Join匹配失败。后来我统一把主键和外键都改成string并统一小写存储问题就消失了。还有一点如果用[Navigate]但外键参数写成了nameof(Student.Id)而不是nameof(Student.ClassId)会把学生ID误当成外键结果自然查不出来。所以写完后建议先跑一个小查询验证一下。6.3 Mapper映射不生效的几种情况Mapper不生效的时候先检查这几点目标对象的导航属性是否有setter如果只读属性框架无法赋值。集合属性是否类型匹配。你定义的ListBook就不能期望Mapper把一个IQueryableBook塞进来。Mapper调用前有没有用Select把实体改造成匿名对象或DTO。如果目标类型不是同一个实体类Mapper可能根本找不到导航属性。这时要么按实体查再转DTO要么在DTO中也定义同名导航属性。如果Mapper写在分页查询的ToPageList之后记得重新传入Id集合过滤否则Mapper可能会查询全表。这些坑每个都能让人debug一小时。我一般在写完Mapper相关的代码后会随手加一个断言测试检查第一个对象的导航集合不为空避免上线后才发现接口返回少了字段。6.4 性能对比何时用Includes何时用Mapper场景IncludesMapper单条详情查询推荐可选列表分页不推荐推荐集合导航且数据量大谨慎推荐多级导航深度2层以内可以推荐只需关联少量单对象导航推荐可选这张表基本就是我的最终抉择逻辑。说白了Includes写起来爽适合“少数据、多Join”的场景Mapper理解有门槛但在分页和集合加载上真的是救命稻草。两个都值得掌握缺了哪一个遇到复杂业务都会很难受。7. 几个值得记住的实操心得最后分享几个我平时总结的经验不一定在文档里写那么细但项目里特别有用。第一个心得如果你发现Includes和Mapper组合使用时报错或结果混乱尽量分开。不要在一个查询中既用Includes展开集合又用Mapper去补别的集合。它们对查询状态的处理机制不同组合使用时SqlSugar虽然不会报错但内部缓存和组装路径会变得复杂出问题非常难排查。第二个心得实体上的[Navigate]标注不要省。虽然SqlSugar能根据外键命名自动推断关系但团队协作时别人拿到你的实体一眼就能看出关联设计比翻文档高效得多。而且显式标注之后Includes的解析效率和准确率都更高。第三个心得导航查询虽然强大但别滥用。如果一个接口只需要主表数据加上一两个汇总字段比如“班级人数”就不要为了这个统计值去加载一个Students集合然后Count()一下。直接在查询时用子查询或单独统计比导航查询节省不少内存。SqlSugar的导航查询做得很接地气但也有自己的脾性。掌握Includes和Mapper这两把钥匙再配合对SQL生成逻辑的理解基本能覆盖99%的业务关联查询场景。遇到问题的时候记得先打开SqlSugar的SQL日志看真实语句很多时候答案就藏在生成的SQL里。