กลับไปหน้าบทความ
#Database Performance#Redis#PostgreSQL#Caching#Denormalization

Query แพงโดยธรรมชาติ: เลือก Cache, Materialized View หรือ Denormalization

แนวทางออกแบบ read path เมื่อการปรับ SQL และ index ยังลด latency ได้ไม่พอ โดยเปรียบเทียบ Cache, Materialized View และ Denormalization ผ่านเรื่องความสดของข้อมูล ต้นทุน และความซับซ้อนที่ต้องแลก

11 สิงหาคม 2569อ่านประมาณ 3 นาที

แชร์บทความ

Query แพงโดยธรรมชาติ: เลือก Cache, Materialized View หรือ Denormalization

Query แพงโดยธรรมชาติ: เลือก Cache, Materialized View หรือ Denormalization

บางครั้ง Query ทำงานช้าไม่ได้เกิดจาก SQL ที่เขียนไม่ดี แต่เกิดจากลักษณะงานที่ต้องประมวลผลข้อมูลจำนวนมากโดยธรรมชาติ ตัวอย่างเช่น Dashboard ที่นับ Order ทั้งเดือน รายงานที่ Join หลายตารางและ Group By หลายมิติ หรือ API ที่ต้องคำนวณยอดขายล่าสุด สถานะลูกค้า และอันดับใหม่ทุกครั้งที่ถูกเรียก

แม้จะเพิ่ม Index ตรวจ Execution Plan แก้ปัญหา N+1 และเลือกเฉพาะ Column ที่จำเป็นแล้ว Latency ก็อาจยังสูงอยู่ สาเหตุคือระบบยังคงนำข้อมูลดิบมาคำนวณงานเดิมซ้ำในทุก Request โดยเฉพาะหน้า Analytics ที่ผู้ใช้หรือระบบเรียกซ้ำทุกไม่กี่วินาที

เมื่อการปรับ SQL อย่างเดียวไม่เพียงพอ

การปรับ SQL และโครงสร้าง Index ควรเป็นสิ่งแรกที่ทำเสมอ เพราะช่วยลดงานที่ไม่จำเป็นโดยไม่เพิ่มส่วนประกอบใหม่ให้ระบบ อย่างไรก็ตาม เมื่อ Query ได้รับการปรับอย่างเหมาะสมแล้วแต่ต้นทุนหลักยังเป็นการ Scan, Join หรือ Aggregate ข้อมูลจำนวนมาก เราต้องเปลี่ยนคำถามจาก “จะปรับ SQL อย่างไรอีก” เป็น “จำเป็นต้องคำนวณผลลัพธ์นี้ใหม่ทุกครั้งหรือไม่”

ก่อนเลือกเทคนิคใด ควรเก็บข้อมูลจากระบบจริงเพื่อให้รู้ว่า Query ใดใช้เวลามาก ถูกเรียกบ่อยเพียงใด และข้อมูลต้นทางเปลี่ยนถี่แค่ไหน ตัวเลขเหล่านี้ช่วยแยกปัญหา Query ที่ช้าแต่แทบไม่มีผู้ใช้ ออกจาก Query ที่อาจไม่ช้ามากแต่สร้างภาระมหาศาลเพราะถูกเรียกซ้ำอย่างต่อเนื่อง

สิ่งที่ควรวัดอย่างน้อยประกอบด้วย:

  • ระยะเวลาตอบสนองของ Query ทั้งค่าเฉลี่ยและค่าเปอร์เซ็นไทล์ เช่น p95 หรือ p99
  • จำนวนครั้งที่ Query ถูกเรียกในแต่ละช่วงเวลา
  • ปริมาณข้อมูลที่อ่านและจำนวนแถวที่ต้องประมวลผล
  • ความถี่ในการเปลี่ยนแปลงของข้อมูลต้นทาง
  • ระดับความสดของข้อมูลที่ผู้ใช้และธุรกิจต้องการจริง

Freshness Requirement คือเข็มทิศ

ข้อมูลทุกประเภทไม่ได้ต้องสดเท่ากัน Dashboard ภายในอาจยอมให้ยอดขายช้ากว่าความเป็นจริง 30 วินาทีได้ แต่ยอดเงินคงเหลือหลังการชำระเงินอาจคลาดเคลื่อนแม้เพียงหนึ่งวินาทีไม่ได้ การระบุ Freshness Requirement จึงเป็นเงื่อนไขทางธุรกิจ ไม่ใช่รายละเอียดเชิงเทคนิคที่ทีมพัฒนาควรเดาเอง

คำว่า Real-time มักถูกใช้กว้างเกินไป จึงควรแปลงให้เป็นตัวเลขที่ตรวจสอบได้ เช่น ผู้ใช้ยอมรับข้อมูลเก่าได้ไม่เกิน 10 วินาที รายงานต้องอัปเดตภายใน 5 นาที หรือยอดคงเหลือต้องสะท้อนผลหลัง Transaction สำเร็จทันที เมื่อกำหนดขอบเขตได้ชัดเจน การเลือก Cache, Materialized View หรือ Denormalization จะง่ายขึ้นมาก

Cache: ลดการคำนวณซ้ำสำหรับข้อมูลที่อ่านบ่อย

Cache เหมาะกับข้อมูลแบบ Read-heavy ซึ่งมีการอ่านซ้ำสูงและยอมให้ผลลัพธ์เก่าได้ในช่วงเวลาหนึ่ง ตัวอย่างที่พบได้บ่อย ได้แก่ Product List, Profile Summary, Configuration, Feature Flag และ Popular Feed โดยระบบอาจอ่านข้อมูลจาก Redis หรือ Memory ก่อน แล้วจึงเรียก Database เมื่อไม่พบข้อมูลใน Cache

สมมติว่ามี Endpoint GET /products?page=1&category=book ซึ่งรับ Traffic สูง แต่ข้อมูลสินค้าไม่ได้เปลี่ยนทุกวินาที การ Cache ผลลัพธ์ไว้ 60 วินาทีอาจลดจำนวน Query ที่เข้าสู่ Database ได้อย่างมาก โดยยังไม่ต้องปรับ Schema หรือสร้างกระบวนการคำนวณข้อมูลชุดใหม่

รูปแบบ Cache-aside โดยทั่วไปมีลำดับดังนี้:

  1. สร้าง Cache Key จากชื่อข้อมูลและ Parameter ที่มีผลต่อผลลัพธ์
  2. ตรวจสอบว่ามีข้อมูลอยู่ใน Redis หรือ Memory หรือไม่
  3. หากพบข้อมูล ให้ส่งผลลัพธ์จาก Cache กลับทันที
  4. หากไม่พบ ให้ Query Database แล้วบันทึกผลลัพธ์ลง Cache พร้อม TTL
  5. เมื่อเกิด Write สำคัญ ให้ลบหรืออัปเดต Cache ที่เกี่ยวข้องตาม Policy

ความท้าทายหลักของ Cache คือ Invalidation เพราะข้อมูลใน Database อาจเปลี่ยนแล้วแต่ค่าเก่ายังอยู่ใน Cache นอกจากนี้ยังต้องระวัง Cache Key ที่ไม่รวม Parameter สำคัญ, TTL ที่สั้นจนแทบไม่ลดโหลด และ TTL ที่ยาวจนผู้ใช้เห็นข้อมูลเก่าเกินระดับที่ยอมรับได้

Policy ของ Cache จึงควรระบุให้ชัดว่าข้อมูลใด Cache ได้ ใช้ Key รูปแบบใด มี TTL เท่าไร และเหตุการณ์ใดต้องทำให้ Cache หมดอายุ การใส่ Cache ให้ทุก Endpoint โดยไม่มีหลักเกณฑ์อาจทำให้ระบบเร็วขึ้นเพียงเล็กน้อย แต่เพิ่มปัญหาที่ตรวจสอบและ Debug ได้ยากมาก

PostgreSQL Materialized View: เตรียมผลลัพธ์ของ Query ราคาแพงไว้ล่วงหน้า

Materialized View เหมาะกับ Query ที่คำนวณแพงแต่มีรูปแบบผลลัพธ์ค่อนข้างคงที่ เช่น รายงานยอดขายรายวัน จำนวน Active User ต่อทีม ยอด Invoice รายเดือน หรือ Leaderboard ที่ไม่จำเป็นต้องเปลี่ยนแบบ Real-time ในทุก Request

View ปกติเก็บเพียงนิยามของ Query และยังต้องประมวลผลใหม่เมื่อถูกอ่าน ส่วน Materialized View จะจัดเก็บผลลัพธ์ที่คำนวณแล้วไว้จริง ทำให้ API สามารถอ่านข้อมูลสรุปขนาดเล็กแทนการ Scan ตารางต้นทางหลายล้านแถวได้ แต่ข้อมูลจะใหม่ขึ้นก็ต่อเมื่อมีการ Refresh

ตัวอย่างการสร้าง Materialized View สำหรับสรุปยอดขายรายวันใน PostgreSQL มีลักษณะดังนี้:

CREATE MATERIALIZED VIEW sales_daily_summary AS
SELECT
  DATE(created_at) AS sale_date,
  SUM(total_amount) AS total_sales,
  COUNT(*) AS order_count
FROM orders
GROUP BY DATE(created_at);

CREATE UNIQUE INDEX sales_daily_summary_date_idx
ON sales_daily_summary (sale_date);

REFRESH MATERIALIZED VIEW CONCURRENTLY sales_daily_summary;

ระบบอาจใช้ Cron หรือ Background Job สั่ง Refresh ทุก 5 นาที จากนั้น Dashboard จะอ่านจาก sales_daily_summary แทนการ Aggregate ตาราง orders ทุกครั้ง ช่วงเวลา Refresh ควรสัมพันธ์กับ Freshness Requirement และต้นทุนการประมวลผลจริง ไม่ใช่เลือกจากค่าที่นิยมใช้ทั่วไป

Refresh Strategy เป็นจุดที่ต้องออกแบบอย่างระมัดระวัง เพราะ Refresh บ่อยเกินไปอาจใช้ทรัพยากรใกล้เคียงกับการ Query ซ้ำ ส่วน Refresh ช้าเกินไปทำให้ข้อมูลเก่ากว่าที่ธุรกิจรับได้ บางระบบอาจต้องใช้ Concurrent Refresh หรือสร้าง Summary Table และอัปเดตแบบ Incremental เพื่อหลีกเลี่ยงการ Block การอ่านและลดปริมาณงานในแต่ละรอบ

Denormalization: ย้ายความซับซ้อนจากฝั่งอ่านไปฝั่งเขียน

Denormalization เหมาะเมื่อ Read Path มีความสำคัญสูงและระบบยอมรับความซับซ้อนเพิ่มเติมตอนเขียนข้อมูลได้ แนวทางนี้อาจเป็นการเก็บ customer_name ไว้ใน Order, เก็บ comment_count ไว้ใน Post, เก็บ last_message_preview ไว้ใน Conversation หรือเก็บ total_spent ไว้ใน Customer Summary

ข้อดีคือ Endpoint สามารถอ่านข้อมูลได้โดยไม่ต้อง Join หรือ Aggregate ซ้ำทุกครั้ง จึงเหมาะกับหน้ารายการที่มี Traffic สูง เช่น Order History, Chat Inbox และ Feed Card อย่างไรก็ตาม ความเร็วในการอ่านที่เพิ่มขึ้นไม่ได้มาฟรี เพราะระบบต้องรับผิดชอบการอัปเดตข้อมูลซ้ำหลายตำแหน่งให้สอดคล้องกัน

ก่อนใช้ Denormalization ควรตอบคำถามให้ได้ว่าหากชื่อลูกค้าเปลี่ยนต้องอัปเดตกี่แห่ง หาก Comment ถูกลบ Counter จะลดลงอย่างถูกต้องหรือไม่ และหาก Background Job ล้มเหลวกลางทางจะตรวจพบข้อมูลที่คลาดเคลื่อนได้อย่างไร หากไม่มีคำตอบที่ชัดเจน ปัญหาด้าน Consistency อาจสร้างความเสียหายมากกว่าประโยชน์จากความเร็ว

ระบบที่ใช้ Denormalization ควรมีเครื่องมือซ่อมและตรวจสอบข้อมูลรองรับ เช่น:

  • Background Reconciliation Job สำหรับเปรียบเทียบค่าที่สรุปไว้กับข้อมูลต้นทาง
  • Script สำหรับ Rebuild Summary หรือ Counter ใหม่ทั้งหมด
  • Event Log ที่สามารถ Replay เหตุการณ์ได้
  • Alert สำหรับตรวจจับค่าที่ผิดเงื่อนไขหรือคลาดเคลื่อนผิดปกติ
  • Test ที่ครอบคลุมกรณีเพิ่ม แก้ไข ลบ และการทำงานล้มเหลวระหว่างทาง

เลือกเทคนิคให้ตรงกับลักษณะงาน

ไม่มีเทคนิคใดดีที่สุดสำหรับทุก Endpoint การตัดสินใจควรพิจารณาทั้งความถี่ในการอ่าน ระดับความสด ตำแหน่งของต้นทุน และผลกระทบเมื่อข้อมูลคลาดเคลื่อน โดยสามารถเริ่มจากคำถามสี่ข้อดังนี้:

  1. Query นี้ถูกเรียกบ่อยเพียงใด และมี Traffic สูงสุดช่วงไหน
  2. ผลลัพธ์ต้องสดระดับวินาที นาที หรือชั่วโมง
  3. ต้นทุนหลักของระบบอยู่ที่ Read หรือ Write
  4. หากผู้ใช้เห็นข้อมูลเก่า จะสร้างเพียงความไม่สะดวกหรือก่อให้เกิดความเสียหายทางธุรกิจ

หากผลลัพธ์เดิมถูกอ่านซ้ำจำนวนมากและยอมให้เก่าได้ช่วงสั้น ๆ Cache มักเป็นจุดเริ่มต้นที่เหมาะสม หากเป็นรายงานหรือ Aggregate ราคาแพงซึ่งอัปเดตเป็นรอบได้ ควรพิจารณา Materialized View หรือ Summary Table ส่วน Endpoint ที่ต้องอ่านเร็วสม่ำเสมอและธุรกิจยอมรับ Write Path ที่ซับซ้อนขึ้นได้จึงค่อยพิจารณา Denormalization

หลายระบบสามารถใช้เทคนิคเหล่านี้ร่วมกันได้ เช่น สร้าง Materialized View ทุก 5 นาทีแล้ว Cache ผลลัพธ์จาก API อีก 30 วินาที หรือใช้ Denormalized Summary เป็นแหล่งอ่านก่อนเก็บ Response ยอดนิยมไว้ใน Redis การผสมเทคนิคควรเกิดจากข้อมูลการใช้งานจริง เพราะทุกชั้นที่เพิ่มเข้ามาย่อมเพิ่ม Failure Mode และภาระในการดูแล

ลำดับการปรับปรุงที่ควบคุมความเสี่ยงได้

การเริ่มต้นด้วยคำตอบสำเร็จรูปอย่าง “ใส่ Redis ก่อน” หรือ “Denormalize ไปเลย” อาจซ่อนสาเหตุที่แท้จริงและเพิ่มความซับซ้อนโดยไม่จำเป็น ลำดับการทำงานที่ช่วยให้วัดผลและย้อนกลับได้ง่ายกว่าคือ:

  1. วัด Query และ Traffic จากระบบจริงเพื่อหา Bottleneck ที่มีผลต่อผู้ใช้
  2. แก้ SQL, Index, N+1 และการเลือก Column ที่มีปัญหาชัดเจนก่อน
  3. ตกลง Freshness Requirement กับเจ้าของผลิตภัณฑ์หรือฝ่ายธุรกิจ
  4. เลือก Cache, Materialized View หรือ Denormalization ตาม Trade-off ของงาน
  5. เพิ่ม Monitoring สำหรับ Latency, Cache Hit Rate, Refresh Duration และข้อมูลที่คลาดเคลื่อน
  6. เปรียบเทียบผลก่อนและหลังปรับเพื่อยืนยันว่า Read Path เร็วขึ้นและโหลด Database ลดลงจริง

การวัดผลหลังเปลี่ยนแปลงมีความสำคัญพอ ๆ กับการออกแบบ หาก Cache Hit Rate ต่ำ Materialized View ใช้เวลา Refresh นานเกินรอบ หรือ Summary Data คลาดเคลื่อนบ่อย ระบบควรมีข้อมูลเพียงพอให้ทีมปรับ Policy หรือย้อนกลับได้โดยไม่ต้องคาดเดา

สรุป

งานด้าน Performance ไม่ได้หมายถึงการทำให้ทุกอย่างเร็วขึ้นโดยไม่มีต้นทุน แต่คือการย้ายต้นทุนไปยังตำแหน่งที่ระบบควบคุมได้ Cache ย้ายภาระไปที่ Invalidation, Materialized View ย้ายไปที่ Refresh และ Denormalization ย้ายไปที่ Write Complexity กับ Data Consistency

เมื่อเข้าใจว่าข้อมูลต้องสดเพียงใดและยอมรับความเสี่ยงแบบไหนได้ เราจะไม่ปรับระบบด้วยเครื่องมือที่กำลังเป็นกระแส แต่จะออกแบบ Read Path ให้เหมาะกับพฤติกรรมการใช้งานจริง เป้าหมายจึงไม่ใช่เพียง Query ที่เร็วกว่าเดิม แต่เป็นระบบที่เร็วพอ ตรวจสอบได้ และรักษาความถูกต้องในระดับที่ธุรกิจต้องการ