Skip to content
BEE
Backend Engineering Essentials

[BEE-19051] 物化視圖與增量計算

INFO

物化視圖(Materialized View)將查詢結果儲存在磁碟上並以資料表形式提供服務——以資料新鮮度換取查詢速度;增量計算進一步延伸此概念,僅重新計算結果中發生變動的部分,而非從頭重建整個視圖。

背景

有些查詢的執行代價過高,無法在每次請求時即時計算。一個需要聚合 5 億筆訂單資料、跨多張資料表 JOIN、依日期分組並套用 HAVING 條件的儀表板查詢,即使在索引完善的情況下也可能需要 30–60 秒。對每次頁面載入都執行此查詢是不可行的。最直覺的解法——將結果快取在應用程式記憶體或 Redis 中——雖然有效,但將快取失效的問題推給應用層,且無法再以 SQL 查詢快取的資料。

物化視圖在資料庫層解決了這個問題。物化視圖是一個將查詢結果儲存為實體資料表的機制。讀取時直接存取已儲存的結果;昂貴的計算只在視圖重新整理(Refresh)時才執行。PostgreSQL 在 9.3 版(2013 年)引入了 CREATE MATERIALIZED VIEW;Oracle 自 1990 年代末便提供此功能;SQL Server 則稱之為「索引視圖(Indexed View)」。

其限制在於資料新鮮度。物化視圖反映的是最後一次重新整理時的資料狀態。若來源資料表頻繁變動,而業務上又需要接近即時的資料,每次變更都觸發完整重建的代價將過於昂貴——反而失去物化的意義。

增量計算解決了這個問題。增量策略識別哪些來源資料列發生了變更,僅重新計算受影響的輸出列,而非重新讀取所有來源資料。PostgreSQL 原生不支援增量重新整理(截至第 16 版);每次執行 REFRESH MATERIALIZED VIEW 都會重建整個視圖。增量重新整理可透過擴充套件(TimescaleDB 的持續聚合,用於時間序列)、應用層框架(dbt 的增量模型)以及流處理系統(Flink、Materialize、RisingWave)來實現,這些系統能在來源資料到達時持續更新結果。

完整重建與增量計算之間的選擇,是資料新鮮度與複雜度之間的取捨。完整重建簡單且正確;增量計算速度更快,但需要推論哪些差異(Delta)需要套用,以及如何處理延遲抵達的資料。

設計思考

重新整理策略

策略機制資料新鮮度每次重整成本
完整重整(排他鎖)REFRESH MATERIALIZED VIEW重整當下的時間點完整查詢代價
完整重整(並發)REFRESH MATERIALIZED VIEW CONCURRENTLY重整當下的時間點完整查詢代價 + 差異比對
排程重整cron / pg_cron取決於重整間隔完整查詢代價
觸發器驅動重整AFTER INSERT/UPDATE/DELETE 觸發器近即時每次寫入觸發完整查詢
增量 / 串流CDC → 流處理器 → 結果資料表延遲數秒至數分鐘與差異大小成正比
持續聚合(TimescaleDB)擴充套件管理時間序列近即時僅重新計算變動的時間桶

CONCURRENTLY 與非並發重整

REFRESH MATERIALIZED VIEW 在整個重整期間會取得 AccessExclusive 鎖——阻擋所有讀取操作。對於重整耗時數秒的視圖而言尚可接受;若需耗時數分鐘則無法接受。

REFRESH MATERIALIZED VIEW CONCURRENTLY 需要視圖上有唯一索引(Unique Index),它會在臨時資料表中計算新結果,與當前視圖資料進行差異比對,再套用差異——整個過程允許讀取。代價是:並發重整因為有差異比對步驟,耗時比非並發更長,且唯一索引的要求限制了哪些視圖可以使用此模式。

新鮮度與重整間隔

重整間隔是主要的操作旋鈕。設定時需要了解:

  • 資料可以有多舊? 分析儀表板可能接受 15 分鐘的延遲;即時定價資料則不行。
  • 重整的代價有多高? 重整必須比間隔更快完成,否則重整工作會堆積。
  • 來源資料表多頻繁變更? 每分鐘重整一個每小時才變動一次的資料表是在浪費資源。

常見模式:在低流量時段依排程重整大型聚合,並在兩次重整之間對視圖加上短 TTL 的應用層快取,以應對重複的相同查詢。

增量計算模型

增量計算要求視圖必須是可維護的(Maintainable):給定一組針對來源的新增 / 更新 / 刪除操作,系統必須能夠在不重新讀取所有來源資料的情況下,推導出對輸出的對應變更。

COUNTSUMAVG 等聚合函數是可維護的。DISTINCTMEDIAN 以及視窗函數 RANK() 在沒有額外狀態的情況下則不可維護。Flink 和 Materialize 等系統實作了一種關聯式運算子,能追蹤每個群組的計數與加總並進行增量更新;涉及不可維護運算子的查詢則退回完整重新計算。

最佳實踐

必須(MUST)在使用 REFRESH MATERIALIZED VIEW CONCURRENTLY 前建立唯一索引。 沒有唯一索引,並發重整會在執行時失敗。索引必須涵蓋能唯一識別視圖中每一列的欄位(或欄位組合)——通常是 GROUP BY 的鍵值。

必須(MUST NOT)不得從應用程式程式碼中以緊密迴圈呼叫重整物化視圖。 每次重整都是一次完整的查詢執行。應從排程工作(pg_cron、外部 cron、任務協調器)以符合可接受新鮮度視窗的節奏呼叫。

應該(SHOULD)對任何需要在重整期間保持可讀的視圖使用 CONCURRENTLY 非並發重整僅適用於視圖夠小、鎖定視窗可忽略不計(毫秒級),或是在維護視窗期間重整的情況。

應該(SHOULD)將物化視圖納入資料庫遷移工作流程,並記錄重整排程。 物化視圖是一個必須與其他 Schema 物件一起建立、追蹤和刪除的 Schema 物件。其重整工作是需要監控的維運基礎設施。

應該(SHOULD)將新鮮度視為一等需求。 明確定義每個視圖可接受的新鮮度 SLO(例如:「在上班時段每 10 分鐘內」)。這將驅動重整間隔的選擇、告警閾值,以及是否需要增量計算。

應該(SHOULD)監控重整持續時間並在超時時發出告警。 若重整耗時超過排程間隔,下一次重整將會重疊。追蹤重整工作本身的執行時間,並在持續時間超過間隔的 80% 時發出告警。

必須(MUST NOT)不得將物化視圖作為寫入操作的權威記錄。 物化視圖是唯讀的衍生資料。所有寫入操作都針對來源資料表。視圖是快取,不是事實來源。

應該(SHOULD)優先使用 dbt 增量模型或 TimescaleDB 持續聚合,而非在應用程式程式碼中手工實作增量邏輯。 在應用程式程式碼中手工實作增量計算容易出錯——必須處理並發寫入者、延遲抵達的資料列以及部分失敗。框架已將這些失敗模式編碼在內。

視覺化

範例

建立與重整物化視圖(PostgreSQL):

sql
-- 昂貴的聚合:依產品類別統計每日營收
CREATE MATERIALIZED VIEW daily_revenue_by_category AS
SELECT
    DATE_TRUNC('day', o.created_at)  AS day,
    p.category,
    SUM(li.quantity * li.unit_price) AS revenue,
    COUNT(DISTINCT o.id)             AS order_count
FROM orders o
JOIN line_items li ON li.order_id = o.id
JOIN products p   ON p.id = li.product_id
WHERE o.status = 'completed'
GROUP BY 1, 2
WITH DATA;  -- 立即填入資料;使用 WITH NO DATA 可延後填入

-- CONCURRENTLY 的必要條件:在 GROUP BY 鍵上建立唯一索引
CREATE UNIQUE INDEX ON daily_revenue_by_category (day, category);

-- 非阻塞重整(整個期間鎖定視圖——若重整快速則可接受)
REFRESH MATERIALIZED VIEW daily_revenue_by_category;

-- 並發重整(全程允許讀取;需要唯一索引)
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue_by_category;

-- 像查詢資料表一樣查詢視圖
SELECT category, SUM(revenue) AS total_revenue
FROM daily_revenue_by_category
WHERE day >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY category
ORDER BY total_revenue DESC;

使用 pg_cron 排程重整:

sql
-- 在上班日的上班時段(UTC)每 15 分鐘重整一次
SELECT cron.schedule(
    'refresh-daily-revenue',
    '*/15 7-19 * * 1-5',  -- 週一至週五,UTC 07:00–19:00,每 15 分鐘
    $$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue_by_category$$
);

-- 監控 cron 工作狀態
SELECT jobid, jobname, schedule, active, command
FROM cron.job
WHERE jobname = 'refresh-daily-revenue';

-- 透過系統目錄查詢最後重整時間戳記
SELECT schemaname, matviewname, ispopulated, definition
FROM pg_matviews
WHERE matviewname = 'daily_revenue_by_category';

dbt 增量模型(應用層增量計算):

sql
-- models/daily_revenue_by_category.sql
{{
    config(
        materialized='incremental',
        unique_key=['day', 'category'],
        incremental_strategy='merge'
    )
}}

SELECT
    DATE_TRUNC('day', o.created_at) AS day,
    p.category,
    SUM(li.quantity * li.unit_price) AS revenue,
    COUNT(DISTINCT o.id)             AS order_count
FROM {{ ref('orders') }} o
JOIN {{ ref('line_items') }} li ON li.order_id = o.id
JOIN {{ ref('products') }} p   ON p.id = li.product_id
WHERE o.status = 'completed'

{% if is_incremental() %}
-- 增量執行時,只處理最近 2 天內更新的資料列
-- (1 天的緩衝,以處理延遲抵達的資料)
AND o.created_at >= (SELECT MAX(day) - INTERVAL '2 days' FROM {{ this }})
{% endif %}

GROUP BY 1, 2

TimescaleDB 持續聚合(時間序列增量重整):

sql
-- 來源:TimescaleDB 超級資料表(hypertable)
CREATE TABLE device_metrics (
    ts        TIMESTAMPTZ NOT NULL,
    device_id INT         NOT NULL,
    value     DOUBLE PRECISION
);
SELECT create_hypertable('device_metrics', 'ts');

-- 持續聚合:物化每小時聚合
CREATE MATERIALIZED VIEW device_metrics_hourly
WITH (timescaledb.continuous) AS
SELECT
    time_bucket('1 hour', ts) AS bucket,
    device_id,
    AVG(value)  AS avg_value,
    MAX(value)  AS max_value,
    MIN(value)  AS min_value,
    COUNT(*)    AS sample_count
FROM device_metrics
GROUP BY 1, 2;

-- TimescaleDB 自動僅重新整理發生變動的時間桶
-- 手動策略:保留 1 個月的即時資料,每 10 分鐘重整一次
SELECT add_continuous_aggregate_policy('device_metrics_hourly',
    start_offset => INTERVAL '1 month',
    end_offset   => INTERVAL '1 hour',  -- 不重整當前(未完成的)時間桶
    schedule_interval => INTERVAL '10 minutes'
);

實作說明

PostgreSQLREFRESH MATERIALIZED VIEW 每次呼叫都會完整重建視圖——原生不支援增量重整。使用 pg_cron(可作為擴充套件安裝)或外部排程器自動化重整。透過 pg_matviews 監控視圖是否存在及填入狀態。若需要在不使用 TimescaleDB 的情況下實現近即時的使用案例,可考慮透過觸發器更新的彙總資料表,但需承擔相應的複雜度。

TimescaleDB:持續聚合使用 WAL 追蹤哪些時間桶有新資料,並僅重新計算這些桶。它們支援即時聚合(為當前桶讀取超級資料表中未物化的部分),是在 PostgreSQL 上進行時間序列物化的推薦解決方案。

dbt:增量模型適用於任何 SQL 資料倉儲(BigQuery、Snowflake、Redshift、DuckDB、PostgreSQL)。is_incremental() 巨集在非完整重新整理執行時啟用條件邏輯,過濾至近期的資料列。unique_key 設定驅動合併 / 更新插入策略。務必包含延遲抵達緩衝(處理已物化最大日期前 1–2 天的資料列),以處理亂序事件。

Materialize / RisingWave:專為串流設計的資料庫,使用資料流引擎即時增量維護視圖。SQL 查詢以視圖的形式編寫一次;引擎在來源資料抵達時負責維護它們。適用於排程重整無法滿足的次秒級新鮮度需求。

OracleQUERY REWRITE 讓最佳化器能在物化視圖涵蓋查詢範圍時,自動以物化視圖取代基底資料表查詢——對應用程式透明。PostgreSQL 目前不支援物化視圖的自動查詢重寫。

相關 BEE

  • BEE-6002 -- 索引深度解析:REFRESH CONCURRENTLY 需要唯一索引;物化視圖存取模式的索引選擇遵循與基底資料表相同的原則
  • BEE-9001 -- 快取基礎與快取層次:物化視圖是資料庫層的快取;理解快取層次有助於釐清何時使用物化視圖而非應用層快取
  • BEE-5003 -- CQRS:物化視圖是 CQRS 中讀取模型的常見實作方式——寫入模型更新來源資料表,讀取模型是從這些資料表重整的物化視圖
  • BEE-19018 -- 變更資料擷取(CDC):CDC 是為增量計算系統提供資料的機制;基於 WAL 的 CDC 讓串流處理器只需消費來源資料表中已變更的資料列

參考資料