ข้ามไปยังเนื้อหา

Views และ materialized views

เมื่อ query ที่ซับซ้อนตัวเดิมโผล่มาเรื่อย ๆ ทั่วทั้งแอปพลิเคชันของคุณ การก็อป SQL ชุดนั้นไปวางทุกที่คือความเปราะบาง PostgreSQL ให้คุณตั้งชื่อให้ query แล้วใช้ชื่อนั้นเหมือน table มีสองแบบที่พฤติกรรมต่างกันมาก คือ view จะรัน query เบื้องหลังใหม่ทุกครั้งที่คุณอ่าน ส่วน materialized view จะเก็บผลลัพธ์ไว้และคำนวณใหม่เฉพาะเมื่อคุณสั่ง

เราใช้ table authors และ books จากโมดูล CRUD ซ้ำสำหรับตัวอย่างด้านล่าง

CREATE VIEW บันทึก SELECT ไว้ภายใต้ชื่อหนึ่ง การอ่าน view จะรัน query นั้นใหม่กับข้อมูลปัจจุบัน ผลลัพธ์จึงเป็นข้อมูลล่าสุดเสมอ และไม่มีอะไรถูกเก็บไว้นอกจากตัวนิยาม (definition)

CREATE VIEW author_book_counts AS
SELECT a.id,
a.name,
count(b.id) AS book_count,
coalesce(sum(b.copies_sold), 0) AS total_sold
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name;
CREATE VIEW

ตอนนี้ query view นี้ได้เหมือนเป็น table เลย:

SELECT name, book_count, total_sold
FROM author_book_counts
WHERE book_count > 0
ORDER BY total_sold DESC;
name | book_count | total_sold
-----------------+------------+------------
Dana Whitfield | 2 | 1800
Marcus Lindqvist| 1 | 950
(2 rows)

เพราะ query รันทุกครั้ง การ insert หนังสือใหม่แล้วอ่าน view ซ้ำจะสะท้อนการเปลี่ยนแปลงทันที ต้นทุนคือทุกการอ่านต้องจ่ายราคาเต็มของ join และการ aggregate

CREATE MATERIALIZED VIEW รัน query หนึ่งครั้งและเก็บ row ต่าง ๆ ไว้บนดิสก์ เหมือนภาพ snapshot การอ่านจึงถูกพอ ๆ กับการอ่าน table — แต่ข้อมูลจะแช่แข็งอยู่ ณ ตอนที่สร้างหรือ refresh ครั้งล่าสุด

CREATE MATERIALIZED VIEW author_book_counts_cached AS
SELECT a.id,
a.name,
count(b.id) AS book_count,
coalesce(sum(b.copies_sold), 0) AS total_sold
FROM authors a
LEFT JOIN books b ON b.author_id = a.id
GROUP BY a.id, a.name;
SELECT 3

ถ้าตอนนี้คุณ insert หนังสือใหม่ materialized view จะไม่เปลี่ยน คุณต้องสั่ง REFRESH เองเพื่อดึงข้อมูลใหม่:

REFRESH MATERIALIZED VIEW author_book_counts_cached;
REFRESH MATERIALIZED VIEW

REFRESH ธรรมดาจะจับ 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 ส่งผ่านไปยังข้อมูลสด ส่วน matview อ่าน row ที่แคชไว้ของตัวเอง
คำถามViewMaterialized 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 นั้นเป็นแบบอ่านอย่างเดียว
ตัวเลือกBenefitCost
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 ธรรมดาสำหรับข้อมูลที่ต้องสดใหม่เสมอ

เกิดอะไรขึ้นเมื่อคุณ SELECT จาก view ธรรมดา (ไม่ใช่ materialized)?
หลังจาก insert row ใหม่ลงใน table พื้นฐาน materialized view จะเห็น row นั้นเมื่อไหร่?
REFRESH MATERIALIZED VIEW CONCURRENTLY ต้องการอะไร?