Views และ materialized views
เมื่อ query ที่ซับซ้อนตัวเดิมโผล่มาเรื่อย ๆ ทั่วทั้งแอปพลิเคชันของคุณ การก็อป SQL ชุดนั้นไปวางทุกที่คือความเปราะบาง PostgreSQL ให้คุณตั้งชื่อให้ query แล้วใช้ชื่อนั้นเหมือน table มีสองแบบที่พฤติกรรมต่างกันมาก คือ view จะรัน query เบื้องหลังใหม่ทุกครั้งที่คุณอ่าน ส่วน materialized view จะเก็บผลลัพธ์ไว้และคำนวณใหม่เฉพาะเมื่อคุณสั่ง
เราใช้ table authors และ books จากโมดูล CRUD ซ้ำสำหรับตัวอย่างด้านล่าง
view คือ query ที่ถูกเก็บไว้
หัวข้อที่มีชื่อว่า “view คือ query ที่ถูกเก็บไว้”CREATE VIEW บันทึก SELECT ไว้ภายใต้ชื่อหนึ่ง การอ่าน view จะรัน query นั้นใหม่กับข้อมูลปัจจุบัน ผลลัพธ์จึงเป็นข้อมูลล่าสุดเสมอ และไม่มีอะไรถูกเก็บไว้นอกจากตัวนิยาม (definition)
CREATE VIEW author_book_counts ASSELECT a.id, a.name, count(b.id) AS book_count, coalesce(sum(b.copies_sold), 0) AS total_soldFROM authors aLEFT JOIN books b ON b.author_id = a.idGROUP BY a.id, a.name;CREATE VIEWตอนนี้ query view นี้ได้เหมือนเป็น table เลย:
SELECT name, book_count, total_soldFROM author_book_countsWHERE book_count > 0ORDER BY total_sold DESC; name | book_count | total_sold-----------------+------------+------------ Dana Whitfield | 2 | 1800 Marcus Lindqvist| 1 | 950(2 rows)เพราะ query รันทุกครั้ง การ insert หนังสือใหม่แล้วอ่าน view ซ้ำจะสะท้อนการเปลี่ยนแปลงทันที ต้นทุนคือทุกการอ่านต้องจ่ายราคาเต็มของ join และการ aggregate
materialized view แคชผลลัพธ์ไว้
หัวข้อที่มีชื่อว่า “materialized view แคชผลลัพธ์ไว้”CREATE MATERIALIZED VIEW รัน query หนึ่งครั้งและเก็บ row ต่าง ๆ ไว้บนดิสก์ เหมือนภาพ snapshot การอ่านจึงถูกพอ ๆ กับการอ่าน table — แต่ข้อมูลจะแช่แข็งอยู่ ณ ตอนที่สร้างหรือ refresh ครั้งล่าสุด
CREATE MATERIALIZED VIEW author_book_counts_cached ASSELECT a.id, a.name, count(b.id) AS book_count, coalesce(sum(b.copies_sold), 0) AS total_soldFROM authors aLEFT JOIN books b ON b.author_id = a.idGROUP BY a.id, a.name;SELECT 3ถ้าตอนนี้คุณ insert หนังสือใหม่ materialized view จะไม่เปลี่ยน คุณต้องสั่ง REFRESH เองเพื่อดึงข้อมูลใหม่:
REFRESH MATERIALIZED VIEW author_book_counts_cached;REFRESH MATERIALIZED VIEWREFRESH ธรรมดาจะจับ lock ที่บล็อกการอ่านตลอดช่วงที่สร้างใหม่ หากต้องการให้ view ยังอ่านได้ระหว่าง refresh ให้เพิ่ม CONCURRENTLY — สิ่งนี้ต้องมี unique index บน materialized view ก่อน:
CREATE UNIQUE INDEX idx_abc_cached_id ON author_book_counts_cached (id);
REFRESH MATERIALIZED VIEW CONCURRENTLY author_book_counts_cached;ใน pgAdmin: ทั้งสองอ็อบเจกต์จะปรากฏในแผนผัง browser — view อยู่ใต้ Views และ materialized view อยู่ใต้ Materialized Views การคลิกขวาที่ materialized view จะมีตัวเลือก Refresh ที่รันคำสั่งนั้นให้คุณ
ทั้งสองต่างกันอย่างไรตอนอ่าน
หัวข้อที่มีชื่อว่า “ทั้งสองต่างกันอย่างไรตอนอ่าน”ทั้งสองมีพฤติกรรมเหมือนกันตอนเขียน (SELECT ... FROM name) แต่ต่างกันที่เบื้องหลัง view ส่งต่อ query ของคุณไปยัง definition ที่เก็บไว้ ส่วน materialized view ตอบจาก row ที่เก็บไว้ของตัวเอง
flowchart LR
subgraph View
Q1[SELECT from view] --> D1[Stored query definition]
D1 --> T1[(Live tables)]
T1 --> R1[Always-fresh result]
end
subgraph Materialized view
Q2[SELECT from matview] --> S2[(Stored result rows)]
S2 --> R2[Fast but possibly stale result]
RF[REFRESH] --> T2[(Live tables)]
T2 --> S2
end การเลือกระหว่างทั้งสอง
หัวข้อที่มีชื่อว่า “การเลือกระหว่างทั้งสอง”| คำถาม | View | Materialized view |
|---|---|---|
| ผลลัพธ์เป็นปัจจุบันเสมอหรือไม่? | ใช่ | เฉพาะหลัง refresh |
| การอ่านแต่ละครั้งมีต้นทุนแค่ไหน? | ต้นทุน query เต็ม | ถูก (อ่าน row ที่เก็บไว้) |
| ใช้ดิสก์เก็บผลลัพธ์หรือไม่? | ไม่ | ใช่ |
| ทำ index ให้ผลลัพธ์โดยตรงได้หรือไม่? | ไม่ | ได้ |
| เหมาะเมื่อ | ข้อมูลเปลี่ยนบ่อย | query มีต้นทุนสูง และยอมรับความล้าสมัยได้ |
ใช้ view ธรรมดาเพื่อจัดระเบียบและนำ query ที่มีต้นทุนยอมรับได้ในทุกการอ่านมาใช้ซ้ำ หยิบ materialized view มาใช้เมื่อ query มีต้นทุนสูงจริง ๆ (join หนัก ๆ การ aggregate บน table ใหญ่) และแอปพลิเคชันของคุณทนกับผลลัพธ์ที่ตามหลังอยู่หลายนาทีหรือหลายชั่วโมงได้
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- materialized view ล้าสมัยโดยนิยามจนกว่าคุณจะสั่ง refresh ดังนั้นตั้งตารางเวลา refresh (cron job, scheduled task หรือ trigger) ให้สอดคล้องกับความสดใหม่ที่ข้อมูลต้องการ
REFRESH MATERIALIZED VIEW CONCURRENTLYต้องมี unique index บน view และทำงานหนักกว่า แต่ปล่อยให้อ่านต่อได้ระหว่างสร้างใหม่ การ refresh ธรรมดาเร็วกว่าแต่บล็อกผู้อ่าน- view ธรรมดาไม่เพิ่มพื้นที่จัดเก็บและไม่มีความล้าสมัย แต่ก็ไม่ได้ช่วยให้เร็วขึ้นเช่นกัน เพราะการอ่านแต่ละครั้งรัน query เต็มใหม่ ประโยชน์อยู่ที่การใช้ซ้ำและความอ่านง่าย ไม่ใช่ประสิทธิภาพ
- โดยทั่วไปคุณเขียนผ่าน view ที่ aggregate หรือ join ไม่ได้ view แบบ table เดียวง่าย ๆ อาจ update ได้ แต่ทันทีที่คุณเพิ่ม
GROUP BYหรือ join ให้ถือว่า view นั้นเป็นแบบอ่านอย่างเดียว
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
VIEW ธรรมดา | ผลลัพธ์เป็นปัจจุบันเสมอ ไม่ใช้พื้นที่ดิสก์เพิ่ม | รัน query เบื้องหลังใหม่ทุกครั้งที่อ่าน ต้นทุนเต็มทุก request |
| Materialized view | อ่านเร็วเพราะเป็นการอ่าน row ที่เก็บไว้ ทำ index บนผลลัพธ์ได้ | ข้อมูลล้าสมัยได้จนกว่าจะ REFRESH และตัว refresh เองก็มีต้นทุน |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- คาดหวังข้อมูลสดจาก materialized view — ข้อมูลสดแค่ ณ ตอน
REFRESHล่าสุดเท่านั้น ถ้าต้องการข้อมูล real-time ให้ใช้ view ธรรมดาหรือ query ตรง table แทน - รัน
REFRESH MATERIALIZED VIEWธรรมดาบน production โดยไม่คิด — คำสั่งนี้จับ lock ที่บล็อกการอ่านระหว่างสร้างใหม่ ถ้าต้องการ non-blocking refresh ต้องใช้CONCURRENTLYร่วมกับ unique index บน materialized view นั้น - ใช้ materialized view กับข้อมูลที่เปลี่ยนทุกวินาที — ถ้าข้อมูลเปลี่ยนเร็วมาก view ธรรมดาหรือ query ตรง ๆ จะง่ายกว่าและถูกต้องเสมอ ไม่ต้องมาคอยจัดการเรื่อง refresh schedule
💡 ตัวอย่างจากของจริง
Dashboard วิเคราะห์ข้อมูลและเครื่องมือ reporting — ที่สร้างบน PostgreSQL มักใช้ materialized view เพื่อ pre-compute การ aggregate ที่มีต้นทุนสูง แล้ว refresh ตามตารางเวลาแทนที่จะคำนวณใหม่ทุก request
API ที่สร้างบน Hasura หรือ PostgREST — มักเปิด view ให้เป็น endpoint ตรง ๆ โดยใช้ materialized view สำหรับรายงานที่หนักและยอมรับความล้าสมัยได้ ส่วน view ธรรมดาสำหรับข้อมูลที่ต้องสดใหม่เสมอ