Window Functions คืออะไรและทำไมเราต้องใช้มัน
เวลาเราเขียน Query (คำสั่งถามข้อมูลจากฐานข้อมูล) ปกติเรามักจะดึงข้อมูลมาเป็นแถวๆ หรือรวมกลุ่มข้อมูลด้วย GROUP BY (การจัดกลุ่มข้อมูล) แต่บ่อยครั้งเราจะเจอปัญหาว่า อยากเห็นข้อมูลทุกแถวเหมือนเดิม แต่ดันอยากรู้ค่าสรุปอย่าง ผลรวมหรืออันดับด้วย ซึ่งปกติทำไม่ได้เพราะ GROUP BY จะยุบข้อมูลให้เหลือแค่แถวสรุปเท่านั้น
Window Functions (ฟังก์ชันคำนวณข้ามแถวโดยไม่ยุบข้อมูล) จึงเข้ามาช่วยแก้ปัญหานี้ เปรียบเทียบง่ายๆ เหมือนการที่คุณยืนอ่านหนังสือในห้องสมุด ปกติคุณอ่านได้ทีละหน้า แต่ถ้าคุณใช้ Window คุณจะสามารถมองเห็นข้อมูลแถวที่อยู่ก่อนหน้าหรือถัดไปได้โดยไม่ต้องเปลี่ยนหน้ากระดาษ ข้อมูลเดิมยังอยู่ครบถ้วน แต่คุณได้ข้อมูลวิเคราะห์เพิ่มเข้ามาด้วย
การเข้าใจเรื่องนี้จะทำให้คุณดูเป็นโปรแกรมเมอร์ที่เขียน SQL ได้ฉลาดขึ้นมาก เพราะไม่ต้องเขียน JOIN (การเชื่อมตารางเข้าด้วยกัน) ซับซ้อนหลายชั้น หรือไม่ต้องเขียนโปรแกรมแยกเพื่อมาคำนวณค่าในภายหลัง มันช่วยให้งานจัดการข้อมูลใน PostgreSQL (ระบบจัดการฐานข้อมูลยอดนิยม) ของคุณจบได้ในคำสั่งเดียว
โครงสร้างพื้นฐานของ OVER และ PARTITION BY
หัวใจสำคัญของฟังก์ชันกลุ่มนี้คือคำสั่ง OVER (คำสั่งกำหนดขอบเขตการคำนวณ) ซึ่งทำหน้าที่บอกฐานข้อมูลว่า จะให้ฟังก์ชันนั้นทำงานกับข้อมูลในขอบเขตไหน ถ้าเราไม่ใส่เงื่อนไขอะไรเลย มันจะมองข้อมูลทั้งหมดในตารางเป็นหนึ่งกลุ่มก้อนเดียว แต่ถ้าเราอยากแบ่งกลุ่มย่อย เราต้องใช้คำสั่ง PARTITION BY (การแบ่งกลุ่มข้อมูลเพื่อแยกการคำนวณ)
ลองนึกภาพการแบ่งพนักงานตามแผนก ถ้าเราอยากหาเงินเดือนรวมของแต่ละแผนกแต่ยังอยากเห็นชื่อพนักงานแต่ละคนอยู่ เราจะใช้ PARTITION BY department เพื่อบอกว่าให้เริ่มนับใหม่ทุกครั้งที่ขึ้นแผนกใหม่ ผลลัพธ์ที่ได้จะคงจำนวนแถวไว้เท่าเดิม แต่จะมีคอลัมน์ใหม่ที่บอกค่าสรุปของกลุ่มนั้นๆ แนบมาด้วย
จุดที่มือใหม่มักพลาดคือการลืมว่า PARTITION BY ไม่ได้ทำให้ข้อมูลยุบตัวลงเหมือน GROUP BY แต่มันแค่สร้าง "หน้าต่าง" หรือขอบเขตให้ฟังก์ชันทำงาน ถ้าคุณไม่ใส่เงื่อนไขเลย OVER () จะหมายถึงทั้งตาราง ซึ่งอาจจะทำให้ค่าที่ได้ในทุกแถวออกมาเหมือนกันหมดจนไม่เกิดประโยชน์
-- ตัวอย่างการใช้ OVER เพื่อหาค่าเฉลี่ยเงินเดือนในแต่ละแผนก
SELECT
name,
department,
salary,
AVG(salary) OVER(PARTITION BY department) as avg_dept_salary
FROM employees;
คำอธิบายโค้ด: บรรทัดแรกเลือกชื่อแผนกและเงินเดือน บรรทัดที่สี่ใช้ AVG (ฟังก์ชันหาค่าเฉลี่ย) ตามด้วย OVER เพื่อบอกว่าให้หาค่าเฉลี่ยนี้โดยแบ่งกลุ่มตามแผนก ผลลัพธ์คือเราจะได้คอลัมน์ใหม่ที่แสดงเงินเดือนเฉลี่ยของแผนกนั้นๆ ในทุกแถวของพนักงาน
ผลลัพธ์ที่ควรเห็น: ข้อมูลพนักงานทุกคนจะปรากฏขึ้นมา พร้อมคอลัมน์ avg_dept_salary ที่แสดงเลขค่าเฉลี่ยเงินเดือนของแผนกที่พนักงานคนนั้นสังกัดอยู่
การจัดลำดับด้วย ROW_NUMBER, RANK และ DENSE_RANK
เมื่อเราต้องการจัดอันดับข้อมูล เช่น ใครมียอดขายสูงสุด เรามักจะสับสนระหว่างฟังก์ชันสามตัวนี้ ROW_NUMBER() (ฟังก์ชันให้เลขลำดับแถว) จะให้เลขไม่ซ้ำกันเลยแม้ค่าจะเท่ากัน ส่วน RANK() (ฟังก์ชันจัดอันดับ) จะให้เลขซ้ำกันถ้าค่าเท่ากัน แต่จะข้ามเลขลำดับถัดไป และ DENSE_RANK() (ฟังก์ชันจัดอันดับแบบต่อเนื่อง) จะให้เลขซ้ำแต่ไม่ข้ามลำดับ
ลองนึกถึงการแข่งวิ่ง ถ้ามีคนเข้าที่ 1 สองคนพร้อมกัน RANK() จะบอกว่าทุกคนที่เหลือเป็นที่ 3 (ข้ามที่ 2 ไป) แต่ DENSE_RANK() จะบอกว่าทุกคนที่เหลือเป็นที่ 2 การเลือกใช้ขึ้นอยู่กับว่าคุณต้องการให้ข้อมูลแสดงออกมาแบบไหนในหน้าจอ Dashboard (หน้าแสดงข้อมูลสรุป) ของคุณ
มือใหม่ควรจำว่าห้ามใช้ฟังก์ชันเหล่านี้เดี่ยวๆ ต้องตามด้วย OVER (ORDER BY ...) เสมอ เพื่อบอกฐานข้อมูลว่าให้เรียงลำดับจากค่าไหนไปค่าไหนก่อนที่จะเริ่มให้เลขลำดับ หากขาด ORDER BY (คำสั่งเรียงลำดับ) ฐานข้อมูลจะไม่รู้ว่าจะเอาอะไรมาวัดอันดับ
-- เปรียบเทียบการจัดอันดับพนักงานตามเงินเดือน
SELECT
name,
salary,
ROW_NUMBER() OVER(ORDER BY salary DESC) as row_num,
DENSE_RANK() OVER(ORDER BY salary DESC) as rank_val
FROM employees;
คำอธิบายโค้ด: ROW_NUMBER() จะให้เลข 1, 2, 3 เรียงไปเรื่อยๆ ตามเงินเดือนมากไปน้อย ส่วน DENSE_RANK() จะให้เลขเดียวกันหากเงินเดือนเท่ากันโดยไม่กระโดดข้ามลำดับ
ผลลัพธ์ที่ควรเห็น: รายชื่อพนักงานเรียงตามเงินเดือน โดยมีคอลัมน์ row_num เป็นเลขลำดับแถวปกติ และ rank_val เป็นอันดับที่สะท้อนถึงการได้เงินเดือนเท่ากัน
การมองข้อมูลก่อนหน้าและถัดไปด้วย LEAD และ LAG
บางครั้งเราไม่ได้อยากรู้ค่าสรุป แต่แค่อยากรู้ว่า "ก่อนหน้านี้เกิดอะไรขึ้น" เช่น ถ้าคุณทำระบบบันทึกราคาหุ้น คุณอาจอยากเปรียบเทียบราคาวันนี้กับเมื่อวาน LAG() (ฟังก์ชันดึงค่าแถวก่อนหน้า) จะช่วยดึงข้อมูลจากแถวบนขึ้นมาแสดงในแถวปัจจุบันได้ทันที
ในทางกลับกัน LEAD() (ฟังก์ชันดึงค่าแถวถัดไป) จะช่วยให้เรามองไปข้างหน้าได้ว่าแถวถัดไปมีค่าเท่าไหร่ ฟังก์ชันเหล่านี้มีประโยชน์มากในการทำ Time Series Analysis (การวิเคราะห์ข้อมูลที่เปลี่ยนแปลงตามเวลา) โดยไม่ต้องเขียนโค้ดซับซ้อนเพื่อเชื่อมตารางกับตัวเอง
ข้อควรระวังคือถ้าไม่มีแถวก่อนหน้าหรือถัดไป (เช่น แถวแรกสุดไม่มี LAG) ฐานข้อมูลจะคืนค่าเป็น NULL (ค่าว่างเปล่า) ออกมาเสมอ คุณควรเตรียมตัวจัดการค่าว่างเหล่านี้ด้วยฟังก์ชันอย่าง COALESCE (ฟังก์ชันแทนที่ค่าว่าง) เพื่อไม่ให้โปรแกรมของคุณเกิดข้อผิดพลาดตอนนำไปใช้งานจริง
-- เปรียบเทียบเงินเดือนพนักงานกับคนที่มีเงินเดือนสูงกว่าลำดับถัดไป
SELECT
name,
salary,
LAG(salary) OVER(ORDER BY salary) as prev_salary
FROM employees;
คำอธิบายโค้ด: LAG(salary) จะไปหยิบค่าเงินเดือนของแถวก่อนหน้า (ตามลำดับ ORDER BY salary) มาวางไว้ในคอลัมน์ prev_salary ทำให้เราเปรียบเทียบได้ง่ายๆ ว่าใครได้น้อยกว่าเราเท่าไหร่
ผลลัพธ์ที่ควรเห็น: ตารางแสดงชื่อและเงินเดือน พร้อมคอลัมน์ prev_salary ที่แสดงค่าเงินเดือนของพนักงานที่มีลำดับต่ำกว่าหนึ่งขั้น
ข้อควรระวังและเทคนิคสำหรับมือใหม่
สิ่งที่มือใหม่พลาดบ่อยที่สุดคือการพยายามใช้ Window Functions ใน WHERE (คำสั่งกรองข้อมูล) ซึ่งฐานข้อมูลจะไม่ยอมให้ทำ เพราะ Window Functions จะทำงานหลังจากที่ WHERE คัดกรองข้อมูลเสร็จแล้ว ถ้าต้องการกรองข้อมูลจากผลลัพธ์ของฟังก์ชันนี้ คุณต้องใช้ Subquery (การเขียนคำสั่งซ้อนในคำสั่งหลัก) หรือ CTE (การตั้งชื่อตารางชั่วคราว) มาครอบอีกที
อีกเรื่องคือประสิทธิภาพการทำงาน การใช้ฟังก์ชันเหล่านี้กับข้อมูลหลักล้านแถวอาจทำให้ Query ทำงานช้าลงได้ ถ้าคุณไม่ใส่ INDEX (ดัชนีช่วยค้นหาข้อมูล) ให้กับคอลัมน์ที่คุณนำมาใช้ใน PARTITION BY หรือ ORDER BY ควรหมั่นตรวจสอบความเร็วด้วยคำสั่ง EXPLAIN ANALYZE (คำสั่งวิเคราะห์ความเร็วการทำงาน) เสมอ
สุดท้ายอย่าพยายามใช้ฟังก์ชันเหล่านี้ทุกครั้งที่ทำได้ บางครั้งการเขียน JOIN ปกติอาจจะอ่านง่ายและดูแลรักษาง่ายกว่าสำหรับทีมงานคนอื่น เลือกใช้ Window Functions เมื่อมันช่วยลดความซับซ้อนของโค้ดจริงๆ ไม่ใช่แค่เพราะมันดูเท่หรือดูเป็นโปรแกรมเมอร์ระดับสูง
สรุป: การนำไปใช้จริงในโปรเจกต์ของคุณ
การเลือกเครื่องมือให้เหมาะกับงาน คือทักษะสำคัญของโปรแกรมเมอร์ สมมติว่าคุณกำลังทำโปรเจกต์เว็บแอปจัดการร้านค้า แล้วต้องการแสดงรายการ "สินค้าที่ขายดีที่สุด 3 อันดับแรกของแต่ละหมวดหมู่" คุณสามารถใช้ DENSE_RANK() ร่วมกับ PARTITION BY category ได้เลย แทนที่จะต้องเขียนโค้ดวนลูปในภาษาโปรแกรมของคุณ
ขั้นตอนการนำไปใช้จริงเริ่มจาก 1. วิเคราะห์ว่าข้อมูลต้องการแบ่งกลุ่มไหม (ถ้าใช่ ใช้ PARTITION BY) 2. ต้องการเรียงลำดับก่อนคำนวณไหม (ถ้าใช่ ใช้ ORDER BY) 3. เลือกฟังก์ชันที่ต้องการ (ROW_NUMBER สำหรับนับ, LEAD/LAG สำหรับเปรียบเทียบ) 4. นำผลลัพธ์ไปครอบด้วย CTE เพื่อกรองเอาเฉพาะอันดับที่ต้องการ
การฝึกฝนเรื่องนี้จะเปลี่ยนวิธีที่คุณมองข้อมูลไปตลอดกาล เริ่มจากลองเขียนโค้ด SQL ง่ายๆ ดึงข้อมูลออกมาแล้วใส่ฟังก์ชันเหล่านี้ดูผลลัพธ์ในเครื่องตัวเองก่อน เมื่อคุณเริ่มคุ้นเคย คุณจะพบว่าปัญหาเรื่องการคำนวณซับซ้อนที่เคยทำให้ปวดหัว จะกลายเป็นเรื่องสนุกที่แก้ได้ด้วยโค้ดไม่กี่บรรทัดครับ