前言

Github:https://github.com/HealerJean

博客:http://blog.healerjean.com

一、模型概述

Doris 中建表时需要指定表模型,以定义数据存储与管理方式。在 Doris 中提供了明细模型聚合模型以及 主键模型 三种表模型,可以应对不同的应用场景需求。不同的表模型具有相应的数据去重、聚合及更新机制。选择合适的表模型有助于实现业务目标,同时保证数据处理的灵活性和高效性。

  明细模型 主键模型 聚合模型
Key 列唯一约束 不支持,Key 列可以重复 支持 支持
同步物化视图 支持 支持 支持
异步物化视图 支持 支持 支持
UPDATE 语句 不支持 支持 不支持
DELETE 语句 部分支持 支持 部分支持
导入时整行更新 不支持 支持 不支持
导入时部分列更新 不支持 支持 部分支持

1、表模型分类

1)[明细模型](Duplicate Key Model

允许指定的 Key 列重复,Doirs 存储层保留所有写入的数据,适用于必须保留所有原始数据记录的情况;

  • 适合任意维度的 Ad-hoc 查询。虽然同样无法利用预聚合的特性,但是不受聚合模型的约束,可以发挥列存模型的优势(只读取相关列,而不需要读取所有 Key 列)。
  • 如同 “数据仓库的杂货铺”,货物(数据)按类别(列)整齐摆放,顾客(查询)可随意挑选任意商品组合,灵活但需要现场打包(实时计算)。

2)[主键模型]Unique Key Model

每一行的 Key 值唯一,可确保给定的 Key 列不会存在重复行,Doris 存储层对每个 key 只保留最新写入的数据,适用于数据更新的情况;

  • 针对需要唯一主键约束的场景,可以保证主键唯一性约束。但是无法利用 ROLLUP 等预聚合带来的查询优势。
  • 如同 “图书馆的借书系统”,每本书(记录)有唯一编号(主键),方便快速查找,但无法直接统计 “某类书的总数量”(需额外计算)。

3)[聚合模型]Aggregate Key Model

可根据 Key 列聚合数据,Doris 存储层保留聚合后的数据,从而可以减少存储空间和提升查询性能;通常用于需要汇总或聚合信息(如总数或平均值)的情况。

  • 可以通过预聚合,极大地降低聚合查询时所需扫描的数据量和查询的计算量,非常适合有固定模式的报表类查询场景。但是该模型对 count(*) 查询很不友好。同时因为固定了 Value 列上的聚合方式,在进行其他类型的聚合查询时,需要考虑语意正确性
  • 如同 “超市的预包装食品”,提前按类别(维度)打包称重(预聚合),适合快速购买固定套餐(固定报表查询),但无法临时更换包装内的商品(灵活聚合)

2、排序键

Doris 中,数据以列的形式存储

一张表可以分为 key 列与 value 列。其中,key 列用于分组与排序,value 列用于参与聚合。Key 列可以是一个或多个字段,在建表时,按照各种表模型中,Aggregate KeyUnique KeyDuplicate Key 的列进行数据排序存储。

不同的表模型都需要在建表时指定 Key 列,分别有不同的意义:

  • 对于 Duplicate Key 模型,Key 列表示排序,没有唯一键的约束。
  • Aggregate KeyUnique Key 模型中,会基于 Key 列进行聚合,Key 列既有排序的能力,又有唯一键的约束。

1)排序键-收益

  • 加速查询性能
    • 减少数据扫描:排序键有助于减少数据扫描量。
    • 定位数据位置:对于范围查询或过滤查询,可以利用排序键直接定位数据的位置。
    • 加速数据排序:对于需要进行排序的查询,也可以利用排序键进行排序加速;
  • 数据压缩优化:数据 按排序键有序存储会提高压缩的效率,相似的数据会聚集在一起,压缩率会大幅度提高,从而减小数据的存储空间。

  • 减少去重成本:当使用 Unique Key 表时,通过排序键,Doris 能更有效地进行去重操作,保证数据唯一性。

2)排序键-建议

选择排序键时,可以遵循以下建议:

  • Key 列必须在所有 Value 列之前。
  • 尽量选择整型类型。因为整型类型的计算和查找效率远高于字符串。
  • 对于不同长度的整型类型的选择原则,遵循够用即可。
  • 对于 VARCHARSTRING 类型的长度,遵循够用即可原则。

二、明细模型(Duplicate Key Model

明细模型是 Doris 中的默认建表模型,用于保存每条原始数据记录。在建表时,通过 DUPLICATE KEY 指定数据存储的排序列,以优化常用查询。一般建议选择三列或更少的列作为排序键,具体选择方式参考[排序键]。明细模型具有以下特点:

1、特点:

  • 保留原始数据:明细模型保留了全量的原始数据,适合于存储与查询原始数据。对于需要进行详细数据分析的应用场景,建议使用明细模型,以避免数据丢失的风险;
  • 不去重也不聚合:与聚合模型与主键模型不同,明细模型不会对数据进行去重与聚合操作。即使两条相同的数据,每次插入时也会被完整保留;
  • 灵活的数据查询:明细模型保留了全量的原始数据,可以从完整数据中提取细节,基于全量数据做任意维度的聚合操作,从而进行元数数据的审计及细粒度的分析。

2、使用场景

一般明细模型中的数据只进行追加,旧数据不会更新。明细模型适用于需要存储全量原始数据的场景:

  • 日志存储:用于存储各类的程序操作日志,如访问日志、错误日志等。每一条数据都需要被详细记录,方便后续的审计与分析;
  • 用户行为数据:在分析用户行为时,如点击数据、用户访问轨迹等,需要保留用户的详细行为,方便后续构建用户画像及对行为路径进行详细分析;
  • 交易数据:在某些存储交易行为或订单数据时,交易结束时一般不会发生数据变更。明细模型适合保留这一类交易信息,不遗漏任意一笔记录,方便对交易进行精确的对账。

3、建表说明

在建表时,可以通过 DUPLICATE KEY 关键字指定明细模型。明细表必须指定数据的 Key 列,用于在存储时对数据进行排序。下例的明细表中存储了日志信息,并针对于 log_timelog_typeerror_code 三列进行了排序:

CREATE TABLE IF NOT EXISTS example_tbl_duplicate
(
    log_time        DATETIME       NOT NULL,
    log_type        INT            NOT NULL,
    error_code      INT,
    error_msg       VARCHAR(1024),
    op_id           BIGINT,
    op_time         DATETIME
)
DUPLICATE KEY(log_time, log_type, error_code)
DISTRIBUTED BY HASH(log_type) BUCKETS 10;

4、数据插入与存储

在明细表中,数据不进行去重与聚合,插入数据即存储数据。明细模型中 Key 列指做为排序。

image-20250623122935457

在上例中,表中原有 4 行数据,插入 2 行数据后,采用追加(APPEND)方式存储,共计 6 行数据:

-- 4 rows raw data
INSERT INTO example_tbl_duplicate VALUES
('2024-11-01 00:00:00', 2, 2, 'timeout', 12, '2024-11-01 01:00:00'),
('2024-11-02 00:00:00', 1, 2, 'success', 13, '2024-11-02 01:00:00'),
('2024-11-03 00:00:00', 2, 2, 'unknown', 13, '2024-11-03 01:00:00'),
('2024-11-04 00:00:00', 2, 2, 'unknown', 12, '2024-11-04 01:00:00');

-- insert into 2 rows
INSERT INTO example_tbl_duplicate VALUES
('2024-11-01 00:00:00', 2, 2, 'timeout', 12, '2024-11-01 01:00:00'),
('2024-11-01 00:00:00', 2, 2, 'unknown', 13, '2024-11-01 01:00:00');

-- check the rows of table
SELECT * FROM example_tbl_duplicate;
+---------------------+----------+------------+-----------+-------+---------------------+
| log_time            | log_type | error_code | error_msg | op_id | op_time             |
+---------------------+----------+------------+-----------+-------+---------------------+
| 2024-11-02 00:00:00 |        1 |          2 | success   |    13 | 2024-11-02 01:00:00 |
| 2024-11-01 00:00:00 |        2 |          2 | timeout   |    12 | 2024-11-01 01:00:00 |
| 2024-11-03 00:00:00 |        2 |          2 | unknown   |    13 | 2024-11-03 01:00:00 |
| 2024-11-04 00:00:00 |        2 |          2 | unknown   |    12 | 2024-11-04 01:00:00 |
| 2024-11-01 00:00:00 |        2 |          2 | unknown   |    13 | 2024-11-01 01:00:00 |
| 2024-11-01 00:00:00 |        2 |          2 | timeout   |    12 | 2024-11-01 01:00:00 |
+---------------------+----------+------------+-----------+-------+---------------------+

三、主键模型

当需要更新数据时,可以选择主键模型(Unique Key Model)。该模型保证 Key 列的唯一性,插入或更新数据时,新数据会覆盖具有相同 Key 的旧数据,确保数据记录为最新。与其他数据模型相比,主键模型适用于数据的更新场景,在插入过程中进行主键级别的更新覆盖。

1、特点

  • 基于主键进行 UPSERT:在插入数据时,主键重复的数据会更新,主键不存在的记录会插入;
  • 基于主键进行去重:主键模型中的 Key 列具有唯一性,会对根据主键列对数据进行去重操作;
  • 高频数据更新:支持高频数据更新场景,同时平衡数据更新性能与查询性能。

2、使用场景

  • 部分列更新:如画像标签场景需要变更频繁改动的动态标签,消费订单场景需要改变交易的状态。通过主键模型部分列更新能力可以完成某几列的变更操作。
  • 高频数据更新:适用于上游 OLTP 数据库中的维度表,实时同步更新记录,并高效执行 UPSERT 操作;
  • 数据高效去重:如广告投放和客户关系管理系统中,使用主键模型可以基于用户 ID 高效去重;

3、实现方式

Doris 中主键模型有两种实现方式:

1)写时合并(merge-on-write

1.2 版本起,Doris 默认使用写时合并模式,数据在写入时立即合并相同 Key 的记录,确保存储的始终是最新数据。写时合并兼顾查询和写入性能,避免多个版本的数据合并,并支持谓词下推到存储层。大多数场景推荐使用此模式;

在建表时,使用 UNIQUE KEY 关键字可以指定主键表。通过显示开启 enable_unique_key_merge_on_write 属性可以指定写时合并模式。自 Doris 1.2 版本以后,默认开启写时合并:

CREATE TABLE IF NOT EXISTS example_tbl_unique
(
    user_id         LARGEINT        NOT NULL,
    user_name       VARCHAR(50)     NOT NULL,
    city            VARCHAR(20),
    age             SMALLINT,
    sex             TINYINT
)
UNIQUE KEY(user_id, user_name)
DISTRIBUTED BY HASH(user_id) BUCKETS 10
PROPERTIES (
    "enable_unique_key_merge_on_write" = "true"
);

2)读时合并(merge-on-read

1.2 版本前,Doris 中的主键模型默认使用读时合并模式,数据在写入时并不进行合并,以增量的方式被追加存储,在 Doris 内保留多个版本。查询或 Compaction 时,会对数据进行相同 Key 的版本合并。读时合并适合写多读少的场景,在查询是需要进行多个版本合并,谓词无法下推,可能会影响到查询速度

在建表时,使用 UNIQUE KEY 关键字可以指定主键表。通过显示关闭 enable_unique_key_merge_on_write 属性可以指定读时合并模式。在 Doris 1.2 版本之前,默认开启读时合并:

CREATE TABLE IF NOT EXISTS example_tbl_unique
(
    user_id         LARGEINT        NOT NULL,
    username        VARCHAR(50)     NOT NULL,
    city            VARCHAR(20),
    age             SMALLINT,
    sex             TINYINT
)
UNIQUE KEY(user_id, username)
DISTRIBUTED BY HASH(user_id) BUCKETS 10
PROPERTIES (
    "enable_unique_key_merge_on_write" = "false"
);

3)更新方式

  • 整行更新Unique Key 模型默认的更新语义为整行UPSERT,即 UPDATE OR INSERT,该行数据的 Key 如果存在,则进行更新,如果不存在,则进行新数据插入。在整行 UPSERT 语义下,即使用户使用 Insert Into 指定部分列进行写入,Doris 也会在 Planner 中将未提供的列使用 NULL 值或者默认值进行填充。
  • 部分列更新:如果用户希望更新部分字段,需要使用写时合并实现,并通过特定的参数来开启部分列更新的支持。

4、数据插入与存储

在主键表中,Key 列不仅用于排序,还用于去重,插入数据时,相同 Key 的记录会被覆盖。

image-20250623133036114

如上例所示,原表中有 4行数据,插入 2 行后,新插入的数据基于主键进行了更新:

-- insert into raw data
INSERT INTO example_tbl_unique VALUES
(101, 'Tom', 'BJ', 26, 1),
(102, 'Jason', 'BJ', 27, 1),
(103, 'Juice', 'SH', 20, 2),
(104, 'Olivia', 'SZ', 22, 2);

-- insert into data to update by key
INSERT INTO example_tbl_unique VALUES
(101, 'Tom', 'BJ', 27, 1),
(102, 'Jason', 'SH', 28, 1);

-- check updated data
SELECT * FROM example_tbl_unique;
+---------+----------+------+------+------+
| user_id | username | city | age  | sex  |
+---------+----------+------+------+------+
| 101     | Tom      | BJ   |   27 |    1 |
| 102     | Jason    | SH   |   28 |    1 |
| 104     | Olivia   | SZ   |   22 |    2 |
| 103     | Juice    | SH   |   20 |    2 |
+---------+----------+------+------+------+

5、注意事项

  • Unique 表的实现方式只能在建表时确定,无法通过 schema change 进行修改;
  • 在整行 UPSERT 语义下,即使用户使用 insert into 指定部分列进行写入,Doris 也会在 Planner 中将未提供的列使用 NULL 值或者默认值进行填充;
  • 部分列更新。如果用户希望更新部分字段,需要使用写时合并实现,并通过特定的参数来开启部分列更新的支持。
  • 使用 Unique 表时,为了保证数据的唯一性,分区键必须包含在 Key 列内。

四、聚合模型

Doris 的聚合模型专为高效处理大规模数据查询中的聚合操作设计。它通过预聚合数据,减少重复计算,提升查询性能。聚合模型只存储聚合后的数据,节省存储空间并加速查询。

1、使用场景

  • 明细数据进行汇总:用于电商平台的月销售业绩、金融风控的客户交易总额、广告投放的点击量等业务场景中,进行多维度汇总;
  • 不需要查询原始明细数据:如驾驶舱报表、用户交易行为分析等,原始数据存储在数据湖中,仅需存储汇总后的数据。

2、原理

每一次数据导入会在聚合模型内形成一个版本,在 Compaction 阶段进行版本合并,在查询时会按照主键进行数据聚合:

  1. 数据导入阶段:数据按批次导入,每批次生成一个版本,并对相同聚合键的数据进行初步聚合(如求和、计数);
  2. 后台文件合并阶段(Compaction:多个版本文件会定期合并,减少冗余并优化存储;
  3. 查询阶段:查询时,系统会聚合同一聚合键的数据,确保查询结果准确。

3、建表说明

使用 AGGREGATE KEY 关键字在建表时指定聚合模型,并指定 Key 列用于聚合 Value 列。

CREATE TABLE IF NOT EXISTS example_tbl_agg
(
    user_id             LARGEINT    NOT NULL,
    load_dt             DATE        NOT NULL,
    city                VARCHAR(20),
    last_visit_dt       DATETIME    REPLACE DEFAULT "1970-01-01 00:00:00",
    cost                BIGINT      SUM DEFAULT "0",
    max_dwell           INT         MAX DEFAULT "0",
)
AGGREGATE KEY(user_id, load_dt, city)
DISTRIBUTED BY HASH(user_id) BUCKETS 10;

上例中定义了用户信息和访问行为表,将 user_idload_datecityage 作为 Key 列进行聚合。数据导入时,Key 列会聚合成一行,Value 列会按照指定的聚合类型进行维度聚合。

在聚合表中支持以下类型的维度聚合:

聚合方式 描述
SUM 求和,多行的 Value 进行累加。
REPLACE 替代,下一批数据中的 Value 会替换之前导入过的行中的 Value
MAX 保留最大值。
MIN 保留最小值。
REPLACE_IF_NOT_NULL 非空值替换。与 REPLACE 的区别在于对 null 值,不做替换。
HLL_UNION HLL 类型的列的聚合方式,通过 HyperLogLog 算法聚合。
BITMAP_UNION BITMAP 类型的列的聚合方式,进行位图的并集聚合。

4、数据插入与存储

在聚合表中,数据基于主键进行聚合操作。数据插入后及完成聚合操作。

image-20250623134152541

在上例中,表中原有 4 行数据,在插入 2 行数据后,基于 Key 列进行维度列的聚合操作:

-- 4 rows raw data
INSERT INTO example_tbl_agg VALUES
(101, '2024-11-01', 'BJ', '2024-10-29', 10, 20),
(102, '2024-10-30', 'BJ', '2024-10-29', 20, 20),
(101, '2024-10-30', 'BJ', '2024-10-28', 5, 40),
(101, '2024-10-30', 'SH', '2024-10-29', 10, 20);

-- insert into 2 rows
INSERT INTO example_tbl_agg VALUES
(101, '2024-11-01', 'BJ', '2024-10-30', 20, 10),
(102, '2024-11-01', 'BJ', '2024-10-30', 10, 30);

-- check the rows of table
SELECT * FROM example_tbl_agg;
+---------+------------+------+---------------------+------+----------------+
| user_id | load_date  | city | last_visit_date     | cost | max_dwell_time |
+---------+------------+------+---------------------+------+----------------+
| 102     | 2024-10-30 | BJ   | 2024-10-29 00:00:00 |   20 |             20 |
| 102     | 2024-11-01 | BJ   | 2024-10-30 00:00:00 |   10 |             30 |
| 101     | 2024-10-30 | BJ   | 2024-10-28 00:00:00 |    5 |             40 |
| 101     | 2024-10-30 | SH   | 2024-10-29 00:00:00 |   10 |             20 |
| 101     | 2024-11-01 | BJ   | 2024-10-30 00:00:00 |   30 |             20 |
+---------+------------+------+---------------------+------+----------------+

FAQ

1、明细模型

1)明细模型 为什么适合任意维度的 Ad-hoc 查询

  • 灵活性: 不限制查询维度的组合,用户可自由选择任意列(包括非 Key 列)作为查询条件,无需受限于预定义的聚合模型,比如Duplicate 模型没有聚合模型 count 的这个局限性。因为该模型不涉及聚合语意,在做 count(*) 查询时,任意选择一列查询,即可得到语意正确的结果。。

  • 列存优势: 列存数据库会按列独立存储数据。当查询仅涉及部分列时,可仅读取相关列的数据,避免扫描全量 Key 列,大幅减少 IO 开销。举例:表结构为 Key = (date, region),Value=(sales, users),若查询 “2025 年 6 月华东地区的用户数”,列存模型只需读取dateregionusers 三列,而非所有Key 列。

2) 明细模型 为什么无法利用预聚合?

答案:预聚合需要提前定义聚合规则(如按时间、地区聚合销售额),而 Duplicate Key 模型不预设聚合逻辑,数据以原始粒度存储,因此无法通过预计算减少查询时的计算量。

2、主键模型

a:主键模型 为什么适合唯一主键约束场景?

  • 数据唯一性保障:例如用户表中user_id 作为 Unique Key,可避免重复用户数据插入。
  • 主键索引:通过唯一键建立索引,加速等值查询( 如 WHERE user_id=123)。

b:主键模型 为什么无法利用 ROLLUP 等预聚合?

答案:Unique Key 模型的核心是唯一性,数据以单条记录粒度存储,未进行预聚合,因此无法利用ROLLUP 优化查询。例如:若表以user_idUnique Key,存储用户明细数据,无法直接通过 ROLLUP 快速获取 “各地区用户数”,需实时聚合计算。

3、聚合模型

聚合模型的局限性本质是 空间效率与查询效率的权衡

  • 通过预聚合减少存储成本,但牺牲了部分查询场景的便捷性和性能(如COUNT(*))。
  • 业务设计时需根据查询模式选择合适的模型(如聚合模型适用于 OLAP 分析,而非频繁COUNT(*)的场景)

1)未聚合数据的查询一致性问题

在聚合模型中,模型对外展现的,是最终聚合后的数据。也就是说,任何还未聚合的数据(比如说两个不同导入批次的数据),不同导入批次的同源数据(如同一user_id+date)在物理存储中是独立的,需通过查询时聚合算子确保对外展示结果的一致性。

假设表结构如下:

ColumnName Type AggregationType Comment
user_id LARGEINT   用户 id
date DATE   数据灌入日期
cost BIGINT SUM 用户总消费

假设存储引擎中有如下两个已经导入完成的批次的数据:

batch 1

user_id date cost
10001 2017/11/20 50
10002 2017/11/21 39

batch 2

user_id date cost
10001 2017/11/20 1
10001 2017/11/21 5
10003 2017/11/22 22

可以看到,用户 10001 分属在两个导入批次中的数据还没有聚合。但是为了保证用户只能查询到如下最终聚合后的数据:

user_id date cost
10001 2017/11/20 51
10001 2017/11/21 5
10002 2017/11/21 39
10003 2017/11/22 22

我们在查询引擎中加入了聚合算子,来保证数据对外的一致性。

另外,在聚合列(Value)上,执行与聚合类型不一致的聚合类查询时,要注意语意。比如在如上示例中执行如下查询:

SELECT MIN(cost) FROM table;

得到的结果是 5,而不是 1实现代价:一致性保证依赖查询引擎实时聚合,增加了查询处理的计算开销。

2)聚合函数与模型定义的兼容性问题

当查询的聚合函数与表定义的AggregationType 不匹配时,结果可能不符合预期。

如上:对 cost 列(定义为SUM)执行 MIN(cost) 查询,结果为聚合后的最小值(5 ),而非原始数据中的最小值(1

3)COUNT(*) 查询的效率瓶颈

性能问题:当聚合键列较多时,COUNT(*) 需扫描大量数据,导致查询效率显著下降。

select count(*) from table; 的正确结果应该为 4
  • 传统数据库:可通过 统计信息单列扫描快速计算 COUNT(*),因为在实现上,我们可以通过如“导入时对行进行计数,保存 count 的统计信息”,或者在查询时“仅扫描某一列数据,获得 count

  • 聚合模型中:COUNT(*) 需扫描所有聚合键(AGGREGATE KEY)列,并执行实时聚合,原因如下:

    • 若仅扫描部分列,可能因未聚合的重复键导致结果错误(如案例中仅扫描 user_id 会误判行数为 3 )。
    • 若不进行查询时聚合,会返回原始行数(案例中为 5 ),与实际聚合结果( 4 行)不符。
    • 正确结果:4,必须同时读取 user_iddate 这两列的数据,再加上查询时聚合,才能返回 4 这个正确的结果,也就是说,在 count(*) 查询中,Doris 必须扫描所有的 AGGREGATE KEY 列(这里就是 user_id date),并且聚合后,才能得到语意正确的结果。* 当聚合列非常多时,count(*) 查询需要扫描大量的数据。
  1. 方案一:添加SUM 类型的计数列
  • 操作:新增 count 列(值恒为 1,聚合类型 SUM ),用 SUM(count) 替代 COUNT(*)
  • 效率优势:仅需扫描单列,避免扫描所有聚合键列。
  • 局限性:若存在重复导入(相同聚合键的多行数据),SUM(count)会累加重复行,与COUNT(*)语义不一致。
  1. 方案二:添加 REPLACE 类型的计数列
  • 操作count 列聚合类型改为 REPLACE(值恒为 1)。
  • 优势:无论是否重复导入,SUM(count) 结果始终等于COUNT(*)(因重复行的count会被替换为 1)。
  • 注意:需确保 count列值始终为 1,避免语义错误。

4)为什么适合固定模式的报表类查询?

  • 预聚合优化:数据写入时已按 Key 维度聚合,查询时只需扫描聚合后的少量数据,大幅减少计算量。例:表结构为Key=(date, region),Value=SUM(sales),查询 “2025 年 6 月各地区销售额” 时,直接读取预聚合的结果,无需扫描全量明细数据。
  • 固定模式适配: 报表通常有固定的查询维度(如时间、地区、产品类别),预聚合模型可提前优化这些场景的查询性能。

5)为什么会限制灵活性?

答案:写入时已确定 Value 列的聚合方式(如SUM 销售额),若查询时需要其他聚合方式(如 AVGCOUNT DISTINCT),可能导致语义错误或需要额外计算。

例:若 Value 列按 SUM 预聚合,当需要查询 “各地区平均销售额” 时,需用 SUM (sales) / COUNT (unique_users ),但预聚合中未存储用户数,可能需要关联其他表或重新计算。

ContactAuthor