Views 與 Materialized Views:PostgreSQL 的虛擬關係與物化快取 | PostgreSQL
View(視圖) 是 PostgreSQL 中以 SQL 查詢定義的虛擬關係,本身不儲存資料,每次查詢時動態執行底層定義。Materialized View(物化視圖) 則將查詢結果實體化儲存在磁碟上,以資料新鮮度換取查詢效能。兩者皆是簡化複雜查詢、實作權限控制與建立穩定 API Layer 的核心工具。
View 的內部實作:Rule System
PostgreSQL 的 View 並非透過迭代器直接執行,而是建立在 Rule System(規則系統) 之上。當你執行 CREATE VIEW,PostgreSQL 實際上是在系統目錄中為該視圖物件新增一條重寫規則:
View 查詢改寫流程
原始查詢 → Parser → Rewriter → Planner → Executor
↑
Rule System
(展開 View 定義)
查詢進入 Rewriter 階段時,所有對視圖的引用會被展開成對應的定義查詢。這意味著 View 本質上是 語法糖(Syntactic Sugar),查詢規劃器看到的是完整展開後的查詢,因此能夠進行端到端的最佳化,包括 Predicate Pushdown(下推過濾條件)。
與函數呼叫的關鍵差異: SQL 函數的最佳化邊界在函數外側,而 View 因為在 Rewriter 層展開,規劃器可以跨 View 邊界優化。
系統目錄位置:
| 目錄表 | 用途 |
|---|---|
pg_class | 記錄 View 作為關係物件(relkind = 'v') |
pg_rewrite | 儲存 View 的重寫規則定義 |
pg_depend | 追蹤 View 與底層表格的相依關係 |
-- 查看視圖的重寫規則
SELECT rulename, ev_type, is_instead, ev_qual, ev_action
FROM pg_rewrite
WHERE ev_class = 'my_view'::regclass;
View vs Materialized View 差異表
| 面向 | View | Materialized View |
|---|---|---|
| 資料儲存 | 不儲存,每次執行定義查詢 | 實體儲存在磁碟上 |
| 資料新鮮度 | 永遠是最新(即時) | 取決於最後一次 REFRESH |
| 查詢效能 | 等同底層查詢 | 可大幅提升(避免重複計算) |
| 支援索引 | 不支援 | 支援(可在其欄位建立索引) |
| 更新能力 | 部分支援(Updatable View) | 唯讀,僅能 REFRESH 整體 |
| 磁碟空間 | 無額外佔用 | 需要與結果集相同的空間 |
| 適用場景 | 權限控制、簡化查詢 | 昂貴聚合、報表、快取 |
CREATE VIEW 基本操作
-- 基本 View 建立
CREATE VIEW active_users AS
SELECT
id,
username,
email,
created_at
FROM users
WHERE is_active = TRUE
AND deleted_at IS NULL;
-- 帶欄位別名的 View
CREATE VIEW order_summary (order_id, customer_name, total_amount, status) AS
SELECT
o.id,
c.full_name,
SUM(oi.price * oi.quantity),
o.status
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id, c.full_name, o.status;
-- CREATE OR REPLACE VIEW:替換現有 View(欄位名稱與類型必須相容)
CREATE OR REPLACE VIEW active_users AS
SELECT
id,
username,
email,
last_login_at, -- 新增欄位(只能追加,不能刪除或改變既有欄位類型)
created_at
FROM users
WHERE is_active = TRUE
AND deleted_at IS NULL;
-- 刪除 View
DROP VIEW IF EXISTS active_users;
DROP VIEW IF EXISTS active_users CASCADE; -- 同時刪除相依物件
Updatable View
PostgreSQL 支援 Simple View 自動可更新(Automatically Updatable),但需滿足嚴格條件:
FROM子句只有 一個來源(表格或另一個 Updatable View)- 不含
DISTINCT、GROUP BY、HAVING、LIMIT、OFFSET - 不含集合運算:
UNION、INTERSECT、EXCEPT SELECT清單不含視窗函數或聚合函數
滿足以上條件的 View,PostgreSQL 會自動允許對其執行 INSERT、UPDATE、DELETE,並將操作轉譯至底層表格。
INSTEAD OF Trigger
當 View 不滿足 Simple View 條件時,可透過 INSTEAD OF 觸發器實作可更新行為:
-- 定義處理函數
CREATE OR REPLACE FUNCTION fn_insert_employee_view()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
INSERT INTO employees (name, department_id, salary)
VALUES (NEW.name, NEW.department_id, NEW.salary)
RETURNING id INTO NEW.id;
RETURN NEW;
END;
$$;
-- 在 View 上建立 INSTEAD OF Trigger
CREATE TRIGGER trg_insert_employee_view
INSTEAD OF INSERT ON employee_view
FOR EACH ROW
EXECUTE FUNCTION fn_insert_employee_view();
WITH CHECK OPTION
WITH CHECK OPTION 確保透過 View 進行的 INSERT 或 UPDATE,所產生的結果列必須仍然滿足 View 的篩選條件:
-- 建立帶 CHECK OPTION 的 View
CREATE OR REPLACE VIEW active_employees AS
SELECT id, name, department_id, is_active
FROM employees
WHERE is_active = TRUE
WITH CHECK OPTION;
-- 以下 UPDATE 會失敗:更新後的列不再符合 is_active = TRUE
UPDATE active_employees SET is_active = FALSE WHERE id = 1;
-- ERROR: new row violates check option for view "active_employees"
LOCAL vs CASCADED
-- 巢狀 View 場景
CREATE VIEW eng_employees AS
SELECT * FROM active_employees WHERE department_id = 10;
-- LOCAL:只檢查自身 View 的條件
CREATE OR REPLACE VIEW eng_employees AS
SELECT * FROM active_employees WHERE department_id = 10
WITH LOCAL CHECK OPTION;
-- CASCADED(預設):同時檢查所有上層 View 的條件
CREATE OR REPLACE VIEW eng_employees AS
SELECT * FROM active_employees WHERE department_id = 10
WITH CASCADED CHECK OPTION;
建議使用預設的 CASCADED 模式,確保整個 View 階層的資料完整性。
SECURITY DEFINER vs SECURITY INVOKER
View 的安全性情境決定查詢底層表格時所使用的身份,對權限控制至關重要。
SECURITY DEFINER(預設)
查詢底層表格時使用 View 擁有者 的權限,允許普通用戶透過 View 查詢他們本無權訪問的表格:
-- 表格擁有者:admin,普通用戶:app_user
CREATE VIEW public.salary_summary
SECURITY DEFINER AS
SELECT department_id, AVG(salary) AS avg_salary
FROM hr.salaries -- app_user 本無權存取
GROUP BY department_id;
GRANT SELECT ON public.salary_summary TO app_user;
-- app_user 可查詢聚合資料,但無法直接存取 hr.salaries
SECURITY INVOKER(PG15+)
查詢底層表格時使用 呼叫該 View 的當前用戶 的權限,是 Row Level Security(RLS) 場景的推薦設置:
-- PostgreSQL 15+ 語法
CREATE VIEW orders_view
WITH (security_invoker = true) AS
SELECT id, amount, status, created_at
FROM orders;
| 面向 | SECURITY DEFINER | SECURITY INVOKER |
|---|---|---|
| 執行身份 | View 擁有者 | 呼叫用戶 |
| RLS 相容性 | 以擁有者身份評估(可能繞過) | 以呼叫用戶身份評估(正確) |
| 適用場景 | 提升查詢特定表格的最低權限 | 結合 RLS 實作細粒度控制 |
| 安全風險 | 需謹慎避免 search_path 注入 | 較安全,受呼叫者權限限制 |
Materialized View 建立與刷新
-- 建立 Materialized View 並立即填充資料
CREATE MATERIALIZED VIEW sales_monthly AS
SELECT
DATE_TRUNC('month', created_at) AS month,
product_id,
SUM(quantity) AS total_quantity,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM orders
WHERE status = 'completed'
GROUP BY DATE_TRUNC('month', created_at), product_id
WITH DATA; -- 立即填充(預設)
-- 建立時不填充(稍後 REFRESH)
CREATE MATERIALIZED VIEW sales_monthly AS
... WITH NO DATA;
-- 刷新(鎖表,查詢期間無法讀取)
REFRESH MATERIALIZED VIEW sales_monthly;
-- 並行刷新(不鎖表,查詢期間仍可讀取舊資料)
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_monthly;
REFRESH CONCURRENTLY 的前提條件:
- 物化視圖必須有至少一個 唯一索引(Unique Index)
- 刷新期間佔用更多 CPU 與 I/O(需比較新舊資料)
- 速度比普通 REFRESH 慢,但不阻塞讀取
-- 必須先建立 Unique Index
CREATE UNIQUE INDEX ON sales_monthly (month, product_id);
-- 之後才能使用 CONCURRENTLY
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_monthly;
Materialized View 索引策略
物化視圖支援與普通表格相同的索引類型,這是其相對於一般 View 的核心優勢:
CREATE MATERIALIZED VIEW mv_sales_report AS
SELECT
DATE_TRUNC('day', o.created_at) AS sale_date,
c.region,
p.category_id,
p.id AS product_id,
p.name AS product_name,
SUM(oi.quantity) AS units_sold,
SUM(oi.quantity * oi.price) AS revenue,
COUNT(DISTINCT o.id) AS order_count
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
WHERE o.status = 'completed'
GROUP BY DATE_TRUNC('day', o.created_at), c.region,
p.category_id, p.id, p.name
WITH DATA;
-- 支援 REFRESH CONCURRENTLY 的唯一索引(必要)
CREATE UNIQUE INDEX idx_mv_sales_pk
ON mv_sales_report (sale_date, region, product_id);
-- 按日期範圍查詢
CREATE INDEX idx_mv_sales_date
ON mv_sales_report (sale_date DESC);
-- 按地區篩選
CREATE INDEX idx_mv_sales_region
ON mv_sales_report (region, sale_date DESC);
-- 高收入商品排行
CREATE INDEX idx_mv_sales_revenue
ON mv_sales_report (revenue DESC);
-- 部分索引:只為熱門地區建立
CREATE INDEX idx_mv_sales_tw
ON mv_sales_report (sale_date DESC, product_id)
WHERE region = 'TW';
常見設計模式
模式一:API Layer(穩定的對外介面)
-- 內部結構可以變動,但 View 提供穩定的 API
CREATE VIEW api.v_users AS
SELECT
id,
username,
email,
full_name,
avatar_url,
created_at,
-- 敏感欄位(password_hash、phone)刻意排除
CASE WHEN is_premium THEN 'premium' ELSE 'free' END AS account_type
FROM internal.users
WHERE deleted_at IS NULL;
GRANT SELECT ON api.v_users TO web_role;
模式二:RLS 輔助層
-- 配合 SECURITY INVOKER 與 RLS
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_orders_policy ON orders
USING (user_id = current_setting('app.current_user_id')::INTEGER);
CREATE VIEW my_orders
WITH (security_invoker = true) AS
SELECT id, amount, status, created_at
FROM orders;
模式三:複雜查詢簡化
-- 將多表 JOIN 封裝為 View
CREATE VIEW v_order_detail AS
SELECT
o.id AS order_id,
o.created_at,
c.full_name AS customer_name,
JSON_AGG(
JSON_BUILD_OBJECT(
'product', p.name,
'quantity', oi.quantity,
'price', oi.price
)
ORDER BY oi.id
) AS items,
SUM(oi.quantity * oi.price) AS subtotal,
o.shipping_fee,
SUM(oi.quantity * oi.price) + o.shipping_fee AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id, o.created_at, c.full_name, o.shipping_fee;
-- 應用程式只需簡單查詢
SELECT * FROM v_order_detail WHERE order_id = 12345;
自動刷新策略
pg_cron 定期排程
CREATE EXTENSION pg_cron;
-- 每天凌晨 2 點刷新報表型物化視圖
SELECT cron.schedule(
'refresh-sales-monthly',
'0 2 * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY sales_monthly'
);
-- 每 10 分鐘刷新即時性較高的物化視圖
SELECT cron.schedule(
'refresh-product-stats',
'*/10 * * * *',
'REFRESH MATERIALIZED VIEW CONCURRENTLY product_stats'
);
-- 查看已排程的任務
SELECT * FROM cron.job;
Trigger 觸發刷新
-- 在底層表格的 DML 操作後觸發刷新(適合低頻更新場景)
CREATE OR REPLACE FUNCTION refresh_product_stats()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY product_stats;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_refresh_product_stats
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_product_stats();
-- 注意:高頻寫入時會成為效能瓶頸,建議加入節流機制
各策略比較:
| 策略 | 資料新鮮度 | 實作複雜度 | 適用場景 |
|---|---|---|---|
| pg_cron 定期刷新 | 依排程間隔 | 低 | 報表、日/週/月彙總 |
| Trigger 觸發 | 接近即時 | 中 | 低頻寫入、即時性需求高 |
| ETL 後刷新 | 批次完成後 | 低 | 資料倉庫、ETL 管線 |
| Application 層 | 操作後即時 | 中 | 需精確控制刷新時機 |
| pg_ivm | 即時增量 | 高 | 高頻寫入、即時聚合 |
pg_ivm:增量物化視圖
標準 REFRESH MATERIALIZED VIEW 每次重新計算整個結果集。pg_ivm 擴展實作增量更新機制——只計算「變化的部分」:
-- 安裝 pg_ivm 擴展
CREATE EXTENSION IF NOT EXISTS pg_ivm;
-- 建立增量物化視圖(IMMV)
SELECT create_immv(
'sales_summary_immv',
'SELECT product_id, SUM(quantity) AS total_qty, SUM(amount) AS total_amount
FROM orders
GROUP BY product_id'
);
-- IMMV 在底層表格 DML 操作時自動增量更新
INSERT INTO orders (product_id, quantity, amount) VALUES (1, 5, 500);
-- sales_summary_immv 自動更新 product_id = 1 的列
pg_ivm 的限制:
- 支援:
SELECT、FROM(單表或 JOIN)、WHERE、GROUP BY、聚合函數 - 不支援:
DISTINCT、HAVING、LIMIT/OFFSET、視窗函數、遞迴查詢 - 適用場景:頻繁寫入、需近即時聚合查詢、但無法承受 Full REFRESH 成本
Rules vs INSTEAD OF Triggers
| 面向 | Rules | INSTEAD OF Triggers |
|---|---|---|
| 工作層次 | Query Rewrite 層 | Executor 層 |
| 執行時機 | 查詢改寫階段 | 逐列執行時 |
| 批次效能 | Statement-level,效率高 | Row-level,大批次較慢 |
| 錯誤處理 | 困難 | 靈活(PL/pgSQL) |
| 偵錯難易度 | 困難(黑盒子) | 較容易 |
| 社群推薦 | 通常不推薦 | 推薦用於 View DML |
最佳實踐: Rule System 幾乎只保留其核心用途(View 定義本身)。對於需要攔截 View 上 DML 操作的場景,一律優先使用 INSTEAD OF Trigger。
版本演進
| 版本 | 視圖相關更新 |
|---|---|
| 9.3 | 引入 Materialized View,Automatically Updatable Views |
| 9.4 | REFRESH CONCURRENTLY(需 Unique Index) |
| 10 | 更多複雜 View 自動可更新 |
| 14 | 改進 WITH CHECK OPTION 行為一致性 |
| 15 | 正式引入 SECURITY INVOKER 語法 |
| 16 | Materialized View 的 REFRESH 效能改進 |
常見陷阱
SELECT * 與欄位順序問題
-- 危險:底層表格新增欄位後,View 不會自動更新
CREATE VIEW user_info AS SELECT * FROM users;
ALTER TABLE users ADD COLUMN bio TEXT;
-- user_info 仍然看不到 bio 欄位!
-- 正確做法:明確列出所需欄位
CREATE VIEW user_info AS
SELECT id, username, email, created_at FROM users;
SECURITY DEFINER 的 search_path 風險
-- 危險:攻擊者可能透過修改 search_path 影響 View 行為
CREATE VIEW risky_view SECURITY DEFINER AS
SELECT * FROM users;
-- 安全做法:使用完整 Schema 路徑
CREATE VIEW safe_view SECURITY DEFINER AS
SELECT * FROM public.users;
Materialized View 的資料延遲
物化視圖的資料不是即時的。在高更新頻率場景,務必明確設定刷新策略並告知應用程式資料的時效性,可在 View 中暴露最後刷新時間欄位。
總結
View 與 Materialized View 是 PostgreSQL 封裝複雜查詢與實作權限控制的核心工具:
- View 透過 Rule System 在 Rewriter 層展開,本質是語法糖,規劃器可跨 View 邊界優化
- Updatable View 需滿足嚴格條件,不滿足時使用
INSTEAD OF Trigger - WITH CHECK OPTION 確保透過 View 的 DML 操作不會違反 View 條件
- SECURITY INVOKER(PG15+)是結合 RLS 的推薦設置
- Materialized View 透過
REFRESH CONCURRENTLY實現不鎖表刷新,但需要唯一索引 - 自動刷新策略依場景選擇:pg_cron、Trigger、ETL 後刷新或 pg_ivm
下一篇,我們將深入探討 觸發器與事件觸發器——PostgreSQL 如何透過 Trigger 實現自動化業務邏輯與資料完整性保障。