GreptimeDB 在 1.0 的 beta 版本里实现了一种新的数据组织方式:flat 格式,以及对应的读写路径。这可以让 GreptimeDB 在高基数场景下拥有更好的性能表现。本文将进一步介绍为什么需要设计这样一个新的数据读写路径,以及 flat 格式是如何实现的。
背景
GreptimeDB 基于 LSM-Tree[1] 结构组织数据。数据写入 GreptimeDB 时会先持久化到 WAL[2],同时写入到内存的 memtable 中。当内存里的数据到达一定阈值后,GreptimeDB 会将 memtable 的数据编码为 Apache Parquet[3] 格式存储,然后对 WAL 里的数据进行裁剪。此外,GreptimeDB 会自动对数据按照时间分区,如 1 天一个分区。不同时间分区的数据存储在不同的 memtable 和 Parquet 文件中。
+------------------+ +------------------+ +------------------+
| Time Partition 1 | | Time Partition 2 | | Time Partition 3 |
| (Day 1) | | (Day 2) | | (Day 3) |
| +──────────────+ | | +──────────────+ | | +──────────────+ |
┌─────>| | Memtable | | ┌──>| | Memtable | | ┌──>| | Memtable | |
│ | +──────────────+ | │ | +──────────────+ | │ | +──────────────+ |
│ | | | │ | | | │ | | |
│ | v flush | │ | v flush | │ | v flush |
│ | +──────────────+ | │ | +──────────────+ | │ | +──────────────+ |
│ | | Parquet Files| | │ | | Parquet Files| | │ | | Parquet Files| |
│ | | ┌─────┬────┐ | | │ | | ┌─────┬────┐ | | │ | | ┌─────┐ | |
│ | | │SST 1│SST2│ | | │ | | │SST 1│SST2│ | | │ | | │SST 1│ | |
│ | | └─────┴────┘ | | │ | | └─────┴────┘ | | │ | | └─────┘ | |
│ | | ┌─────┐ | | │ | | ┌─────┐ | | │ | | | |
│ | | │SST 3│ | | │ | | │SST 3│ | | │ | | | |
│ | | └─────┘ | | │ | | └─────┘ | | │ | | | |
│ | +──────────────+ | │ | +──────────────+ | │ | +──────────────+ |
│ +------------------+ │ +------------------+ │ +------------------+
│ │ │
+─┴────────────────────────────┴─────────────────────────┴─+
| WAL |
+──────────────────────────────────────────────────────────+
^
│
Data用户在建表时需要在建表语句中指定 primary key(主键),而主键定义了表的时间线(series)[4]。一个主键的值对应了一个时间线。在 memtable 和 Parquet 文件中,相同时间线的数据会存储在一起。对于主键和时间戳的数据,GreptimeDB 默认会用新写入的数据覆盖之前的数据。用户也可以选择开启 append 模式允许存储主键和时间戳都相同的数据。这种老的组织格式我们称之为 primary-key 格式(在 SQL 中对应参数值 'primary_key')。
使用 primary-key 格式时,GreptimeDB 处理数据是以时间线为基本单元的:
- Memtable 内部会为每个时间线都分配一个独立的 buffer
- 扫描数据时,一次最多只能返回一个时间线的数据
- 为了方便比较,所有的 tag 列(primary key 约束中的列)会被编码为一个列存储
当一个时间分区内的时间线数量在数十万级别以下时,这样的设计有着十分不错的性能表现。然而,如果用户将一些高基数的列,如用户请求的 ID,加入到主键,就会造成数据库读写效率显著降低:
- Memtable 内部为大量的时间线维护独立的结构,降低内存利用效率;
- 由于时间线变多,每个时间线内的数据变少,数据库扫描效率退化到接近行存;
- 如果表设置了去重,合并和去重[5]的开销也跟时间线的数量成正比。
为了优化问题,GreptimeDB 实现了新的数据存储方式和读写逻辑,提高高基数场景下的性能,同时尽量减少在原有优势场景下的性能回退。
Flat 格式
Flat 格式相比 primary-key 格式做了以下调整
- 调整了数据在 Parquet 中的存储方式
- 针对高基数场景实现了新的 memtable BulkMemtable
- 查询时不再限制一次只能返回一个时间线的数据,合并和去重算法也实现了支持多时间线的版本,减少大量时间线下查询性能的退化
存储方式
假设我们在 GreptimeDB 中创建了如下结构的表:
CREATE TABLE IF NOT EXISTS `cpu` (
`hostname` STRING NULL,
`region` STRING NULL,
`datacenter` STRING NULL,
`team` STRING NULL,
`usage_user` BIGINT NULL,
`usage_system` BIGINT NULL,
`usage_idle` BIGINT NULL,
`greptime_timestamp` TIMESTAMP(9) NOT NULL,
TIME INDEX (`greptime_timestamp`),
PRIMARY KEY (`hostname`, `region`, `datacenter`, `team`)
);在老的 primary-key 格式中,这张表的数据会以以下形式存储到 Parquet 文件中:
┌────────────┬──────────────┬────────────┬────────────────────┬───────────────┬────────────┬───────────┐
│ usage_user │ usage_system │ usage_idle │ greptime_timestamp │ __primary_key │ __sequence │ __op_type │
├────────────┼──────────────┼────────────┼────────────────────┼───────────────┼────────────┼───────────┤
│ 10 │ 5 │ 85 │ 2024-01-01 00:00 │ key0 │ 1 │ PUT │
│ 12 │ 6 │ 82 │ 2024-01-01 00:01 │ key0 │ 2 │ PUT │
│ 15 │ 8 │ 77 │ 2024-01-01 00:02 │ key0 │ 3 │ PUT │
│ 20 │ 10 │ 70 │ 2024-01-01 00:00 │ key1 │ 1 │ PUT │
│ 22 │ 11 │ 67 │ 2024-01-01 00:01 │ key1 │ 2 │ PUT │
└────────────┴──────────────┴────────────┴────────────────────┴───────────────┴────────────┴───────────┘可以看到所有 tag 列(hostname,region,datacenter,team)被编码为一个二进制列 __primary_key,数据按照主键排序,相同时间线的数据连续存储。 这种设计的好处是方便查询时按时间线维度处理数据,同时尽量节省空间。 但是,这样的设计也带来了以下问题:
- Tag 列的值被编码到一起,不易于利用 Parquet 列存的统计信息进行查询过滤
- 要读取一个 tag 的数据需要读取整个主键并解码
- 不方便使用其他工具分析 GreptimeDB 生成的 Parquet 文件
而 flat 格式对这个问题做了一定的折中,除了单独存储编码后的主键外,额外存储了原始的 tag 列。在 flat 格式下,这张表的数据按照如下方式存储到 Parquet 文件中:
┌──────────┬────────┬────────────┬──────┬────────────┬──────────────┬────────────┬────────────────────┬───────────────┬────────────┬───────────┐
│ hostname │ region │ datacenter │ team │ usage_user │ usage_system │ usage_idle │ greptime_timestamp │ __primary_key │ __sequence │ __op_type │
├──────────┼────────┼────────────┼──────┼────────────┼──────────────┼────────────┼────────────────────┼───────────────┼────────────┼───────────┤
│ host1 │ cn │ dc1 │ t1 │ 10 │ 5 │ 85 │ 2024-01-01 00:00 │ key0 │ 1 │ PUT │
│ host1 │ cn │ dc1 │ t1 │ 12 │ 6 │ 82 │ 2024-01-01 00:01 │ key0 │ 2 │ PUT │
│ host1 │ cn │ dc1 │ t1 │ 15 │ 8 │ 77 │ 2024-01-01 00:02 │ key0 │ 3 │ PUT │
│ host2 │ us │ dc2 │ t2 │ 20 │ 10 │ 70 │ 2024-01-01 00:00 │ key1 │ 1 │ PUT │
│ host2 │ us │ dc2 │ t2 │ 22 │ 11 │ 67 │ 2024-01-01 00:01 │ key1 │ 2 │ PUT │
└──────────┴────────┴────────────┴──────┴────────────┴──────────────┴────────────┴────────────────────┴───────────────┴────────────┴───────────┘单独存储 tag 列有以下好处:
- Parquet 会为每个列记录统计信息。在查询时,数据库可以利用这些信息进行谓词下推,跳过不满足条件的 Row Group,提高查询效率。在老的 primary-key 格式中,tag 列被编码在一起,只有第一个 tag 列的统计信息能用于过滤
- 单独存储的 tag 列每一列都可以独立过滤
- 部分情况下,可以只读取个别 tag 列,无需解码整个主键
保留编码后的 __primary_key 列能够方便对时间线进行排序,合并和去重。尽管数据存储存在冗余,但得益于 Parquet 使用了列式存储和字典压缩,在 TSBS 等测试数据集下,两种格式的文件大小几乎没有明显变化。
由于 flat 格式的文件相比 primary-key 格式可以看作只是新增了一些列,因此 flat 格式可以很好地兼容 primary-key 格式。
BulkMemtable
在时间线数量较多时,原来 memtable 为每个时间线维护单独的结构和缓冲区的内存效率较差,因为每个时间线都会带来固定的内存开销。BulkMemtable 则参考了 LSM-Tree 的实现思路,将数据看作是一个个独立的 part,不再维护每个时间线的结构。 写入的数据会被转化为一个有序的 BulkPart 结构,直接添加到 BulkMemtable 的 part 列表中。
每个 BulkPart 内部使用 Apache Arrow[6] RecordBatch[7] 存储数据,schema 同样采用 flat 格式。Part 中数据按 (主键, 时间戳, sequence 降序) 排序。
+-----------------------------------------------------------------------------------------------+
| BulkPart |
+-----------------------------------------------------------------------------------------------+
| min_timestamp: 2024-01-01 00:00 |
| max_timestamp: 2024-01-01 00:02 |
| sequence: 3 |
+-----------------------------------------------------------------------------------------------+
| RecordBatch (Arrow) |
| ┌──────────┬────────┬────────────┬─────┬───────────────┬────────────┬───────────┐ |
| │ hostname │ region │ usage_user │ ... │ __primary_key │ __sequence │ __op_type │ |
| ├──────────┼────────┼────────────┼─────┼───────────────┼────────────┼───────────┤ |
| │ host1 │ cn │ 10 │ ... │ key0 │ 1 │ PUT │ |
| │ host1 │ cn │ 12 │ ... │ key0 │ 2 │ PUT │ |
| │ host2 │ us │ 20 │ ... │ key1 │ 1 │ PUT │ |
| │ host2 │ us │ 22 │ ... │ key1 │ 2 │ PUT │ |
| └──────────┴────────┴────────────┴─────┴───────────────┴────────────┴───────────┘ |
| ↑ |
| Sorted by (primary key, timestamp, seq desc) |
+-----------------------------------------------------------------------------------------------+在 BulkMemtable 内部,会根据 BulkPart 的大小将其放到不同的列表中维护。如下图所示:
+------------------+
| BulkMemtable |
+------------------+
|
v
+------------------+
| BulkParts |
+------------------+
| |
| +-------------+ |
| |UnorderedPart|--|--> small parts
| +-------------+ |
| | |
| v |
| +-------------+ |
| | parts |--|--> large parts
| | (Vec) | |
| +-------------+ |
| | |
| v |
| +-------------+ |
| |encoded_parts|--|--> parts encoded in Apache Parquet format
| | (Vec) | |
| +-------------+ |
+------------------+BulkMemtable 使用 UnorderedPart 来优化小批量写入。当写入请求的数据行数小于阈值时(默认为 1024 行),BulkPart 会被放入 UnorderedPart 缓存起来,当 UnorderedPart 里的数据总行数达到阈值(默认为 4096 行)后再合并排序成一个 BulkPart,避免频繁合并大量小 part 的计算开销。根据数据特征的不同,这些阈值或许还会有进一步的调优空间。
如果 BulkPart 的大小足够大,则可以直接进入 part 列表。当列表中 part 的数量到达阈值时会触发合并。合并时,BulkMemtable 会按照数据行数对 part 排序,将行数接近的 part 合并到一起,提高合并效率,同时数据保持有序。如果没有开启 append 模式,合并时还会对数据进行去重。
BulkPart 合并时,会按照 flat 格式编码为 EncodedBulkPart,即一个内存中的 Parquet 文件。数据经过 Parquet 格式编码压缩后,可以进一步减少内存的占用。
由于 BulkMemtable 的特性,用户在写入时使用更大的批可以提高写入的吞吐,减少 memtable 内部合并的计算开销。
性能
我们在 TSBS[8] 场景下验证了 flat 格式在高基数场景下的性能。TSBS 是由 TimescaleDB 团队开发的行业标准时序数据库基准测试套件。
压测环境为一台 16C32G 的机器。
| CPU | AMD Ryzen 7 7735HS (16 cores 3.2GHz) |
| Memory | 32GB |
| Disk | SOLIDIGM SSDPFKNU010TZ |
| OS | Ubuntu 22.04.2 LTS |
在 4000 个时间线下,flat 格式的写入性能相比老的 primary-key 格式有下降,不过增大 batch size 后可缓解该问题
| 写入客户端参数 | primary-key (rows/s) | flat (rows/s) |
|---|---|---|
| 6 写入线程, batch size 3000 | 401415.95 | 371473.45 |
| 6 写入线程, batch size 20000 | 402473.43 | 407407.93 |
| 6 写入线程, batch size 30000 | 412435.98 | 405962.92 |
| 12 写入线程, batch size 20000 | 441445.36 | 478442.11 |
在增大时间线数量到 200 万后,flat 格式的写入性能优势十分明显
| 写入客户端参数 | primary-key (rows/s) | flat (rows/s) |
|---|---|---|
| 6 写入线程, batch size 3000 | 87141.33 | 363740.65 |
查询方面,在 200 万时间线规模下,部分查询如 cpu-max-all-8 和 single-groupby-1-8-1 等都有显著提升。
| 查询 | primary-key (ms) | flat (ms) | 提升 |
|---|---|---|---|
| cpu-max-all-1 | 530.62 | 364.69 | 1.5× |
| cpu-max-all-8 | 17317.18 | 3522.58 | 4.9× |
| high-cpu-1 | 542.29 | 291.12 | 1.9× |
| single-groupby-1-1-1 | 124.65 | 60.52 | 2.1× |
| single-groupby-1-1-12 | 408.15 | 437.35 | 0.9× |
| single-groupby-1-8-1 | 3554.64 | 430.95 | 8.2× |
| single-groupby-5-1-1 | 147.15 | 67.84 | 2.2× |
| single-groupby-5-1-12 | 492.27 | 476.50 | 1.0× |
| single-groupby-5-8-1 | 4722.53 | 467.78 | 10.1× |
使用和最佳实践
如果用户需要将基数较高的列放到主键中,那么就可以使用 flat 格式,以获得更高的读写性能。以一个实际案例为例,某用户在使用 primary-key 格式时碰到了写入瓶颈:尽管某个表写入流量不算高,但该表每次 flush 需要处理超过 1400 万时间线的数据,导致写入基本不可用。针对这种情况,我们建议用户切换到了 flat 格式,解决了写入问题。
目前,用户有两种方式可以为一张表启用 flat 格式。
一种方式是直接在建表时指定表的 sst_format 参数为 'flat'[9]。这样将创建一张使用 flat 格式的表。
CREATE TABLE http_logs (
access_time TIMESTAMP TIME INDEX,
application STRING,
remote_addr STRING,
http_status STRING,
http_method STRING,
http_refer STRING,
user_agent STRING,
request_id STRING,
request STRING,
PRIMARY KEY(application, request_id)
) with ('append_mode'='true', 'sst_format'='flat');例如,如果有一张 http_logs,其主要查询方式是根据应用和请求的 request_id 查询请求的日志,那么就可以考虑将 request_id 字段加入到主键。 而在使用 primary-key 格式时,将 request_id 放入主键可能会导致 OOM 等问题。
而另外一种是通过 alter 语句修改表的 sst_format[10]。例如,用户可以将一张已经存在的表 cpu 修改为使用 flat 格式。
ALTER TABLE cpu SET 'sst_format' = 'flat';需要注意的是,格式一旦切换为 flat 后,就不再支持切换为 primary-key 格式,因为 primary-key 格式目前不支持读取 flat 格式生成的数据文件。
以下是不同格式使用的一些最佳实践
- 如果时间线总量不超过百万,则不需要马上升级到 flat 格式
- 使用 flat 格式时,请更新 GreptimeDB 到最新的版本
- 使用更大的 batch size ,例如 10000 行以上,以提高写入吞吐
- 尽管 flat 格式支持高基数主键,但如果用户需要对数据做去重,则去重引入的额外计算开销是无法避免的,因此 append-only 表仍然会有更高的查询性能
由于 flat 格式可以看作是老格式的超集,未来我们将逐步使用 flat 格式替换掉老的格式。目前,我们还在不断测试和优化 flat 格式的读写路径,也欢迎更多的用户试用。


