增量物化视图

增量物化视图介绍

什么是增量物化视图

增量物化视图是一种特殊的物化视图,它也具备物化视图的以下特点:

  • 都会把查询结果落盘,提升后续查询速度
  • 都需要考虑刷新延迟(结果不一定和底表完全实时一致)
  • 都要付出额外的存储成本维护成本

它和物化视图最大的不同是体现在刷新机制上:当底层数据发生变化时,增量物化视图不再对全量数据重新计算,而是只处理这次变化带来的那一小部分影响(Δ,delta),并把这个影响合并到已有的物化结果里。

这里体现了两种不同的刷新方式:

  • 全量刷新(Full Refresh):每次刷新都等价于“把整张结果表删了再重算一遍”
  • 增量刷新(Incremental Refresh):每次只算“变更产生的增量结果”,再合并到已有结果

那么,普通的物化视图的每次刷新都是全量刷新。而增量物化视图同时具备了两种刷新能力,在可行的时候尽量使用增量刷新,但当发现做不到增量刷新的时候,自动回退成全量刷新。

增量刷新原理

  1. 捕获变化:知道底表发生了什么变化

    • 新增/更新/删除的行,或分区级别的新增和删除
  2. 计算增量影响:把“底表变化”转换成“视图结果需要怎么变”

    • 例如:count 需要 +1 / -1;sum 需要加上差值;某个 group 需要新增或修正
    • 如果视图包含 join / 去重 / 窗口等复杂逻辑,计算增量影响也会更复杂一些
  3. 合并到物化结果:将增量结果合并到已落盘的结果表中

    • 对物化视图结果表进行新增/更新/删除等操作

与普通物化视图的区别

在使用上,增量物化视图和普通物化视图具备一些相同点,比如:

  • 结果数据都会以表的形式存储
  • 都可以像普通的物理表一样自定义 Access Method,创建二级索引等

不同点则体现在:

  • 刷新成本模型不同
    • 普通 MV:成本与“底表总数据量”强相关
    • 增量 MV:成本与“本次变更量”以及“查询复杂度“强相关
  • 对查询表达能力/可维护性的要求更高
    • 增量 MV 往往要求查询是可增量维护的:例如聚合类(sum/count/min/max 等),Join 类比较常见
    • 一些复杂的查询/非确定函数可能受限
  • 系统内部通常需要更多“状态”
    • 为了支持复杂逻辑的增量计算,可能需要维护额外中间表、索引等辅助结构

增量物化视图实践策略

在使用增量物化视图之前,我们建议从业务的角度按照下面的流程进行端到端的评估。

可行性评估

首先我们需要评估增量 MV 对目标场景是否合适。下面这些场景,增量 MV 通常收益较大:

  1. 底表很大、全量刷新太贵

    • 全量重算的时间成本或计算成本不可接受
    • 但每次写入量相对较小,且结果的变化幅度不大
  2. 写多读多,且希望“准实时”看到结果

    • 比全量刷新更容易做到分钟级/秒级更新
    • 适合数据看板、告警指标、运营分析等

而下面这些往往是不同适用的场景:

  1. 底表更新/删除非常频繁且影响范围大

    • 可能会导致增量的结果也非常大,成本接近或超过全量刷新
  2. 查询不固定、偶尔才查一次

    • 如果很少被查询,预计算的收益不大,反而增加定期维护开销

合理划分增量物化视图

增量物化视图的定义应兼顾两点:

  • 覆盖尽可能多的下游查询(提升复用)
  • 选择计算开销高、且结构稳定的子查询(提升收益/降低维护复杂度)

考虑一个多表 Join + 多维分析的业务场景,我们会有以下两种选择:

  1. Join 逻辑比较固定,但希望保留分析灵活性:这种建议把多表 Join 抽取为 MV 定义,下游直接读取 MV 进行 ad hoc 分析
  2. Join 和聚合维度都比较固定,希望做大量点查:这种建议把 Join + Agg 抽取为 MV 定义,在 MV 上利用主键或二级索引服务点查。

存储与索引选择

物化视图以物理表形式存在,表级存储与索引策略会直接影响查询性能与增量刷新成本。

  1. 列命名 建议显式指定 MV 的列名。显式列名可提升可读性,并便于后续索引、治理与调试。

  2. Access Method 选择 根据 MV 的访问模式来确定合适的 AM:

  • 中间层 MV,需要直接访问和参与下游增量计算,建议 hybrid 格式
  • 服务层 MV,分析为主选择 columnar,点查为主选择 row,查询模式多样选择 hybrid
  1. 索引策略
  • 点查场景:按访问键构建二级索引以降低查询延迟
  • 列存过滤:结合系统支持的过滤类索引(例如 split index)优化扫描效率

使用增量物化视图

创建增量物化视图

在使用增量物化视图之前,我们需要先创建 imv 这个插件:

CREATE EXTENSION imv;

然后通过 CREATE MATERIALIZED VIEW 语法进行创建:

CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] table_name
    [ (column_name [, ...] ) ]
    [ USING method ]
    [ TABLESPACE tablespace_name ]
    WITH ( refresh_mode = 'incremental', storage_parameter [= value] [, ... ] )
    AS query
    [ WITH [ NO ] DATA ]

参数说明:

  • refresh_mode: 刷新模式,incremental表示增量刷新模式
  • 其他参数含义参考全量物化视图定义

示例:

-- 订单表
CREATE TABLE orders (
    order_id VARCHAR(20) PRIMARY KEY,
    user_id INT NOT NULL,
    order_time TIMESTAMP NOT NULL,
    total_amount DECIMAL(10,2) NOT NULL,
    product_list TEXT,
    shipping_address TEXT,
    order_status VARCHAR(20) DEFAULT 'created' -- created, paid, shipped, completed
);
 
-- 插入示例数据
INSERT INTO orders VALUES
('ORD10001', 101, '2023-06-01 10:00:00', 299.99, 'P1001,P1002', '北京市海淀区', 'created'),
('ORD10002', 102, '2023-06-01 11:30:00', 159.50, 'P1003', '上海市浦东新区', 'created'),
('ORD10003', 101, '2023-06-02 09:15:00', 89.99, 'P1004,P1005', '北京市海淀区', 'created'),
('ORD10004', 103, '2023-06-03 14:20:00', 450.00, 'P1006,P1007', '广州市天河区', 'created'),
('ORD10005', 102, '2023-06-03 16:45:00', 120.00, 'P1008', '上海市浦东新区', 'created'),
('ORD10006', 101, '2023-06-04 09:30:00', 75.50, 'P1009', '北京市海淀区', 'created');
 
-- 支付表
CREATE TABLE payments (
    payment_id VARCHAR(20) PRIMARY KEY,
    order_id VARCHAR(20) NOT NULL,
    payment_time TIMESTAMP NOT NULL,
    payment_amount DECIMAL(10,2) NOT NULL,
    payment_method VARCHAR(20), -- alipay, wechat, credit_card
    payment_status VARCHAR(20) -- success, failed, processing
);
 
-- 插入示例数据
INSERT INTO payments VALUES
('PAY20001', 'ORD10001', '2023-06-01 10:05:23', 299.99, 'alipay', 'success'),
('PAY20002', 'ORD10002', '2023-06-01 11:35:45', 159.50, 'wechat', 'success'),
('PAY20003', 'ORD10003', '2023-06-02 09:20:10', 89.99, 'credit_card', 'failed'),
('PAY20004', 'ORD10004', '2023-06-03 14:25:30', 450.00, 'alipay', 'success'),
('PAY20005', 'ORD10005', '2023-06-03 16:50:15', 120.00, 'wechat', 'failed');
 
-- 订单与支付关联的物化视图
CREATE MATERIALIZED VIEW order_payment_summary 
USING HYBRID
WITH (refresh_mode='incremental') 
AS
SELECT
    o.order_id,
    o.user_id,
    o.order_time,
    o.total_amount,
    o.order_status,
    p.payment_id,
    p.payment_time,
    p.payment_method,
    p.payment_status
FROM
    orders o
LEFT JOIN
    payments p ON o.order_id = p.order_id;
 
-- 结果表条数
SELECT count(1) FROM order_payment_summary;
 count
-------
     6
(1 row)
 
 
-- 按用户统计订单和支付情况
SELECT
    user_id,
    COUNT(*) AS total_orders,
    SUM(CASE WHEN payment_status = 'success' THEN 1 ELSE 0 END) AS paid_orders,
    SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) AS failed_payments,
    SUM(CASE WHEN payment_status = 'processing' THEN 1 ELSE 0 END) AS processing_payments
FROM
    order_payment_summary
GROUP BY
    user_id;
 user_id | total_orders | paid_orders | failed_payments | processing_payments
---------+--------------+-------------+-----------------+---------------------
     101 |            3 |           1 |               1 |                   0
     102 |            2 |           1 |               1 |                   0
     103 |            1 |           1 |               0 |                   0
(3 rows)    
 

刷新增量物化视图

增量物化视图的刷新命令和普通的物化视图完全一致。

示例:

-- 更新 ORD10003 的支付状态为成功(之前是失败)
UPDATE payments SET payment_status = 'success', payment_time = '2023-06-02 10:15:00'
WHERE payment_id = 'PAY20003';
 
-- 刷新 order_payment_summary
REFRESH MATERIALIZED VIEW order_payment_summary;
 
-- 按用户统计订单和支付情况,我们可以看到 101 user 的 paid_orders 和 failed_payments 已经更新
SELECT
    user_id,
    COUNT(*) AS total_orders,
    SUM(CASE WHEN payment_status = 'success' THEN 1 ELSE 0 END) AS paid_orders,
    SUM(CASE WHEN payment_status = 'failed' THEN 1 ELSE 0 END) AS failed_payments,
    SUM(CASE WHEN payment_status = 'processing' THEN 1 ELSE 0 END) AS processing_payments
FROM
    order_payment_summary
GROUP BY
    user_id;
 user_id | total_orders | paid_orders | failed_payments | processing_payments
---------+--------------+-------------+-----------------+---------------------
     101 |            3 |           2 |               0 |                   0
     102 |            2 |           1 |               1 |                   0
     103 |            1 |           1 |               0 |                   0    
 
 
-- 查询刷新历史表,可以看到上面的增量刷新只修改了一条记录
SELECT 
    oid::regclass, refresh_action, statistics 
FROM imv.pg_stat_refresh_history 
WHERE oid = 'order_payment_summary'::regclass AND refresh_action = 'incremental';
          oid          | refresh_action |     statistics
-----------------------+----------------+---------------------
 order_payment_summary | incremental    | {"affected_row": 1}
(1 row)
 

修改增量物化视图

增量物化视图也可以像普通物化视图一样进行修改,包括修改名字,修改 Access Method 等,详见:ALTER MATERIALIZED VIEW

除此之外,增量物化视图还额外支持了修改查询定义的功能。

ALTER MATERIALIZED VIEW [ IF EXISTS ] view_name AS query;

在真实的复杂场景中,多个增量物化视图往往会形成一些依赖关系,比如数仓中的分层逻辑。当我们希望对中间层 MV 做一些简单的修改时(常见的有加字段,去除一些下游不再依赖的字段等),常规手段只能先删除掉物化视图再重新创建。并且,如果这个 MV 被其他下游 MV 引用,在删除的时候我们还需要使用 DROP CASCADE,把所有下游一起删除掉重建。这个会直接影响整个数据链路的可用性。

通过修改物化视图定义的功能,可以做到不影响下游的前提下,对计算逻辑进行迭代。在修改的过程中,引擎会对新老 schema 的兼容性进行校验,并在发现不兼容的情况下进行报错。如果 schema 的修改是兼容的,ALTER 会自动触发一次全量刷新。

示例:

-- 给原来的物化视图增加一列: payment_status_category,用来替代原来的 payment_status 列
ALTER MATERIALIZED VIEW order_payment_summary AS
SELECT
    o.order_id,
    o.user_id,
    o.order_time,
    p.payment_id,
    p.payment_time,
    p.payment_method,
    -- p.payment_status,
    CASE
        WHEN p.payment_status = 'success' THEN 'paid'
        WHEN p.payment_status = 'failed' THEN 'payment_failed'
        ELSE 'awaiting_payment'
    END AS payment_status_category
FROM
    orders o
LEFT JOIN
    payments p ON o.order_id = p.order_id;
 
 
-- 通过 refresh history,能看到这次 alter 语句触发了一次全量刷新
SELECT
    oid::regclass, refresh_action, refresh_action_reason, statistics
FROM imv.pg_stat_refresh_history
WHERE oid = 'order_payment_summary'::regclass;
          oid          | refresh_action | refresh_action_reason |     statistics
-----------------------+----------------+-----------------------+---------------------
 order_payment_summary | full           | alter query           | {"affected_row": 6}
 

删除增量物化视图

删除的命令和全量物化视图一样,详见:DROP MATERIALIZED VIEW

增量 Query 调优指南

增量物化视图虽然支持只对变更的数据进行计算,但由于增量计算本身的复杂性,不同查询语句的增量计算效率差异很大。这里我们总结了一些在真实场景观察得到的一些最佳实践,能够有效的提升增量刷新效率。

非确定性函数

非确定性函数(常见的如 NOW() / CURRENT_TIMESTAMP / CURRENT_DATE)由于结果的不确定性,会给增量计算带来一些额外的复杂度。目前,ProtonBase 只支持在 WHERE 条件里使用这些函数,比如:

SELECT * FROM t WHERE ts >= NOW() - INTERVAL '1 hour';
 
-- 这种写法会报错
SELECT *NOW() FROM t;

并且为了支持前者的增量计算,每次增量刷新的时候我们还需要对 t 表进行全表扫描,过滤出 在前一次刷新时还合法,但是本次刷新已经不合法的数据。如果底表比较大,建议在 ts 字段上构建一个二级索引,以便能快速筛选出这部分数据。

除了上述常见的非确定性函数,我们还可以通过 current_setting 这个函数配合 set 语句来给增量 MV 传递自定义变量:

 
-- set 语句除了可以设置内置的 guc 变量外,还可以设定自定义变量,需要定义成 a.b 的形式
set protonbase.watermark = '2025-10-24 14:00:00';
 
-- 自定义变量可以通过 show 命令来查看
show protonbase.watermark;
 protonbase.watermark
----------------------
 2025-10-24 14:00:00
(1 row)
 
-- 也可以通过 current_setting 函数来获取
select current_setting('protonbase.watermark');
   current_setting
---------------------
 2025-10-24 14:00:00
(1 row)
 
-- 在 MV 定义中使用 current_setting
CREATE MATERIALIZED VIEW imv_with_setting 
WITH (refresh_mode='incremental') AS
SELECT * FROM t WHERE ts >= current_setting('protonbase.watermark')::timestamptz;
 
-- 在每次 REFRESH 前设置合适的值
set protonbase.watermark = '2025-10-24 14:10:00';
REFRESH MATERIALIZED VIEW imv_with_setting;
 
set protonbase.watermark = '2025-10-24 14:20:00';
REFRESH MATERIALIZED VIEW imv_with_setting;

GROUP BY 语句

Primary Key

Group By MV 常见用途是按维度做点查(例如按某个 group key 查一行聚合结果),因此用户通常期望:Group By 的字段能自动成为 MV 的主键

但数据库的 Primary Key 有一个硬要求:主键列必须是 NOT NULL。如果 Group By 字段可能为 NULL,系统就不能安全地自动声明成主键。

推荐按以下思路处理:

  1. 业务允许时,将 Group By 的字段在底表上申明成 NOT NULL
    这是最清晰的做法,也能减少 “NULL 维度代表什么” 的歧义。

  2. 不方便改源表约束,但业务上不需要 NULL 作为维度值
    可以在 MV 定义里过滤掉 NULL,让系统推断该 key 在结果中为 NOT NULL,从而自动生成主键:

   SELECT ...
   FROM T
   WHERE key IS NOT NULL
   GROUP BY key;
  1. 业务必须保留 NULL 维度值,同时又需要点查

这时主键无法包含该列(因为主键不允许 NULL),建议在 MV 上创建 secondary index(二级索引) 来提供点查访问路径。

Group By 字段顺序

Group By 字段往往会成为 MV 的主键(即使不是主键也会直接影响主键的值),因此字段顺序会决定主键排序与数据分布。在默认 range 分布 下,这会直接影响增量刷新时写入是否“集中到一段 key range”,从而影响写入的并行程度。 举例:

-- ts 为分钟级时间字段,通常随业务时间递增
SELECT SUM(a)
FROM T
GROUP BY ts, b;

如果同一分钟内 b 的取值非常多(高基数),那么 MV 的主键会按 ts 先聚集:同一分钟的数据挤在一起。 当新的增量数据到来时(通常是更大的 ts),写入会倾向落在主键空间的尾部连续区间。range 分布下容易形成写入热点,导致增量刷新并发受限。

因此建议将更能打散写入的高基数字段放前面:

SELECT SUM(a)
FROM T
GROUP BY b, ts;

这样增量到来时,会分散写入到不同的 b 分组区域,更容易形成多个并行写入点,通常能获得更好的刷新吞。

小结

  • 单调递增字段(时间戳、递增 ID)不要放在 Group By 的第一位
  • 优先把高基数、离散度高的维度放在前面,把时间类字段放后面作为次级排序

多组 Count Distinct

一个业务上常见的需求是在聚合的时候根据不同的过滤条件来计算 Count Distinct,比如计算不同口径的 UV。一种常见的写法是:

SELECT 
    COUNT(DISTINCT CASE WHEN x = 1 THEN user_id ELSE NULL) AS uv1,
    COUNT(DISTINCT CASE WHEN x = 2 THEN user_id ELSE NULL) AS uv2,
    COUNT(DISTINCT CASE WHEN x = 3 THEN user_id ELSE NULL) AS uv3,
...

上述的例子中 count distinct 的目标字段都是 user_id,只是过滤条件不同。这种情况我们建议更换一种等价的写法,可以让增量计算更加高效:

SELECT 
    COUNT(DISTINCT user_id) FILTER (WHERE x = 1) AS uv1,
    COUNT(DISTINCT user_id) FILTER (WHERE x = 2) AS uv2,
    COUNT(DISTINCT user_id) FILTER (WHERE x = 3) AS uv3,
...

JOIN 语句

当增量物化视图(IMV)的定义包含 Join 时,增量刷新的成本主要取决于两点:变更数据量(Δ)有多大,以及系统是否能用高效方式把大表与 Δ 做匹配。下面给出三条常用且有效的优化建议。

优先使用 Inner Join

从功能上,我们支持 INNER / LEFT OUTER / RIGHT OUTER / FULL OUTER 的增量计算。但从效率来看,Outer Join 通常比 Inner Join 开销更大,尤其当 Outer 表(例如 LEFT OUTER JOIN 的右表)变更较为频繁时,增量计算需要处理更多“补齐/撤销/空值扩散”等逻辑,影响范围更大,刷新成本更高。

建议:

  • 业务语义允许时,尽量使用 INNER JOIN
  • 如确需 Outer Join,建议重点关注 Outer 表的变更频率与刷新周期,避免频繁刷新导致资源抖动

使用等值条件

带等值条件的 Join(例如 S.k1 = T.k2)更容易被优化:

  • 可以使用 Hash Join / Merge Join 等经典算法加速
  • 也为后续利用索引进行 Lookup 加速提供前提(见下一条)

如果 Join 只有非等值条件(例如范围条件或复杂表达式),通常会降低优化空间,并增加增量计算的扫描成本。

建议:

  • 每个 Join 尽量至少包含一个等值条件
  • 其他条件可以作为附加过滤(AND ...)保留

利用二级索引

考虑如下 Join 场景:

S INNER JOIN T ON S.k1 = T.k2

其中 S 是大表,T 是小表。增量刷新时,小表 T 的变更会产生一小批变更数据 ΔT。此时增量计算本质上是在运行:

S INNER JOIN ΔT ON S.k1 = ΔT.k2

如果大表 Sk1 字段上构建了二级索引,系统就可以对 ΔT 的每一行通过索引进行 Lookup 来定位匹配行,避免扫描整张 S,从而显著降低增量刷新成本。

为了让索引生效,等值条件应尽量直接使用原始列。下面两类写法常见但会破坏优化,使执行计划退化为扫描或更慢的匹配方式:

  1. 在 Join key 上做类型转换(类型不匹配时临时 cast):
S.k1::text = T.k2
  1. 在 Join key 上使用函数加工:
UPPER(S.k1) = T.k2

推荐做法:

  • 尽量保证 Join 两侧字段类型一致,避免在 Join 条件中临时 cast
  • 如需做规范化(upper/lower/trim 等),建议提前将结果计算好后再参与 Join(例如新增预处理列、生成列,或在 ETL 中提前加工好):
S.k1_norm = T.k2_norm

小结

  • 优先使用 INNER JOIN:增量维护更简单、影响范围更小、成本更可控
  • 每个 Join 至少包含一个等值条件:更容易采用高效 Join 算法,也更利于索引加速
  • 大表 Join Key 建二级索引,并避免在 Join 条件里对 key 做 cast 或函数加工,以确保索引可用

TOP N 语句

业务上 Top-N 的典型写法是:

SELECT a, b, c
FROM (
  SELECT
    a, b, c,
    ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...) AS rn
  FROM t
)
WHERE rn = 1;          -- Top-1
-- 或者:WHERE rn <= 10;  -- Top-10

说明:

  • PARTITION BY 定义分区(例如按用户、商品、业务 key 分组)
  • ORDER BY 定义排序规则(例如按时间倒序、版本号倒序)
  • rn = 1 表示每个分区只保留第一条;rn <= N 表示保留前 N 条

其中,Top-1 最常见的目的是“按 key 去重“,尤其是在已有的离线计算场景。因为大部分大数据系统缺少数据库意义上的 Primary Key 约束,会保留所有未去重的明细数据,只有在需要唯一结果时才用 Top-1(rn = 1)取“每个 key 的一条记录”。

对于这种“Top-1 只是为了去重”的场景,通常可以用 主键表 + Upsert 直接替代 Top-1。在数据同步入 ProtonBase 前就在表上定义好 Primary Key,并采用 INSERT ON CONFLICT DO UPDATE(UPSERT) 的方式写入数据:

  • 同一个主键的新数据写入时覆盖(或按规则更新)旧版本
  • 表在系统内天然就是“按 PK 去重后的结果”
  • 下游查询不再需要 Top-1 去重

收益:

  • 降低计算成本:避免在查询中做排序/窗口/过滤
  • 简化查询逻辑:去重语义由主键约束与写入规则保证
  • 增量更稳定:下游的增量只需要处理 Upsert 带来的变更,不用再计算 Top-1

Optimize Notice

为了帮助你在创建阶段就获得更好的增量刷新性能,ProtonBase 会在创建增量 MV 时对查询进行分析,并在需要时通过 NOTICE 给出一些调优的建议(最常见的是建议创建二级索引)。 这些 NOTICE 不会改变执行结果,也不会自动创建索引;它们是基于查询结构与增量计算路径的建议,你可以按需采纳。

示例:

CREATE TABLE t (a int, b int);
 
CREATE MATERIALIZED VIEW imv_join
WITH (refresh_mode='incremental') AS
SELECT t1.a, t2.b
FROM t AS t1, t AS t2
WHERE t1.a = t2.a;

系统可能输出:

NOTICE:  suggested index: CREATE INDEX ON t (b ASC)
NOTICE:  suggested index: CREATE INDEX ON t (a ASC)
  • 该查询包含 Join 条件 t.b = t1.a
  • 增量刷新时,系统往往需要用变更数据(Δ)去匹配另一侧表
  • 在 Join Key 上建立二级索引,可以让增量计算通过索引 Lookup 加速,避免扫描大表

总结

  1. 先创建 MV,观察创建阶段输出的 NOTICE
  2. 评估建议是否适用
  • 表是否为大表、增量是否频繁、该 MV 是否是热点
  • 索引会带来额外写入与存储开销,建议结合负载选择
  • 按需创建索引(可直接复制 NOTICE 给出的语句)
  1. 验证效果
  • 对比增量刷新耗时、资源使用、以及 MV 查询延迟

自动化运维建议

  • 利用调度系统(如 Airflow、crontab)定时刷新物化视图,结合业务低峰期执行。
  • 监控刷新耗时、失败率、延迟等指标,及时告警。
  • 利用系统表(如 pg_matviewspg_depend)自动分析依赖关系,避免因基础表变更导致刷新失败。
  • 定期清理不再使用的物化视图,节省存储和维护成本。

实践案例

案例一:用户增长分析

假如我们需要评估一些市场推广或广告投放的效果,新用户数量是一个很好的指标。判断一个用户是否是新用户,可以通过他是否在过去 30 天内在我们的网站内有任何活动来判断。我们需要类似下面这样的查询:

SELECT COUNT(DISTINCT user_id)
FROM user_activities
WHERE pt = current_date
AND user_id not in (
    -- 统计过去30天出现过的所有用户
    SELECT DISTINCT user_id    
    FROM user_activities
    WHERE pt >= current_date - internal '30 day' AND pt < current_date AND pt)

我们注意到"统计过去 30 天出现过的所有用户"这样的查询计算量还是比较大的,并且结果基本不变(假设不考虑 user_activities 有一些迟到的数据)。如果新用户分析的查询经常发生,我们就可以考虑把 "统计过去 30 天出现过的所有用户" 这部分结果使用物化视图的方式存储起来。考虑到这个查询只需要每天运行一次,我们可以选择全量物化视图,在每天凌晨全量刷新一下结果即可。

类似的思路,也可以扩展到用户留存分析,不同渠道新用户增长分析等场景。

案例二:数仓分层

在典型的数仓架构中,我们一般都会采取分层策略,比如:

  • ODS(明细数据层):也称贴源层,这里存储最原始未加工的数据,后后续数据加工的来源。
  • DWD(数据基础层):主要对 ODS 层的数据做一些清洗/过滤/规范化的加工操作,也经常涉及到宽表的加工等
  • DWS / ADS(数据服务层):基于 DWD 层的数据,做不同程度的汇总操作。

离线数仓和实时数仓的主要区别,就在于每一层的时效性,即最原始的数据变化,在多少时间后会体现在这一层的内容里。从模型上来讲,除去 ODS 层之外,其他层的数据都可以看作是某种意义上的物化视图,定期根据数据的依赖关系进行刷新操作。

结合 ProtonBase 强大的存储和查询能力,我们可以构建一个离线和实时一体化的数仓架构:

  • ODS
    • 由外部实时写入或由工具实时同步进入 Protonbase
    • 一些最灵活的业务场景(如数据分析师验证想法等)可以直接查询 ODS 数据
  • DWD
    • 按不同的时效性需求,基于 ODS 层数据构建物化视图。通过 pg_depend, pg_class 等系统表,可以自动分析出物化视图和表之间的依赖关系
    • 小时/天级时效性:构建全量物化视图,定期按照依赖关系全量刷新
    • 分钟级时效性:构建增量物化视图,支持常见的过滤,多表关联等,定期按照依赖关系增量刷新
  • DWS/AWS:
    • 根据不同的业务场景需要,基于 DWD 和 ODS 层构建物化视图。
    • 一些较为固定的查询 pattern:可以构建一些轻度聚合的物化视图,直接在上面做二次聚合分析或者点查。
    • 灵活性很高的查询:可以不使用物化视图,直接查询 ODS 和 DWD 层的数据

通过这个架构,我们不需要为离线和实时各准备一套完整的数据架构,只需要根据时效性和查询灵活性等需求在 Protonbase 里创建合理的表和物化视图等,即可同时满足离线和实时的需求,极大的降低了成本,也提高了易用性。

案例三:金融风控

在金融行业中,实时风控是保障业务安全的重要环节。通过增量物化视图,可以实现对用户交易行为的实时监控和风险识别。

业务场景: 银行或支付平台需要实时监控用户的交易行为,识别异常交易模式,防止欺诈行为。例如,监控用户按小时、按天的交易频次、交易金额分布等指标,当这些指标超过预设阈值时,触发风控策略。

技术实现:

-- 创建基础增量物化视图,按时间粒度聚合用户交易数据
CREATE MATERIALIZED VIEW user_transaction_base
WITH (refresh_mode = 'incremental') AS
SELECT 
  user_id,
  DATE_TRUNC('hour', transaction_time) as transaction_hour,
  DATE_TRUNC('day', transaction_time) as transaction_day,
  COUNT(*) as transaction_count,
  SUM(amount) as total_amount,
  AVG(amount) as avg_amount,
  MAX(amount) as max_amount,
  COUNT(DISTINCT merchant_id) as merchant_count
FROM transactions 
GROUP BY user_id, DATE_TRUNC('hour', transaction_time), DATE_TRUNC('day', transaction_time);
 
-- 创建高级物化视图,基于基础物化视图进行进一步聚合
CREATE MATERIALIZED VIEW user_risk_metrics
WITH (refresh_mode = 'incremental') AS
SELECT
  user_id,
  transaction_day,
  SUM(transaction_count) as daily_transaction_count,
  SUM(total_amount) as daily_total_amount,
  AVG(avg_amount) as daily_avg_amount,
  MAX(max_amount) as daily_max_amount,
  AVG(transaction_count) as hourly_avg_transactions,
  STDDEV(transaction_count) as transaction_count_stddev
FROM user_transaction_base
GROUP BY user_id, transaction_day;

优势:

  • 分层聚合:通过基础物化视图和高级物化视图的分层设计,提高查询效率
  • 多时间粒度:支持按小时、按天等不同时间粒度的统计分析
  • 实时性:增量物化视图能够实时反映用户最新的交易行为
  • 性能:避免每次风控检查时都进行全表扫描和实时计算

应用场景:

  • 异常高频交易检测(如短时间内大量小额交易)
  • 大额交易监控(单笔或累计大额交易)
  • 交易模式异常识别(与历史模式偏差较大)
  • 黑名单商户交易监控

案例四: IoT 设备监控

在物联网(IoT)场景中,通常需要处理来自数百万设备的传感器数据,通过增量物化视图可以高效地进行数据聚合和实时监控。

业务场景: 工业物联网中,需要实时监控各类设备的运行状态,包括温度、压力、振动等传感器数据。通过对这些数据进行实时聚合分析,可以及时发现设备异常,预防设备故障。

技术实现:

-- 创建基础增量物化视图,按设备和时间粒度聚合传感器数据
CREATE MATERIALIZED VIEW device_sensor_base
WITH (refresh_mode = 'incremental') AS
SELECT 
  device_id,
  device_type,
  DATE_TRUNC('hour', reading_time) as reading_hour,
  DATE_TRUNC('day', reading_time) as reading_day,
  AVG(temperature) as avg_temperature,
  MAX(temperature) as max_temperature,
  MIN(temperature) as min_temperature,
  AVG(pressure) as avg_pressure,
  STDDEV(vibration) as vibration_stddev,
  COUNT(*) as reading_count,
  COUNT(CASE WHEN temperature > 80 THEN 1 END) as high_temp_count
FROM sensor_readings 
GROUP BY device_id, device_type, DATE_TRUNC('hour', reading_time), DATE_TRUNC('day', reading_time);
 
-- 创建高级物化视图,基于基础物化视图计算设备健康指标
CREATE MATERIALIZED VIEW device_health_metrics
WITH (refresh_mode = 'incremental') AS
SELECT
  device_id,
  device_type,
  reading_day,
  AVG(avg_temperature) as daily_avg_temperature,
  MAX(max_temperature) as daily_max_temperature,
  AVG(vibration_stddev) as daily_avg_vibration_stddev,
  SUM(high_temp_count) as daily_high_temp_count,
  COUNT(*) as hourly_readings_count,
  -- 计算设备异常率
  SUM(CASE WHEN high_temp_count > 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as high_temp_rate
FROM device_sensor_base
GROUP BY device_id, device_type, reading_day;

优势:

  • 分层处理:基础物化视图处理原始数据,高级物化视图计算健康指标
  • 多维度分析:支持按设备类型、时间粒度等多维度统计
  • 高效处理:能够处理数百万设备的海量传感器数据
  • 异常检测:快速识别异常设备,支持预防性维护

应用场景:

  • 设备健康状态监控
  • 异常率统计和趋势分析
  • 预防性维护决策支持
  • 设备性能基准比较

案例五:日志分析

在大规模分布式系统中,日志分析是保障系统稳定性和安全性的重要手段。通过增量物化视图,可以实现对日志数据的实时聚合和分析。

业务场景: 互联网公司需要实时监控系统日志,统计各类错误、告警、访问量等指标,以便及时发现系统问题并驱动自动化运维和安全响应。

技术实现:

-- 创建基础增量物化视图,按服务和时间粒度聚合日志数据
CREATE MATERIALIZED VIEW log_base_metrics
WITH (refresh_mode = 'incremental') AS
SELECT 
  service_name,
  DATE_TRUNC('hour', log_time) as log_hour,
  DATE_TRUNC('day', log_time) as log_day,
  log_level,
  COUNT(*) as log_count,
  COUNT(CASE WHEN log_level = 'ERROR' THEN 1 END) as error_count,
  COUNT(CASE WHEN log_level = 'WARN' THEN 1 END) as warn_count,
  COUNT(CASE WHEN log_level = 'INFO' THEN 1 END) as info_count,
  COUNT(DISTINCT host_ip) as active_hosts,
  APPROX_COUNT_DISTINCT(user_id) as affected_users
FROM application_logs 
GROUP BY service_name, DATE_TRUNC('hour', log_time), DATE_TRUNC('day', log_time), log_level;
 
-- 创建高级物化视图,基于基础物化视图计算系统健康度指标
CREATE MATERIALIZED VIEW system_health_metrics
WITH (refresh_mode = 'incremental') AS
SELECT
  service_name,
  log_day,
  log_hour,
  SUM(log_count) as total_logs,
  SUM(error_count) as total_errors,
  SUM(warn_count) as total_warnings,
  -- 计算错误率
  CASE 
    WHEN SUM(log_count) > 0 THEN SUM(error_count) * 100.0 / SUM(log_count)
    ELSE 0 
  END as error_rate,
  -- 计算警告率
  CASE 
    WHEN SUM(log_count) > 0 THEN SUM(warn_count) * 100.0 / SUM(log_count)
    ELSE 0 
  END as warning_rate,
  MAX(active_hosts) as peak_active_hosts,
  SUM(affected_users) as total_affected_users
FROM log_base_metrics
GROUP BY service_name, log_day, log_hour;

优势:

  • 分层聚合:基础物化视图处理原始日志,高级物化视图计算健康指标
  • 多时间粒度:支持按小时、按天等时间粒度的统计分析
  • 实时监控:实时反映系统最新状态和问题
  • 性能优化:避免每次分析时都扫描大量日志数据

应用场景:

  • 系统健康度监控
  • 错误和告警实时统计
  • 用户访问量分析
  • 安全事件检测(如异常登录、恶意请求等)
  • 性能瓶颈识别

常见问题与排查

  • 刷新失败:检查基础表/视图权限、依赖对象是否存在、SQL 语法是否兼容增量物化。
  • 数据不一致:关注基础表延迟、迟到数据、并发写入等场景,必要时采用全量重刷。
  • 性能瓶颈:分析物化视图定义的 SQL 复杂度、索引覆盖情况、存储方式(行/列/混合)是否合理。
  • 依赖变更:基础表结构变更后,需同步调整物化视图定义,避免刷新异常。

排查建议:

  • 刷新失败优先检查依赖对象和权限。
  • 数据不一致时关注迟到数据和并发写入,必要时全量重刷。
  • 性能瓶颈优先分析 SQL 复杂度和索引覆盖。
  • 基础表变更需同步调整物化视图定义。