Data Analyst › SQL

Window Functions

ประมาณ 35 นาที

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

  • ใช้ ROW_NUMBER / RANK / DENSE_RANK จัดลำดับภายในกลุ่มด้วย PARTITION BY
  • ใช้ LAG เทียบแถวปัจจุบันกับแถวก่อนหน้าของกลุ่มเดียวกัน
  • ทำผลรวมสะสม (running total) ด้วย SUM() OVER และรู้ว่าต้องห่อ query ด้วย subquery ก่อนถึงจะกรองผลลัพธ์ของ window function ได้

window function ต่างจาก aggregate function ยังไง

GROUP BY ยุบหลายแถวให้เหลือแถวเดียวต่อกลุ่ม แต่บางครั้งเราอยากได้ “อันดับ” หรือ “ผลรวมสะสม” โดยยังเห็นทุกแถวเหมือนเดิม นี่คือหน้าที่ของ window function — คำนวณค่าเพิ่มเข้าไปในทุกแถว โดยอ้างอิงแถวอื่นๆ ในกลุ่มเดียวกัน (ไม่ยุบแถวทิ้ง)

รูปแบบทั่วไป:

ฟังก์ชัน(...) OVER (PARTITION BY คอลัมน์ที่แบ่งกลุ่ม ORDER BY คอลัมน์ที่เรียง)

ROW_NUMBER, RANK, DENSE_RANK

ทั้งสามให้ “เลขลำดับ” แต่ต่างกันตอนมีค่าเท่ากัน (tie):

ฟังก์ชัน ถ้าราคาเท่ากัน 2 แถวที่อันดับ 1
ROW_NUMBER() 1, 2, 3 — ให้เลขไม่ซ้ำกันเสมอ
RANK() 1, 1, 3 — เว้นเลขข้าม
DENSE_RANK() 1, 1, 2 — ไม่เว้นเลขข้าม
SELECT product_name, price,
       ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
FROM products

PARTITION BY ทำงานเหมือน GROUP BY ตรงที่แบ่งข้อมูลเป็นกลุ่ม แต่ไม่ยุบแถว — ทุกแถวยังอยู่ครบ

กรองผลลัพธ์ของ window function ต้องห่อด้วย subquery

Window function ทำงานหลัง WHERE ในลำดับการประมวลผล จึงเอาผลลัพธ์ของมัน (เช่น rn) ไปใช้ใน WHERE ของ SELECT เดียวกันตรงๆ ไม่ได้ ต้องห่อ query ด้วย subquery อีกชั้นก่อน:

SELECT * FROM (
  SELECT product_name, price,
         ROW_NUMBER() OVER (PARTITION BY category ORDER BY price DESC) AS rn
  FROM products
) t
WHERE rn <= 3

รูปแบบนี้คือวิธีมาตรฐานในการหา “top-N ต่อกลุ่ม” — ใช้บ่อยมากทั้งในงานจริงและห้องสัมภาษณ์

LAG — เทียบกับแถวก่อนหน้า

LAG(col) OVER (PARTITION BY ... ORDER BY ...) ดึงค่าจากแถวก่อนหน้า (ของกลุ่มเดียวกัน) มาไว้ในแถวปัจจุบัน มีประโยชน์มากตอนอยากรู้ “เปลี่ยนแปลงจากครั้งก่อนแค่ไหน”:

SELECT customer_id, order_date,
       LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_date
FROM orders

ต้องมี ORDER BY ข้างใน OVER() เสมอ ไม่งั้น “แถวก่อนหน้า” จะหมายถึงแถวก่อนหน้าตามลำดับที่เก็บในตาราง ซึ่งไม่ตรงกับความหมายที่ต้องการ (และจะไม่มี error เตือนด้วย)

ผลรวมสะสม (running total)

SUM(col) OVER (ORDER BY ...) โดยไม่มี PARTITION BY จะรวมค่าสะสมไปเรื่อยๆ ตามลำดับที่กำหนด:

SELECT order_date, COUNT(*) AS n,
       SUM(COUNT(*)) OVER (ORDER BY order_date) AS running_total
FROM orders
GROUP BY order_date

สังเกตว่าใช้ window function ร่วมกับ GROUP BY ได้ — คำนวณ aggregate ปกติก่อน (COUNT(*)) แล้วค่อยเอาผลนั้นไปทำ running total อีกที

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

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

หาออเดอร์แรกสุดของลูกค้าแต่ละคน (top-1 ต่อกลุ่ม)

ทีมการตลาดอยากรู้ว่าลูกค้าแต่ละคนซื้อครั้งแรกเมื่อไหร่ ด้วยออเดอร์ไหน

  1. ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) ให้เลขลำดับ 1, 2, 3, ... แยกทีละกลุ่ม (แบ่งกลุ่มด้วย PARTITION BY เหมือน GROUP BY แต่ไม่ยุบแถว ยังเห็นทุกแถวเหมือนเดิม) ใส่ order_id เป็นตัวตัดสิน (tie-break) ต่อจากวันที่ เผื่อลูกค้าสั่งซื้อ 2 ครั้งในวันเดียวกัน

    SQL

หาจำนวนวันระหว่างออเดอร์ปัจจุบันกับออเดอร์ก่อนหน้าของลูกค้าคนเดียวกัน

อยากรู้ว่าลูกค้าแต่ละคนสั่งซื้อถี่แค่ไหน โดยดูระยะห่างระหว่างออเดอร์แต่ละครั้งของตัวเอง

  1. LAG(col) OVER (PARTITION BY ... ORDER BY ...) ดึงค่าจาก "แถวก่อนหน้า" ของกลุ่มเดียวกันมาไว้ในแถวปัจจุบัน (แถวแรกของแต่ละกลุ่มจะได้ NULL เพราะไม่มีแถวก่อนหน้า)

    SQL

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

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

ยังไม่ได้ทำ

หาผลรวมสะสม (running total) ของจำนวนสินค้าที่มีในแต่ละหมวด เรียงตามชื่อหมวด A-Z เติม ___ ให้ครบ

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

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

🟢 พื้นฐาน

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

จัดอันดับสินค้าหมวด Beauty ตามราคาจากแพงไปถูก ด้วย RANK (คอลัมน์ product_name, price, rnk)

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

หมายเลขลำดับการสมัคร (ROW_NUMBER) ของลูกค้าเมือง Phuket เรียงตามวันสมัคร (ถ้าวันสมัครซ้ำ ให้ customer_id น้อยกว่ามาก่อน) เอาแค่ 5 คนแรก

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

DENSE_RANK ของหมวดสินค้าตามราคาเฉลี่ย มากไปน้อย (คอลัมน์ category, avgp ปัดเป็นจำนวนเต็ม, rnk)

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

นับจำนวนแถวที่ได้เมื่อหา "ออเดอร์ล่าสุด" ของลูกค้าแต่ละคน (ROW_NUMBER แบบ DESC, ใช้ order_id DESC เป็น tie-break)

🟡 ประยุกต์

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

จำนวนวันระหว่างออเดอร์กับออเดอร์ก่อนหน้าของลูกค้า customer_id=5 เรียงตามวันที่

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

ลูกค้าที่ใช้จ่ายมากที่สุด (completed) ของแต่ละเมือง (top-1 ต่อกลุ่มด้วย RANK) แสดง city, customer_id, revenue เรียงตามชื่อเมือง

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

ผลรวมสะสม (running total) ของจำนวนออเดอร์ต่อสถานะ เรียงตามชื่อสถานะ A-Z

🔴 ท้าทาย

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

ลูกค้า customer_id=2083 (คนที่ใช้จ่ายมากที่สุดในระบบ) ใช้ออเดอร์ลำดับที่เท่าไร (เรียงตามวันที่) ยอดสะสมถึงจะทะลุ 50000 บาทเป็นครั้งแรก (นับเฉพาะ completed) — แสดง rn, order_date, cum

Quiz

1. ROW_NUMBER, RANK, DENSE_RANK ต่างกันยังไงเวลามีค่าเท่ากัน (tie)
2. PARTITION BY ต่างจาก GROUP BY ยังไง
3. ทำไมเอาผลลัพธ์ของ window function ไปใช้ใน WHERE ของ SELECT เดียวกันตรงๆ ไม่ได้

สรุป

  • ROW_NUMBER/RANK/DENSE_RANK จัดลำดับได้ทั้งตาราง หรือแยกกลุ่มด้วย PARTITION BY โดยไม่ยุบแถว
  • LAG ดึงค่าจากแถวก่อนหน้าของกลุ่มเดียวกันมาเทียบกับแถวปัจจุบัน ต้องมี ORDER BY ข้างใน OVER() เสมอ
  • ผลลัพธ์ของ window function ใช้ใน WHERE ของ SELECT เดียวกันไม่ได้ ต้องห่อด้วย subquery ก่อน

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

จะหา "สินค้าขายดี 3 อันดับต่อหมวด" ด้วย SQL ทำยังไง

ใช้ window function (มักเป็น RANK หรือ ROW_NUMBER) พร้อม PARTITION BY ตามหมวดสินค้า เรียงตามยอดขายจากมากไปน้อยในแต่ละกลุ่ม แล้วห่อ query นั้นด้วย subquery อีกชั้น เพื่อกรอง WHERE rnk <= 3 (ใช้ RANK ไม่ใช้ ROW_NUMBER ถ้าอยากให้สินค้าที่ยอดขายเท่ากันติดอันดับพร้อมกัน)

รูปแบบนี้เป็น pattern มาตรฐานสำหรับโจทย์ "top-N ต่อกลุ่ม" ทุกแบบ ไม่ใช่แค่สินค้าขายดี