LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

JOIN 不是拼上就完事,多表查询这几个坑先跑一遍

zhenglin
2026年7月6日 16:54 本文热度 97

单表查询跑顺以后,多表查询很快就会出现。业务接口里很少只有一张表:用户要带订单,订单要带支付,统计时还要按用户、状态、时间分组。join 这几个字本身不难,真正容易出问题的是结果行到底从哪张表开始保留,哪些字段会被补成 NULL,条件放在 on 里还是放在 where 里,以及统计时到底数的是用户、订单,还是连接后的结果行。

从 MySQL 切到 KingbaseES,不需要一上来就追复杂场景。先用三张小表把结果边界跑清楚,比背一串 JOIN 类型更有用。



先准备三张小表

这次建三张表:t_join_usert_join_ordert_join_payment。字段都很普通,用户表放用户和状态,订单表放订单号、金额、订单状态,支付表放支付状态和支付金额。

这里没有加外键。不是建议业务表这么设计,而是为了放两类不完整数据:订单表里有一条 user_id = 6,用户表里没有这个用户;支付表里有一条 order_id = 106,订单表里没有这笔订单。没有这种边界数据,left joinright joinfull join 看起来就太顺了,很多坑反而暴露不出来。

插入的数据量也不大。用户表 5 行:alicebobcindydavideric。订单表 5 行:alice 有两笔订单,bobcindy 各一笔,还有一笔订单挂在不存在的用户 6 上。支付表 4 行:订单 101、103 支付成功,104 支付失败,另有一条支付记录挂在不存在的订单 106 上。

这批数据的重点不是模拟真实业务,而是让连接结果里同时出现三种情况:能匹配、左边有右边没有、右边有左边没有。JOIN 的差别主要就体现在这些边界上。

表名都带了 t_join_ 前缀,是为了和前面单表查询用过的测试表分开。字段名也故意保留了几个容易重名的部分,比如用户表有 user_id,订单表也有 user_id;订单表有 order_id,支付表也有 order_id。多表查询一旦字段同名,直接写裸字段很快就会读不清,所以后面的 SQL 都用 uop 做别名。

INNER JOIN 只留下匹配成功的行

先从最常见的内连接开始:

select

  u.user_id,

  u.user_name,

  o.order_id,

  o.order_no,

  o.order_status,

  o.amount

from app_schema.t_join_user u

inner join app_schema.t_join_order o

  on u.user_id = o.user_id

order by u.user_id, o.order_id;


结果只有 4 行。alice 出现两次,对应订单 101 和 102;bob 对应订单 103;cindy 对应订单 104。davideric 没有订单,不返回。订单 105 的 user_id 是 6,用户表里找不到,也不返回。

这就是 inner join 最朴素的规则:两边都能连上,才进入结果集。一对多关系会把主表行展开,alice 在用户表里只有一行,连订单以后变成两行。后面做统计时,这个展开会直接影响 count(*) 的结果。

内连接适合回答“已经发生关联的记录有哪些”。比如只关心有订单的用户、只关心能找到用户的订单,inner join 很干净。但如果接口要查用户列表,再顺带展示订单信息,用内连接就会把没有订单的用户吞掉。SQL 没报错,页面上少了人,这种问题比语法错误更隐蔽。

LEFT JOIN 会保留左表

如果要查所有用户,以及用户有没有订单,内连接就不够了。换成左连接:

select

  u.user_id,

  u.user_name,

  u.status,

  o.order_id,

  o.order_status,

  o.amount

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id

order by u.user_id, o.order_id;

这次返回 6 行。alice 仍然因为两笔订单出现两次;bobcindy 各一行;davideric 虽然没有订单,也被保留下来,只是订单相关字段为空。订单 105 不会出现,因为这条 SQL 的左表是用户表。

这个结果比定义更好记:从用户表出发,用户不会丢;订单能匹配就带出来,匹配不到就补 NULL。很多列表页、后台管理页、报表底表都会用到这种写法。

这里的 NULL 也要当成结果的一部分看。davideric 的订单字段为空,并不表示查错了,恰好说明“用户存在,但没有匹配订单”。如果后面接 Java 代码、MyBatis 映射或者接口 JSON,右表字段要允许为空。把这些 NULL 又强行转成默认对象,很容易让调用方误以为真的有一笔空订单。

条件放 ON 里,和放 WHERE 里不是一回事

外连接最容易写错的地方,不是 left join 本身,而是右表条件放在哪里。

先把订单状态条件放在 on 里:

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id

 and o.order_status = 'paid'

结果返回 5 行,5 个用户都还在。alice 只匹配到已支付订单 101,未支付订单 102 不再作为订单行出现;bob 匹配到 103;cindydavideric 都保留,只是订单字段为空。

同样的条件放到 where

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id

where o.order_status = 'paid'

结果只剩 2 行:alice 的 101 和 bob 的 103。没有已支付订单的用户都被过滤掉了。

这一步在真实开发里很常见。需求说“查所有用户,并展示已支付订单”,条件应该放在 on 里;需求说“只查有已支付订单的用户”,条件放在 where 里更直接。两个 SQL 看起来只差一行位置,结果从 5 行变成 2 行。

不要把它粗暴记成某个固定口诀。直接看执行后的结果更稳:on 控制右表怎么匹配,where 会对连接后的结果再过滤。右表字段为 NULL 的那些行,遇到 where o.order_status = 'paid' 自然留不下来。

这个差别在订单状态筛选里尤其明显。需求如果写的是“所有用户都要展示,只看他们的已支付订单”,用 on 更符合语义;需求如果写的是“只展示有已支付订单的用户”,用 where 更符合语义。同样都是 paid,业务语义不一样,SQL 位置就不一样。写之前先把这句话问清楚,比写完再看结果少很多返工。

RIGHT JOIN 和 FULL JOIN 看边界更直观

right join 可以从订单表角度看用户匹配情况:

from app_schema.t_join_user u

right join app_schema.t_join_order o

  on u.user_id = o.user_id

结果返回订单表的 5 行。订单 101、102、103、104 能找到用户,订单 105 找不到用户,所以用户侧字段为空。用户表里的 davideric 不会出现,因为右表是订单表。

再看 full join

from app_schema.t_join_user u

full join app_schema.t_join_order o

  on u.user_id = o.user_id

结果返回 7 行。匹配成功的 4 行正常出现;davideric 作为只有用户、没有订单的数据保留下来;订单 105 作为只有订单、没有用户的数据也保留下来。

MySQL 用户平时更常写 left joinright join 换个方向也能理解。full join 值得单独跑一下,因为它能同时看两边不匹配的数据。做数据核对时,这种结果很直观:哪边缺了,NULL 就出现在另一边。

right join 在日常代码里不一定非用不可。很多团队会把右连接改写成左连接,只是为了让主表从左往右读起来更顺。full join 的用途更偏核对和排查,它不是每个列表接口都需要,但遇到“两边都可能有孤立数据”的场景时,比把两个左连接结果再拼起来更直接。

三表 LEFT JOIN 会受出发点影响

再把支付表连进来:

select

  u.user_id,

  u.user_name,

  o.order_id,

  o.order_status,

  o.amount,

  p.payment_id,

  p.pay_status,

  p.pay_amount

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id

left join app_schema.t_join_payment p

  on o.order_id = p.order_id

order by u.user_id, o.order_id, p.payment_id;

结果还是从用户表出发。alice 的 101 有成功支付,102 没有支付;bob 的 103 有成功支付;cindy 的 104 有失败支付;davideric 没有订单,订单和支付字段都为空。

支付表里那条 payment_id = 1004 没有出现。它挂在 order_id = 106 上,而订单表里没有 106;这条 SQL 又是从用户表一路左连接到订单、支付,所以孤立支付不会被带出来。

这说明三表连接时,不能只看 JOIN 类型,还要看查询从哪张表出发。用户维度的查询适合从用户表出发;订单和支付对账,就不应该指望这条 SQL 把所有支付异常都查出来。

三表连接还有一个习惯要保留:每个字段都带上别名前缀。u.user_ido.order_idp.payment_id 看着比裸字段啰嗦一点,但排查时能少很多猜测。尤其是查询结果里同时出现订单状态和支付状态时,order_statuspay_status 分清楚很重要。支付成功不等于订单状态一定正确,订单已支付也不等于支付表一定存在记录。

订单和支付对账,用 FULL JOIN 更合适

select

  o.order_id as order_id_from_order,

  o.order_no,

  o.order_status,

  p.payment_id,

  p.order_id as order_id_from_payment,

  p.pay_status,

  p.pay_amount

from app_schema.t_join_order o

full join app_schema.t_join_payment p

  on o.order_id = p.order_id

order by coalesce(o.order_id, p.order_id), p.payment_id;

这次结果有 6 行。订单 101、103、104 能匹配到支付记录;订单 102、105 没有支付,所以支付侧为空;支付 1004 对应的订单 106 不存在,所以订单侧为空。

这类 SQL 很适合做核对。左连接只能强调一边,full join 可以把两边的缺口放在同一张结果里。业务表正式设计时当然可以靠外键、状态机、业务校验减少这种异常,但排查历史数据时,能直接把缺口查出来很省事。

这里 102 和 105 是“有订单、没有支付”,1004 是“有支付、没有订单”。两类问题在同一组结果里出现,后面就能继续加条件分开处理:订单侧为空的查支付异常,支付侧为空的查未支付订单。比先写一条查订单、再写一条查支付更容易对齐口径。

JOIN 后统计,先确认数的是什么

一对多连接以后,统计口径很容易乱。先按用户统计订单数和已支付订单数:

select

  u.user_id,

  u.user_name,

  count(*) as joined_rows,

  count(o.order_id) as order_count,

  count(case when o.order_status = 'paid' then 1 end) as paid_order_count,

  coalesce(sum(o.amount), 0) as total_amount

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id

group by u.user_id, u.user_name

order by u.user_id;

alicejoined_rows 是 2,因为她有两笔订单;order_count 也是 2;已支付订单只有 1;金额合计是 258。davideric 没有订单,但左连接后仍然各有一行,所以 joined_rows 是 1,order_count 是 0,金额用 coalesce 转成了 0。

再看不分用户的一次总统计:

select

  count(*) as joined_rows,

  count(u.user_id) as user_id_count,

  count(distinct u.user_id) as distinct_user_count,

  count(o.order_id) as order_count

from app_schema.t_join_user u

left join app_schema.t_join_order o

  on u.user_id = o.user_id;

结果是 joined_rows = 6user_id_count = 6distinct_user_count = 5order_count = 4

这几个数字正好把口径拆开了。count(*) 数的是连接后的行数;count(u.user_id) 也是 6,因为左表用户字段每一行都有值;count(distinct u.user_id) 才是去重后的用户数;count(o.order_id) 数的是能从用户表匹配到的订单,订单 105 不在这个左连接结果里,所以只算 4。

很多报表问题就出在这里。SQL 本身没报错,结果也不是空,但计数对象错了。接口要的是用户数,却写成了 count(*);要的是订单数,却从用户左连接的结果里直接数行。JOIN 之后先把“数什么”说清楚,比后面再修数据更省时间。

这组结果还暴露了一个常见误会:没有订单的用户,在 left join 后并不是消失,而是各占一行。所以 davidericjoined_rows 都是 1,order_count 才是 0。只要报表里混了主表全量和从表统计,count(*) 就不能随手写。该数主表去重,就写 count(distinct u.user_id);该数订单,就写 count(o.order_id);该数已支付订单,就把状态条件写进聚合表达式里。

EXISTS 适合只判断有没有

最后用 exists 做一个对照。只想查“有订单的用户”,不一定要 JOIN 后再去重:

select

  u.user_id,

  u.user_name,

  u.status

from app_schema.t_join_user u

where exists (

  select 1

  from app_schema.t_join_order o

  where o.user_id = u.user_id

)

order by u.user_id;

结果返回 alicebobcindy 三个用户。davideric 没有订单,不返回;订单 105 挂在不存在的用户 6 上,也不会影响用户表的结果。

exists 不替代 JOIN。它适合回答“有没有匹配记录”这种问题;JOIN 适合把两边字段一起查出来。写接口 SQL 时,可以先判断需求到底要字段,还是只要存在性。只要存在性,exists 的表达更直接。

这条 SQL 没有返回订单字段,也没有因为 alice 有两笔订单就返回两次。它只判断子查询里是否存在匹配记录,符合条件的用户返回一行。遇到“查有订单的用户”“查有支付记录的订单”“查已经绑定角色的账号”这类需求时,exists 往往比 JOIN 后再 distinct 更贴近问题本身。


到这里,多表查询的几个基本边界就清楚了。inner join 只保留匹配行;left join 保留左表;right join 反过来看右表;full join 同时保留两边不匹配的数据;外连接条件放在 on 还是 where,会直接改变结果;一对多连接以后,count(*) 不再天然等于主表数量。对从 MySQL 过来的开发者来说,JOIN 语法本身不陌生,但这些结果边界必须实测一遍,后面写复杂 SQL 才不容易凭感觉下结论。


阅读原文


该文章在 2026/7/6 16:54:47 编辑过
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-9  粤公网安备44030602007207号