拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SqlSugar联表查询实战:从Join到分页的完整指南

SqlSugar联表查询实战:从Join到分页的完整指南

写SqlSugar联表查询这几年,我最大的感受是:查询本身不难,但真正写得好的人不多。尤其是在做报表、订单列表、后台管理系统时,一张主表要带出用户昵称、商品名、分类名,甚至还要带上子表汇总数据,如果还在手写SQL然后用DataTable去拼,说实话既累又容易出错。SqlSugar 5.x这套ORM框架,最实用的能力之一就是把联表查询做得足够顺手,链式API一眼能看懂,翻译出来的SQL也比较干净。

这篇内容我会直接把联表查询的完整思路、代码写法、分页模板、常见坑一次讲完。适合刚接触SqlSugar的新手,也适合已经用了很久但没深入玩过联表的老手。我不打算做教科书式的功能清单罗列,而是从实际业务场景出发,讲清楚“什么时候用Join、什么时候用导航属性、什么时候用子查询”,以及各种写法背后的取舍。

1. 联表查询的整体思路与方案选型

1.1 为什么单独聊联表查询而不是基础CURD

单表查询确实没什么好讲的,框架都封装得很完善。但联表查询是另一个层次的东西,它牵扯到表关系设计、查询性能、返回模型设计、条件过滤层级等多个方面。很多人写单表的时候很顺手,一到联表就乱了,要么查出来一堆重复数据,要么字段对不上,要么分页的时候count统计错误,这些问题的根源其实不是代码能力,而是“对ORM联表机制的理解不到位”。

SqlSugar 5.x的联表查询核心思想是:你只需要描述“表与表之间怎么关联”,框架负责生成SQL、映射实体、缓存执行计划。这就意味着,写代码的人必须把表关系在脑子里想清楚,是一对一、一对多还是多对多,然后选择对应的关联方式。一旦这一步搞错,后面查出来的数据大概率是错的,而且怎么调都调不对。

我一直觉得,ORM不是用来逃避SQL学习的,它是用来解放生产力的。前提是你得知道它底层干了什么。比如SqlSugar联表查询,底层就是生成JOIN语句,了解了这一点,就理解为什么有时候要指定字段、为什么空值过滤要放在Join条件里而不是Where里。

1.2 四条联表路线,分别应对什么场景

SqlSugar 5.x里,联表查询大概有四条主路,我根据自己的使用经验整理了一个对照关系。

路线典型API适用场景优缺点
显式JoinJoinTable/LeftJoin/InnerJoin/RightJoin多表拼接、需要全字段查询、复杂条件筛选最强最灵活,但要手动处理字段冲突,书写量稍大
导航属性Includes/Nav一对多、多对一,顺序加载或条件加载代码简洁,适合层级结构,但批量场景需要防N+1
子查询.Mapper/ 表达式子查询查“某表某字段是否存在”、统计汇总字段避免打散查询,可以用在Select里组成列
纯粹SQLSqlQuery/Ado超复杂报表、存储过程、框架覆盖不到的场景灵活但放弃强类型,返回实体时字段要一致

这四条路我全都用过,日常用得最多的还是第一种显式Join。因为大部分联表业务不是单纯的“带出关联表数据”,而是要基于关联表做条件过滤,比如“查订单,但只查包含某个商品的订单”,或者“查用户列表,附带他们最近一单的价值”,这种情况导航属性不是不行,但要么查出来的数据需要二次加工,要么生成多条件SQL时比较别扭。

我的建议是:如果没有特殊的层级展示需求,显式Join是第一选择。它虽然写起来多敲几个字,但查询范围、条件、返回模型全程可控,出问题的概率最低。

1.3 选型之前先想清楚返回模型

这里我要特别强调一点,很多人在做联表查询之前完全没规划过返回模型,直接.Select(o => new { ... })一把梭。匿名类型应付临时需求可以,但进入业务核心逻辑,我建议永远使用明确的DTO(Data Transfer Object)。

为什么?因为联表查询的返回模型,直接决定你后续在View、API返回、报表导出时能不能省事。如果DTO字段设计得好,比如UserName、ProductName、TotalAmount,前端拿来直接用;如果字段是col1、col2,那后面所有地方都要做一次映射转换,得不偿失。

更重要的是,SqlSugar联表查询时,DTO可以作为泛型参数直接传给Queryable<Order>的关联方法,或者在.Select里映射,框架会自动只查询DTO需要的字段,这样连SQL层面的性能都有提升。

2. 核心细节解析与实操要点

2.1 JoinTable的三种常见写法,以及别名重要性

SqlSugar 5.x中,JoinTable是一个高频方法。它的常见用法有两种:一种是两个表之间建立关系,另一种是多表接连Join。

先看一个最简单的订单表关联用户表:

var list = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .Select((o, u) => new OrderDto { Id = o.Id, OrderNo = o.OrderNo, UserName = u.Name, CreateTime = o.CreateTime }) .ToList();

这段代码的核心在于lambda表达式里的表别名参数(o, u)。o代表Order表的别名,u代表User表的别名。别名的顺序对应Queryable后面Join的顺序,这个顺序必须和后面Select、Where里的别名一一对应,否则编译都过不了。

这里有个容易忽略的细节:如果两张表都有Id字段,你在条件里只写Id = 1,框架未必知道你说的是哪张表的Id。所以联表查询里有经验的开发者都会主动指定表别名,比如o.Id == 1。这一点在书写时多花两秒钟,能省掉后面大量的排查时间。

JoinTable的另一种写法是直接指定关联表达式,适合从主表主动“拉取”关联表:

var list = db.Queryable<Order>() .JoinTable<OrderItem>((o, i) => o.Id == i.OrderId) .JoinTable<Product>((o, i, p) => i.ProductId == p.Id) .Select((o, i, p) => new OrderItemDto { OrderNo = o.OrderNo, ProductName = p.Name, Quantity = i.Quantity }) .ToList();

注意这里三表Join之后,Select里已经变成了三个别名参数,顺序是o、i、p,对应Order、OrderItem、Product。SqlSugar会根据lambda参数的类型自动推断,但我们写的时候要保持清晰,尤其是后面条件多的时候,别让人看得一头雾水。

2.2 三种Join语义怎么选

SqlSugar 5.x提供了InnerJoin、LeftJoin、RightJoin三个方法,分别对应SQL里的INNER JOIN、LEFT JOIN、RIGHT JOIN。

我自己的经验是:业务系统里90%用LeftJoin和InnerJoin就够,RightJoin用得极少。因为设计表的时候一般都是主表在前,所以LeftJoin最自然,意思是“主表的数据全部保留,关联表有就带出来,没有就补null”。InnerJoin则是在做筛选时才用,它表示“只有两边都匹配才出现在结果里”,比如我要查“有订单商品详情的订单”,用InnerJoin就非常合适。

举个实际场景:订单列表页需要显示用户昵称,即使这个用户被删了,订单也要显示出来,这时候必须用LeftJoin。但如果查询条件里要求“只看用户状态为正常的订单”,那就不光是显示问题,还得参与过滤,此时建议把用户状态条件放在Where里,用LeftJoin也能达到效果,但要注意空值问题。

为什么说要注意空值?因为LeftJoin后,如果关联表没有匹配数据,用户字段全是null,条件u.Status == 1在SQL里会被翻译成u.Status = 1,null永远不等于1,所以该订单会被过滤掉,这反而符合“用户不存在就不显示”的业务逻辑。但如果你用u.Status != null && u.Status == 1这种写法在表达式里可能显得多余,直接在Where里写u.Status == 1就能达到InnerJoin的筛选效果,还能保留主表结构,一箭双雕。

2.3 列冲突才是联表查询最磨人的问题

联表查询的坑,十个里有七个是“列名冲突”。比如Order表有Id,OrderItem表也有Id;User表有Name,Product表也有Name。如果查询时直接把实体返回,SqlSugar就只能根据实体属性映射,很容易串字段,或者因为物理表字段重名导致后面的映射错乱。

解决办法其实很简单,就两个词:明确字段和DTO接收。在Select里明确指定要映射到DTO的每个字段,绝不要直接把整个实体返回。这样做的好处有三层:第一,不会查出多余的大字段,比如备注、描述;第二,不会因为字段重名而串数据;第三,返回模型稳定,不会因为表结构变更影响前端接口。

我见过很多同学这样写:

var list = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .Select((o, u) => new { o.Id, u.Name }) .ToList();

这确实能跑,但列表是匿名类型,后续想传给Service层或者做类型约束就麻烦。所以我更推荐建立一个OrderViewDto,把需要展示的字段全部放进去,然后Select里一个个映射。虽然代码看着多了几行,但维护成本大幅降低,尤其当查询逻辑越来越多时,这个习惯的价值会被无限放大。

3. 实操过程与核心环节实现

3.1 先准备一套演示实体

纸上谈兵没有意义,我们还是用一套实际例子走一遍。假设现在有三个表:用户表、订单表、订单明细表。订单表关联用户表,订单明细表关联产品和订单。

实体定义如下:

public class User { public int Id { get; set; } public string Name { get; set; } public int Status { get; set; } } public class Order { public int Id { get; set; } public string OrderNo { get; set; } public int UserId { get; set; } public DateTime CreateTime { get; set; } public decimal TotalAmount { get; set; } } public class OrderItem { public int Id { get; set; } public int OrderId { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public decimal Price { get; set; } } public class Product { public int Id { get; set; } public string Name { get; set; } public string Category { get; set; } }

这些实体看起来就是基础的三层结构。接下来我们要查询的结果是:订单列表,包含订单号、用户昵称、订单总金额、下单时间,并且附带每个订单的商品明细数量。如果只查订单+用户,一个LeftJoin就完事;但还要明细数量,就涉及子查询或者GroupJoin了。

3.2 两表Join是最常用场景

先写一个基础版:查订单和用户昵称。

var list = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .Where(o => o.Status == 1) // 假设订单表有Status字段,这里只是演示 .OrderBy(o => o.CreateTime, OrderByType.Desc) .Select((o, u) => new OrderDto { Id = o.Id, OrderNo = o.OrderNo, UserName = u.Name, TotalAmount = o.TotalAmount, CreateTime = o.CreateTime }) .ToList();

这个写法我自己用了很多年,非常稳定。注意Where里我只写了o.Status == 1,如果订单表确实有Status字段那就没问题,这就是强类型带来的好处,写错了编译期就报错。

有时候条件来自前端,字段可有可无,那么就得用WhereIF:

var userName = "张三"; var query = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .WhereIF(!string.IsNullOrEmpty(userName), (o, u) => u.Name.Contains(userName));

WhereIF的第一个参数是bool,只有为true时才拼接后面的条件。这个API非常适合做搜索条件,记得要把关联表的别名类型也写上,比如这里就是(o, u)。

3.3 三表Join加汇总统计

如果订单列表上要同时展示订单明细的数量合计,可以这样写:

var query = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .LeftJoin<OrderItem>((o, u, i) => o.Id == i.OrderId) .GroupBy((o, u, i) => new { o.Id, o.OrderNo, u.Name, o.CreateTime }) .Select((o, u, i) => new OrderDto { Id = o.Id, OrderNo = o.OrderNo, UserName = u.Name, TotalAmount = o.TotalAmount, ItemCount = SqlFunc.AggregateCount(i.Id), CreateTime = o.CreateTime }) .ToList();

这里的核心是GroupBy,因为一个订单会关联多条明细,如果不分组,订单信息会被拆成多行,这显然不是我们想要的。用GroupBy按订单和用户维度聚合,再用SqlFunc.AggregateCount统计明细数量,就能得到一行一个订单的正确结果。

有人可能会问,既然要统计明细数量,为什么不直接在订单表加一个字段存数量呢?这其实是计数冗余设计的范畴。小项目用实时统计没问题,数据量大了再考虑冗余字段或缓存。我的建议是:先用这种联表+聚合写法满足需求,等到表数据真的到了几十万以上,再考虑把统计结果落地成字段,避免每次列表页都去聚合。

3.4 联表分页完整模板

分页是联表查询的另一个高频场景。SqlSugar 5.x里的分页API很好用,最经典的写法是:

var pageIndex = 1; var pageSize = 20; var totalCount = 0; var query = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id); if (userId > 0) { query = query.Where((o, u) => o.UserId == userId); } var list = query .OrderBy((o, u) => o.CreateTime, OrderByType.Desc) .Select((o, u) => new OrderDto { Id = o.Id, OrderNo = o.OrderNo, UserName = u.Name, TotalAmount = o.TotalAmount, CreateTime = o.CreateTime }) .ToPageList(pageIndex, pageSize, ref totalCount);

ToPageList会自动执行count查询,并把总记录数写入totalCount,然后返回当前页数据。这里有一个小细节:totalCount是用ref传的,所以调用前需要先初始化为0。有些同学忘了加ref,或者不知道这个参数是干嘛的,就会导致分页总条数一直为0,前端分页控件显示不对。

从性能角度说,如果查询条件复杂,count查询本身也会有开销。SqlSugar 5会对同一条查询生成独立的count SQL,基本原则是简化掉OrderBy、Select里的计算字段,只保留主表关联和过滤条件。这个机制大多数时候表现不错,但如果你的条件里有子查询,还是要注意索引设计。

3.5 用Mapper做子查询式联表

除了显式Join,还有一种场景特别适合用Mapper:主表查询结果集不大,但每行需要带出关联表的某个统计字段或最新记录。比如用户列表,每个用户要显示“最近一笔订单金额”。

var userList = db.Queryable<User>() .Mapper(u => u.LastOrderAmount, u => db.Queryable<Order>() .Where(o => o.UserId == u.Id) .OrderBy(o => o.CreateTime, OrderByType.Desc) .Select(o => o.TotalAmount) .First());

这个写法说白了对每个用户执行一次子查询,适合用户量不大(几十到几百条)的场景,比如导出用户列表时补充信息。如果用户量几千上万,那就别这么写,会引发明显的性能问题,这时候应该改为一次性联表或者用分组后内存装配。

SqlSugar 5还支持Mapper批量处理,比如Mapper(async it => {...}),但核心思想不变。一句话总结:子查询式联表适合小批量数据补充字段,不适合大数据量列表页。

4. 常见问题与排查技巧实录

4.1 数据返回null,关联表就是查不出来

这种情况大多数不是SqlSugar的问题,而是关联条件没对上或者用了错误的Join类型。最常见的是把InnerJoin当LeftJoin用,导致主表数据被过滤掉。比如查“所有订单”,即使订单下没有明细,也希望订单显示出来,但用了InnerJoin后,没有明细的订单就丢了。

排查思路:第一,确认关联字段是否存在且类型一致。第二,确认Join类型是否符合业务预期。第三,先去数据库里手工执行一下生成的SQL,看看是SQL本身错了还是映射错了。SqlSugar在调试时可以开启AOP打印SQL,加上下面的配置就能看到:

db.Aop.OnLogExecuting = (sql, pars) => { Console.WriteLine(sql); };

这个习惯非常重要,我几乎每次排查查询问题都会先打开SQL日志。看到真实SQL后,再去数据库工具里执行一次,就能快速判断问题是出在SQL逻辑还是框架映射。

4.2 列名冲突导致数据串位

如果两张表都有Name字段,你Select(u => u.Name)还好,要是想偷懒直接SelectAll,那映射就可能错乱。SqlSugar 5虽然做了很多优化,但物理表字段重复时,还是要靠显式映射才能保证正确。

解决方式就是我在2.3里说的:建立DTO,并且Select里一个个映射。尤其要警惕那种“查出来数量对,但某些字段值不对”的诡异情况,多半就是列名冲突。这种问题调试起来非常耗时间,因为SQL执行结果可能没问题,只是ORM映射按名称对错了字段。

4.3 分页数据不准或TotalCount不对

分页不准有三个常见原因。第一,没有正确排序,数据在多次查询时顺序不一致,看起来“翻页乱跳”。第二,GroupBy和Distinct场景下,count统计的是分组前或去重前的记录数,导致总条数比实际展示多。第三,ref参数没用对,导致总条数一直是0或者上一次的值。

针对GroupBy分页,SqlSugar 5.x基本能正确处理,但建议还是先单独验证一下count结果。如果发现count不对,可以先简化查询,去掉不必要的关联表。某些场景下,宁可分两步:先查符合条件的订单Id列表和总数,再根据Id列表查明细数据。这样虽然多写点代码,但逻辑清晰,性能反而更好。

4.4 联表查询性能优化心得

讲了很多功能,最后聊聊性能。联表查询最容易出现性能问题的点有三个:Join字段没有索引、关联了大文本字段、查询返回列过多。

Join字段加索引是老生常谈,但很多人就是记不住。订单表的UserId、OrderItem的OrderId和ProductId,只要这几个字段建立索引,绝大多数联表查询性能都差不到哪去。其次,Select里只取需要的字段,别把Content、Remark这些大字段带到列表页的DTO里,需要详情时再单独查一次。

还有一个容易被忽略的点:不要在关联字段上做函数运算。比如o.CreateTime.ToString()或者o.UserId + 1 == u.Id这种写法,会让索引失效。SqlSugar实际生成SQL时可能不会主动优化这些,得靠写代码的人自觉。

4.5 一条很实用的排查顺序

我做了这么多年项目,总结出一套联表查询问题排查顺序,现在分享出来:

  • 先看SQL日志,拿到SqlSugar翻译出来的完整语句。
  • 到数据库客户端执行这条SQL,确认结果是否符合预期。
  • 如果SQL结果正确,再查DTO映射和实体字段,大概率是列名映射问题。
  • 如果SQL结果就不对,回头检查Join类型、关联条件和Where条件位置。
  • 最后看执行计划,确认索引有没有生效。

这套流程听着简单,但真的很管用。很多时候我们容易被框架封装迷惑,总觉得是ORM的Bug,其实绝大多数问题都是写代码时逻辑没理清。

5. 一个进阶技巧:条件分支与多表动态拼接

联表查询写多了之后,你会发现最麻烦的不是静态查询,而是前端传一堆筛选条件,你要动态决定“当前需不需要关联某张表”。比如订单列表的筛选条件里,用户选择了“只看包含某商品的订单”,这时候才需要关联订单明细表;如果没有这个条件,关联订单明细表纯属浪费性能。

老手做法是用Queryable类型变量动态拼接:

var query = db.Queryable<Order>() .LeftJoin<User>((o, u) => o.UserId == u.Id) .WhereIF(!string.IsNullOrEmpty(orderNo), (o, u) => o.OrderNo.Contains(orderNo)); if (productId > 0) { query = query.InnerJoin<OrderItem>((o, u, i) => o.Id == i.OrderId) .Where((o, u, i) => i.ProductId == productId); }

注意我在if里用的是InnerJoin。原因很简单:用户既然选了“只看包含某个商品的订单”,那么没有该商品的订单就可以直接排除,这也是语义上的Inner。如果此时用LeftJoin,还得再在Where里过滤i.ProductId == productId,虽然结果一样,但SQL上多了一层无效的左连接关联,性能略差一些。

动态拼接的核心是要维护好lambda表达式的参数数量。两表时是(o, u),三表时是(o, u, i),不能混用。SqlSugar在编译时会校验这些参数,写错了会直接编译报错,这反而是好事,能帮我们尽早发现问题。

如果你需要的关联表非常多,比如五张六张,一口气拼出来也不是不行,但我建议做一次重构,把查询拆成多个子查询或视图。个人感受是,超过四张表的单个查询,无论用什么ORM都会变得难以维护,不如考虑数据库视图或者在应用层做两次组装。

关于联表查询,我最后再分享一个小体会:SqlSugar 5的联表能力已经足够覆盖绝大多数业务场景,真正决定代码质量的还是使用者对表关系的理解,以及对返回模型的规划。把表关系理清、把DTO设计好、把Join类型选对,写出来的查询自然又快又稳。希望这篇内容能帮你在实际项目里少踩几个坑。

返回列表