表模型最佳实践
本文从模型选型、表结构设计、模型局限性等角度,介绍 Doris 数据模型在使用过程中需要注意的事项,帮助用户选择合适的模型并规避常见性能陷阱。
数据模型选择建议
数据模型在建表时即已确定,且无法修改。因此选择合适的数据模型至关重要。
三种模型对比
| 数据模型 | 适用场景 | 优势 | 注意事项 |
|---|---|---|---|
| Duplicate | 任意维度的 Ad-hoc 查询 | 不受聚合模型约束,可充分发挥列存优势(按需读列) | 无法利用预聚合特性 (但可以通过异步物化视图支持) |
| Unique | 需要主键唯一性约束,需要数据更新 | 保证主键唯一性,可灵活更新 | 无法利用预聚合特性。(但可以通过异步物化视图支持) |
| Aggregate | 固定模式的报表类查询 | 通过预聚合显著降低查询扫描数据量与计算量 | count(*) 查询不友好;执行非声明聚合类型的聚合查询时需关注语义正确性 |
部分列更新需求
如果业务中有部分列更新的需求,请参阅以下文档获取使用建议:
建表时列类型建议
创建表时,建议遵循以下列类型与顺序规范:
- 列顺序:Key 列必须排在所有 Value 列之前。
- 优先整型:尽量选择整型类型,因为整型的计算和查找效率远高于字符串。
- 整型长度够用即可:在不同长度的整型类型之间选择时,遵循"够用即可"原则。
- 变长字符串够用即可:对于 VARCHAR 和 STRING 类型的长度,同样遵循"够用即可"原则。
Key 列在不同模型中的语义差异
Duplicate、Aggregate、Unique 三种模型在建表时都会指定 Key 列,但其语义有所区别:
| 模型 | Key 列的角色 |
|---|---|
| Duplicate | 仅作为"排序列",不具备唯一标识作用 |
| Aggregate、Unique | 兼具"排序列"与"唯一标识列",是真正意义上的 "Key 列" |
Aggregate 模型的局限性
Aggregate 模型对外展示的是最终聚合后的数据。也就是说,任何还未聚合的数据(例如分属两个不同导入批次的数据),都必须通过某种方式保证对外展示的一致性。
一致性保证示例
假设表结构如下:
| 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 |
Doris 在查询引擎中加入了聚合算子,以保证数据对外的一致性。
非一致聚合查询的语义陷阱
在聚合列(Value 列)上执行与声明的聚合类型不一致的聚合查询时,需要注意语义。例如:
SELECT MIN(cost) FROM table;
得到的结果是 5,而不是 1。
count(*) 查询的开销问题
聚合模型的一致性保证机制,会在某些查询中显著降低查询效率,最典型的就是 count(*) 查询:
SELECT COUNT(*) FROM table;
在其他数据库中,这类查询都会很快地返回结果。在实现上,可以通过"导入时对行进行计数,保存 count 的统计信息",或者在查询时"仅扫描某一列数据,获得 count 值"等方式,以极小的开销获得查询结果。但是在 Doris 的聚合模型中,这种查询的开销非常大。
以上述数据为例,最终聚合结果为:
| user_id | date | cost |
|---|---|---|
| 10001 | 2017/11/20 | 51 |
| 10001 | 2017/11/21 | 5 |
| 10002 | 2017/11/21 | 39 |
| 10003 | 2017/11/22 | 22 |
SELECT COUNT(*) FROM table; 的正确结果应为 4。但是:
- 仅扫描
user_id一列 + 查询时聚合 → 结果为3(10001、10002、10003),错误 - 仅扫描
user_id一列 + 不进行查询时聚合 → 结果为5(两批次共 5 行数据),错误
为了得到正确结果,必须同时读取 user_id 和 date 两列数据,再加上查询时聚合,才能返回 4 这个正确结果。也就是说,在 count(*) 查询中,Doris 必须扫描所有的 AGGREGATE KEY 列(这里就是 user_id、date),并且聚合后才能得到语义正确的结果。当聚合列非常多时,count(*) 查询需要扫描大量的数据。
count(*) 性能优化方案
当业务上有频繁的 count(*) 查询时,建议通过新增一个值恒为 1、聚合类型为 SUM 的列来模拟 count(*)。在前述示例的基础上修改表结构:
| ColumnName | Type | AggregateType | Comment |
|---|---|---|---|
| user_id | BIGINT | 用户 ID | |
| date | DATE | 数据灌入日期 | |
| cost | BIGINT | SUM | 用户总消费 |
| count | BIGINT | SUM | 用于计算 count |
导入数据时,count 列的值恒为 1,则:
SELECT COUNT(*) FROM table;
-- 等价于
SELECT SUM(count) FROM table;
后者的查询效率将远高于前者。
使用限制:用户需自行保证不会重复导入 AGGREGATE KEY 列都相同的行;否则 SELECT SUM(count) FROM table; 只能表示原始导入的行数,而不再等价于 SELECT COUNT(*) FROM table; 的语义。
替代方案:将上述 count 列的聚合类型改为 REPLACE,且依然保持值恒为 1。这种情况下 SELECT SUM(count) FROM table; 与 SELECT COUNT(*) FROM table; 的结果将一致,并且没有导入重复行的限制。
Unique 模型的写时合并实现
Unique 模型的写时合并实现没有聚合模型的上述局限性。仍以前述数据为例,写时合并为每次导入的 rowset 增加了对应的 delete bitmap,用于标记哪些数据被覆盖。
第一批数据导入后:
batch 1
| user_id | date | cost | delete bit |
|---|---|---|---|
| 10001 | 2017/11/20 | 50 | FALSE |
| 10002 | 2017/11/21 | 39 | FALSE |
第二批数据导入完成后,第一批数据中重复的行就会被标记为已删除:
batch 1
| user_id | date | cost | delete bit |
|---|---|---|---|
| 10001 | 2017/11/20 | 50 | TRUE |
| 10002 | 2017/11/21 | 39 | FALSE |
batch 2
| user_id | date | cost | delete bit |
|---|---|---|---|
| 10001 | 2017/11/20 | 1 | FALSE |
| 10001 | 2017/11/21 | 5 | FALSE |
| 10003 | 2017/11/22 | 22 | FALSE |
查询时,所有在 delete bitmap 中被标记删除的数据都不会被读出,无需进行任何数据聚合。上述数据中有效行数为 4 行,查询出的结果也应为 4 行,因此可以采用开销最小的方式获取结果,即前面提到的"仅扫描某一列数据,获得 count 值"。
性能数据:在测试环境中,count(*) 查询在 Unique 模型写时合并实现上的性能,相比聚合模型有 10 倍以上的提升。
Duplicate 模型
Duplicate 模型没有聚合模型的上述局限性。由于该模型不涉及聚合语义,在执行 count(*) 查询时,任意选择一列进行扫描即可得到语义正确的结果。