Materialized Views trong PostgreSQL: Biến các truy vấn 15 giây thành bản chụp nhanh 5ms

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Khi Dashboard của bạn bắt đầu “rùa bò”

Tôi đã dành những năm đầu sự nghiệp của mình nhảy qua lại giữa MySQL, PostgreSQL và MongoDB để cố gắng tìm kiếm giải pháp tối ưu hiệu năng “hoàn hảo”. Mặc dù mỗi cái đều có ưu điểm riêng, nhưng PostgreSQL nổi bật nhờ bộ công cụ tích hợp sẵn để xử lý các tác vụ phân tích nặng. Tất cả chúng ta đều đã từng thấy điều này: một truy vấn dashboard chỉ mất 200ms trong quá trình phát triển, nhưng đột nhiên tốn tới 15 giây khi bảng orders đạt mốc 10 triệu dòng.

Index (chỉ mục) rất tuyệt vời, nhưng cuối cùng chúng cũng sẽ chạm ngưỡng giới hạn. Khi bạn bắt đầu chồng chất các phép join bốn bảng, các hàm window function và các phép gộp (aggregation) phức tạp, một chỉ mục B-tree tiêu chuẩn là không đủ. Đây chính là lúc Materialized Views (MV) trở thành cứu cánh. Hãy coi MV như một bản chụp nhanh vật lý (physical snapshot). Thay vì tính toán lại tổng doanh thu cho năm năm qua mỗi khi người dùng nhấn ‘Refresh’, cơ sở dữ liệu sẽ cung cấp kết quả đã được tính toán trước và lưu trực tiếp trên đĩa.

Standard Views vs. Materialized Views: Khác biệt là gì?

Trước khi bắt đầu cấu trúc lại (refactoring), bạn cần biết MVs nằm ở đâu trong kiến trúc của mình. Tôi thường phân loại các chiến lược báo cáo thành ba nhóm:

  • Standard Views (View tiêu chuẩn): Đây chỉ là các bí danh truy vấn được lưu lại. Mỗi khi bạn gọi một view, PostgreSQL sẽ chạy toàn bộ truy vấn gốc bên dưới. Chúng giúp mã nguồn của bạn sạch sẽ hơn, nhưng không mang lại hiệu năng tăng thêm.
  • Materialized Views (View vật lý hóa): Những view này lưu kết quả truy vấn vào đĩa. Việc đọc từ một MV nhanh như đọc từ một bảng thông thường vì về mặt kỹ thuật, nó chính là một bảng. Tuy nhiên, dữ liệu không ở trạng thái “live” (trực tiếp) — nó chỉ cập nhật khi bạn kích hoạt lệnh làm mới (refresh) một cách thủ công.
  • Application-level Caching (Redis): Cách này cực kỳ nhanh (dưới một mili giây) nhưng gây ra chi phí quản lý đáng kể. Bạn phải viết logic để xử lý việc thu hồi cache (cache invalidation), một nơi thường xuyên phát sinh các lỗi không đồng bộ dữ liệu.

Materialized Views là điểm cân bằng lý tưởng. Chúng mang lại cho bạn tốc độ truy cập cao trong khi vẫn giữ logic nghiệp vụ ở đúng vị trí của nó: bên trong cơ sở dữ liệu.

Sự đánh đổi: Kỹ thuật cơ sở dữ liệu là sự cân bằng

Không có sự tối ưu nào là miễn phí. Mặc dù MVs có thể biến một báo cáo 30 giây thành kết quả trong 5ms, chúng đòi hỏi một chút công sức bảo trì.

Ưu điểm

  • Đọc tức thì: CPU của bạn không cần phải làm việc để join các bảng hoặc tính tổng hàng triệu dòng trong quá trình yêu cầu. Nó chỉ việc lấy ra các hàng đã được tính toán trước.
  • Tùy biến Indexing: Không giống như view tiêu chuẩn, bạn có thể tạo index trên một Materialized View. Nếu báo cáo của bạn cần lọc dữ liệu đã cache theo một user_id hoặc date_range cụ thể, một index trên MV sẽ giúp nó nhanh hơn nữa.
  • Giảm tải cho Production: Các báo cáo nặng sẽ không làm khóa (lock) các bảng chính như orders hoặc users vì chúng đang truy vấn trên một bản chụp riêng biệt.

Nhược điểm

  • Dữ liệu cũ (Stale Data): Theo thiết kế, dữ liệu chỉ mới như lần refresh cuối cùng của bạn. Nếu người dùng cần thấy giao dịch ngay giây phút nó xảy ra, MVs không phải là công cụ phù hợp.
  • Sử dụng dung lượng đĩa: Vì bạn đang lưu trữ kết quả một cách vật lý, MVs tiêu tốn thêm bộ nhớ. Một báo cáo gộp 100 triệu dòng có thể chỉ chiếm vài megabyte, nhưng đó là điều cần theo dõi.
  • Chi phí làm mới (Refresh Overhead): Việc xây dựng lại một view lớn sẽ tốn tài nguyên. Bạn cần lập lịch các lần refresh này vào giờ thấp điểm hoặc sử dụng tính năng cập nhật đồng thời (concurrent updates).

Danh sách kiểm tra 3 điểm đơn giản

Tôi thường chạy qua ba câu hỏi này trước khi quyết định dùng MV:

  1. Truy vấn có mất hơn 2 giây để thực thi không?
  2. Dữ liệu gốc có thay đổi chậm không (như nhật ký hàng ngày hoặc các chỉ số tài chính lịch sử)?
  3. Người dùng cuối có thể chấp nhận dữ liệu cũ khoảng 15 phút hoặc thậm chí một giờ không?

Nếu bạn trả lời “Có” cho cả ba, bạn đã có một ứng cử viên hoàn hảo cho MV. Đây là quy trình tiêu chuẩn cho các bảng xếp hạng doanh số hàng ngày hoặc tóm tắt doanh thu hàng tháng.

Triển khai: Từ khởi tạo đến làm mới đồng thời

Hãy xem xét một kịch bản thực tế. Hãy tưởng tượng một trang thương mại điện tử cần hiển thị tổng doanh số theo danh mục cho năm qua. Điều này liên quan đến việc join các bảng orders, order_items, và products — một truy vấn sẽ chậm dần theo cấp số nhân khi cửa hàng của bạn phát triển.

Bước 1: Tạo bản chụp nhanh (Snapshot)

CREATE MATERIALIZED VIEW category_sales_stats AS
SELECT 
    p.category_name,
    SUM(oi.price * oi.quantity) as total_revenue,
    COUNT(DISTINCT o.id) as order_count,
    NOW() as last_updated
FROM products p
JOIN order_items oi ON p.id = oi.product_id
JOIN orders o ON oi.order_id = o.id
WHERE o.created_at >= NOW() - INTERVAL '1 year' -- Chỉ lấy dữ liệu trong 1 năm qua
GROUP BY p.category_name;

Việc truy vấn SELECT * FROM category_sales_stats; giờ đây diễn ra gần như tức thì.

Bước 2: Thêm Index

Nếu ứng dụng của bạn thường xuyên tìm kiếm một danh mục cụ thể, hãy đánh index cho nó giống như một bảng thông thường:

CREATE INDEX idx_category_name ON category_sales_stats(category_name);

Bước 3: Làm mới không gây gián đoạn

Các lệnh refresh tiêu chuẩn sẽ khóa (lock) view. Điều này có nghĩa là trong khi cơ sở dữ liệu đang tính toán lại dữ liệu, người dùng của bạn sẽ gặp lỗi timeout hoặc thông báo lỗi. Trong môi trường production, đây là một điều không thể chấp nhận.

Để khắc phục điều này, hãy sử dụng từ khóa CONCURRENTLY. Lưu ý rằng việc này **yêu cầu một unique index** trên view để PostgreSQL có thể theo dõi hàng nào cần cập nhật.

-- Đầu tiên, đảm bảo có một định danh duy nhất
CREATE UNIQUE INDEX idx_category_unique ON category_sales_stats(category_name);

-- Bây giờ, hãy làm mới mà không chặn các truy vấn của người dùng
REFRESH MATERIALIZED VIEW CONCURRENTLY category_sales_stats;

Cách tự động hóa quy trình

PostgreSQL không có trình lập lịch “nội tại” cho MVs, vì vậy bạn phải tự mình kích hoạt lệnh refresh. Dưới đây là hai cách đáng tin cậy nhất để thực hiện.

Lựa chọn A: Sử dụng pg_cron

Nếu bạn đang dùng AWS RDS hoặc một dịch vụ được quản lý, pg_cron thường có sẵn. Bạn có thể lập lịch refresh trực tiếp trong SQL console:

-- Làm mới số liệu thống kê vào đầu mỗi giờ
SELECT cron.schedule('0 * * * *', $$REFRESH MATERIALIZED VIEW CONCURRENTLY category_sales_stats$$);

Lựa chọn B: Sử dụng Cron Job trên Linux

Nếu bạn đang tự quản lý máy chủ của mình, một đoạn mã bash script 4 dòng sẽ giải quyết vấn đề hoàn hảo. Tạo tệp refresh_views.sh:

#!/bin/bash
# Chạy lệnh làm mới và ghi nhật ký kết quả vào system journal
psql -d my_db -U postgres -c "REFRESH MATERIALIZED VIEW CONCURRENTLY category_sales_stats;"
logger "Đã làm mới PostgreSQL MV: category_sales_stats"

Thêm nó vào crontab (crontab -e) để chạy mỗi giờ:

0 * * * * /path/to/refresh_views.sh

Lời kết

Materialized Views là một cách tuyệt vời để tăng tốc mọi thứ, nhưng hãy giữ cho các truy vấn gốc của bạn gọn gàng. Đừng sử dụng SELECT *; chỉ cache các cột cụ thể mà báo cáo của bạn thực sự cần. Hãy để mắt đến pg_stat_user_tables để theo dõi mức tăng trưởng lưu trữ. Bằng cách chuyển các tính toán nặng nề vào các bản chụp vật lý này, bạn sẽ giữ cho ứng dụng của mình luôn mượt mà và máy chủ cơ sở dữ liệu không bị quá tải.

Share: