สร้างระบบความปลอดภัยและการตรวจสอบย้อนหลังที่ไม่มีวันถูก Bypass! เจาะลึกการสร้าง Trigger และ Trigger Function ระดับ Database Engine, เข้าใจความต่างที่ส่งผลถึงชีวิตของ BEFORE vs AFTER, ไขรหัสตัวแปรมหัศจรรย์ OLD และ NEW, พร้อมสร้าง Generic JSONB Audit Logging Engine ที่บันทึกประวัติการเปลี่ยนแปลงของทุกตารางลงตารางกลางเพียงตารางเดียว
เสาหลักต้นที่สามไม่ได้อยู่ที่ความเร็ว แต่อยู่ที่ความปลอดภัย — ใครแตะข้อมูลแถวไหน เปลี่ยนจากค่าอะไรเป็นอะไร และไม่มีใครแอบบายพาสกล้องวงจรปิดนี้ได้
โค้ดที่ฐานข้อมูลเรียกให้ทำงานเอง เมื่อเกิดเหตุการณ์กับตาราง เช่น INSERT, UPDATE, DELETE เลือกได้ว่าจะให้ทำงานก่อนคำสั่งนั้น (BEFORE) หรือหลังคำสั่งนั้นจบ (AFTER)
การบันทึกประวัติว่าใครมาทำอะไรกับข้อมูลแถวไหน เปลี่ยนจากค่าเก่าเป็นค่าอะไร เมื่อไหร่
โดยรวมวันนี้คือการผูก Trigger ไว้กับตาราง แล้วให้มันแปลงค่าเก่า/ใหม่เป็น JSONB บันทึกลงตาราง Audit Log ทุกครั้งที่มีการแก้ไข ไม่ว่าจะแก้ผ่านแอป สคริปต์ หรือเข้าไปแตะในฐานข้อมูลตรงๆ
ทำให้ไม่ว่าจะแก้ข้อมูลผ่านแอป สคริปต์ หรือแก้ในฐานข้อมูลตรงๆ ก็ถูกบันทึก Audit Trail ครบทุกครั้ง
ในระบบงานระดับ Enterprise หัวใจที่สำคัญที่สุดไม่แพ้เรื่องความเร็วคือ "ความปลอดภัยและการตรวจสอบย้อนหลังได้ (Auditability & Compliance)" ทีมพัฒนามักทำผิดพลาดครั้งใหญ่ด้วยการเขียนโค้ดบันทึก Audit Log ไว้บน Application Server (เช่น Node.js / Python / C#) ซึ่งก่อให้เกิดช่องโหว่ร้ายแรง เพราะหากมีใครใช้สิทธิ DBA หรือแอบยิง SQL ผ่านเครื่องมืออย่าง DBeaver/pgAdmin ข้อมูลจะถูกแก้ไขเงียบๆ โดย ไม่มี Audit Log ใดๆ เกิดขึ้นเลย!
สถาบันการเงินแห่งหนึ่งตรวจพบว่ามีบัญชีลูกค้ารายใหญ่ถูกขยายวงเงินสินเชื่อ (credit_limit) จาก 1,000,000 บาท กลายเป็น 50,000,000 บาท ทีม Security ตรวจไฟล์ Log ของ Application ปรากฏว่า "ไม่มีประวัติการทำงานใดๆ เลย" เพราะคนร้ายใช้สิทธิ DB Admin เชื่อมต่อตรงเข้ามาแก้ข้อมูลในตาราง customers:
ทางออกเชิงสถาปัตยกรรมหนึ่งเดียวที่ไม่มีวันถูก Bypass คือการย้ายกลไกตรวจสอบและบันทึกประวัติมาไว้ที่ In-Database Event Triggers ที่ทำงานอยู่ระดับรากฐานของ PostgreSQL Engine ครับ!
| มิติเปรียบเทียบสถาปัตยกรรม | BEFORE Trigger (Gatekeeper) | AFTER Trigger (Auditor) |
|---|---|---|
| จังหวะการทำงาน (Execution Timing) | ก่อนข้อมูลถูกบันทึก/แก้ไขลง Data Page จริง | หลังจากข้อมูลถูกเขียนลง Data Page ใน Transaction แล้ว |
| ความสามารถในการแก้ไขตัวแปร NEW | ✅ แก้ไขได้ (เช่น NEW.col := value;) |
❌ แก้ไขไม่ได้ (ตัวแปร NEW เป็น Read-only) |
| ผลลัพธ์เมื่อสั่ง RETURN NULL; | 🚨 ยกเลิก (Cancel) คำสั่งหลักทันที ข้อมูลแถวนั้นจะไม่ถูกบันทึก | ✅ ปลอดภัย คำสั่งหลักยังคงทำงานสำเร็จตามปกติ |
| งานที่เหมาะสม (Sweet Spot) | Data Sanitization, Auto Timestamp, Business Rule Validation | Audit Logging, Cascade Notification, External Event Triggering |
| ความเสี่ยงด้าน Performance | ส่งผลโดยตรงต่อ Execution Plan ก่อนเขียนดิสก์ | เพิ่มภาระ I/O เขียนตาราง Audit เพิ่มเติมใน Transaction เดียวกัน |
"ทุกท่านครับ Audit Log ที่เขียนบน App Code สามารถถูก Bypass ได้ง่ายๆ เพียงแค่ใครสักคนใช้สิทธิ Admin ยิง SQL ตรงเข้าฐานข้อมูล!
ด้วย Pillar 3 Architecture เราจะสร้าง In-Database Non-Bypassable Audit Engine ที่ใช้ AFTER Trigger ร่วมกับกลไก to_jsonb(OLD) และ to_jsonb(NEW) บันทึกประวัติค่าเดิมและค่าใหม่ลงตาราง Audit กลางให้อัตโนมัติ 100% ดักจับได้ทุกทิศทางโดยไม่มีทางถูก Bypass และไม่กระทบต่อคำสั่งหลักหน้าบ้านครับ"
ลองเลือกคำสั่ง (Operation), เลือกระยะ Trigger (Phase), และเลือกค่าที่ฟังก์ชันส่งคืน (Return Value) เพื่อดูว่า PostgreSQL จะตอบสนองอย่างไร และคำสั่งหลักจะรอดหรือพัง
การสร้าง Trigger ใน PostgreSQL ต้องทำ 2 สเต็ปเสมอ: สเต็ปที่ 1 สร้าง Trigger Function ที่มี RETURNS TRIGGER และสเต็ปที่ 2 ผูก Trigger เข้ากับตารางด้วยคำสั่ง CREATE TRIGGER
-- [สเต็ปที่ 1: สร้าง Trigger Function]
CREATE OR REPLACE FUNCTION fn_trg_sample_audit()
RETURNS TRIGGER -- 🎯 [1] ต้องระบุชนิดผลลัพธ์เป็น TRIGGER เท่านั้น
LANGUAGE plpgsql
AS $$
BEGIN
-- [2] ตัวแปรสภาวะแวดล้อม TG_OP บอกว่ากำลังทำอะไร ('INSERT', 'UPDATE', 'DELETE')
IF (TG_OP = 'UPDATE') THEN
-- [3] เข้าถึงเรกคอร์ด OLD (ค่าเดิม) และ NEW (ค่าใหม่)
RAISE NOTICE 'แก้ไขแถว ID % จากสถานะ % เป็น %', OLD.id, OLD.status, NEW.status;
END IF;
-- [4] กฎเหล็กของการคืนค่า:
-- สำหรับ BEFORE: ต้อง RETURN NEW; (ถ้า RETURN NULL คำสั่งหลักจะถูกยกเลิก!)
-- สำหรับ AFTER: สามารถ RETURN NULL; ได้อย่างปลอดภัย
RETURN NEW;
END $$;
-- [สเต็ปที่ 2: ผูก Trigger เข้ากับตารางเป้าหมาย]
CREATE TRIGGER trg_sample_audit
AFTER UPDATE ON customers -- 🎯 [5] เลือกระยะ (BEFORE/AFTER), เหตุการณ์ (INSERT/UPDATE/DELETE), และตาราง
FOR EACH ROW -- 🎯 [6] บังคับให้รันย่อยทีละแถว (Row-level Trigger) เพื่อให้มี OLD/NEW
EXECUTE FUNCTION fn_trg_sample_audit(); -- [7] ชี้ไปยังฟังก์ชันในสเต็ปที่ 1
OLD, NEW, TG_OP, และ TG_TABLE_NAME
-- [1] สร้างฟังก์ชันปรับแต่ง timestamp อัตโนมัติก่อนบันทึกลงดิสก์
CREATE OR REPLACE FUNCTION fn_trg_set_timestamp()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- 🎯 แก้ไขฟิลด์ updated_at ในเรกคอร์ด NEW โดยตรงก่อนเขียนลงดิสก์
NEW.updated_at := CURRENT_TIMESTAMP;
-- 🎯 ใน BEFORE Trigger ต้องส่งคืน NEW เพื่อให้ข้อมูลถูกนำไปเซฟลงตาราง
RETURN NEW;
END $$;
-- [2] ผูก Trigger เข้ากับตาราง customers
CREATE TRIGGER trg_customers_set_timestamp
BEFORE UPDATE ON customers
FOR EACH ROW
EXECUTE FUNCTION fn_trg_set_timestamp();
-- 📤 ทดสอบรันคำสั่งแก้ไขข้อมูล:
-- UPDATE customers SET name = 'สมศักดิ์' WHERE id = 101;
-- คอลัมน์ updated_at จะถูกปรับเวลาเป็นวินาทีปัจจุบันให้อัตโนมัติทันที!
BEFORE UPDATE เพราะเราต้องการแทรกแซงและแก้ไขค่าในเรกคอร์ด NEW ก่อนที่มันจะถูกเขียนลง Data Page บนดิสก์
-- [1] ฟังก์ชันตรวจสอบความปลอดภัย: ห้ามเพิ่มวงเงินเกิน 50% ในครั้งเดียว
CREATE OR REPLACE FUNCTION fn_trg_validate_credit_limit()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- 🎯 เทียบค่าใหม่ (NEW.credit_limit) กับค่าเดิม (OLD.credit_limit)
IF (NEW.credit_limit > OLD.credit_limit * 1.5) THEN
-- พ่น EXCEPTION เพื่อ Abort และ Rollback Transaction ทันที
RAISE EXCEPTION 'ไม่อนุญาตให้เพิ่มวงเงินเกิน 50%% ในครั้งเดียว (วงเงินเดิม: %, วงเงินใหม่: %)',
OLD.credit_limit, NEW.credit_limit;
END IF;
-- ถ้าผ่านเงื่อนไข ให้คืนค่า NEW ดำเนินการต่อตามปกติ
RETURN NEW;
END $$;
-- [2] ผูก Trigger แบบเจาะจงเฉพาะคอลัมน์ (UPDATE OF column_name) เพื่อลด Overhead
CREATE TRIGGER trg_validate_credit_limit
BEFORE UPDATE OF credit_limit ON customers -- 🎯 ทำงานเฉพาะเมื่อมีการแก้ credit_limit เท่านั้น
FOR EACH ROW
EXECUTE FUNCTION fn_trg_validate_credit_limit();
BEFORE UPDATE OF credit_limit ช่วยเพิ่มความเร็ว เพราะหากคำสั่งแก้เฉพาะชื่อหรือที่อยู่ Trigger ตัวนี้จะไม่ถูกปลุกขึ้นมาทำงานเลย
เปรียบเทียบโค้ดเดิมที่ใช้ BEFORE Trigger แล้วใส่ RETURN NULL; ผิดพลาด จนคำสั่งหลักโดน Cancel ทิ้ง กับโค้ดใหม่ที่เป็น Generic JSONB Audit Engine สมบูรณ์แบบ:
-- ❌ หายนะทางสถาปัตยกรรม: ใช้ BEFORE Trigger ทำ Audit Log
-- แล้วส่งคืนค่า RETURN NULL;
CREATE OR REPLACE FUNCTION fn_trg_audit_flawed()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
-- บันทึกลงตาราง Log
INSERT INTO audit_logs(table_name, old_data)
VALUES (TG_TABLE_NAME, OLD.status);
-- 🚨 พังพินาศทันที! การส่ง RETURN NULL ใน BEFORE Trigger
-- จะสั่งให้ PostgreSQL ยกเลิกคำสั่ง UPDATE หลักทิ้งทันที!
RETURN NULL;
END $$;
CREATE TRIGGER trg_audit_bad
BEFORE UPDATE ON customers
FOR EACH ROW
EXECUTE FUNCTION fn_trg_audit_flawed();
-- ลูกค้ากดอัปเดตข้อมูล แต่ข้อมูลในตารางไม่เคยเปลี่ยนเลย!
RETURN NULL; ถือเป็นคำสั่ง Cancel Operation ทำให้ตารางหลักไม่ถูกอัปเดตเลยแม้แต่แถวเดียว
-- [Statement 1] สร้างตาราง Audit Log กลางสำหรับรองรับประวัติทุกตารางในระบบ
CREATE TABLE IF NOT EXISTS audit_logs (
audit_id BIGSERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
operation VARCHAR(10) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE'
old_data JSONB, -- บันทึกโครงสร้างเก่าเป็น JSONB
new_data JSONB, -- บันทึกโครงสร้างใหม่เป็น JSONB
changed_by VARCHAR(100) DEFAULT CURRENT_USER,
changed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
-- [Statement 2] สร้าง Generic Trigger Function แปลงเรกคอร์ดเป็น JSONB
CREATE OR REPLACE FUNCTION fn_trg_generic_audit_engine()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
-- [บรรทัดที่ 1-2] ประกาศตัวแปรเก็บโครงสร้าง JSONB ค่าเดิมและค่าใหม่
v_old_jsonb JSONB := NULL;
v_new_jsonb JSONB := NULL;
BEGIN
-- [บรรทัดที่ 3-7] ตรวจสอบเหตุการณ์ TG_OP เพื่อแปลงเรกคอร์ดเป็น JSONB
IF (TG_OP = 'UPDATE') THEN
v_old_jsonb := to_jsonb(OLD); -- แปลงเรกคอร์ด OLD เป็น JSONB
v_new_jsonb := to_jsonb(NEW); -- แปลงเรกคอร์ด NEW เป็น JSONB
ELSIF (TG_OP = 'DELETE') THEN
v_old_jsonb := to_jsonb(OLD);
ELSIF (TG_OP = 'INSERT') THEN
v_new_jsonb := to_jsonb(NEW);
END IF;
-- [บรรทัดที่ 8-16] บันทึกลงตาราง audit_logs กลาง
INSERT INTO audit_logs (
table_name,
operation,
old_data,
new_data
) VALUES (
TG_TABLE_NAME, -- ชื่อนามตารางอัตโนมัติ
TG_OP, -- ชนิดการทำงาน ('INSERT'/'UPDATE'/'DELETE')
v_old_jsonb,
v_new_jsonb
);
-- 🎯 [บรรทัดที่ 17] สำหรับ AFTER Trigger คืนค่า NULL ได้ ปลอดภัย ไม่กระทบคำสั่งหลัก!
RETURN NULL;
END $$;
-- [Statement 3] ผูก Trigger เข้ากับตารางสำคัญแบบ AFTER FOR EACH ROW
CREATE TRIGGER trg_audit_customers_change
AFTER INSERT OR UPDATE OR DELETE ON customers
FOR EACH ROW
EXECUTE FUNCTION fn_trg_generic_audit_engine();
to_jsonb() รองรับทุกโครงสร้างคอลัมน์, ทำงานในระยะ AFTER การันตีว่าคำสั่งหลักทำงานสำเร็จและไม่โดน Cancel แน่นอน
แบบทดสอบวัดระดับความเข้าใจ Pillar 3: Event Triggers & Audit Logging Mechanics (BEFORE vs AFTER, OLD/NEW, TG_OP, to_jsonb) และข้อสอบสะสมทบทวนครบทั้ง Day 1–6 คลิกเลือกคำตอบ ก, ข, ค, หรือ ง เพื่อดูผลลัพธ์และคำอธิบายอย่างละเอียดทันที
บทเรียน Day 7: Pillar 3 - Event Triggers & Audit Logging Mechanics แสดงให้เห็นถึงการสร้างระบบรักษาความปลอดภัยและการตรวจสอบย้อนหลังที่ไม่มีทางถูก Bypass ด้วย In-Database Triggers การเลือกใช้ AFTER Trigger ร่วมกับตัวแปร OLD, NEW, TG_OP และเทคนิคการแปลงเป็น JSONB ช่วยให้เราบันทึกประวัติการเปลี่ยนแปลงข้อมูลของทั้งระบบได้อย่างมีประสิทธิภาพสูงสุด
Pillar 4 - Pre-computation with Materialized Views & pg_cron: ในวันถัดไป เราจะไปเจาะลึกเสาหลักสุดท้ายของ Phase 2 นั่นคือ Pre-computation & Background Scheduling Engine ด้วย Materialized Views (REFRESH MATERIALIZED VIEW CONCURRENTLY) และการตั้งเวลาทำงานอัตโนมัติด้วย pg_cron เพื่อยกเลิกภาระการคำนวณแดชบอร์ดหนักๆ ในเวลา Peak ครับ!