JOIN ไม่ได้ช้าเสมอไป: เข้าใจต้นเหตุจริงของ Query ที่อืด
ปัญหาของ JOIN มักไม่ได้อยู่ที่การใช้คำสั่ง JOIN เอง แต่อยู่ที่การทำให้แถวระหว่างทางบวมเกินจำเป็นจนฐานข้อมูลต้องทำงานหนักเกินไป บทความนี้อธิบายวิธีคิดในการออกแบบ query ให้กรองเร็ว ใช้ index ถูกจุด และเลือกโครงสร้างที่เหมา

JOIN มักถูกมองว่าเป็นต้นเหตุของ query ที่ช้า แต่ในความเป็นจริงแล้ว database ถูกออกแบบมาให้จัดการงานประเภทนี้ได้ดีมาก ปัญหาจริงมักเกิดจากการเขียน JOIN ในรูปแบบที่ทำให้เกิด intermediate rows หรือจำนวนแถวระหว่างทางมากเกินจำเป็น จนระบบต้องแบกข้อมูลมหาศาลก่อนจะคัดทิ้งในตอนท้าย
พูดให้ชัดคือ JOIN ไม่ได้แพงเพราะมีคำว่า JOIN แต่มันแพงเมื่อเส้นทางการประมวลผลทำให้จำนวนแถวพุ่งจากหลักพันไปเป็นหลักแสนหรือหลักล้าน ทั้งที่ผลลัพธ์สุดท้ายอาจต้องการเพียงไม่กี่สิบแถวเท่านั้น
ทำไม JOIN บางครั้งถึงช้า
ลองนึกถึงกรณีที่มีตาราง users, orders, order_items และ products แล้วต้องการดูคำสั่งซื้อล่าสุดของผู้ใช้ที่ยัง active หากเริ่มจากการ JOIN ทุกตารางเข้าด้วยกันก่อน แล้วค่อยคัดกรองด้วย WHERE users.status = 'active' และปิดท้ายด้วย ORDER BY orders.created_at DESC LIMIT 20 ฐานข้อมูลอาจต้องจับคู่ข้อมูลจำนวนมากก่อนจะรู้ว่าจริง ๆ แล้วต้องใช้เพียง 20 รายการ
ยิ่งถ้า order หนึ่งมีหลาย item จำนวนแถวจะขยายตัวเร็วมาก เช่น
users10,000 แถวorders200,000 แถวorder_items1,500,000 แถว
สุดท้ายระบบอาจเสียเวลาไปกับข้อมูลระดับล้านแถว ทั้งที่ผลลัพธ์จริงเล็กนิดเดียว นี่คืออาการคลาสสิกของ row explosion
หลักคิดสำคัญ: Filter Early
แนวคิดแรกที่ควรจำคือ กรองข้อมูลให้เล็กที่สุดก่อน แล้วค่อย JOIN เท่าที่จำเป็น วิธีนี้ช่วยลดภาระของ database ได้มาก
workflow ที่ใช้ได้บ่อยมีดังนี้
- เริ่มจากตารางหลักที่ตอบคำถามทางธุรกิจ
- ใส่
WHEREที่ลดจำนวนแถวได้มากที่สุดตั้งแต่ต้น - ค่อย JOIN ตาราง lookup หรือรายละเอียดที่จำเป็น
- เลือกเฉพาะ column ที่ต้องใช้
- ใช้
EXPLAINเพื่อตรวจดูว่าจำนวนแถวเริ่มบวมตรงไหน
ตัวอย่างเช่น หากต้องการ order ล่าสุดของ active users ควรคิดก่อนว่าควรหากลุ่มผู้ใช้ active หรือ order ล่าสุดให้แคบลงก่อนหรือไม่ แล้วจึงค่อยดึงรายละเอียดเพิ่มเติมในภายหลัง
อย่าใช้ SELECT * โดยไม่จำเป็น
อีกจุดที่พบบ่อยมากคือการใช้ SELECT * กับ query ที่ JOIN หลายตาราง การทำแบบนี้ไม่ได้แค่ทำให้ response ใหญ่ขึ้น แต่ยังเพิ่มต้นทุนให้ database ต้องอ่าน ส่ง และจัดเรียง column มากเกินความจำเป็น
โดยเฉพาะถ้ามี column ขนาดใหญ่ เช่น TEXT, JSON หรือข้อมูล blob ต่าง ๆ ติดมาด้วย memory ที่ใช้ใน query อาจเพิ่มขึ้นทันทีโดยไม่จำเป็น
ทางเลือกที่ดีกว่าคือระบุ field ให้ชัดเจน เช่น
orders.idorders.created_atusers.emailSUM(order_items.qty)
ข้อดีคือ
- database อ่านข้อมูลน้อยลง
- backend serialize ข้อมูลน้อยลง
- network เบาขึ้น
- process ฝั่งแอป เช่น Node.js ไม่ต้องถือ object ขนาดใหญ่เกินไป
Index สำคัญกับ JOIN มากกว่าที่คิด
JOIN ที่ดีควรมี index รองรับบน key ที่ใช้จับคู่เสมอ เช่นถ้าใช้เงื่อนไข orders.user_id = users.id อย่างน้อย orders.user_id ก็ควรถูก index
หากมีการ filter หรือ sort บ่อยด้วย orders.created_at ก็อาจต้องพิจารณา composite index เช่น (user_id, created_at) เพื่อช่วยให้ query วิ่งตรงทางมากขึ้น
อย่างไรก็ตาม การเพิ่ม index ไม่ควรทำแบบสุ่มทุก column แต่ควรอิงจาก query ที่ใช้งานจริงและผลจาก EXPLAIN จริง เพราะ index ที่มากเกินไปก็มีต้นทุนทั้งด้าน storage และการเขียนข้อมูล
ระวัง many-to-many row explosion
จุดอันตรายมากของ JOIN คือความสัมพันธ์แบบ many-to-many หรือการ JOIN ตารางลูกหลายชุดพร้อมกัน เช่น posts JOIN tags JOIN comments
ถ้า post หนึ่งมี 5 tags และ 100 comments การ JOIN พร้อมกันอาจทำให้ post เดียวกลายเป็น 500 แถวทันที และถ้ามี 1,000 posts ก็อาจขยายเป็น 500,000 แถวได้ง่ายมาก
กรณีแบบนี้ วิธีแก้ไม่มีสูตรเดียว แต่แนวทางที่ใช้บ่อยคือ
- pre-aggregate ก่อน เช่นนับจำนวน comments ต่อ post ใน subquery แล้วค่อย JOIN กลับ
- แยก query เช่นดึง posts 20 รายการก่อน แล้วค่อยดึง comments ด้วย
WHERE post_id IN (...) - ออกแบบ pagination ให้เกิดก่อนการดึงรายละเอียดที่มี cardinality สูง
โดยเฉพาะใน API ที่มีการแบ่งหน้า การแยก query บางครั้งให้ผลดีกว่า query เดียวขนาดใหญ่เสียอีก
Query เดียวไม่ได้เร็วกว่าเสมอไป
มีความเชื่อที่เจอบ่อยว่า “รวมทุกอย่างไว้ใน query เดียวต้องเร็วกว่า” แต่ในทางปฏิบัติ query เดียวที่ทำให้ intermediate rows ใหญ่มาก อาจช้ากว่า 2 query ที่แต่ละอันเล็ก กระชับ และใช้ index ได้ดี
ดังนั้นอย่ากลัวการแยก query อัตโนมัติ โดยเฉพาะเมื่อมันช่วยควบคุมจำนวนแถวและลดการขยายตัวของข้อมูลได้ สิ่งสำคัญที่สุดคือ วัดจากของจริง ไม่ใช่เดา
อ่าน EXPLAIN ให้เป็น จะเห็นปัญหาเร็วขึ้น
EXPLAIN เป็นเครื่องมือสำคัญมากในการดูว่า query ทำงานอย่างไร และควรสังเกตทั้งจำนวนแถวโดยประมาณและชนิดของ join algorithm
Nested Loop
เหมาะเมื่อฝั่งหนึ่งมีข้อมูลน้อย และอีกฝั่งมี index ให้ค้นหาได้เร็ว
Hash Join
เหมาะกับการจับคู่ข้อมูลจำนวนมาก โดยสร้าง hash table จากฝั่งหนึ่งก่อน
Merge Join
เหมาะเมื่อข้อมูลเรียงตาม key อยู่แล้ว หรือมี index ที่ช่วยให้ join ตามลำดับได้
สิ่งที่ควรระวังคือ
- ถ้าเห็น
Nested Loopแต่ row estimate สูงมาก และ inner table ถูกวนซ้ำจำนวนมาก อาจเป็นไปได้ว่า index หายหรือ filter มาช้าเกินไป - ถ้าเห็น
Hash Joinใช้ memory สูง ควรสงสัยว่ากำลัง hash ตารางใหญ่เกินจำเป็นหรือไม่ - ถ้า estimate กับ actual ต่างกันมาก อาจต้อง update statistics หรือปรับรูปทรงของ query ใหม่
มองทั้งระบบ ไม่ใช่แค่ query อย่างเดียว
สำหรับ backend developer การดูแค่เวลารวมของ query อาจยังไม่พอ เพราะหลังจาก query จบแล้ว API endpoint อาจยังมีงานต่ออีกมาก
บางครั้ง database ส่งคืนมา 50,000 rows แล้วฝั่ง Node.js ค่อยเอาไป group หรือ aggregate เองใน memory ทั้งที่ database สามารถทำ GROUP BY หรือ pre-aggregate ให้ได้มีประสิทธิภาพกว่า
แต่บางกรณีก็กลับกัน หาก JOIN ซับซ้อนเกินไปจนควบคุมจำนวนแถวยาก การแยก query แล้วค่อยประกอบผลลัพธ์ในแอปอาจอ่านง่ายและควบคุมต้นทุนได้ดีกว่า
Pitfall ที่พบบ่อยใน ORM
อีกปัญหาที่เจอบ่อยคือการ include relation ลึกเกินไปใน ORM เช่น
- users include orders
- orders include items
- items include product
- product include reviews
แม้โค้ดจะดูสะดวกและอ่านง่าย แต่ SQL ที่ถูก generate อาจใหญ่ ซ้ำ และทำให้จำนวนแถวเพิ่มขึ้นมหาศาลโดยไม่รู้ตัว
แนวทางที่ดีคือก่อน merge code ควร
- เปิด log SQL
- เอา query ที่ได้ไปรัน
EXPLAIN - ดูว่าจำนวน rows เพิ่มขึ้นตรงจุดไหน
- ประเมินว่าควร pre-aggregate หรือแยก query หรือไม่
Checklist ก่อนสรุปว่า JOIN ช้า
ก่อนจะบอกว่า JOIN เป็นตัวปัญหา ลองตรวจสอบสิ่งต่อไปนี้ก่อน
- มี filter ที่ลดจำนวนแถวตั้งแต่ต้นแล้วหรือยัง
- JOIN key มี index ที่เหมาะสมหรือยัง
- เลือกเฉพาะ column ที่จำเป็นหรือยัง
- มี many-to-many ที่ทำให้แถวระเบิดหรือไม่
- pagination เกิดก่อนหรือหลังการ JOIN ตาราง detail
- อ่าน
EXPLAINแล้วเข้าใจ join node หลักหรือยัง
สรุป
JOIN ที่ดีไม่ใช่ JOIN ที่เขียนสั้นที่สุด แต่คือ JOIN ที่ทำให้ database ทำงานน้อยที่สุด
หัวใจสำคัญคือการลดจำนวนแถวให้เร็วที่สุด ใช้ key ที่มี index ให้เหมาะ เลือกเฉพาะ column ที่จำเป็น และตัดสินใจให้ถูกว่าอะไรควร JOIN อะไรควร aggregate ก่อน และอะไรควรแยกเป็นหลาย query
หากกำลังเริ่ม optimize query ให้เริ่มจากคำถามง่าย ๆ ข้อนี้เสมอ:
ก่อนถึงผลลัพธ์สุดท้าย database ต้องสร้างแถวระหว่างทางเยอะแค่ไหน?
คำถามนี้มักพาเราไปเจอต้นเหตุจริงของ JOIN ที่ช้าได้ตรงจุดที่สุด