Data Analyst › SQL

JOIN ทุกแบบ

ประมาณ 30 นาที

เป้าหมายของบท

  • เชื่อมตารางด้วย INNER/LEFT/RIGHT/FULL JOIN และรู้ว่าแบบไหนให้ผลต่างกันยังไง
  • หา "แถวที่ไม่มีคู่" ด้วย LEFT JOIN ร่วมกับ IS NULL
  • เชื่อมได้มากกว่า 2 ตาราง และระวังปัญหาแถวเบิ้ล (fan-out)

ทำไมต้อง JOIN

ข้อมูลของร้านค้าออนไลน์กระจายอยู่หลายตาราง — ลูกค้าอยู่ที่ customers ออเดอร์อยู่ที่ orders รายการสินค้าในแต่ละออเดอร์อยู่ที่ order_items และรายละเอียดสินค้าอยู่ที่ products JOIN คือการเชื่อมตารางเหล่านี้เข้าด้วยกันตาม “คีย์” ที่เชื่อมโยงกัน

SELECT o.order_id, c.city
FROM orders o
JOIN customers c ON o.customer_id = c.customer_id

INNER JOIN vs LEFT JOIN vs RIGHT JOIN vs FULL JOIN

แบบ เก็บแถวไหนไว้
INNER JOIN (หรือเขียนแค่ JOIN) เฉพาะแถวที่จับคู่ได้ทั้งสองฝั่ง
LEFT JOIN ทุกแถวจากตารางซ้าย ต่อให้หาคู่ในตารางขวาไม่เจอก็ใส่ NULL แทน
RIGHT JOIN ทุกแถวจากตารางขวา (ตรงข้ามกับ LEFT)
FULL JOIN ทุกแถวจากทั้งสองฝั่ง ไม่ว่าจะจับคู่ได้หรือไม่

ลองเชื่อม customers กับ orders ทั้ง 4 แบบดู แล้วสังเกตจำนวนแถวที่ได้:

แบบ จำนวนแถว
INNER 6,938
LEFT 7,399
RIGHT 6,938
FULL 7,399

สังเกตว่า RIGHT เท่ากับ INNER และ FULL เท่ากับ LEFT ในกรณีนี้ — เพราะทุกออเดอร์อ้างอิงลูกค้าที่มีจริงเสมอ (ไม่มี “ออเดอร์กำพร้า”) ความต่างระหว่าง RIGHT/FULL กับ INNER/LEFT จะเห็นชัดก็ต่อเมื่อฝั่งขวา (หรือทั้งสองฝั่ง) มีแถวที่จับคู่ไม่ได้ ซึ่งในที่นี้เกิดฝั่ง customers แทน — มีลูกค้า 461 คนที่ไม่เคยมีออเดอร์เลย ทำให้ LEFT JOIN ได้แถวเพิ่มมา 461 แถว (7,399 − 6,938) นี่คือสิ่งที่ LEFT JOIN เผยให้เห็น

หา “แถวที่ไม่มีคู่” (anti-join)

รูปแบบที่ใช้บ่อยมากคือหา “อะไรที่ไม่มี” เช่น ลูกค้าที่ไม่เคยสั่งซื้อ:

SELECT c.customer_id FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
WHERE o.order_id IS NULL

หลักการ: LEFT JOIN ทำให้แถวที่จับคู่ไม่ได้มีค่า NULL ในคอลัมน์ของตารางขวา กรองด้วย IS NULL บนคอลัมน์คีย์ของตารางขวา ก็จะเหลือเฉพาะแถวที่ “ไม่มีคู่” จริงๆ

เชื่อมได้มากกว่า 2 ตาราง — และปัญหาแถวเบิ้ล (fan-out)

SELECT p.category, SUM(i.quantity * i.unit_price) AS revenue
FROM orders o
JOIN order_items i ON o.order_id = i.order_id
JOIN products p ON i.product_id = p.product_id
GROUP BY p.category

เชื่อมกี่ตารางก็ได้ต่อกันเรื่อยๆ แต่ต้องระวัง fan-out: ถ้าตารางที่เชื่อมมีความสัมพันธ์แบบ “หนึ่งต่อหลาย” (1 ออเดอร์มีหลายรายการสินค้า) การ JOIN จะคูณแถวขึ้นตามจำนวนรายการ ทำให้ COUNT(*) นับเกินจำนวนที่ต้องการ (เช่น นับ “รายการสินค้า” ทั้งที่ตั้งใจนับ “ออเดอร์”) วิธีแก้คือใช้ COUNT(DISTINCT ...) บนคีย์ของตารางที่ไม่อยากให้นับซ้ำ

USING เมื่อชื่อคอลัมน์ตรงกัน

ถ้าคอลัมน์ที่ใช้เชื่อมชื่อเดียวกันทั้งสองตาราง เขียนสั้นลงได้ด้วย USING:

SELECT * FROM order_items i JOIN products p USING(product_id)
-- เท่ากับ: JOIN products p ON i.product_id = p.product_id

Self-join

JOIN ตารางกับตัวเองก็ทำได้ ใช้เมื่อต้องการเทียบแถวในตารางเดียวกันเอง เช่น “หาสินค้าคู่ที่ราคาใกล้กัน” ต้องตั้งชื่อ alias ให้ต่างกัน (เช่น p1, p2) และมักต้องมีเงื่อนไข p1.id < p2.id กันไม่ให้นับคู่ซ้ำ

ตัวอย่างแก้โจทย์

กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้

ลูกค้าคนไหนที่ยังไม่เคยสั่งซื้อเลย

ทีมการตลาดอยากส่งโปรโมชันไปกระตุ้นลูกค้าที่สมัครแล้วแต่ยังไม่เคยสั่งซื้อเลยสักครั้ง

  1. ลอง INNER JOIN customers กับ orders ก่อน — สังเกตว่าจำนวนแถวที่ได้ (6938) ไม่ใช่จำนวนลูกค้า แต่เป็นจำนวนออเดอร์ เพราะลูกค้าคนเดียวมีได้หลายออเดอร์ INNER JOIN จึงคูณแถวตามนั้น

    SQL

รายได้รวมแต่ละหมวดสินค้า (เฉพาะออเดอร์ completed)

อยากรู้ว่าหมวดไหนทำรายได้ให้ร้านมากที่สุด นับเฉพาะออเดอร์ที่จบสมบูรณ์ (completed)

  1. รายได้คำนวณจาก order_items (quantity × unit_price) เชื่อมกับ orders เพื่อกรองสถานะได้

    SQL

เติมช่องว่าง

แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ

ยังไม่ได้ทำ

นับจำนวนออเดอร์ (ไม่ซ้ำ) ที่ซื้อสินค้าหมวด Electronics ให้ถูกต้อง (ไม่ติดปัญหานับซ้ำแบบตัวอย่าง) เติม ___ ให้ครบ

แบบฝึกท้ายบท

บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม

🟢 พื้นฐาน

🟢 พื้นฐานยังไม่ได้ทำ

จับคู่ order_items กับ products ด้วย USING แล้วนับจำนวนแถวทั้งหมด

🟢 พื้นฐานยังไม่ได้ทำ

ออเดอร์กับเมืองของลูกค้าที่สั่ง (JOIN customers) 5 ออเดอร์แรกสุด (order_id น้อยสุด)

🟢 พื้นฐานยังไม่ได้ทำ

จำนวนลูกค้าที่ยังไม่เคยสั่งซื้อเลย แยกตามเมือง เฉพาะเมือง Phuket และ Hat Yai

🟢 พื้นฐานยังไม่ได้ทำ

รายได้รวม (quantity × unit_price) ของออเดอร์ที่ completed ทั้งหมด

🟡 ประยุกต์

🟡 ประยุกต์ยังไม่ได้ทำ

หมวดสินค้าที่มีรายได้มากที่สุด (เฉพาะ completed) และรายได้เท่าไร

🟡 ประยุกต์ยังไม่ได้ทำ

5 ลูกค้าเมือง Bangkok ที่ใช้จ่ายมากที่สุด (เฉพาะ completed) เรียงจากมากไปน้อย

🟡 ประยุกต์ยังไม่ได้ทำ

จำนวนลูกค้าที่ไม่เคยสั่งซื้อเลย แยกตามเมือง เรียงจากมากไปน้อย

🔴 ท้าทาย

🔴 ท้าทายยังไม่ได้ทำ

จำนวนคู่สินค้าในหมวดเดียวกันที่ราคาต่างกันไม่เกิน 10 บาท (self-join กับตัวเอง) นับแต่ละคู่แค่ครั้งเดียว (ไม่นับ (A,B) ซ้ำกับ (B,A) และไม่นับคู่กับตัวเอง)

Quiz

1. INNER JOIN กับ LEFT JOIN ต่างกันยังไง
2. ทำไม COUNT(*) หลัง JOIN กับตารางลูก (เช่น order_items) ถึงมากกว่าจำนวนออเดอร์จริง
3. จะหา "แถวที่ไม่มีคู่" (เช่น ลูกค้าที่ไม่เคยสั่งซื้อ) ต้องใช้ JOIN แบบไหนและเงื่อนไขอะไร

สรุป

  • LEFT JOIN เก็บทุกแถวจากตารางซ้าย ใส่ NULL เมื่อหาคู่ในตารางขวาไม่เจอ ส่วน INNER JOIN ตัดแถวที่ไม่มีคู่ทิ้ง
  • หา "แถวที่ไม่มีคู่" ด้วย LEFT JOIN + WHERE คอลัมน์คีย์ของตารางขวา IS NULL
  • JOIN กับตารางแบบ one-to-many (เช่น order_items) ทำให้แถวเบิ้ล ระวัง COUNT(*) นับซ้ำ ใช้ COUNT(DISTINCT ...) แทน

คำถามสัมภาษณ์

เจอ bug ว่าตัวเลขหลัง JOIN เยอะกว่าที่ควรจะเป็น สาเหตุที่เป็นไปได้คืออะไร และแก้ยังไง

สาเหตุที่พบบ่อยที่สุดคือ fan-out: JOIN กับตารางที่มีความสัมพันธ์แบบ one-to-many (เช่น 1 ออเดอร์มีหลายรายการสินค้าใน order_items) ทำให้แถวของฝั่ง "one" ถูกคูณซ้ำตามจำนวนแถวฝั่ง "many"

วิธีตรวจสอบ: ลองนับจำนวนแถวก่อนและหลัง JOIN เทียบกัน ถ้าหลัง JOIN เพิ่มขึ้นมาก ให้สงสัยความสัมพันธ์แบบ one-to-many ก่อนเสมอ

วิธีแก้: ใช้ COUNT(DISTINCT ...) เพื่อนับไม่ซ้ำ หรือ aggregate ตารางลูกให้เป็นแถวเดียวต่อคีย์ก่อน (เช่นด้วย subquery/CTE) แล้วค่อย JOIN