Views 與 Materialized Views:PostgreSQL 的虛擬關係與物化快取 | PostgreSQL

2026/06/29
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 差異表

面向ViewMaterialized 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),但需滿足嚴格條件:

  1. FROM 子句只有 一個來源(表格或另一個 Updatable View)
  2. 不含 DISTINCTGROUP BYHAVINGLIMITOFFSET
  3. 不含集合運算:UNIONINTERSECTEXCEPT
  4. SELECT 清單不含視窗函數或聚合函數

滿足以上條件的 View,PostgreSQL 會自動允許對其執行 INSERTUPDATEDELETE,並將操作轉譯至底層表格。

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 進行的 INSERTUPDATE,所產生的結果列必須仍然滿足 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 DEFINERSECURITY 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 的限制:

  • 支援:SELECTFROM(單表或 JOIN)、WHEREGROUP BY、聚合函數
  • 不支援:DISTINCTHAVINGLIMIT/OFFSET、視窗函數、遞迴查詢
  • 適用場景:頻繁寫入、需近即時聚合查詢、但無法承受 Full REFRESH 成本

Rules vs INSTEAD OF Triggers

面向RulesINSTEAD 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.4REFRESH CONCURRENTLY(需 Unique Index)
10更多複雜 View 自動可更新
14改進 WITH CHECK OPTION 行為一致性
15正式引入 SECURITY INVOKER 語法
16Materialized 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 中暴露最後刷新時間欄位。

總結

ViewMaterialized 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 實現自動化業務邏輯與資料完整性保障。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。