เปลี่ยนผ่าน Mindset จากการเขียนโค้ดวนลูปทีละแถว (Procedural RBAR - Row-By-Agonizing-Row) ที่บล็อกเซิร์ฟเวอร์ให้วิ่งแค่ Single-Core นาน 14 ชั่วโมง มาเป็นการคิดเชิงเซต (Set-Based Logic) เปิดทางให้ PostgreSQL ทำ Vectorized Processing ประมวลผลข้อมูลใน Memory เป็นบล็อก และปลดล็อก Multi-Core CPU ด้วย Parallel Query Optimization ลดเวลาเหลือเพียง 8 วินาที!
สี่เสาหลักสร้างได้แล้ว แต่ถ้ายังเขียนลูปวนทีละแถว ฐานข้อมูลจะใช้ CPU ได้แค่คอร์เดียว วันนี้คือการเลิกคิดแบบแถวต่อแถว แล้วเขียน SQL ให้ทำงานเป็นชุด
กลไกของเอนจินที่ดึงและประมวลผลข้อมูลเป็นชุดบล็อกหรืออาร์เรย์ใน memory registers แทนที่จะค่อยๆ ดึงทีละแถว
การเขียน SQL ให้จัดการกลุ่มข้อมูลทั้งหมดในครั้งเดียว ตรงข้ามกับ RBAR (Row-By-Agonizing-Row) ที่เอาลูปมาวนอ่านทีละแถว
ความสามารถของ Query Planner ในการแบ่งสแกนข้อมูลขนาดใหญ่ ให้ CPU หลายคอร์ช่วยกันประมวลผลพร้อมกัน
โดยรวมวันนี้คือการเลิกเขียนลูปวนทีละแถว เปลี่ยนมาใช้คำสั่ง SQL เชิงเซตคำสั่งเดียว เพื่อเปิดทางให้ฐานข้อมูลทำ Vectorized Processing และใช้ทุก CPU core สแกนข้อมูลขนานกัน
รวบยอดเทคนิคการเขียน SQL ใน Phase 2 เปลี่ยนโค้ดที่รันนานสิบสี่ชั่วโมง ให้เหลือเพียงไม่กี่วินาที โดยไม่ต้องเพิ่มฮาร์ดแวร์
เมื่อเราก้าวผ่านการสร้าง Stored Units ทั้ง 4 เสาหลัก (UDFs, Procedures, Triggers, Materialized Views) ปัญหาคลาสสิกต่อมาของนักพัฒนาที่ย้ายสายมาจาก Imperative Programming (เช่น Java, C#, Python) คือการเขียนโค้ดสไตล์ Procedural RBAR (Row-By-Agonizing-Row) หรือการเอา LOOP / CURSOR มาวนอ่านและอัปเดตข้อมูลทีละแถว ซึ่งทำลายสถาปัตยกรรมฐานข้อมูลเชิงสัมพันธ์อย่างร้ายแรง!
ระบบคำนวณเบี้ยปรับดอกเบี้ยผิดนัดชำระและปรับปรุงระดับชั้นลูกหนี้ (Credit Scoring Engine) สำหรับสถาบันการเงินแห่งหนึ่ง ต้องประมวลผลบัญชีสินเชื่อจำนวน 20,000,000 บัญชี นักพัฒนาเขียน PL/pgSQL โดยใช้ CURSOR เปิดวนลูป LOOP ทีละ 1 แถวเพื่อเช็คเงื่อนไข แล้วสั่ง UPDATE accounts SET penalty = ... WHERE account_id = v_rec.account_id; วนซ้ำ 20 ล้านรอบ:
ต้นตอของวิกฤตนี้คือ RBAR Anti-Pattern บังคับให้ฐานข้อมูลประมวลผลแบบ Synchronous Single-threaded ทีละแถวตามลำดับ ทำให้ Query Planner ไม่สามารถแบ่งงานแบบ Parallel Execution ได้เลย! ทางออกเดียวคือการเปลี่ยน Mindset มาใช้ Set-Based Vectorized Processing ครับ!
| มิติเปรียบเทียบสถาปัตยกรรม | Procedural RBAR (FOR LOOP / CURSOR) | Set-Based Vectorized (UPDATE / MERGE / CTE) |
|---|---|---|
| กระบวนทัศน์การประมวลผล (Paradigm) | Imperative Procedural (สั่งทีละขั้นตอน ทีละแถว) | Declarative Relational (ระบุผลลัพธ์เชิงเซตที่ต้องการ) |
| การใช้ประโยชน์จาก CPU Cores | ❌ ติดอยู่ Core เดียว (Single-threaded 12.5%) | ✅ กระจายทุก Cores ผ่าน Parallel Gather Workers |
| Context Switching Overhead | สูงมาก (สลับระหว่าง PL Engine กับ SQL Engine $N$ รอบ) | ต่ำมาก (ประมวลผลจบภายใน Relational Engine รอบเดียว) |
| การอ่าน Data Pages บนดิสก์/RAM | สแกนซ้ำซ้อนและขอ Lock ทีละแถว ($N$ Random Accesses) | สแกนอ่านแบบบล็อก Vectorized Block ใน 1 Single Pass |
| ภาระ Transaction Log (WAL) | WAL Buffers บวมพองจากการบันทึก Header ยิบย่อย | บันทึก WAL แบบกระชับและต่อเนื่อง มีประสิทธิภาพสูงสุด |
| เวลาประมวลผล (Execution Duration) | ระดับหลายชั่วโมง (14 ชั่วโมงในเคส 20 ล้านแถว) | ระดับไม่กี่วินาที (8 วินาที เร็วขึ้น 6,000x) |
"ทุกท่านครับ สคริปต์ Batch ที่รันนาน 14 ชั่วโมง ไม่ได้เกิดจากฐานข้อมูลประมวลผลไม่ไหว แต่เกิดจากการที่เราไปสั่งให้มัน 'วนลูปอ่านทีละแถว' หรือ RBAR ซึ่งไปบล็อกไม่ให้ฐานข้อมูลดึงพลัง Multi-core CPU มาช่วยทำงาน!
วันนี้ด้วย Set-Based Vectorized Processing เราจะลบ LOOP ทิ้งทั้งหมดแล้วรวบเป็น Single Relational Query เพื่อเปิดทางให้ PostgreSQL สั่งใช้ทุก CPU Cores สแกนประมวลผลแบบ Parallel พร้อมกัน ผลลัพธ์คือการตัดเวลาประมวลผลจาก 14 ชั่วโมง เหลือเพียง 8 วินาที ประหยัดเวลาไป 99.9% โดยไม่ต้องซื้อ CPU เพิ่มแม้แต่ตัวเดียวครับ"
ลองปรับจำนวนแถวข้อมูลในตาราง และเลือกจำนวน Parallel Worker CPU Cores เพื่อเปรียบเทียบเวลาประมวลผลระหว่าง Procedural RBAR Loop (รันทีละแถวบน 1 Core) กับ Set-Based Parallel Optimization
การเปลี่ยนผ่านจากการเขียนโค้ดแบบ Procedural มาเป็น Set-Based ใน PostgreSQL ต้องเริ่มตั้งแต่การเข้าใจความต่างของโครงสร้างคำสั่ง, การปรับจูนพารามิเตอร์ Session ให้เปิดใช้งาน Parallel Workers, และการเขียนคำสั่ง UPDATE ... FROM ร่วมกับ CASE WHEN:
-- [สเต็ปที่ 1: ปรับแต่ง Session Parameters เปิดใช้งาน Multi-core Parallel Processing]
SET LOCAL max_parallel_workers_per_gather = 4; -- 🎯 [1] ให้ Planner ดึง Worker มาช่วยได้สูงสุด 4 Cores
SET LOCAL parallel_setup_cost = 1000; -- 🎯 [2] ลดเกณฑ์ต้นทุนการเริ่มต้น Worker เพื่อกระตุ้น Parallelism
SET LOCAL parallel_tuple_cost = 0.1; -- 🎯 [3] ต้นทุนการสแกนต่อแถวต่ำลง กระตุ้นให้แบ่งงานหลาย Thread
-- [สเต็ปที่ 2: เขียนคำสั่งเชิงเซต (Set-Based) รวบหลายเงื่อนไขใน 1 Single Pass Scan]
UPDATE accounts a
SET
penalty_fee = CASE -- 🎯 [4] ใช้ CASE WHEN แทน IF-ELSE ในลูป
WHEN a.overdue_days > 90 THEN a.balance * 0.15
WHEN a.overdue_days > 30 THEN a.balance * 0.08
ELSE a.balance * 0.02
END,
credit_class = CASE
WHEN a.overdue_days > 90 THEN 'NPL'
ELSE 'ACTIVE'
END,
updated_at = CURRENT_TIMESTAMP
WHERE a.status = 'ACTIVE' AND a.overdue_days > 0; -- 🎯 [5] สแกนอ่านและเขียน Data Pages บนดิสก์เพียง 1 รอบ!
LOOP หรือ CURSOR ให้หยุดคิดเสมอว่าสามารถเขียนด้วย UPDATE ... WHERE, UPDATE ... FROM หรือ CASE WHEN ได้หรือไม่ เพราะการคิดเชิงเซตจะเร็วกว่าลูปหลายร้อยเท่าเสมอ
-- [1] ตรวจสอบและตั้งค่าพารามิเตอร์ระดับ Session เพื่อเปิดใช้ Parallel Workers
SET max_parallel_workers_per_gather = 4; -- กำหนดให้ใช้ Parallel Workers ได้สูงสุด 4 Cores ต่อ Query
SET parallel_setup_cost = 1000; -- ปรับ Cost เกณฑ์การเริ่มใช้งาน Parallel
SET parallel_tuple_cost = 0.1; -- ปรับ Cost ประมวลผลต่อ Tuple
-- [2] ทดสอบ Query สแกนข้อมูลปริมาณมากเพื่อดู Parallel Execution Plan
EXPLAIN (ANALYZE, BUFFERS)
SELECT category_id, SUM(amount) AS total_amount
FROM orders
GROUP BY category_id;
-- 📤 ตัวอย่างผลลัพธ์ใน Execution Plan:
-- Finalize HashAggregate (actual time=45.210..45.215 rows=10 loops=1)
-- -> Gather (actual time=43.120..44.890 rows=40 loops=1)
-- Workers Planned: 4
-- Workers Launched: 4 <-- 🎯 พิสูจน์ว่าดึงพลัง 4 Cores มาสแกนพร้อมกันจริง!
-- -> Partial HashAggregate (actual time=38.100..38.110 rows=10 loops=4)
-- -> Parallel Seq Scan on orders (actual time=0.080..25.400 rows=2500000 loops=4)
Parallel Seq Scan และ Gather ใน EXPLAIN ANALYZE แสดงว่า PostgreSQL กำลังแบ่งตารางออกเป็นส่วนๆ และสแกนบนหลาย CPU Cores พร้อมกันอย่างเต็มที่
-- [1] รีแฟกเตอร์ตรรกะการคำนวณแบบหลายเงื่อนไขให้อยู่ในคำสั่ง Set-Based เดียว
WITH computed_metrics AS (
SELECT
account_id,
CASE
WHEN overdue_days > 90 THEN amount * 0.15
WHEN overdue_days > 30 THEN amount * 0.08
ELSE amount * 0.02
END AS computed_penalty,
CASE
WHEN overdue_days > 90 THEN 'NPL'
ELSE 'ACTIVE'
END AS new_class
FROM account_delinquency
WHERE is_processed = FALSE
)
-- [2] สั่ง UPDATE เชื่อมโยงกับผลลัพธ์จาก CTE ด้วยไวยากรณ์ UPDATE ... FROM
UPDATE accounts a
SET penalty_fee = c.computed_penalty,
credit_class = c.new_class,
updated_at = CURRENT_TIMESTAMP
FROM computed_metrics c
WHERE a.account_id = c.account_id;
UPDATE ... FROM ร่วมกับ CTE ช่วยให้เราสามารถ Join ตารางอ้างอิงและอัปเดตหลายคอลัมน์พร้อมกันได้ใน 1 Single Set-Based Scan โดยไม่ต้องเขียนลูปแม้แต่บรรทัดเดียว
เปรียบเทียบโค้ดเดิมที่เป็น Procedural RBAR Loop ที่บังคับให้ CPU รันอยู่ Core เดียวจนนาน 14 ชั่วโมง กับโค้ดใหม่ที่เป็น Set-Based Vectorized Processing พร้อมเปิดใช้งาน Parallel Optimization:
-- ❌ ห้ามทำในระบบ Production! วนลูป 20 ล้านรอบ ทำให้ช้า 14 ชั่วโมง
CREATE OR REPLACE PROCEDURE pr_process_penalties_flawed()
LANGUAGE plpgsql AS $$
DECLARE
v_row RECORD;
BEGIN
-- 🚨 เปิด Cursor วนลูปทีละแถว บล็อกไม่ให้ทำ Parallel Query!
FOR v_row IN SELECT id, balance, overdue_days FROM loans WHERE status = 'ACTIVE' LOOP
IF v_row.overdue_days > 60 THEN
UPDATE loans
SET penalty = v_row.balance * 0.10, status = 'DEFAULT'
WHERE id = v_row.id;
ELSIF v_row.overdue_days > 30 THEN
UPDATE loans
SET penalty = v_row.balance * 0.05
WHERE id = v_row.id;
END IF;
END LOOP;
END $$;
-- ผลลัพธ์: เกิด Context Switching 20 ล้านครั้ง CPU ติด Core เดียว นาน 14 ชั่วโมง!
-- [Statement 1] สร้าง Stored Procedure สำหรับประมวลผลเชิงเซต (Set-Based)
CREATE OR REPLACE PROCEDURE pr_process_penalties_production()
LANGUAGE plpgsql
AS $$
DECLARE
v_rows_affected INT := 0;
v_start_time TIMESTAMPTZ := CURRENT_TIMESTAMP;
BEGIN
-- [บรรทัดที่ 1-2] ตั้งค่า Session ให้ใช้ Multi-Core Parallel Workers ได้เต็มประสิทธิภาพ
SET LOCAL max_parallel_workers_per_gather = 4;
-- [บรรทัดที่ 3-18] ยุบการวนลูป 20 ล้านรอบให้เหลือคำสั่ง UPDATE เชิงเซตเพียงคำสั่งเดียว!
-- ใช้ CASE WHEN คำนวณเงื่อนไขพร้อมกันทุกแถวในสภาวะ Vectorized Block
WITH target_loans AS (
UPDATE loans
SET
penalty = CASE
WHEN overdue_days > 60 THEN balance * 0.10
WHEN overdue_days > 30 THEN balance * 0.05
ELSE penalty
END,
status = CASE
WHEN overdue_days > 60 THEN 'DEFAULT'
ELSE status
END,
last_calculated_at = CURRENT_TIMESTAMP
WHERE status = 'ACTIVE' AND overdue_days > 30
RETURNING id
)
SELECT COUNT(*) INTO v_rows_affected FROM target_loans;
-- [บรรทัดที่ 19-20] บันทึกเวลาประมวลผลจริงและ Commit Transaction
RAISE NOTICE 'Set-Based Processing Completed: % rows updated in %',
v_rows_affected, CURRENT_TIMESTAMP - v_start_time;
COMMIT;
END $$;
-- [Statement 2] เรียกใช้งานผ่าน CALL (ประมวลผล 20 ล้านแถวเสร็จในเวลาเพียง 8 วินาที!)
CALL pr_process_penalties_production();
แบบทดสอบวัดระดับความเข้าใจ Day 9: Vectorized Processing, Set-Based Logic & Parallel Query Optimization (RBAR Anti-Pattern, max_parallel_workers_per_gather, UPDATE ... FROM, work_mem) และข้อสอบสะสมทบทวนครบทั้ง Days 1–8 คลิกเลือกคำตอบ ก, ข, ค, หรือ ง เพื่อดูผลลัพธ์และคำอธิบายอย่างละเอียดทันที
บทเรียน Day 9: Vectorized Processing, Set-Based Logic & Parallel Query Optimization ได้ปลูกฝังการเปลี่ยน Mindset ทางสถาปัตยกรรมจากการเขียนโค้ดวนลูปทีละแถว (Procedural RBAR) มาเป็นการคิดแบบ Set-Based Vectorized Processing ร่วมกับการเปิดใช้งาน Parallel Query Execution ซึ่งช่วยขจัดภาระ Context Switching และดึงพลังของ Multi-core CPU มาช่วยประมวลผลได้อย่างเต็มศักยภาพ
ในวันถัดไป เราจะไปสรุปรวบยอดเสาหลักทั้ง 4 ต้น (UDFs, Procedures, Triggers, Materialized Views) พร้อมทำแบบจำลองสถาปัตยกรรมระบบ E-Commerce / Financial Engine ฉบับสมบูรณ์ ก่อนก้าวเข้าสู่ Phase 3: DevOps, Maintainability & Architectural Boundaries ครับ!