erDiagram
CUSTOMERS ||--o{ TRANSACTIONS : places
STORES ||--o{ TRANSACTIONS : receives
TRANSACTIONS ||--o{ ORDER_ITEMS : contains
MENUS ||--o{ ORDER_ITEMS : "listed in"
CUSTOMERS {
BIGINT customer_id PK
VARCHAR age_group
VARCHAR loyalty_tier
VARCHAR discovery_channel
BIGINT health_conscious
}
STORES {
BIGINT store_id PK
VARCHAR store_name
VARCHAR branch_type
VARCHAR district
}
TRANSACTIONS {
BIGINT transaction_id PK
BIGINT store_id FK
BIGINT customer_id FK "nullable"
VARCHAR channel
DOUBLE net_sales_thb
DOUBLE contribution_margin_thb
BIGINT satisfaction_score
}
ORDER_ITEMS {
BIGINT transaction_id PK,FK
BIGINT sku_id FK
BIGINT line_no PK
DOUBLE net_line_sales_thb
VARCHAR add_on
}
MENUS {
BIGINT sku_id PK
VARCHAR sku_name
VARCHAR family
VARCHAR tea_base
}
วัตถุประสงค์การเรียนรู้
เมื่อจบบทนี้ ผู้เรียนจะสามารถ
- อธิบายหลักการทำงานของ
INNER JOIN - อธิบายหลักการทำงานของ
LEFT JOIN - เขียนคำสั่ง
JOINเชื่อมหลายตาราง - เลือกใช้
LEFT JOINได้ถูกต้องเมื่อต้องการเก็บแถวที่ไม่มีข้อมูลคู่
บทนำ
หนึ่งในความสามารถสำคัญของฐานข้อมูลเชิงสัมพันธ์คือการเชื่อมโยงข้อมูลจากหลายตารางเข้าด้วยกัน เพื่อให้สามารถตอบคำถามทางธุรกิจที่ซับซ้อนได้อย่างมีประสิทธิภาพ
ตัวอย่างเช่น หากต้องการตอบคำถามว่า “ลูกค้า Gen Z ที่สาขา Siam Square นิยมสั่งเครื่องดื่มประเภทใดมากที่สุด และลูกค้ากลุ่มนี้มีระดับความพึงพอใจสูงกว่ากลุ่มอื่นหรือไม่” คำตอบย่อมไม่ได้อยู่ในตารางใดตารางหนึ่งเพียงลำพัง แต่กระจายอยู่ในหลายตารางที่เก็บข้อมูลคนละด้านของธุรกิจ
การวิเคราะห์คำถามลักษณะนี้จำเป็นต้องอาศัยข้อมูลจากหลายแหล่งร่วมกัน ได้แก่ transactions ซึ่งบอกว่าลูกค้าซื้อสินค้าที่สาขาใดและผ่านช่องทางใด, customers ซึ่งให้ข้อมูลด้านอายุและระดับสมาชิก, stores ซึ่งอธิบายรายละเอียดของสาขา, order_items ซึ่งแสดงรายการสินค้าที่อยู่ในแต่ละออเดอร์, และ menus ซึ่งระบุประเภทสินค้าและราคา
หากไม่มีการใช้ JOIN ผู้วิเคราะห์จะต้องเปิดข้อมูลจากหลายตารางแล้วจับคู่ข้อมูลด้วยตนเอง ซึ่งทั้งซับซ้อน ใช้เวลามาก และเสี่ยงต่อความผิดพลาด แต่ด้วยคำสั่ง JOIN เราสามารถเชื่อมโยงข้อมูลเหล่านี้เข้าด้วยกันอย่างเป็นระบบ และสรุปคำตอบที่ต้องการได้ภายในคำสั่ง SQL ชุดเดียว
ดังนั้น ก่อนจะเริ่มใช้ JOIN อย่างมีประสิทธิภาพ ผู้เรียนจึงควรทำความเข้าใจโครงสร้างของตารางและความสัมพันธ์ระหว่างข้อมูลในฐานข้อมูล PiCha ให้ชัดเจนเสียก่อน
รูปที่ 4.1: แผนผัง ER ของฐานข้อมูล PiCha (แบบย่อ) แสดงความสัมพันธ์ 4 ทางที่ใช้ในบทนี้
ความสัมพันธ์หลัก:
transactions.store_idเชื่อมstores.store_idทุกออเดอร์ที่มีรหัสสาขาตรงกันtransactions.customer_idเชื่อมcustomers.customer_idบางออเดอร์ไม่มีcustomer_id(เป็นNULL)order_items.transaction_idเชื่อมtransactions.transaction_idทุก line item อยู่ในออเดอร์เดียวกันorder_items.sku_idเชื่อมmenus.sku_idทุก line item อ้างอิงเมนูที่ถูกต้อง
เมื่อเข้าใจความสัมพันธ์ระหว่างตารางแล้ว ขั้นถัดไปคือศึกษาคำสั่ง JOIN ซึ่งเป็นเครื่องมือในการ “เชื่อม” ความสัมพันธ์ระหว่างตารางเหล่านี้
JOIN
JOIN ใช้เชื่อมโยงแถวข้อมูลจากหลายตารางเข้าด้วยกันเพื่อให้ได้ผลลัพธ์ตามต้องการ ความสามารถนี้เป็นหัวใจสำคัญของฐานข้อมูลเชิงสัมพันธ์ เพราะการเชื่อมตารางต่าง ๆ คือการสร้างความสัมพันธ์ระหว่างข้อมูลหรือตารางนั่นเอง
การเชื่อมตารางอาศัยคอลัมน์ร่วม (common column) โดยทั่วไปใช้ คีย์นอก (foreign key) ของตารางหนึ่งเชื่อมกับ คีย์หลัก (primary key) ของอีกตาราง เช่น transactions.store_id เชื่อมกับ stores.store_id เป็นต้น
ในงานวิเคราะห์ข้อมูลทางธุรกิจ มักใช้ JOIN 2 ประเภทหลัก ได้แก่
| ประเภท | ลักษณะการทำงาน |
|---|---|
INNER JOIN |
แสดงเฉพาะแถวที่ match กันทั้งสองตาราง |
LEFT JOIN |
แสดงทุกแถวจากตารางซ้าย |
ในงานจริง
INNER JOINและLEFT JOINครอบคลุม use case ของนักวิเคราะห์มากกว่า 90% JOIN ประเภทอื่น เช่น RIGHT/FULL OUTER JOIN พบได้น้อยกว่าสำหรับงาน dashboard / analytics ทั่วไป
ในครึ่งแรกของบทนี้ เราจะเริ่มจาก INNER JOIN ซึ่งใช้บ่อยที่สุดเมื่อต้องการเฉพาะแถวที่มีข้อมูลครบทั้งสองฝั่ง จากนั้นครึ่งหลังจะขยายไปสู่ LEFT JOIN เมื่อเราต้องการรักษา “แถวที่ไม่มีคู่” เอาไว้เพื่อวิเคราะห์ช่องว่างของข้อมูล
INNER JOIN
INNER JOIN เชื่อมแถวจาก 2 ตารางขึ้นไป โดย แสดงเฉพาะแถวที่มีค่าคอลัมน์จับคู่ตรงกัน ตามเงื่อนไขที่ระบุใน ON clause
โดยทั่วไป
- ฝั่งหนึ่งเป็นตาราง fact ที่มี foreign key (เช่น
transactions.store_id) - อีกฝั่งเป็นตาราง dimension ที่มี primary key (เช่น
stores.store_id) - ผลลัพธ์จะมีแถวเฉพาะเมื่อค่า key ตรงกันทั้งสองตาราง
เพื่อให้เห็นภาพการทำงานของ INNER JOIN ชัดเจน เราจะเริ่มจากตัวอย่างง่ายสุดคือการเชื่อม 2 ตาราง ก่อนขยายไปสู่หลายตาราง
ไวยากรณ์ INNER JOIN 2 ตาราง
โดยทั่วไปการเขียน
JOINโดยไม่ระบุคำว่าINNERจะถือว่าเป็นINNER JOINดังนั้นJOINกับINNER JOINจึงให้ผลลัพธ์เหมือนกัน ต่างกันแค่ความชัดเจนในการอ่านโค้ดเท่านั้น
การเชื่อม 2 ตาราง: รายได้ตามสาขา
หากต้องการเห็นรายได้, จำนวนออเดอร์ และ margin ของแต่ละสาขา พร้อมประเภทสาขาและเขต จะต้องเชื่อมตาราง transactions และ stores เข้าด้วยกัน เพราะ
transactionsมีstore_idแต่ไม่มีชื่อสาขา/เขตstoresมีstore_name,branch_type,district
จึงต้องใช้ INNER JOIN ดึงข้อมูลจากทั้งสองตารางเข้าด้วยกัน
SELECT -- เลือกคอลัมน์ที่ต้องการแสดงผล
s.store_name, -- ชื่อสาขา
s.branch_type, -- ประเภทสาขา
s.district, -- เขต/อำเภอ
COUNT(*) AS order_count, -- นับจำนวนออเดอร์ทั้งหมด
ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รายได้รวม
ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value, -- มูลค่าออเดอร์เฉลี่ย
ROUND(SUM(t.contribution_margin_thb), 0) AS total_cm -- contribution margin
FROM transactions t -- จากตาราง transactions (ตั้งชื่อแฝง t)
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตาราง stores ด้วย store_id
GROUP BY s.store_name, s.branch_type, s.district -- จัดกลุ่มตามชื่อ ประเภท และเขต
ORDER BY total_revenue DESC; -- เรียงลำดับตามรายได้รวมจากมากไปน้อย| store_name | branch_type | district | order_count | total_revenue | avg_order_value | total_cm |
|---|---|---|---|---|---|---|
| PiCha centralwOrld | mall | Pathum Wan | 11,935 | 2,451,113 | 205 | 1,318,149 |
| PiCha Siam Square | street | Pathum Wan | 10,738 | 2,311,860 | 215 | 1,205,243 |
| PiCha ICONSIAM | mall_flagship | Khlong San | 8,702 | 1,763,926 | 203 | 966,501 |
| PiCha Vanit Village | mixed_use | Ratchathewi | 7,257 | 1,465,764 | 202 | 797,878 |
| PiCha Park Silom | office | Bang Rak | 7,611 | 1,450,877 | 191 | 806,787 |
| PiCha Exchange Tower | office | Khlong Toei | 7,080 | 1,343,861 | 190 | 747,494 |
จะเห็นได้ว่า CentralWorld และ Siam Square ซึ่งอยู่ในเขต Pathum Wan สร้างรายได้รวมร่วมกัน 4.76 ล้านบาท คิดเป็นประมาณ 44% ของรายได้ทั้งหมด ขณะที่สาขาประเภท office เช่น Park Silom และ Exchange Tower มี AOV ต่ำกว่า (190–191 บาท) แต่จากบทก่อนเราทราบว่า CM% ของสาขากลุ่มนี้สูงที่สุด จึงเป็น “เครื่องยนต์กำไร” ที่มีโครงสร้างต้นทุน lean แม้รายได้รวมจะรองลงมา
ในแง่เทคนิค ตัวอย่างนี้สรุป pattern สำคัญของ INNER JOIN
- ใช้ตาราง
transactionsเป็นฐาน แล้วJOINกับตารางstoresเพื่อ “แปลรหัสให้เป็นเนื้อหา” - SELECT ดึงคอลัมน์ทั้งจาก
tและsได้หลัง JOIN - GROUP BY สามารถใช้คอลัมน์จากหลายตารางได้ แต่ทุกคอลัมน์ที่ไม่ใช่ aggregate ต้องระบุใน
GROUP BY
หลังจากเข้าใจการ JOIN ข้ามตารางในมุม “สาขา” แล้ว ส่วนถัดไปจะใช้แนวคิดเดียวกันกับมุม “สินค้า”
การเชื่อม 2 ตาราง: Top เมนูสร้างรายได้
คราวนี้เปลี่ยนคำถาม: “เมนูใดสร้างรายได้สูงสุด และแต่ละเมนูอยู่ในตระกูลและฐานชาใด”
ตารางที่เกี่ยวข้องมีดังนี้
order_itemsบอกว่าแต่ละออเดอร์มีสินค้าใดบ้างและมูลค่าแถวละเท่าไร (net_line_sales_thb)menusเก็บรายละเอียดของสินค้า เช่นsku_name,family,tea_base
จึงใช้ INNER JOIN เพื่อดึงรายละเอียดเมนูมาอธิบาย line item
SELECT -- เลือกคอลัมน์ที่ต้องการแสดงผล
m.sku_name, -- ชื่อสินค้า (SKU)
m.family, -- ตระกูลสินค้า
m.tea_base, -- ชาหลัก
COUNT(*) AS times_ordered, -- จำนวนครั้งที่ถูกสั่งซื้อ
ROUND(SUM(oi.net_line_sales_thb), 0) AS total_revenue, -- รายได้รวม
ROUND(AVG(oi.net_line_sales_thb), 0) AS avg_line_revenue -- รายได้เฉลี่ยต่อบรรทัด
FROM order_items oi -- จากตาราง order_items (ตั้งชื่อแฝง oi)
INNER JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตาราง menus ด้วย sku_id
GROUP BY m.sku_name, m.family, m.tea_base -- จัดกลุ่มตามชื่อ ตระกูล และฐานชา
ORDER BY total_revenue DESC -- เรียงลำดับตามรายได้รวมจากมากไปน้อย
LIMIT 10; -- แสดงแค่ 10 อันดับแรก| sku_name | family | tea_base | times_ordered | total_revenue | avg_line_revenue |
|---|---|---|---|---|---|
| Jasmine Milk Tea L | milk_tea | jasmine | 9,698 | 1,151,147 | 119 |
| Da Hong Pao Milk Tea L | milk_tea | da_hong_pao | 7,811 | 1,047,280 | 134 |
| White Peach Milk Tea L | milk_tea | white_peach | 8,555 | 1,014,856 | 119 |
| Fresh Orange Jasmine Tea L | fruit_tea | jasmine | 4,636 | 718,110 | 155 |
| Tie Guan Yin Milk Tea L | milk_tea | tie_guan_yin | 4,168 | 558,680 | 134 |
| Pu’er Milk Tea L | milk_tea | puer | 4,384 | 521,230 | 119 |
| Peach Jasmine Tea L | fruit_tea | jasmine | 5,224 | 513,396 | 98 |
| Glutinous Green Milk Tea L | milk_tea | glutinous_green | 4,351 | 448,015 | 103 |
| Tie Guan Yin Milk Tea R | milk_tea | tie_guan_yin | 3,915 | 441,183 | 113 |
| Jasmine Grapefruit Tea L | fruit_tea | jasmine | 3,872 | 420,031 | 108 |
จากตารางจะเห็นว่า Top 10 SKU ที่สร้างรายได้สูงสุดเป็นกลุ่ม milk_tea ถึง 7 รายการ และ fruit_tea 3 รายการ โดย Jasmine Milk Tea L ทำรายได้สูงสุดกว่า 1.15 ล้านบาท และเมนูขนาด L มักมีรายได้รวมสูงกว่าขนาด R อย่างชัดเจน
ในมุมของ JOIN ตัวอย่างนี้เน้นว่า
- เมื่อ
JOINorder_itemsกับmenusแล้ว เราสามารถสร้าง “มุมมองตามสินค้า” ได้ - การ
GROUP BYพร้อมกันหลายคอลัมน์ได้ เช่น sku_name, family, tea_base
คำถามทางธุรกิจต่อยอด เช่น “Base ชาไหนได้รับความนิยมข้ามสาขา” จะนำไปสู่การ JOIN เพิ่มเติมอีก 2 ตารางในตัวอย่างถัดไป
การเชื่อมมากกว่า 2 ตาราง: tea_base × สาขา
หากต้องการทราบว่า tea_base แต่ละประเภทมียอดสั่งซื้อเท่าไรในแต่ละสาขา?
ตารางที่เกี่ยวข้อง
order_items→ รหัสเมนู (sku_id) และออเดอร์ (transaction_id)menus→ ฐานชา (tea_base)transactions→ สาขาที่ออเดอร์เกิด (store_id)stores→ ชื่อสาขา (store_name)
SELECT -- เลือกคอลัมน์ที่ต้องการแสดงผล
s.store_name, -- ชื่อสาขา
m.tea_base, -- ฐานชาหลัก
COUNT(*) AS times_ordered -- จำนวนครั้งที่ถูกสั่งซื้อ
FROM order_items oi -- จากตาราง order_items (ตั้งชื่อแฝง oi)
INNER JOIN menus m ON oi.sku_id = m.sku_id -- เชื่อมตาราง menus ด้วย sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id -- เชื่อมตาราง transactions ด้วย transaction_id
INNER JOIN stores s ON t.store_id = s.store_id -- เชื่อมตาราง stores ด้วย store_id
GROUP BY s.store_name, m.tea_base; -- จัดกลุ่มตามชื่อสาขาและฐานชาคำถามนี้ต้อง JOIN ตารางจำนวน 4 ตาราง เพื่อเชื่อมโยงข้อมูลตั้งแต่ระดับสาขาไปจนถึงประเภทของชาในแต่ละรายการขาย
order_itemsมีคอลัมน์sku_idและtransaction_idmenusจับคู่ด้วยsku_id→ แต่ละ line item รู้แล้วว่าตัวเองเป็นชาอะไรtransactionsเข้ามาจับคู่ด้วยtransaction_id→ แต่ละ line item รู้แล้วว่าอยู่ในออเดอร์ไหนstoresเข้ามาจับคู่ด้วยstore_id→ ออเดอร์นั้นอยู่ที่สาขาไหนลำดับการเขียน JOIN ไม่ส่งผลต่อผลลัพธ์ในกรณี INNER JOIN เนื่องจากทุกการจับคู่จะตัดแถวที่ไม่มีคู่ทิ้งอยู่แล้ว ไม่ว่าจะเลือกเชื่อมกลุ่มใดก่อน ผลลัพธ์สุดท้ายจะเหมือนกันเสมอ
ผลลัพธ์ที่ได้มีลักษณะดังนี้
| store_name | tea_base | times_ordered |
|---|---|---|
| PiCha Exchange Tower | jasmine | 3,136 |
| PiCha Exchange Tower | glutinous_green | 1,866 |
| PiCha Exchange Tower | white_peach | 1,597 |
| PiCha Exchange Tower | da_hong_pao | 1,486 |
| PiCha Exchange Tower | tie_guan_yin | 1,444 |
| PiCha Exchange Tower | puer | 1,019 |
| PiCha Exchange Tower | osmanthus | 619 |
| PiCha Exchange Tower | mango | 312 |
| PiCha Exchange Tower | thai_tea | 29 |
| PiCha ICONSIAM | jasmine | 4,405 |
| PiCha ICONSIAM | white_peach | 2,096 |
| PiCha ICONSIAM | glutinous_green | 2,053 |
| … | … | … |
เมื่อดูครบทั้งตารางจะพบว่า tea base ประเภท jasmine มียอดสั่งซื้อสูงสุดในทุกสาขา โดย CentralWorld และ Siam Square เป็นสาขาที่สั่ง jasmine สูงสุด
จุดสำคัญของตัวอย่างนี้คือ pattern ของ multi-table INNER JOIN
- เริ่มจาก fact ที่ละเอียดสุด (
order_items) - JOIN กับ dimension ตามสายโซ่ key →
menus(ด้วยsku_id),transactions(ด้วยtransaction_id),stores(ด้วยstore_id) - ผลลัพธ์สุดท้ายรวมทั้ง “สาขา × ฐานชา × จำนวนครั้ง” ในตารางเดียว ซึ่งไม่มีทางได้จากตารางใดตารางเดียว
สำหรับ INNER JOIN การสลับลำดับของการ JOIN หลายตาราง (ตราบใดที่ ON ถูกต้อง) จะให้ผลลัพธ์เหมือนเดิม เพราะทุกขั้นจะตัดแถวที่ไม่มีคู่ทิ้ง
ครึ่งแรกของบทนี้แสดงให้เห็นแล้วว่า INNER JOIN ใช้เชื่อมข้อมูลเพื่อสร้างมุมมองข้ามตารางได้อย่างไร ต่อไปจะเห็นกรณีที่เรา “ตั้งใจเก็บแถวที่ไม่มีคู่” ด้วย LEFT JOIN
LEFT JOIN
มีหลายสถานการณ์ที่เราต้องการทั้งแถวที่มีคู่ (matching rows) และแถวที่ไม่มีคู่ (non-matching rows) จากการ JOIN เช่น
- อยากรู้เมนูใดบ้าง “ไม่เคยถูกสั่ง” ในช่วงเวลาหนึ่ง
- อยากเห็นลูกค้าระดับ gold ที่ “ยังไม่เคยทำรายการ”
- อยากนับออเดอร์ทั้งหมด รวมทั้งออเดอร์ที่ไม่มีข้อมูลลูกค้า (anonymous)
ในกรณีเหล่านี้ INNER JOIN จะตัดแถวที่ไม่มีคู่ทิ้งทั้งหมด ทำให้เรามองไม่เห็น “ช่องว่างของข้อมูล” ที่บางครั้งสำคัญกว่าข้อมูลที่มีอยู่ด้วยซ้ำ ตรงนี้เองที่ LEFT JOIN มีบทบาทสำคัญ
การเชื่อมแบบ LEFT JOIN จะเก็บทุกแถวจากตารางฝั่งซ้าย ถ้าไม่เจอคู่ในตารางขวา ให้เติมค่า NULL ในคอลัมน์ฝั่งขวา
ในฐานข้อมูล PiCha ถ้าเราเขียน
- ตารางซ้าย (
t) คือtransactions→ มีออเดอร์ทั้งหมด 53,323 แถว - ตารางขวา (
c) คือcustomers→ มีเฉพาะลูกค้าที่ลงทะเบียน - ออเดอร์ที่ไม่มี
customer_id(anonymous ~ 74.3%) จะยังอยู่ในผลลัพธ์ แต่คอลัมน์จากcustomersจะเป็นNULLทั้งแถว - ถ้าใช้
INNER JOINแทน ออเดอร์ anonymous ในtransactionจะหายไปจากผลลัพธ์ทั้งหมด
การเข้าใจความต่างนี้สำคัญมาก เพราะจะส่งผลโดยตรงต่อทั้งตัวเลขและการตีความ โดยเฉพาะเมื่อวิเคราะห์รายได้จากลูกค้า anonymous เทียบกับลูกค้าที่ลงทะเบียนแล้ว
มาดูตัวอย่างการใช้ LEFT JOIN เพื่อหาว่า “ลูกค้าแต่ละประเภท (รวม anonymous คือมี transactions.customer_id เป็น NULL) สร้างรายได้และมีความพึงพอใจเฉลี่ยเท่าใด” เราต้องการให้ทุกออเดอร์ที่อยู่ใน transactions ปรากฏในผลลัพธ์ แม้ออเดอร์นั้นจะไม่รู้ customer_id
SELECT
CASE -- กำหนดเงื่อนไข
WHEN c.customer_id IS NULL THEN 'Anonymous' -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
ELSE c.loyalty_tier -- มิฉะนั้นใช้ระดับสมาชิก
END AS customer_type, -- สิ้นสุดเงื่อนไข ตั้งชื่อว่า customer_type
COUNT(*) AS order_count, -- นับจำนวนออร์เดอร์
ROUND(SUM(t.net_sales_thb), 0) AS total_revenue, -- รวมยอดขายสุทธิ (บาท)
ROUND(AVG(t.net_sales_thb), 0) AS avg_order_value, -- ค่าเฉลี่ยต่อออร์เดอร์ (บาท)
ROUND(AVG(t.satisfaction_score), 2) AS avg_satisfaction -- ค่าเฉลี่ยคะแนนความพึงพอใจ
FROM transactions t -- จากตารางธุรกรรม
LEFT JOIN customers c ON t.customer_id = c.customer_id -- เชื่อมกับตารางลูกค้า (รวมธุรกรรมที่ไม่มีข้อมูลลูกค้า)
GROUP BY -- จัดกลุ่มตาม
CASE -- กำหนดเงื่อนไขเดียวกันกับ SELECT
WHEN c.customer_id IS NULL THEN 'Anonymous' -- ถ้าไม่มีข้อมูลลูกค้า ให้เป็น Anonymous
ELSE c.loyalty_tier -- มิฉะนั้นใช้ระดับสมาชิก
END -- สิ้นสุดเงื่อนไข
ORDER BY total_revenue DESC; -- เรียงตามยอดขายรวมจากมากไปน้อยตรรกะการทำงานเป็นขั้นตอนดังนี้
ขั้นที่ 1: FROM + LEFT JOIN
- ตาราง
transactionsบางแถวมีcustomer_id = NULL(ลูกค้าไม่ได้สแกนสมาชิก)
ตัวอย่าง mini-table
| t.transaction_id | t.customer_id | c.loyalty_tier |
|---|---|---|
| 1001 | 305 | gold |
| 1002 | NULL | NULL |
| 1003 | 812 | silver |
- LEFT JOIN → เก็บทุกแถวจาก
transactionsไว้ทั้งหมด แถวที่ไม่มีคู่ในcustomersจะมีคอลัมน์ฝั่งลูกค้าเป็น NULL - INNER JOIN → แถวที่
customer_idเป็น NULL จะไม่ถูกนำมาแสดงเลย
กรณีนี้เราอยากนับทุกออเดอร์ จึงจำเป็นต้องใช้ LEFT JOIN
ขั้นที่ 2: CASE WHEN แปลง NULL ให้มีความหมาย
- ถ้า
c.customer_idเป็น NULL → แสดงว่าออเดอร์นั้นเป็น anonymous → กำหนดเป็น'Anonymous' - ถ้าไม่ NULL → ใช้ loyalty tier (
gold,silver,member,none) เป็นประเภทลูกค้า
ผลลัพธ์คือคอลัมน์ใหม่ customer_type ที่มีค่าหนึ่งใน Anonymous, gold, silver, member, none
หากใช้ c.loyalty_tier ตรง ๆ โดยไม่แปลง anonymous จะกลายเป็นกลุ่มที่ชื่อเป็น NULL ซึ่งอ่านแล้วไม่สื่อความหมาย
ขั้นที่ 3: เลือกตัวชี้วัดจาก transactions
ทุกตัวมาจากตาราง t ซึ่งเป็นแหล่งข้อมูลยอดขายและคะแนนความพึงพอใจ
ขั้นที่ 4: GROUP BY expression เดียวกับใน SELECT
จุดสำคัญ: ต้องเขียน CASE WHEN เหมือนกับใน SELECT ซ้ำอีกครั้ง เพราะ GROUP BY ทำงานก่อน SELECT และไม่สามารถอ้าง alias customer_type ได้ใน engine ส่วนใหญ่
ผลคือ การจัดกลุ่มทั้ง 53,323 ออเดอร์ให้เหลือเพียง 5 กลุ่ม customer_type
ขั้นที่ 5: ORDER BY ตามรายได้รวม
เรียงจากกลุ่มที่สร้างรายได้มากสุดลงมาด้วย ORDER BY total_revenue DESC
ผลลัพธ์
| customer_type | order_count | total_revenue | avg_order_value | avg_satisfaction |
|---|---|---|---|---|
| Anonymous | 39,621 | 8,003,465 | 202 | 4.02 |
| member | 4,698 | 968,686 | 206 | 4.05 |
| silver | 3,857 | 776,128 | 201 | 4.09 |
| none | 2,999 | 609,214 | 203 | 4.04 |
| gold | 2,148 | 429,908 | 200 | 4.10 |
ตารางนี้ชี้ให้เห็นว่า
- Anonymous orders คิดเป็นประมาณ 74% ของจำนวนออเดอร์ทั้งหมด และสร้างรายได้กว่า 8.0 ล้านบาท มากกว่ากลุ่มสมาชิกทั้งหมดรวมกันประมาณ 2.9 เท่า
- กลุ่ม gold มีจำนวนออเดอร์น้อยที่สุดในบรรดากลุ่มสมาชิก แต่มีความพึงพอใจเฉลี่ยสูงสุด (4.10) แม้ AOV กลับต่ำสุดในกลุ่มสมาชิก
ในภาพรวม PiCha พึ่งพารายได้จากลูกค้าที่ติดต่อไม่ได้โดยตรง (anonymous) ในระดับสูงมาก ซึ่งเป็นความเสี่ยงเชิงกลยุทธ์ หากแพลตฟอร์ม delivery หรือช่องทางที่ดึงลูกค้า anonymous เปลี่ยนนโยบายหรืออัลกอริทึม อาจกระทบต่อรายได้หลักของบริษัทได้ การออกแบบให้ลูกค้ากลุ่มนี้สมัครสมาชิก เช่น ผ่าน QR code หรือโปรโมชันสมัครครั้งแรก จึงเป็นแนวทางสำคัญที่ควรพิจารณา โดยต้องวิเคราะห์ต่อว่า Anonymous มาจาก delivery หรือ walk-in เป็นหลัก เพราะแนวทางแก้ต่างกัน
LEFT JOIN vs INNER JOIN แบบย่อ
| มุมมอง | INNER JOIN | LEFT JOIN |
|---|---|---|
| แถวจากตารางซ้าย | แสดงเฉพาะแถวที่มีคู่ในตารางขวา | แสดงทุกแถวจากตารางซ้าย |
| แถวจากตารางขวา | แสดงเฉพาะแถวที่มีคู่ในตารางซ้าย | แสดงเมื่อมีคู่เท่านั้น |
| แถวที่ไม่เจอคู่ | ถูกตัดทิ้งทั้งหมด | แสดงคอลัมน์ฝั่งซ้าย + ใส่ NULL ให้ฝั่งขวา |
| ใช้เมื่อ… | ทุกแถวควรมีข้อมูลครบทุกตาราง | ต้องการเก็บ “ช่องว่างของข้อมูล” เช่น anonymous หรือสินค้าไม่เคยขาย |
| ตัวอย่างใน PiCha | transactions JOIN stores |
transactions LEFT JOIN customers |
ข้อผิดพลาดที่พบบ่อย
-- ผิด: ใช้ INNER JOIN แล้วไป WHERE c.customer_id IS NULL ❌ SELECT * FROM transactions t INNER JOIN customers c ON t.customer_id = c.customer_id WHERE c.customer_id IS NULL; -- จะไม่เคยได้แถวใดเลย -- ถูก: ใช้ LEFT JOIN แล้วค่อยกรอง NULL ✅ SELECT * FROM transactions t LEFT JOIN customers c ON t.customer_id = c.customer_id WHERE c.customer_id IS NULL; -- ได้ anonymous orders
สรุป
ในบทนี้ เราได้เรียนรู้แนวคิดพื้นฐานของการเชื่อมตารางด้วย JOIN ซึ่งเป็นทักษะสำคัญในการทำงานกับฐานข้อมูลเชิงสัมพันธ์ เพราะข้อมูลที่ใช้วิเคราะห์จริงมักไม่ได้เก็บอยู่ในตารางเดียว แต่กระจายอยู่หลายตารางตามหน้าที่ เช่น ตารางธุรกรรม ตารางสาขา ตารางลูกค้า และตารางสินค้า การใช้ JOIN อย่างถูกต้องจึงช่วยให้เรารวมข้อมูลจากหลายแหล่งเข้าด้วยกันเพื่อสร้างมุมมองที่ครบถ้วนมากขึ้นสำหรับการวิเคราะห์
ตารางต่อไปนี้สรุปความหมายของแนวคิดสำคัญที่ใช้ในบทนี้ พร้อมตัวอย่างการใช้งานในบริบทธุรกิจ
| แนวคิด | ความหมาย | ตัวอย่าง |
|---|---|---|
INNER JOIN |
แสดงเฉพาะแถวที่มีข้อมูลตรงกันในทั้งสองตาราง | transactions INNER JOIN stores |
LEFT JOIN |
เก็บทุกแถวจากตารางซ้าย และแสดง NULL เมื่อไม่พบข้อมูลที่จับคู่ได้ในตารางขวา |
transactions LEFT JOIN customers |
| Table Alias | ใช้ชื่อย่อแทนชื่อตารางเพื่อลดความซ้ำซ้อนและช่วยให้ query อ่านง่ายขึ้น | FROM transactions t |
ON clause |
ระบุเงื่อนไขที่ใช้จับคู่ข้อมูลระหว่างตาราง | ON t.store_id = s.store_id |
| Multi-table JOIN | เชื่อมข้อมูลมากกว่าสองตารางโดยเขียน JOIN ต่อเนื่องหลายชุด |
order_items → menus → transactions → stores |
| NULL handling | จัดการค่าที่ไม่พบจากการเชื่อมตาราง เช่น ใช้ CASE WHEN หรือ COALESCE เพื่อแทน NULL ด้วยค่าที่มีความหมาย |
COALESCE(c.loyalty_tier, 'Anonymous') |
จากตัวอย่างที่ผ่านมา จะเห็นได้ว่า “ลำดับการเขียนคำสั่ง” กับ “ลำดับการประมวลผลของระบบฐานข้อมูล” ไม่ได้เหมือนกันเสมอไป แม้เราจะเขียน SELECT ไว้เป็นส่วนแรกของคำสั่ง แต่ในทางปฏิบัติ ระบบจะเริ่มจากการระบุตารางต้นทางและเชื่อมตารางก่อน แล้วจึงค่อยกรอง จัดกลุ่ม และเลือกคอลัมน์ที่ต้องการแสดงผล ความเข้าใจจุดนี้มีความสำคัญมาก เพราะช่วยให้เราออกแบบ query ได้ถูกต้อง และลดความสับสนเมื่อต้องเขียนคำสั่งที่ซับซ้อนขึ้น
ลำดับการประมวลผล SQL เมื่อมี JOIN
graph TD A["1. FROM โหลดตารางหลัก"] --> B["2. JOIN เชื่อมตารางที่เกี่ยวข้อง"] B --> C["3. ON กำหนดเงื่อนไขการจับคู่"] C --> D["4. WHERE กรองแถวที่ต้องการ"] D --> E["5. GROUP BY จัดกลุ่มข้อมูล"] E --> F["6. HAVING กรองผลลัพธ์ระดับกลุ่ม"] F --> G["7. SELECT เลือกคอลัมน์ที่จะแสดง"] G --> H["8. ORDER BY เรียงลำดับผลลัพธ์"]
รูปที่ 4.2: ระบบประมวลผล JOIN หลัง FROM แต่ก่อน WHERE
จุดที่ผู้เรียนมักสับสนคือความแตกต่างระหว่างลำดับ “ที่เราเขียน” กับลำดับ “ที่ระบบทำงานจริง” โดยเราเขียนคำสั่งในรูปแบบดังนี้
SELECT ... FROM ... JOIN ... ON ... WHERE ... GROUP BY ... HAVING ... ORDER BY ...
แต่ระบบจะประมวลผลตามลำดับแนวคิดดังนี้
FROM→JOIN→ON→WHERE→GROUP BY→HAVING→SELECT→ORDER BY
เมื่อเข้าใจลำดับนี้แล้ว ผู้เรียนจะสามารถอธิบายได้ชัดเจนขึ้นว่าทำไมบางคอลัมน์จึงยังไม่สามารถอ้างอิงได้ในบางช่วงของ query และจะเลือกวางเงื่อนไขใน ON, WHERE, หรือ HAVING ได้เหมาะสมยิ่งขึ้น
เพื่อเสริมความเข้าใจเชิงปฏิบัติ ควรทดลองทำแบบฝึกหัดที่ตามมาโดยเปรียบเทียบการใช้ INNER JOIN และ LEFT JOIN กับโจทย์หลายรูปแบบ เช่น กรณีที่ต้องการเฉพาะข้อมูลที่จับคู่ได้ครบทุกตาราง หรือกรณีที่ต้องการเก็บข้อมูลจากตารางหลักไว้ทั้งหมดแม้บางแถวจะไม่มีข้อมูลประกอบจากอีกตารางหนึ่ง การฝึกแยกแยะสถานการณ์เหล่านี้จะช่วยให้สามารถเลือกใช้ JOIN ได้อย่างเหมาะสมและแม่นยำมากขึ้นในการทำงานจริง
ในบทถัดไป (CASE Expressions) เราจะใช้ความเข้าใจลำดับนี้ร่วมกับ CASE เพื่อสร้างคอลัมน์ใหม่แบบมีเงื่อนไขและจัดกลุ่มข้อมูลในคำสั่งเดียว
คำถามท้ายบท
- ใช้ INNER JOIN ระหว่าง transactions และ stores เพื่อหาสาขาที่มี avg_satisfaction สูงสุด 3 อันดับแรก พร้อมแสดง branch_type และ district
ผลลัพธ์ที่คาดหวัง
| store_name | branch_type | district | avg_satisfaction |
|---|---|---|---|
| PiCha ICONSIAM | mall_flagship | Khlong San | 4.53 |
| PiCha CentralWorld | mall | Pathum Wan | 4.19 |
| PiCha Vanit Village | mixed_use | Ratchathewi | 4.07 |
คลิกเพื่อดูเฉลย
- ใช้ LEFT JOIN ระหว่าง customers และ transactions เพื่อหาลูกค้าระดับ gold ที่ยังไม่เคยสั่งเลย (ไม่มี transaction) พร้อมแสดง age_group และ discovery_channel
ผลลัพธ์ที่คาดหวัง
| customer_id | loyalty_tier | age_group | discovery_channel |
|---|---|---|---|
| 1044 | gold | Gen_Z_18_24 | word_of_mouth |
| 1059 | gold | Gen_Z_18_24 | |
| 1083 | gold | Gen_Z_25_29 | tiktok |
| 1089 | gold | Gen_Z_18_24 | tiktok |
| 1100 | gold | Millennial_30_35 | |
| …. | …. | …. | …. |
คลิกเพื่อดูเฉลย
- ใช้ 4-table JOIN (order_items, menus, transactions, stores) เพื่อหาว่าสาขาไหนขาย Osmanthus Milk Tea ได้มากที่สุด พร้อมแสดงจำนวนครั้งและรายได้รวม
ผลลัพธ์ที่คาดหวัง
| store_name | branch_type | district | times_sold | total_revenue_thb |
|---|---|---|---|---|
| PiCha CentralWorld | mall | Pathum Wan | 1,092 | 122,797.54 |
| PiCha Siam Square | street | Pathum Wan | 917 | 105,362.47 |
| PiCha ICONSIAM | mall_flagship | Khlong San | 831 | 92,191.27 |
| PiCha Vanit Village | mixed_use | Ratchathewi | 693 | 78,096.02 |
| PiCha Park Silom | office | Bang Rak | 663 | 73,293.44 |
| PiCha Exchange Tower | office | Khlong Toei | 619 | 68,787.75 |
คลิกเพื่อดูเฉลย
SELECT
s.store_name,
s.branch_type,
s.district,
SUM(oi.qty) AS units_sold,
ROUND(SUM(oi.net_line_sales_thb), 2) AS total_revenue_thb
FROM order_items oi
INNER JOIN menus m ON oi.sku_id = m.sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id
INNER JOIN stores s ON t.store_id = s.store_id
WHERE m.sku_name LIKE 'Osmanthus Milk Tea%'
GROUP BY s.store_id, s.store_name, s.branch_type, s.district
ORDER BY total_revenue_thb DESC;- ใช้ INNER JOIN วิเคราะห์ว่าลูกค้าที่ค้นพบผ่าน TikTok สั่ง fruit_tea สัดส่วนเท่าไหร่เทียบกับสั่ง milk_tea (ใช้ COUNT แยก family)
ผลลัพธ์ที่คาดหวัง
| family | orders | pct |
|---|---|---|
| milk_tea | 4,681 | 74.9% |
| fruit_tea | 1,571 | 25.1% |
คลิกเพื่อดูเฉลย
SELECT
m.family,
COUNT(*) AS orders,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM order_items oi
INNER JOIN menus m ON oi.sku_id = m.sku_id
INNER JOIN transactions t ON oi.transaction_id = t.transaction_id
INNER JOIN customers c ON t.customer_id = c.customer_id
WHERE c.discovery_channel = 'tiktok'
AND m.family IN ('fruit_tea', 'milk_tea')
GROUP BY m.family
ORDER BY orders DESC;- ใช้
LEFT JOINtransactions กับ customers และ GROUP BY เพื่อแสดงจำนวนออเดอร์แยกตามloyalty_tier(รวม Anonymous) พร้อมavg_basket
ผลลัพธ์ที่คาดหวัง
| loyalty_tier | orders | avg_basket_thb |
|---|---|---|
| member | 4,698 | 206.19 |
| none | 2,999 | 203.14 |
| Anonymous | 39,621 | 202.00 |
| silver | 3,857 | 201.23 |
| gold | 2,148 | 200.14 |