侧边栏壁纸
  • 累计撰写 16 篇文章
  • 累计创建 6 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

Apache Doris 学习笔记(一): 三种表模型 & 分区分桶

NewBoy
2025-04-15 / 0 评论 / 2 点赞 / 47 阅读 / 0 字

Doris的三种表模型

Doris与其他MPP架构的数据引擎最大的区别,它天生支持3种表模式,以应对不同的应用场景。分别是:

表模式分类

  • 明细模型(Duplicated Key Model)
  • 主键模型(Unique Key Model)
  • 聚合模型(Aggregate Key Model)

三种不同的模式对比如下:
以下是 Doris 三种核心表模型(Duplicate、Aggregate、Unique)的对比表格,总结其核心特性、适用场景及差异:


特性Duplicate 模型Aggregate 模型Unique 模型
存储方式存储所有原始数据行,不进行任何聚合或去重,是Doris的默认表模式。按聚合键(AGGREGATE KEY)自动合并数据(如 SUM/MAX/MIN 等)。按主键(UNIQUE KEY)自动去重,保留最新版本数据(类似 Upsert)。
数据唯一性允许完全重复的行。同一聚合键的多行会被合并为一行。主键相同的行会被覆盖,保证主键唯一性。
适用场景需要完整明细数据的场景(如日志分析、订单流水)。预聚合统计(如 PV/UV、销售额汇总)。需要主键唯一性的场景(如用户表、商品表,需更新数据)。
数据更新不支持更新操作,仅追加数据。不支持update方式更新,但可以通过insert方式更改聚合的数据(如 SUM 累加)。主键冲突时,新数据覆盖旧数据(支持 Upsert)。
查询性能明细查询快,但聚合查询需实时计算,性能较低。聚合查询极快(数据已预计算),但明细查询无法进行。主键点查极快,范围查询效率取决于主键设计。
是否支持删除支持通过 DELETE WHERE 删除数据。支持通过 DELETE 语句按主键删除。支持通过 DELETE 语句按主键删除。
聚合能力无自动聚合,需手动写 SQL 聚合。内置预聚合(如 SUM、REPLACE、MAX 等)。无自动聚合,但可通过主键更新覆盖旧值。
存储空间占用占用较大(存储原始明细)。占用较小(数据已压缩聚合)。占用中等(按主键去重,但需保留历史版本时可能较大)。
典型使用案例日志流水、行为轨迹分析。实时报表(如每日销售额总和、用户活跃数)。用户资料表、商品库存表(需按主键更新)。
物化视图支持支持,可加速聚合查询。通常不需要(本身已聚合)。支持,用于加速非主键列的查询。
建表示例DUPLICATE KEY(dt, user_id)AGGREGATE KEY(dt, product_id) SUM(sales)UNIQUE KEY(user_id)
KEY列作用仅排序列,并非起到唯一标识的作用兼顾 "排序列" 和 "唯一标识列",是真正意义上的 "Key 列"兼顾 "排序列" 和 "唯一标识列",是真正意义上的 "Key 列"

核心差异总结

  1. 数据粒度

    • Duplicate:保留原始明细。
    • Aggregate:存储聚合后的粗粒度数据。
    • Unique:存储主键粒度的最新数据。
  2. 写入更新

    • Aggregate 通过聚合函数合并数据,Unique 通过主键覆盖更新,Duplicate 仅追加。
  3. 查询效率

    • Aggregate 对预聚合字段查询极快,Unique 主键查询高效,Duplicate 适合复杂过滤但聚合慢。
  4. 适用场景

    • 明细存储 → Duplicate,统计报表 → Aggregate,主键更新 → Unique。

选型建议

  • 需要 100% 原始数据 → 选 Duplicate
  • 需要 预计算统计指标 → 选 Aggregate
  • 需要 按主键更新或去重 → 选 Unique

实际操作验证

明细模型(Duplicated Key Model)

创建明细模式表时候,需要通过如下关键字指定:

DUPLICATE KEY(a,b)

例如创建一个有3个重复主键的表:

CREATE TABLE IF NOT EXISTS log_table
(
    log_time        DATETIME       NOT NULL,
    log_type        INT            NOT NULL,
    error_code      INT,
    error_msg       VARCHAR(1024),
_id           BIGINT,
    op_time         DATETIME
)
DUPLICATE KEY(log_time, log_type, error_code)
DISTRIBUTED BY HASH(log_type) BUCKETS 10
PROPERTIES (
    "replication_num" = "1"
);

-- 插入初始数据
INSERT INTO log_table 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');

查询结果如下:
查询结果

再次插入重复key的数据:

INSERT INTO log_table 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');

从测试结果中不难发现,第二次插入数据与前面的数据,虽然有重复的key,所有结果都完全保存了。

更新数据

从下面的错误提示可知,明细表是不支持update,只有unique表支持update操作。:

learn_doris> update log_table set error_msg='success' where log_time='2024-11-01 00:00:00'
[2025-04-16 08:46:27] [HY000][1105] errCode = 2, detailMessage = Only unique table could be updated.

删除数据

明细表可以通过delete删除数据,而且可以通过duplicated key 和其他的列删除数据。

# 通过主键删除
learn_doris> delete from log_table where log_time='2024-11-01 01:00:00'
[2025-04-16 08:49:08] completed in 94 ms

# 通过非主键删除
learn_doris> delete from log_table where op_id='13'
[2025-04-16 08:49:42] completed in 77 ms

结论:明细表的crud操作,除了不能update,其他和关系型数据库类似

主键模型(Unique Key Model)

创建主键模型,需要通过如下关键字指定:

UNIQUE KEY(id)

建表:

CREATE TABLE IF NOT EXISTS user_info
(
    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", --启用写时合并
    "replication_num" = "1"
);

插入数据

INSERT INTO user_info VALUES
(101, 'Tom', 'BJ', 26, 1),
(102, 'Jason', 'BJ', 27, 1),
(103, 'Juice', 'SH', 20, 2),
(104, 'Olivia', 'SZ', 22, 2);

现在数据库中的数据如下:

再次插入重复主键的数据:

-- 插入新数据,更新已有记录
INSERT INTO user_info VALUES
(101, 'Tom', 'BJ', 27, 1),  -- 更新 user_id = 101 的记录
(102, 'Jason', 'SH', 28, 1); -- 更新 user_id = 102 的记录

结果如下:

从测试结果中可以看出,第二次插入的数据,覆盖了相同主键的数据。

更新数据

主键模型表,支持更新操作,但只支持更新非主键上的数据,下面是实际操作日志:

# 成功更新非主键的值
learn_doris> update user_info set age=23 where user_id=104
[2025-04-16 08:57:29] 1 row affected in 143 ms

# 更新主键的值失败
learn_doris> update user_info set user_name='Jerry' where user_id=101
[2025-04-16 08:58:36] [HY000][1105] errCode = 2, detailMessage = Only value columns of unique table could be updated

删除操作

支持通过主键和非主键delete数据:

# 通过逐渐删除数据
learn_doris> delete from user_info where user_id=104
[2025-04-16 09:02:50] 1 row affected in 111 ms

# 通过非主键删除数据
learn_doris> delete from user_info where city='BJ'
[2025-04-16 09:03:23] 1 row affected in 90 ms

结论:主键类型表,crud操作,不支持update主键外,其他与关系型数据库一致。

聚合模型(Aggregate Key Model)

创建聚合模型,需要通过如下关键字指定:

AGGREGATE KEY(dt, product_id)

与前面不同的是,除了需要指定主键外(dt,product_id),还需要指定聚合的列(sales)以及聚合规则。
完整示例:

CREATE TABLE IF NOT EXISTS user_behavior_agg
(
    user_id             LARGEINT    NOT NULL,
    load_date           DATE        NOT NULL,
    city                VARCHAR(20),
    last_visit_date     DATETIME    REPLACE DEFAULT "1970-01-01 00:00:00",  --新值替换旧值
    cost                BIGINT      SUM DEFAULT "0",  --汇总计算
    max_dwell_time      INT         MAX DEFAULT "0"  --最大值计算
)
AGGREGATE KEY(user_id, load_date, city)
DISTRIBUTED BY HASH(user_id) BUCKETS 10
PROPERTIES (
    "replication_num" = "1"
);

上面的例子为例,创建了三个主键(user_id, load_date, city),同时创建了3个聚合列:

  • last_visit_date:新值替换旧值
  • cost:sum汇总
  • max_dwell_time:取最大值

插入数据

INSERT INTO user_behavior_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);

再次插入相同key的数据:

INSERT INTO user_behavior_agg VALUES
(101, '2024-11-01', 'BJ', '2024-10-30', 20, 10),
(102, '2024-11-01', 'BJ', '2024-10-30', 10, 30);

从结果中可以看到,相同key的数据直接在原有数据的基础上进行更新,而新的key数据正常插入。

更新数据

不支持更新数据:

learn_doris> update user_behavior_agg set cost=111 where user_id=102
[2025-04-16 09:15:38] [HY000][1105] errCode = 2, detailMessage = Only unique table could be updated.

删除数据

支持主键删除,不支持通过值删除:

learn_doris> delete from user_behavior_agg where cost=20

[2025-04-16 09:16:32] [HY000][1105] errCode = 2, detailMessage = delete predicate on value column only supports Unique table with merge-on-write enabled and Duplicate table, but Table[user_behavior_agg] is an Aggregate table.

learn_doris> delete from user_behavior_agg where user_id=101
[2025-04-16 09:16:35] completed in 89 ms

结论:aggregate类型表不支持更新数据,以及仅支持通过主键删除数据。

CRUD特性汇总

特性/表类型DUPLICATE 表UNIQUE 表AGGREGATE 表
创建 (Create)允许插入重复数据不允许插入重复主键数据允许插入重复数据,但会进行聚合
读取 (Read)返回所有数据,包括重复数据返回唯一数据,去重返回聚合后的数据
更新 (Update)不支持直接更新,需删除后重新插入支持更新,更新时会检查主键唯一性不支持直接更新,需删除后重新插入
删除 (Delete)支持删除,删除时会删除所有匹配行支持删除,删除时会删除所有匹配行支持删除,删除时会删除所有匹配行
适用场景日志数据、需要保留所有历史记录的数据需要唯一性约束的数据需要实时聚合计算的数据

分区 & 分桶机制

同Hive一样,Doris也存在分区和分桶机制,而且分区和分桶的定义与hive基本一致。

  • 分区:主要用来查询数据进行裁剪,将不在查询范围内的数据通过分区过滤掉。
  • 分桶:在分区内部多个分桶内进行并行查询,提升查询效率。
  • 表、分区、分桶的层级关系如下图所示:

分区(Partition)

  • 定义:分区是将数据按照一定维度(如时间、地域等)划分为更大的逻辑单元。
  • 作用:
    • 数据管理:分区可以独立管理,例如删除或移动某个分区,适合按时间管理冷热数据。
    • 查询优化:通过分区裁剪(Partition Pruning),Doris 可以根据查询条件直接跳过不相关的分区,减少数据扫描范围。
  • 类比:就像图书馆将图书按类别(如文学、科技、历史等)划分到不同的区域,查询时可以直接定位到相关区域,而无需搜索整个图书馆。

分区类型:

  • Range 分区:根据分区列的值范围将数据行分配到对应分区
    • 关键词:PARTITION BY RANGE()
CREATE TABLE orders (
    order_id BIGINT,
    user_id BIGINT,
    product_id BIGINT,
    order_date DATE,
    amount DECIMAL(10, 2)
)
PARTITION BY RANGE(order_date) (
    PARTITION p202301 VALUES LESS THAN ("2023-02-01"),
    PARTITION p202302 VALUES LESS THAN ("2023-03-01"),
    PARTITION p202303 VALUES LESS THAN ("2023-04-01")
)
DISTRIBUTED BY HASH(order_id) BUCKETS 10
properties(
"replication_num" = "1"
);

-- 插入测试数据
INSERT INTO orders (order_id, user_id, product_id, order_date, amount) VALUES
(1, 101, 201, '2023-01-15', 100.00),
(2, 102, 202, '2023-02-15', 200.00),
(3, 103, 203, '2023-03-15', 300.00);
  • List 分区:根据分区列的具体值将数据行分配到对应分区。
    • 关键词:PARTITION BY LIST()
CREATE TABLE products (
    product_id BIGINT,
    product_name VARCHAR(50),
    category VARCHAR(50),
    price DECIMAL(10, 2)
)
PARTITION BY LIST(category) (
    PARTITION p_electronics VALUES IN ('electronics'),
    PARTITION p_clothing VALUES IN ('clothing'),
    PARTITION p_books VALUES IN ('books')
)
DISTRIBUTED BY HASH(product_id) BUCKETS 10
properties(
    "replication_num"=1
);

INSERT INTO products (product_id, product_name, category, price) VALUES
(1, 'Smartphone', 'electronics', 500.00),
(2, 'Laptop', 'electronics', 1200.00),
(3, 'T-Shirt', 'clothing', 20.00),
(4, 'Jeans', 'clothing', 50.00),
(5, 'Novel', 'books', 15.00),
(6, 'Textbook', 'books', 100.00);

分区模式:

手动分区:用户手动创建分区

  • 前面的两个例子,是用手工创建分区的方式,这里跳过。

动态分区:系统根据时间调度规则自动创建分区,但写入数据时不会按需创建分区。

动态分区具有如下特点:

  • 动态分区会按照设定的规则,滚动添加、删除分区,从而实现对表分区的生命周期管理(TTL),减少数据存储压力。在日志管理,时序数据管理等场景,通常可以使用动态分区能力滚动删除过期的数据。
  • 可以通过创建一个动态分区管理的表,由Doris托管该表数据的生命周期,实现改变数据的自动生命周期管理。
  • 由于分区的个数是动态调整的,因此可以避免由脏数据造成的无效分区,例如:‘1970-01-01’
CREATE TABLE dynamic_orders (
    order_id BIGINT,  -- 订单ID,类型为BIGINT
    user_id BIGINT,   -- 用户ID,类型为BIGINT
    product_id BIGINT, -- 产品ID,类型为BIGINT
    order_date DATE,  -- 订单日期,类型为DATE
    amount DECIMAL(10, 2) -- 订单金额,类型为DECIMAL(10, 2)
)
PARTITION BY RANGE(order_date)()  -- 按订单日期范围进行分区
DISTRIBUTED BY HASH(order_id) BUCKETS 10  -- 按订单ID的哈希值分布到10个桶中
PROPERTIES (
    "dynamic_partition.enable" = "true",  -- 启用动态分区
    "dynamic_partition.time_unit" = "DAY",  -- 动态分区的时间单位为天
    "dynamic_partition.start" = "-3",  -- 动态分区的起始时间为当前日期的前3天
    "dynamic_partition.end" = "3",  -- 动态分区的结束时间为当前日期的后3天
    "dynamic_partition.prefix" = "p",  -- 动态分区的前缀为 'p'
    "dynamic_partition.create_history_partition" = "true",  -- 允许创建历史分区
    "dynamic_partition.buckets" = "10",  -- 动态分区的桶数为10
    "replication_num"=1  -- 副本数为1
);

上面的DDL,创建一个采用动态分区的表,含有前3天+当天+未来3天,共7个分区的数据,
假如当天的日期是2025-04-15插入合法数据:

learn_doris> INSERT INTO dynamic_orders (order_id, user_id, product_id, order_date, amount) VALUES
             (1, 101, 201, '2025-04-12', 100.00),
             (2, 102, 202, '2025-04-13', 200.00),
             (3, 103, 203, '2025-04-14', 300.00),
             (4, 104, 204, '2025-04-15', 400.00),
             (5, 105, 205, '2025-04-16', 500.00),
             (6, 106, 206, '2025-04-17', 600.00),
             (7, 107, 207, '2025-04-18', 700.00)
[2025-04-15 00:00:10] 7 rows affected in 162 ms

插入2025-04-19分区的数据,会提示分区不存在:

learn_doris> INSERT INTO dynamic_orders (order_id, user_id, product_id, order_date, amount) VALUES
             (8, 108, 208, '2025-04-19', 800.00)
[2025-04-15 00:03:20] [HY000][1105] errCode = 2,
detailMessage = Insert has filtered data in strict mode. url: http://192.168.10.3:8040/api/_load_error_log?file=__shard_6/error_log_insert_stmt_57f821333aa1434c-941b904d1428f66d_57f821333aa1434c_941b904d1428f66d

同时,当系统时间超过这个分区的数据会自动删除,引用官网的一张图来说明:

自动分区:数据写入时,系统根据需要自动创建相应的分区,使用时注意脏数据生成过多的分区。

自动分区功能主要解决了用户预期基于某列对表进行分区操作,但该列的数据分布比较零散或者难以预测,在建表或调整表结构时难以准确创建所需分区,或者分区数量过多以至于手动创建过于繁琐的问题。

  • AUTO RANGE PARTITION 按范围自动分区
CREATE TABLE auto_partition_orders (
    order_id        BIGINT          NOT NULL,
    user_id         BIGINT          NOT NULL,
    order_date      DATE            NOT NULL,
    product_id      BIGINT          NOT NULL,
    quantity        INT             NOT NULL,
    price           DECIMAL(10, 2)  NOT NULL,
    total_amount    DECIMAL(10, 2)  NOT NULL
)
AUTO PARTITION BY RANGE (date_trunc(`order_date`, 'day')) ()
DISTRIBUTED BY HASH(user_id) BUCKETS 10
PROPERTIES (
    "replication_num" = "1"
);

-- 插入测试数据
INSERT INTO auto_partition_orders (order_id, user_id, order_date, product_id, quantity, price, total_amount) VALUES
(1, 101, '2025-04-14', 201, 2, 50.00, 100.00),
(2, 102, '2025-04-15', 202, 1, 200.00, 200.00),
(3, 103, '2025-04-16', 203, 3, 100.00, 300.00);

这时查询该表的分区,可以看到系统自动创建了3个按天的分区:

  • AUTO Partition By LIst 根据列表自动分区
CREATE TABLE product_sales (
    sale_id          BIGINT          NOT NULL,  -- 销售ID,类型为BIGINT,非空
    product_id       BIGINT          NOT NULL,  -- 产品ID,类型为BIGINT,非空
    product_category VARCHAR(50)     NOT NULL,  -- 产品类别,类型为VARCHAR(50),非空
    sale_date        DATE            NOT NULL,  -- 销售日期,类型为DATE,非空
    quantity         INT             NOT NULL,  -- 数量,类型为INT,非空
    price            DECIMAL(10, 2)  NOT NULL,  -- 单价,类型为DECIMAL(10, 2),非空
    total_amount     DECIMAL(10, 2)  NOT NULL  -- 总金额,类型为DECIMAL(10, 2),非空
)
AUTO PARTITION BY LIST (`product_category`) ()  -- 按产品类别进行自动分区
DISTRIBUTED BY HASH(product_id) BUCKETS 10  -- 按产品ID的哈希值分布到10个桶中
PROPERTIES (
    "replication_num" = "1"  -- 副本数为1
);

INSERT INTO product_sales (sale_id, product_id, product_category, sale_date, quantity, price, total_amount) VALUES
(1, 101, 'Electronics', '2025-04-14', 2, 500.00, 1000.00),
(2, 102, 'Clothing', '2025-04-15', 1, 200.00, 200.00),
(3, 103, 'Books', '2025-04-16', 3, 100.00, 300.00),
(4, 104, 'Electronics', '2025-04-14', 1, 1500.00, 1500.00),
(5, 10

同理,查看该表的分区,也已经自动创建了按照product_category建立的分区。

这里要说明的是:自动分区auto partition与动态分区 dynamic partition是可以一起使用的,即按照字段的值,根据时间自动创建分区,同时让Doris根据动态分区的设置,管理数据的生命周期。

分桶

一个分区可以根据业务需求进一步划分为多个数据分桶(bucket)。每个分桶都作为一个物理数据分片(tablet)存储。合理的分桶策略可以有效降低查询时的数据扫描量,提升查询性能并增加并发处理能力。

  • Hash 分桶
    • 原理:根据分桶列的值进行哈希计算,哈希值相同的数据会被分配到同一个分桶中。
    • 关键词:DISTRIBUTED BY HASH() BUCKETS 10
    • 适用场景:
      • 业务需求频繁基于某个字段进行过滤时,可将该字段作为分桶键,利用 Hash 分桶提高查询效率。
      • 当表中的数据分布较为均匀时,Hash 分桶同样是一种有效的选择。
  CREATE TABLE hash_bucket_tbl (
      oid         BIGINT,
      dt          DATE,
      region      VARCHAR(10),
      amount      INT
  )
  DUPLICATE KEY(oid)
  PARTITION BY RANGE(dt) (
      PARTITION p250101 VALUES LESS THAN("2025-01-01"),
      PARTITION p250102 VALUES LESS THAN("2025-01-02")
  )
  DISTRIBUTED BY HASH(region) BUCKETS 8;
  • Random 分桶
    • 原理:在每个分区中,数据随机分配到各个分桶中,不依赖于某个字段的哈希值。
    • 关键词:distributed by random buckets 8
    • 适用场景:
      • 在任意维度分析的场景中,业务没有特别针对某一列频繁进行过滤或关联查询时,可以选择 Random 分桶。
      • 当经常查询的列或组合列数据分布极其不均匀时,使用 Random 分桶可以避免数据倾斜。
CREATE TABLE orders (
    order_id        BIGINT          NOT NULL,
    user_id         BIGINT          NOT NULL,
    order_date      DATE            NOT NULL,
    product_id      BIGINT          NOT NULL,
    quantity        INT             NOT NULL,
    price           DECIMAL(10, 2)  NOT NULL,
    total_amount    DECIMAL(10, 2)  NOT NULL
)
DISTRIBUTED BY RANDOM BUCKETS 8;

参考资料

2

评论区