JOIN 不是拼上就完事,多表查詢這幾個坑先跑一遍

單表查詢跑順以後,多表查詢很快就會出現。業務介面裡很少只有一張表:使用者要帶訂單,訂單要帶付款,統計時還要按使用者、狀態、時間分組。`join` 這幾個字本身不難,真正容易出問題的是結果列到底從哪張表開始保留,哪些欄位會被補成 NULL,條件放在 `on` 裡還是放在 `where` 裡,以及統計時到底數的是使用者、訂單,還是連接後的結果列。

從 MySQL 切到 KingbaseES,不需要一上來就追複雜場景。先用三張小表把結果邊界跑清楚,比背一串 JOIN 類型更有用。

另最近的幾個活動推薦給大家

1、薦商機・贏好禮——金倉社群「同行者計畫」開啟(bbs.kingbase.com.cn/forumDetail…) 2、2026金倉資料庫智慧運維工具開發大賽(bbs.kingbase.com.cn/forumDetail…)(bbs.kingbase.com.cn/forumDetail…

先準備三張小表

這次建三張表:t_join_usert_join_ordert_join_payment。欄位都很普通,使用者表放使用者和狀態,訂單表放訂單號、金額、訂單狀態,付款表放付款狀態和付款金額。

這裡沒有加外鍵。不是建議業務表這麼設計,而是為了放兩類不完整資料:訂單表裡有一筆 user_id = 6,使用者表裡沒有這個使用者;付款表裡有一筆 order_id = 106,訂單表裡沒有這筆訂單。沒有這種邊界資料,left joinright joinfull join 看起來就太順了,很多坑反而暴露不出來。

建立三張 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 只留下匹配成功的列

先從最常見的內連接開始:

vbnet 代碼解讀複製代碼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 只返回兩邊匹配的資料

這就是 inner join 最樸素的規則:兩邊都能連上,才進入結果集。一對多關係會把主表列展開,alice 在使用者表裡只有一行,連訂單以後變成兩行。後面做統計時,這個展開會直接影響 count(*) 的結果。

內連接適合回答「已經發生關聯的記錄有哪些」。比如只關心有訂單的使用者、只關心能找到使用者的訂單,inner join 很乾淨。但如果介面要查使用者列表,再順帶展示訂單資訊,用內連接就會把沒有訂單的使用者吞掉。SQL 沒報錯,頁面上少了人,這種問題比語法錯誤更隱蔽。

LEFT JOIN 會保留左表

如果要查所有使用者,以及使用者有沒有訂單,內連接就不夠了。換成左連接:

vbnet 代碼解讀複製代碼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 的左表是使用者表。

LEFT JOIN 保留左表資料

這個結果比定義更好記:從使用者表出發,使用者不會丟;訂單能匹配就帶出來,匹配不到就補 NULL。很多列表頁、後台管理頁、報表底表都會用到這種寫法。

這裡的 NULL 也要當成結果的一部分看。davideric 的訂單欄位為空,並不表示查錯了,恰好說明「使用者存在,但沒有匹配訂單」。如果後面接 Java 代碼、MyBatis 映射或者介面 JSON,右表欄位要允許為空。把這些 NULL 又強行轉成預設物件,很容易讓呼叫方誤以為真的有一筆空訂單。

條件放 ON 裡,和放 WHERE 裡不是一回事

外連接最容易寫錯的地方,不是 left join 本身,而是右表條件放在哪裡。

先把訂單狀態條件放在 on 裡:

ini 代碼解讀複製代碼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

csharp 代碼解讀複製代碼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。沒有已付款訂單的使用者都被過濾掉了。

LEFT JOIN 條件放 ON 和 WHERE 的差異

這一步在真實開發裡很常見。需求說「查所有使用者,並展示已付款訂單」,條件應該放在 on 裡;需求說「只查有已付款訂單的使用者」,條件放在 where 裡更直接。兩個 SQL 看起來只差一行位置,結果從 5 行變成 2 行。

不要把它粗暴記成某個固定口訣。直接看執行後的結果更穩:on 控制右表怎麼匹配,where 會對連接後的結果再過濾。右表欄位為 NULL 的那些行,遇到 where o.order_status = 'paid' 自然留不下來。

這個差別在訂單狀態篩選裡尤其明顯。需求如果寫的是「所有使用者都要展示,只看他們的已付款訂單」,用 on 更符合語義;需求如果寫的是「只展示有已付款訂單的使用者」,用 where 更符合語義。同樣都是 paid,業務語義不一樣,SQL 位置就不一樣。寫之前先把這句話問清楚,比寫完再看結果少很多返工。

RIGHT JOIN 和 FULL JOIN 看邊界更直觀

right join 可以從訂單表角度看使用者匹配情況:

csharp 代碼解讀複製代碼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

csharp 代碼解讀複製代碼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 作為只有訂單、沒有使用者的資料也保留下來。

RIGHT JOIN 和 FULL JOIN 邊界

MySQL 使用者平時更常寫 left joinright join 換個方向也能理解。full join 值得單獨跑一下,因為它能同時看兩邊不匹配的資料。做資料核對時,這種結果很直觀:哪邊缺了,NULL 就出現在另一邊。

right join 在日常代碼裡不一定非用不可。很多團隊會把右連接改寫成左連接,只是為了讓主表從左往右讀起來更順。full join 的用途更偏核對和排查,它不是每個列表介面都需要,但遇到「兩邊都可能有孤立資料」的場景時,比把兩個左連接結果再拼起來更直接。

三表 LEFT JOIN 會受出發點影響

再把付款表連進來:

css 代碼解讀複製代碼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 沒有訂單,訂單和付款欄位都為空。

三表 LEFT JOIN 查詢訂單和付款

付款表裡那筆 payment_id = 1004 沒有出現。它掛在 order_id = 106 上,而訂單表裡沒有 106;這條 SQL 又是從使用者表一路左連接到訂單、付款,所以孤立付款不會被帶出來。

這說明三表連接時,不能只看 JOIN 類型,還要看查詢從哪張表出發。使用者維度的查詢適合從使用者表出發;訂單和付款對帳,就不應該指望這條 SQL 把所有付款異常都查出來。

三表連接還有一個習慣要保留:每個欄位都帶上別名前綴。u.user_ido.order_idp.payment_id 看著比裸欄位囉唆一點,但排查時能少很多猜測。尤其是查詢結果裡同時出現訂單狀態和付款狀態時,order_statuspay_status 分清楚很重要。付款成功不等於訂單狀態一定正確,訂單已付款也不等於付款表一定存在記錄。

訂單和付款對帳,用 FULL JOIN 更合適

把訂單表和付款表單獨做一次 full join

css 代碼解讀複製代碼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 不存在,所以訂單側為空。

FULL JOIN 找訂單和付款孤立資料

這類 SQL 很適合做核對。左連接只能強調一邊,full join 可以把兩邊的缺口放在同一張結果裡。業務表正式設計時當然可以靠外鍵、狀態機、業務校驗減少這種異常,但排查歷史資料時,能直接把缺口查出來很省事。

這裡 102 和 105 是「有訂單、沒有付款」,1004 是「有付款、沒有訂單」。兩類問題在同一組結果裡出現,後面就能繼續加條件分開處理:訂單側為空的查付款異常,付款側為空的查未付款訂單。比先寫一條查訂單、再寫一條查付款更容易對齊口徑。

JOIN 後統計,先確認數的是什麼

一對多連接以後,統計口徑很容易亂。先按使用者統計訂單數和已付款訂單數:

sql 代碼解讀複製代碼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。

再看不分使用者的一次總統計:

csharp 代碼解讀複製代碼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

JOIN 後 count 統計口徑

這幾個數字正好把口徑拆開了。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 後再去重:

csharp 代碼解讀複製代碼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 查詢有訂單的使用者

exists 不替代 JOIN。它適合回答「有沒有匹配記錄」這種問題;JOIN 適合把兩邊欄位一起查出來。寫介面 SQL 時,可以先判斷需求到底要欄位,還是只要存在性。只要存在性,exists 的表達更直接。

這條 SQL 沒有返回訂單欄位,也沒有因為 alice 有兩筆訂單就返回兩次。它只判斷子查詢裡是否存在匹配記錄,符合條件的使用者返回一行。遇到「查有訂單的使用者」「查有付款記錄的訂單」「查已經綁定角色的帳號」這類需求時,exists 往往比 JOIN 後再 distinct 更貼近問題本身。

到這裡,多表查詢的幾個基本邊界就清楚了。inner join 只保留匹配列;left join 保留左表;right join 反過來看右表;full join 同時保留兩邊不匹配的資料;外連接條件放在 on 還是 where,會直接改變結果;一對多連接以後,count(*) 不再天然等於主表數量。對從 MySQL 過來的開發者來說,JOIN 語法本身不陌生,但這些結果邊界必須實測一遍,後面寫複雜 SQL 才不容易憑感覺下結論。


原文出處:https://juejin.cn/post/7658601500857597978


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬3   ❤️4
691
🥈
我愛JS
📝2   💬5   ❤️3
139
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登