ทลายข้อจำกัดร้ายแรงของ Stored Functions ที่ห้ามสั่ง COMMIT ระหว่างทาง! เรียนรู้วิธีสร้าง Stored Procedure (PostgreSQL 11+) เพื่อควบคุม Transaction Boundaries ภายในตัวโปรแกรม สั่ง COMMIT / ROLLBACK กลางลูปการประมวลผลเพื่อทำ Chunked Batch Processing ปลด Row Locks, ระบาย Write-Ahead Log (WAL), และการันตีความปลอดภัยของข้อมูลในระดับ Enterprise
UDF คำนวณสูตรได้ แต่ห้าม COMMIT กลางทาง วันนี้เป็นเสาหลักต้นที่สอง — งานชุดใหญ่ที่ต้องจัดการข้อมูลปริมาณมหาศาล และตัดยอดเป็นระยะๆ ได้เอง
โปรแกรมยูนิตในฐานข้อมูลที่ทำงานเป็นขั้นตอน รับพารามิเตอร์ แล้วสั่งงานกับตารางต่างๆ ได้ ไม่ได้แค่คืนค่าคำนวณ
ความสามารถในการสั่งเซฟเป็นระยะๆ (COMMIT) หรือยกเลิกทั้งหมด (ROLLBACK) ได้เองกลางโปรแกรม
โดยรวมวันนี้คือการใช้ Stored Procedure ประมวลผลข้อมูลก้อนใหญ่ทีละส่วน (chunking) แล้วสั่ง COMMIT ตัดยอดเป็นระยะ เพื่อให้ล็อกหลุด และไม่ให้ไฟล์บันทึกการทำงาน (WAL) บวมจนดิสก์เต็ม
ต่างจาก UDF ตรงที่ Procedure สั่ง COMMIT กลางทางได้ เหมาะกับงานตัดเงิน คัดแยกข้อมูล หรือย้ายข้อมูลระดับล้านรายการ โดยไม่ทำให้ระบบล่มทั้งก้อน
ในบทก่อนหน้า (Day 5) เราได้เรียนรู้การใช้ User-Defined Functions (UDFs) เป็นเครื่องมือคำนวณระดับอะตอมที่ฝังใน Query แต่ฟังก์ชันมี "กฎเหล็กอันเด็ดขาด" คือ ต้องรันอยู่ภายใต้ Transaction Context ของผู้เรียกภายนอกเสมอ และห้ามมีคำสั่ง COMMIT หรือ ROLLBACK อยู่ภายในฟังก์ชันเด็ดขาด หากระบบต้องประมวลผลงานย้ายข้อมูล หรือ Batch Job ปริมาณมหาศาลระดับ 5–10 ล้านแถว การมัดรวมทุกอย่างใน Transaction เดียวจะกลายเป็นระเบิดเวลาที่ทำลายเสถียรภาพของทั้งระบบ!
ระบบคลังสินค้าต้องทำการปรับปรุงยอดคงเหลือและตั้งค่าดัชนีสิ้นปีสำหรับสินค้า 5,000,000 รายการ ทีมพัฒนาเขียนสคริปต์ห่อไว้ใน CREATE FUNCTION ให้กวาดอัปเดตข้อมูลทั้งหมดในคำสั่งเดียว เมื่อสคริปต์ประมวลผลไปถึงนาทีที่ 120 (ทำไปแล้ว 4,900,000 รายการ) เกิด Data Constraint Error ขึ้นเพียง 1 แถว:
ต้นตอของวิกฤตนี้คือการใช้ Function ที่บังคับรันแบบ Single Giant Transaction สถาปนิกจึงต้องใช้ Stored Procedures (PostgreSQL 11+) เพื่อทำ Chunked Transaction Control ปล่อย Lock และ Flush WAL เป็นระยะสั้นๆ ครับ!
| มิติเปรียบเทียบสถาปัตยกรรม | Stored Function (CREATE FUNCTION) | Stored Procedure (CREATE PROCEDURE) |
|---|---|---|
| วิธีการเรียกใช้งาน (Invocation) | เรียกเป็นส่วนหนึ่งของ Query เช่น SELECT fn_calc(); |
เรียกใช้ด้วยคำสั่ง CALL pr_batch(); โดยตรง |
| ข้อกำหนดการคืนค่า (Return Contract) | บังคับ (Mandatory RETURNS) ต้องคืนค่าเสมอ | ไม่มี RETURNS (ส่งค่าออกผ่านพารามิเตอร์ OUT / INOUT) |
| Explicit Transaction Control | ❌ ห้ามทำเด็ดขาด (เกิด Error: invalid transaction termination) | ✅ สั่ง COMMIT; และ ROLLBACK; ได้กลางลูป |
| กลไกการปล่อย Lock & WAL | แช่ Row Locks และสะสม WAL ไว้จนจบ Query หลัก | ปลด Row Locks และ Flush WAL ลงดิสก์ทันทีเมื่อสั่ง COMMIT; |
| งานที่เหมาะสม (Sweet Spot) | คำนวณและแปลงค่าระดับแถว (Atomic Math / Inlining) | งานหลายขั้นตอน (Workflows) และ Chunked Batch Processing ขนาดใหญ่ |
| เวอร์ชันที่เริ่มรองรับ | รองรับมาตั้งแต่เวอร์ชันแรกๆ ของ PostgreSQL | เพิ่มเข้ามาตั้งแต่ PostgreSQL 11+ เพื่อรองรับงานระดับ Enterprise |
"ทุกท่านครับ งาน Batch ขนาดใหญ่ที่รันแล้วค้างหรือพังจน Rollback ทิ้งทั้งหมด 2 ชั่วโมง ไม่ได้เกิดจากฐานข้อมูลประมวลผลช้า แต่เกิดจากการฝืนใช้ Function ที่บังคับรันใน Single Transaction เดียวจน WAL บวมและถือ Lock ค้าง
ทางออกเชิงสถาปัตยกรรมตาม Pillar 2 คือการเปลี่ยนมาใช้ CREATE PROCEDURE ร่วมกับ Chunked Transaction Control สั่ง COMMIT; ทุกๆ 10,000 รายการ ซึ่งปลด Row Lock ทันที, ระบาย WAL ลงดิสก์ และการันตีว่างานที่ทำสำเร็จไปแล้วจะปลอดภัย 100% แม้ระบบดับกะทันหันครับ"
ลองปรับจำนวนแถวข้อมูล ขนาด Chunk Size และจุดที่สมมติว่าเกิด Failure เพื่อดูว่าระบบ Stored Procedure ช่วยปกป้องข้อมูลที่ถูก COMMIT ไปแล้วได้มากเพียงใด เทียบกับ Single Giant Transaction ใน Function
การสร้าง Stored Procedure ใน PostgreSQL เริ่มต้นจากคำสั่ง CREATE PROCEDURE, เรียกใช้ด้วยคำสั่ง CALL, ไม่มีประโยค RETURNS และสามารถสั่ง COMMIT; หรือ ROLLBACK; ได้อย่างอิสระ:
CREATE OR REPLACE PROCEDURE pr_process_batch_orders (
p_chunk_size INT DEFAULT 10000 -- [1] พารามิเตอร์ขาเข้า (Input Parameter)
)
LANGUAGE plpgsql -- [2] ใช้ภาษา PL/pgSQL
-- 🎯 [3] สังเกตว่า "ไม่มีประโยค RETURNS" เหมือน Function
AS $$
DECLARE
v_rows_updated INT := 0;
v_total_updated INT := 0;
BEGIN
LOOP
-- [Statement 1] ประมวลผลอัปเดตเฉพาะกลุ่มย่อย (Chunk) ด้วย CTE + LIMIT
WITH updated_chunk AS (
UPDATE orders
SET status = 'ARCHIVED',
archived_at = CURRENT_TIMESTAMP
WHERE id IN (
SELECT id FROM orders
WHERE status = 'COMPLETED' AND archived_at IS NULL
ORDER BY id ASC -- 🎯 เรียงลำดับ ID แน่นอน ป้องกัน Deadlock
LIMIT p_chunk_size
FOR UPDATE SKIP LOCKED -- 🎯 ข้ามแถวติดล็อก ไม่ชนกับ Worker อื่น
)
RETURNING id
)
SELECT COUNT(*) INTO v_rows_updated FROM updated_chunk;
-- [Statement 2] เงื่อนไขออกจากลูปเมื่อไม่มีข้อมูลเหลือให้ทำแล้ว
IF v_rows_updated = 0 THEN
EXIT;
END IF;
v_total_updated := v_total_updated + v_rows_updated;
RAISE NOTICE 'Processed and Committed Chunk: % rows (Total: %)', v_rows_updated, v_total_updated;
-- 🎯 [Statement 3: Explicit Transaction Control]
-- ปลด Row Lock, Flush WAL และเริ่ม Transaction ใหม่ทันทีในลูป!
COMMIT;
END LOOP;
END $$;
COMMIT; หรือ ROLLBACK; ภายในบล็อกที่มีส่วน EXCEPTION WHEN OTHERS THEN เด็ดขาด เนื่องจากบล็อกที่มี EXCEPTION จะสร้าง Implicit Subtransaction (Savepoint) ซึ่ง PostgreSQL ห้ามสั่ง COMMIT ภายในนั้น!
-- [1] สร้าง Procedure สำหรับปรับอัปเดตสถานะบัญชีที่ไม่มีการเคลื่อนไหว
CREATE OR REPLACE PROCEDURE pr_deactivate_inactive_users()
LANGUAGE plpgsql
AS $$
BEGIN
-- อัปเดตผู้ใช้ที่ไม่ได้ล็อกอินเกิน 1 ปี
UPDATE users
SET is_active = FALSE
WHERE last_login < CURRENT_DATE - INTERVAL '1 year';
-- 🎯 สั่ง COMMIT ปิด Transaction ภายใน Procedure ทันที
COMMIT;
RAISE NOTICE 'ทำความสะอาดข้อมูลผู้ใช้ที่ไม่ใช้งานเรียบร้อยแล้ว';
END $$;
-- 📤 การเรียกใช้งาน Stored Procedure ต้องใช้คำสั่ง CALL (ห้ามใช้ SELECT):
CALL pr_deactivate_inactive_users();
CALL pr_name(); และตัว Procedure สามารถสั่ง COMMIT; ได้อย่างสมบูรณ์โดยไม่เกิด Error
-- [1] สร้าง Procedure โอนเงินที่สามารถตัดสินใจสั่ง ROLLBACK หรือ COMMIT ได้เอง
CREATE OR REPLACE PROCEDURE pr_transfer_funds_safe (
p_sender_id INT,
p_receiver_id INT,
p_amount NUMERIC
)
LANGUAGE plpgsql
AS $$
DECLARE
v_sender_bal NUMERIC;
BEGIN
-- สั่งตัดเงินจากบัญชีผู้ส่ง
UPDATE accounts SET balance = balance - p_amount WHERE account_id = p_sender_id;
-- ตรวจสอบว่ายอดเงินติดลบหรือไม่
SELECT balance INTO v_sender_bal FROM accounts WHERE account_id = p_sender_id;
IF v_sender_bal < 0 THEN
RAISE NOTICE 'ยอดเงินไม่พอ! (ยอดติดลบ %) ทำการ ROLLBACK ยกเลิกการโอน', v_sender_bal;
-- 🎯 สั่ง ROLLBACK ย้อนกลับคำสั่งหักเงินทันที
ROLLBACK;
RETURN;
END IF;
-- เพิ่มเงินให้บัญชีผู้รับ
UPDATE accounts SET balance = balance + p_amount WHERE account_id = p_receiver_id;
-- 🎯 ยืนยัน Transaction สมบูรณ์
COMMIT;
RAISE NOTICE 'โอนเงินสำเร็จ % บาท', p_amount;
END $$;
-- 📤 ทดสอบเรียกใช้งาน:
CALL pr_transfer_funds_safe(101, 102, 500.00);
ROLLBACK; RETURN; เพื่อสั่งยกเลิกคำสั่งใน Transaction ปัจจุบันและออกจาก Procedure ได้ทันทีเมื่อพบเงื่อนไขที่ผิดปกติ
เปรียบเทียบโค้ดเดิมที่เป็น CREATE FUNCTION ใน Single Giant Transaction กับโค้ดใหม่ที่เป็น CREATE PROCEDURE พร้อมการทำ Chunked COMMIT เป็นระยะ:
-- ❌ ฟังก์ชันไม่สามารถใส่ COMMIT ได้ เมื่อรัน 5 ล้านแถว
-- เกิด Memory Leak, WAL Bloat 200GB และ Rollback ทั้งหมดถ้าพัง
CREATE OR REPLACE FUNCTION fn_archive_old_logs_flawed()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT;
BEGIN
-- 🚨 อัปเดตทีเดียว 5,000,000 แถว ถือ Lock ค้าง 2 ชม. และบวม WAL
UPDATE logs
SET status = 'ARCHIVED'
WHERE log_date < '2025-01-01';
GET DIAGNOSTICS v_count = ROW_COUNT;
-- 🚨 หากเขียน COMMIT; ตรงนี้ จะเกิด Error:
-- ERROR: invalid transaction termination!
RETURN v_count;
END $$;
-- ❌ เรียกใช้ใน Query:
-- SELECT fn_archive_old_logs_flawed();
-- [Statement 1] สร้าง Stored Procedure สำหรับ Batch Processing ปริมาณมหาศาล
CREATE OR REPLACE PROCEDURE pr_archive_old_logs_production(
p_batch_size INT DEFAULT 10000 -- กำหนดขนาด Chunk ละ 10,000 แถว
)
LANGUAGE plpgsql
AS $$
DECLARE
-- [บรรทัดที่ 1-3] ประกาศตัวแปรติดตามจำนวนแถวที่ประมวลผล
v_chunk_updated INT := 0;
v_total_updated INT := 0;
BEGIN
LOOP
-- [บรรทัดที่ 4-13] ประมวลผลเฉพาะกลุ่มย่อย 10,000 แถวโดยใช้ CTE + LIMIT
WITH target_chunk AS (
SELECT id FROM logs
WHERE status = 'PENDING' AND log_date < '2025-01-01'
ORDER BY id ASC -- 🎯 จัดลำดับ ID แน่นอน ป้องกัน Deadlock
LIMIT p_batch_size
FOR UPDATE SKIP LOCKED -- 🎯 ข้ามแถวติดล็อก ไม่ขวางคนอื่น
),
updated_rows AS (
UPDATE logs
SET status = 'ARCHIVED',
archived_at = CURRENT_TIMESTAMP
FROM target_chunk
WHERE logs.id = target_chunk.id
RETURNING logs.id
)
SELECT COUNT(*) INTO v_chunk_updated FROM updated_rows;
-- [บรรทัดที่ 14-16] เช็คถ้าจบงานแล้ว ให้หลุดออกจากลูป
IF v_chunk_updated = 0 THEN
EXIT;
END IF;
v_total_updated := v_total_updated + v_chunk_updated;
-- [บรรทัดที่ 17] แจ้ง Log ความคืบหน้า
RAISE NOTICE 'Batch Success: % rows archived. Total: %', v_chunk_updated, v_total_updated;
-- 🎯 [บรรทัดที่ 18] สั่ง COMMIT ปิด Transaction ของ Chunk นี้!
-- ยืนยันข้อมูลลงดิสก์, ปลด Row Locks ทั้งหมด และระบาย WAL ทันที!
COMMIT;
END LOOP;
RAISE NOTICE '== เสร็จสิ้นกระบวนการ ARCHIVE ข้อมูลทั้งหมด % รายการ ==', v_total_updated;
END $$;
-- [Statement 2] เรียกใช้งานผ่านคำสั่ง CALL
CALL pr_archive_old_logs_production(10000);
แบบทดสอบวัดระดับความเข้าใจ Pillar 2: Stored Procedures & Transaction Control (COMMIT / ROLLBACK), Chunked Batch Processing, WAL Flushing, และข้อสอบสะสมทบทวนครบทั้ง Day 1–5 คลิกเลือกคำตอบ ก, ข, ค, หรือ ง เพื่อดูผลลัพธ์และคำอธิบายอย่างละเอียดทันที
บทเรียน Day 6: Pillar 2 - Stored Procedures & Transaction Control แสดงให้เห็นถึงความทรงพลังของการสลับมาใช้ CREATE PROCEDURE ร่วมกับกลไก Chunked Transaction Control (COMMIT / ROLLBACK) ซึ่งช่วยขจัดปัญหา Batch Job พังแล้ว Rollback งานมหาศาลทิ้ง, ลดการถือ Row Lock ค้างยาวนาน และป้องกันปัญหา WAL Accumulation Bloat ได้อย่างเด็ดขาด
Pillar 3 - Event Triggers & Audit Logging Mechanics: ในวันถัดไป เราจะไปเจาะลึกเสาหลักต้นที่ 3 นั่นคือ Triggers & Audit Logging (BEFORE / AFTER Triggers) พร้อมการจัดการโครงสร้างข้อมูล OLD และ NEW เพื่อสร้างระบบบันทึกประวัติการเปลี่ยนแปลงแบบ Real-time Automatic Audit Engine ครับ!