数据划分
本文档主要介绍 Doris 的建表和数据划分,以及建表操作中可能遇到的问题和解决方法。
基本概念
在 Doris 中,数据都以表(Table)的形式进行逻辑上的描述。
Row & Column
一张表包括行(Row)和列(Column):
Row:即用户的一行数据;
Column:用于描述一行数据中不同的字段。
Column 可以分为两大类:Key 和 Value。从业务角度看,Key 和 Value 可以分别对应维度列和指标列。从聚合模型的角度来说,Key 列相同的行,会聚合成一行。其中 Value 列的聚合方式由用户在建表时指定。关于更多聚合模型的介绍,可以参阅 Doris 数据模型 。
Tablet & Partition
在 Doris 的存储引擎中,用户数据被水平划分为若干个数据分片(Tablet,也称作数据分桶)。每个 Tablet 包含若干数据行。各个 Tablet 之间的数据没有交集,并且在物理上是独立存储的。
多个 Tablet 在逻辑上归属于不同的分区(Partition)。一个 Tablet 只属于一个 Partition。而一个 Partition 包含若干个 Tablet。因为 Tablet 在物理上是独立存储的,所以可以视为 Partition 在物理上也是独立。Tablet 是数据移动、复制等操作的最小物理存储单元。
若干个 Partition 组成一个 Table。Partition 可以视为是逻辑上最小的管理单元。数据的导入与删除,仅能针对一个 Partition 进行。
数据划分
我们以一个建表操作来说明 Doris 的数据划分。
Doris 的建表是一个同步命令,SQL 执行完成即返回结果,命令返回成功即表示建表成功。具体建表语法可以参考
CREATE TABLE
,也可以通过
HELP CREATE TABLE;
查看更多帮助。
本小节通过一个例子,来介绍 Doris 的建表方式。
-- Range Partition
CREATE TABLE IF NOT EXISTS example_db.example_range_tbl
(
`user_id` LARGEINT NOT NULL COMMENT "用户id",
`date` DATE NOT NULL COMMENT "数据灌入日期时间",
`timestamp` DATETIME NOT NULL COMMENT "数据灌入的时间戳",
`city` VARCHAR(20) COMMENT "用户所在城市",
`age` SMALLINT COMMENT "用户年龄",
`sex` TINYINT COMMENT "用户性别",
`last_visit_date` DATETIME REPLACE DEFAULT "1970-01-01 00:00:00" COMMENT "用户最后一次访问时间",
`cost` BIGINT SUM DEFAULT "0" COMMENT "用户总消费",
`max_dwell_time` INT MAX DEFAULT "0" COMMENT "用户最大停留时间",
`min_dwell_time` INT MIN DEFAULT "99999" COMMENT "用户最小停留时间"
)
ENGINE=OLAP
AGGREGATE KEY(`user_id`, `date`, `timestamp`, `city`, `age`, `sex`)
PARTITION BY RANGE(`date`)
(
PARTITION `p201701` VALUES LESS THAN ("2017-02-01"),
PARTITION `p201702` VALUES LESS THAN ("2017-03-01"),
PARTITION `p201703` VALUES LESS THAN ("2017-04-01"),
PARTITION `p2018` VALUES [("2018-01-01"), ("2019-01-01"))
)
DISTRIBUTED BY HASH(`user_id`) BUCKETS 16
PROPERTIES
(
"replication_num" = "3",
"storage_medium" = "SSD",
"storage_cooldown_time" = "2018-01-01 12:00:00"
);
-- List Partition
CREATE TABLE IF NOT EXISTS example_db.example_list_tbl
(
`user_id` LARGEINT NOT NULL COMMENT "用户id",
`date` DATE NOT NULL COMMENT "数据灌入日期时间",
`timestamp` DATETIME NOT NULL COMMENT "数据灌入的时间戳",
`city` VARCHAR(20) NOT NULL COMMENT "用户所在城市",
`age` SMALLINT COMMENT "用户年龄",
`sex` TINYINT COMMENT "用户性别",
`last_visit_date` DATETIME REPLACE DEFAULT "1970-01-01 00:00:00" COMMENT "用户最后一次访问时间",
`cost` BIGINT SUM DEFAULT "0" COMMENT "用户总消费",
`max_dwell_time` INT MAX DEFAULT "0" COMMENT "用户最大停留时间",
`min_dwell_time` INT MIN DEFAULT "99999" COMMENT "用户最小停留时间"
)
ENGINE=olap
AGGREGATE KEY(`user_id`, `date`, `timestamp`, `city`, `age`, `sex`)
PARTITION BY LIST(`city`)
(
PARTITION `p_cn` VALUES IN ("Beijing", "Shanghai", "Hong Kong"),
PARTITION `p_usa` VALUES IN ("New York", "San Francisco"),
PARTITION `p_jp` VALUES IN ("Tokyo")
)
DISTRIBUTED BY HASH(`user_id`) BUCKETS 16
PROPERTIES
(
"replication_num" = "3",
"storage_medium" = "SSD",
"storage_cooldown_time" = "2018-01-01 12:00:00"
);
列定义
这里我们只以 AGGREGATE KEY 数据模型为例进行说明。更多数据模型参阅 Doris 数据模型 。
列的基本类型,可以通过在 mysql-client 中执行
HELP CREATE TABLE;
查看。
AGGREGATE KEY 数据模型中,所有没有指定聚合方式(SUM、REPLACE、MAX、MIN)的列视为 Key 列。而其余则为 Value 列。
定义列时,可参照如下建议:
分区和分桶
Doris 支持两层的数据划分。第一层是 Partition,支持 Range 和 List 的划分方式。第二层是 Bucket(Tablet),仅支持 Hash 的划分方式。
也可以仅使用一层分区。使用一层分区时,只支持 Bucket 划分。下面我们来分别介绍下分区以及分桶:
Partition
Range 分区
分区列通常为时间列,以方便的管理新旧数据。
Partition 支持通过
VALUES LESS THAN (...)
仅指定上界,系统会将前一个分区的上界作为该分区的下界,生成一个左闭右开的区间。也支持通过
VALUES [...)
指定上下界,生成一个左闭右开的区间。
如上
example_range_tbl
示例,当建表完成后,会自动生成如下 3 个分区:
p201701: [MIN_VALUE, 2017-02-01)
p201702: [2017-02-01, 2017-03-01)
p201703: [2017-03-01, 2017-04-01)
当我们增加一个分区 p201705 VALUES LESS THAN ("2017-06-01"),分区结果如下:
p201701: [MIN_VALUE, 2017-02-01)
p201702: [2017-02-01, 2017-03-01)
p201703: [2017-03-01, 2017-04-01)
p201705: [2017-04-01, 2017-06-01)
此时我们删除分区 p201703,则分区结果如下:
p201701: [MIN_VALUE, 2017-02-01)
p201702: [2017-02-01, 2017-03-01)
p201705: [2017-04-01, 2017-06-01)
注意到 p201702 和 p201705 的分区范围并没有发生变化,而这两个分区之间,出现了一个空洞:[2017-03-01, 2017-04-01)。即如果导入的数据范围在这个空洞范围内,是无法导入的。
继续删除分区 p201702,分区结果如下:
p201701: [MIN_VALUE, 2017-02-01)
p201705: [2017-04-01, 2017-06-01)
空洞范围变为:[2017-02-01, 2017-04-01)
现在增加一个分区 p201702new VALUES LESS THAN ("2017-03-01"),分区结果如下:
p201701: [MIN_VALUE, 2017-02-01)
p201702new: [2017-02-01, 2017-03-01)
p201705: [2017-04-01, 2017-06-01)
可以看到空洞范围缩小为:[2017-03-01, 2017-04-01)
现在删除分区 p201701,并添加分区 p201612 VALUES LESS THAN ("2017-01-01"),分区结果如下:
p201612: [MIN_VALUE, 2017-01-01)
p201702new: [2017-02-01, 2017-03-01)
p201705: [2017-04-01, 2017-06-01)
即出现了一个新的空洞:[2017-01-01, 2017-02-01)
综上,分区的删除不会改变已存在分区的范围。删除分区可能出现空洞。通过
VALUES LESS THAN
语句增加分区时,分区的下界紧接上一个分区的上界。
Range 分区除了上述我们看到的单列分区,也支持 多列分区 ,示例如下:
PARTITION BY RANGE(`date`, `id`)
(
PARTITION `p201701_1000` VALUES LESS THAN ("2017-02-01", "1000"),
PARTITION `p201702_2000` VALUES LESS THAN ("2017-03-01", "2000"),
PARTITION `p201703_all` VALUES LESS THAN ("2017-04-01")
)
在以上示例中,我们指定
date
(DATE 类型) 和
id
(INT 类型) 作为分区列。以上示例最终得到的分区如下:
* p201701_1000: [(MIN_VALUE, MIN_VALUE), ("2017-02-01", "1000") )
* p201702_2000: [("2017-02-01", "1000"), ("2017-03-01", "2000") )
* p201703_all: [("2017-03-01", "2000"), ("2017-04-01", MIN_VALUE))
注意,最后一个分区用户缺省只指定了
date
列的分区值,所以
id
列的分区值会默认填充
MIN_VALUE
。当用户插入数据时,分区列值会按照顺序依次比较,最终得到对应的分区。举例如下:
* 数据 --> 分区
* 2017-01-01, 200 --> p201701_1000
* 2017-01-01, 2000 --> p201701_1000
* 2017-02-01, 100 --> p201701_1000
* 2017-02-01, 2000 --> p201702_2000
* 2017-02-15, 5000 --> p201702_2000
* 2017-03-01, 2000 --> p201703_all
* 2017-03-10, 1 --> p201703_all
* 2017-04-01, 1000 --> 无法导入
* 2017-05-01, 1000 --> 无法导入
Range 分区同样支持
批量分区
,通过语句
FROM ("2022-01-03") TO ("2022-01-06") INTERVAL 1 DAY
批量创建按天划分的分区:2022-01-03 到 2022-01-06(不含 2022-01-06 日),分区结果如下:
p20220103: [2022-01-03, 2022-01-04)
p20220104: [2022-01-04, 2022-01-05)
p20220105: [2022-01-05, 2022-01-06)
List 分区
分区列支持
BOOLEAN, TINYINT, SMALLINT, INT, BIGINT, LARGEINT, DATE, DATETIME, CHAR, VARCHAR
数据类型,分区值为枚举值。只有当数据为目标分区枚举值其中之一时,才可以命中分区。
Partition 支持通过
VALUES IN (...)
来指定每个分区包含的枚举值。
下面通过示例说明,进行分区的增删操作时,分区的变化。
如上
example_list_tbl
示例,当建表完成后,会自动生成如下 3 个分区:
p_cn: ("Beijing", "Shanghai", "Hong Kong")
p_usa: ("New York", "San Francisco")
p_jp: ("Tokyo")
当我们增加一个分区 p_uk VALUES IN ("London"),分区结果如下:
p_cn: ("Beijing", "Shanghai", "Hong Kong")
p_usa: ("New York", "San Francisco")
p_jp: ("Tokyo")
p_uk: ("London")
当我们删除分区 p_jp,分区结果如下:
p_cn: ("Beijing", "Shanghai", "Hong Kong")
p_usa: ("New York", "San Francisco")
p_uk: ("London")
List 分区也支持 多列分区 ,示例如下:
PARTITION BY LIST(`id`, `city`)
(
PARTITION `p1_city` VALUES IN (("1", "Beijing"), ("1", "Shanghai")),
PARTITION `p2_city` VALUES IN (("2", "Beijing"), ("2", "Shanghai")),
PARTITION `p3_city` VALUES IN (("3", "Beijing"), ("3", "Shanghai"))
)
在以上示例中,我们指定
id
(INT 类型) 和
city
(VARCHAR 类型) 作为分区列。以上示例最终得到的分区如下:
* p1_city: [("1", "Beijing"), ("1", "Shanghai")]
* p2_city: [("2", "Beijing"), ("2", "Shanghai")]
* p3_city: [("3", "Beijing"), ("3", "Shanghai")]
当用户插入数据时,分区列值会按照顺序依次比较,最终得到对应的分区。举例如下:
* 数据 ---> 分区
* 1, Beijing ---> p1_city
* 1, Shanghai ---> p1_city
* 2, Shanghai ---> p2_city
* 3, Beijing ---> p3_city
* 1, Tianjin ---> 无法导入
* 4, Beijing ---> 无法导入
Bucket
DISTRIBUTED ...
语句描述的是数据在
各个分区内
的划分规则。如果不使用 Partition,则描述的是对整个表的数据的划分规则。
关于 Partition 和 Bucket 的数量和数据量的建议。
一个表的 Tablet 总数量等于 (Partition num * Bucket num)。
一个表的 Tablet 数量,在不考虑扩容的情况下,推荐略多于整个集群的磁盘数量。
单个 Tablet 的数据量理论上没有上下界,但建议在 1G - 10G 的范围内。如果单个 Tablet 数据量过小,则数据的聚合效果不佳,且元数据管理压力大。如果数据量过大,则不利于副本的迁移、补齐,且会增加 Schema Change 或者 Rollup 操作失败重试的代价(这些操作失败重试的粒度是 Tablet)。
当 Tablet 的数据量原则和数量原则冲突时,建议优先考虑数据量原则。
在建表时,每个分区的 Bucket 数量统一指定。但是在动态增加分区时(
ADD PARTITION
),可以单独指定新分区的 Bucket 数量。可以利用这个功能方便的应对数据缩小或膨胀。
一个 Partition 的 Bucket 数量一旦指定,不可更改。所以在确定 Bucket 数量时,需要预先考虑集群扩容的情况。比如当前只有 3 台 host,每台 host 有 1 块盘。如果 Bucket 的数量只设置为 3 或更小,那么后期即使再增加机器,也不能提高并发度。
举一些例子:假设在有 10 台 BE,每台 BE 一块磁盘的情况下。如果一个表总大小为 500MB,则可以考虑 4-8 个分片。5GB:8-16 个分片。50GB:32 个分片。500GB:建议分区,每个分区大小在 50GB 左右,每个分区 16-32 个分片。5TB:建议分区,每个分区大小在 50GB 左右,每个分区 16-32 个分片。
注:表的数据量可以通过
SHOW DATA
命令查看,结果除以副本数,即表的数据量。
关于 Random Distribution 的设置以及使用场景。
load_to_single_tablet
设置为 true),那么在大数据量的导入的时候,一个任务在将数据写入对应的分区时将只写入一个分片,这样将能提高数据导入的并发度和吞吐量,减少数据导入和 Compaction
导致的写放大问题,保障集群的稳定性。
复合分区与单分区
以下场景推荐使用复合分区
用户也可以不使用复合分区,即使用单分区。则数据只做 HASH 分布。
PROPERTIES
在建表语句的最后 PROPERTIES 中,关于 PROPERTIES 中可以设置的相关参数,我们可以查看 CREATE TABLE 中查看详细的介绍。
ENGINE
本示例中,ENGINE 的类型是 olap,即默认的 ENGINE 类型。在 Doris 中,只有这个 ENGINE 类型是由 Doris 负责数据管理和存储的。其他 ENGINE 类型,如 mysql、broker、es 等等,本质上只是对外部其他数据库或系统中的表的映射,以保证 Doris 可以读取这些数据。而 Doris 本身并不创建、管理和存储任何非 olap ENGINE 类型的表和数据。
`IF NOT EXISTS` 表示如果没有创建过该表,则创建。注意这里只判断表名是否存在,而不会判断新建表结构是否与已存在的表结构相同。所以如果存在一个同名但不同构的表,该命令也会返回成功,但并不代表已经创建了新的表和新的结构。
常见问题
建表操作常见问题
如果在较长的建表语句中出现语法错误,可能会出现语法错误提示不全的现象。这里罗列可能的语法错误供手动纠错:
HELP CREATE TABLE;
,检查相关语法结构。
Failed to create partition [xxx] . Timeout
Doris 建表是按照 Partition 粒度依次创建的。当一个 Partition 创建失败时,可能会报这个错误。即使不使用 Partition,当建表出现问题时,也会报
Failed to create partition
,因为如前文所述,Doris 会为没有指定 Partition 的表创建一个不可更改的默认的 Partition。
当遇到这个错误是,通常是 BE 在创建数据分片时遇到了问题。可以参照以下步骤排查:
Failed to create partition
日志。在该日志中,会出现一系列类似
{10001-10010}
字样的数字对。数字对的第一个数字表示 Backend ID,第二个数字表示 Tablet ID。如上这个数字对,表示 ID 为 10001 的 Backend 上,创建 ID 为 10010 的 Tablet 失败了。
Too many open files
。打开的文件句柄数超过了 Linux 系统限制。需修改 Linux 系统的句柄数限制。
如果创建数据分片时超时,也可以通过在 fe.conf 中设置
tablet_create_timeout_second=xxx
以及
max_create_table_timeout_second=xxx
来延长超时时间。其中
tablet_create_timeout_second
默认是 1 秒,
max_create_table_timeout_second
默认是 60 秒,总体的超时时间为 min(tablet_create_timeout_second * replication_num, max_create_table_timeout_second),具体参数设置可参阅
FE 配置项
。
建表命令长时间不返回结果。
Doris 的建表命令是同步命令。该命令的超时时间目前设置的比较简单,即(tablet num * replication num)秒。如果创建较多的数据分片,并且其中有分片创建失败,则可能导致等待较长超时后,才会返回错误。
正常情况下,建表语句会在几秒或十几秒内返回。如果超过一分钟,建议直接取消掉这个操作,前往 FE 或 BE 的日志查看相关错误。