KaiwuDB 时序存储模块:AIoT 场景下的高性能数据存储方案

8 阅读13分钟

引言

支持在同一实例中同时建立时序库和关系库,进行多模数据的融合处理,是KaiwuDB 的一大核心优势。这其中一致可靠的数据持久化离不开底层存储引擎的支持。

众所周知,关系库与时序库服务于不同的数据使用场景,因此,需要不同的存储引擎来针对性地优化性能。关系数据库需要处理复杂的数据结构和多样化的查询请求,包括多表关联和随机读写操作等。这要求关系存储引擎能够支持高效的数据检索、更新和事务处理,同时保持数据的完整性和一致性。相对地,时序库主要处理按时间顺序记录的数据,通常以连续的方式写入,具有高度的时间相关性。正因如此,时序存储引擎的关键设计需求,就是要保证快速处理大量顺序写入的数据。

KaiwuDB 3.0 时序存储引擎架构

KaiwuDB 自主研发的时序存储引擎,专为物联网及工业互联网设备产生的时序数据进行了深度优化。时序数据应用场景的一个显著的特点是:数据的写入频率远超读取频率——这与传统的关系型数据管理有着本质的区别。针对时序数据场景,KaiwuDB 采用列式存储架构,每列数据被独立存储于各自的文件块中。这种列式存储方式具备多项优势:减少磁盘 I/O、提高 CPU 缓存性能、提高压缩效率、支持向量处理。

KaiwuDB 时序引擎使用内存映射(Mmap)技术对这些持久化列存数据文件进行读写。Mmap 通过将文件内容直接映射到进程的地址空间,减少了数据在用户空间和内核空间之间的拷贝,实现了文件 I/O 操作的高效性。Mmap 利用操作系统的页缓存机制来优化文件访问,提高了数据访问的速度和一致性,同时减少了内存的使用。Mmap 在处理大型文件和需要高效文件共享的场景中作用尤其突出。这也正是时序数据库所面向的典型场景。

1. 存储结构

KaiwuDB 的底层时序存储首先基于设备主键(Primary Tag)进行哈希(Hash)划分,将设备数据分配到不同的 VGroup 中,每个 VGroup 中可以存储不同表,不同设备的数据,而且不受限于设备的数量。每个 VGroup 内通过时间分区进行数据分组管理,方便快速通过时间过滤查询。所有持久化文件均采用列存结构,以保证优秀的压缩效果、查询和就地计算性能。时序存储引擎将所有时序数据存放在目录./kwbase-data/tsdb下。存储结构整体可分为三部分:

✅元数据:存储各个表的元数据信息,位于schema目录下,如78.tag.et(Tag 表索引)、78.tag.mt(Tag 元数据)等;

数据文件:分为 LastSegment 与 EntitySegment 两类,前者由内存刷盘生成(文件名如last.ver-000000001020),存储较新数据;后者由多个 LastSegment 合并而成(包含block.ver-*header.e.ver-*等文件组),存储长期数据。下面示例中的 vg_001 ~ vg_004用于存储数据;

✅WAL:位于wal目录下,记录写前日志,确保系统崩溃时的数据一致性,分为 ddl、engine、各 VGroup 专属 WAL 文件。

一个典型的时序库路径如下:

├── schema
│   └── 78                          //表ID
│       ├── metric
│       │   └── 78.bt_1            // 不同版本的Metric数据
│       └── tag
│           ├── 78.tag.et           // Tag表的Entity Row Index数据文件
│           ├── 78.tag.ht           // Tag表的Hash Index数据文件
│           ├── 78.tag.mt_1         // 不同版本的Tag元数据
│           └── tag_version_1       // Tag表版本1 数据目录
│               ├── 78.tag.header   // Tag表的Delete Mark数据文件
│               ├── 78.tag.mt       // 该版本的Tag元数据
│               ├── 78.tag.pt       // Tag表的Primary Tag数据文件
│               └── 78.tag.rinfo    // Tag表的OSN数据文件
├── vg_001                                // VGroup-1 数据目录
│   ├── CURRENT                          // 版本控制文件
│   ├── db00077_+0001726272000           // 库目录,1726272000为分区时间
│   │   ├── agg.ver-000000000006        // 聚合文件,记录每个数据块的聚合信息
│   │   ├── block.ver-000000000005      // 数据块文件,以压缩形式存储
│   │   ├── entity_count.item           // count文件,统计各个设备的数据条数
│   │   ├── header.b.ver-000000000004   // Block Header文件,记录数据块的元信息
│   │   ├── header.e.ver-000000001015   // Entity Header文件,记录各个设备的元信息
│   │   ├── last.ver-000000001001       // LastSegment文件,由内存刷盘形成
│   │   ├── last.ver-000000001006
│   │   ├── last.ver-000000001016
│   │   ├── last.ver-000000001017
│   │   ├── last.ver-000000001020
│   │   └── partition_del.item          // 记录标记删除信息
│   ├── db00077_+0001727136000
│   │   ...
│   └── TSVERSION-000000000000           // 版本控制文件
├── vg_002
│   ├── ...
├── vg_003
│   ├── ...
├── vg_004
│   ├── ...
└── wal
    ├── ddl
    │   ├── KaiwuDB_wal.cur
    │   └── KaiwuDB_wal.meta
    ├── engine
    │   ├── KaiwuDB_wal.cur
    │   └── KaiwuDB_wal.meta
    ├── vg_001
    │   ├── KaiwuDB_wal.cur
    │   └── KaiwuDB_wal.meta
    ├── vg_002
    │   ├── ...
    ├── vg_003
    │   ├── ...
    └── vg_004
        └──  ...

在每个 VGroup 下,数据会按照库 ID 和时间戳进行分组分区,每个分区路径下存放原始数据以及聚合数据的相关信息。LastSegment 和 EntitySegment 两种数据文件都是按“Block”(块)来组织数据的,通过调整块的大小,可以在压缩效果和查询速度之间找到一个较好的平衡点。

LastSegment:对应文件名last.ver-000000000006等文件,LastSegment 由内存刷盘产生。LastSegment 中以 Block 组织数据,每个 Block 可能会包含多个设备的数据,Block 内的数据以列存格式存储。文件采用如下编码顺序:

其中数据块部分用于存放列存数据,块信息部分存储如何解析数据块(记录每块中每列的偏移,数据条数等),块索引记录简单的聚合信息用于查询时的快速过滤,元数据块为可选项,以提供部分拓展支持,Footer 用于记录索引块的数量和偏移。

当 LastSegment 数量满足一定阈值时,后台任务会自动将多个 LastSegment 文件合并,如果同一设备的数据条数达到 EntitySegment 中 Block 的最小阈值,就会将这部分数据追加写入到 EntitySegment 中。而不满足数据的将写入到一个新的 LastSegment 文件中。

EntitySegment:对应文件组block.ver-*header.e.ver-*header.b.ver-*agg.ver-*四个文件,Block 文件用于存储原始数据,header.e 与 header.b 用于存索引信息,Agg 文件存储每个 Block 的聚合结果。EntitySegment 中的 Block 数据是列存压缩的,且只存储同一设备的数据,并按照时间戳排序。每个分区下,通常只有一组 EntitySegment 文件。EntitySegment 文件由多个 LastSegment 合并而成,为保证原子化,在合并时 block, header.b, Agg 文件均为追加写,header.e 会重写。header.e、header.b 文件为索引文件,大致结构如下:

每个设备的 BlockItem 都串成一个链表。可以将 header.e 理解为记录链表头,header.b 理解为记录链表内节点,每次刷盘更新时,将链表节点追加到 header.b 文件中,同时更新 header.e 记录的链表头。Block 和 Agg 数据分别追加到 Block 和 Agg 文件中,由于 header.b 中记录了该 Block 对应的偏移,在未来可能的查询中通过 header.b 记录的 BlockItem 结构即可查询到该 Block。Block 和 Agg 的文件结构分别如下:

📌 KaiwuDB 3.0 时序存储的特点

• 时序存储会根据设备的主键(Primary Tag) 拆分到不同 VGroup 中。

• 随着时序数据的写入,会按照写入时序数据的时间戳来写入不同的分区目录。

• 支持历史分区的写入、导入。

• 以 Block 为单位进行实时压缩,针对不同数据类型采取相应的压缩算法。

2. 数据读写流程

2.1 存储读写架构

在时序存储模块中,数据将按照三层结构进行划分与管理,各层承担不同的功能,具体如下:

✅MemSegment:完全驻留于内存中。其核心作用是对实时写入的时序数据进行快速整合与排序,通过内存级别的高效操作,确保数据在初步存储阶段就保持有序状态,为后续的持久化和查询加速奠定基础。由于内存的高速访问特性,MemSegment 能显著降低实时写入时的排序延迟,提升整体数据处理吞吐量。

✅LastSegment:当 MemSegment 达到阈值时,会被持久化到磁盘中变成 LastSegment 文件,通常保持较新的数据。它由 MemSegment 中刷盘而来,避免了内存数据因容量限制丢失的风险,又通过磁盘存储为数据提供了持久化保障。同时,由于存储的是相对较新的数据,LastSegment 也会作为高频查询的优先访问对象,平衡数据持久性与查询效率。

✅EntitySegment:同样以持久化形式存储在磁盘上。这些数据通常是从 LastSegment 中进一步合并、压缩而来,按照时序数据的设备 ID 进行归类存储。EntitySegment 侧重于数据的长期留存与高效检索,通过结构化的磁盘存储策略,支持对海量历史时序数据的快速查询与分析。

2.2 数据写入

数据在写入时,以行存的形式写入到 MemSegment 中,MemSegment 内部以无锁跳表的方式实现,能够高效地对写入数据按照设备 ID、时间戳、OSN(Operation Sequence Number)的方式进行排序。

MemSegment 大小达到阈值时,将主动触发 Flush(刷盘)线程,Flush 线程将 MemSegment 中的行存数据首先按照时间分区分组,然后组织成列存数据,并以追加写的方式持久化到一个新的 LastSegment 文件中。存储层支持同一设备在相同时间戳下的多条数据进行去重,可通过 CLUSTER SETTING 设置集群级去重规则。写入时根据去重策略对数据进行去重,保证相同文件内无重复输数据。由于 LastSegment 是由 MemSegment 直接 Flush 生成的,因此其中的数据同样保持有序。当某个分区中的 LastSegment 数量达到阈值时,会触发合并操作,合并多个 LastSegment 并将数据量满足条数的设备以压缩的方式追加写入到 EntitySegment 中。

时序数据写入基本流程图

2.3 数据查询

进行查询时,存储层首先按照下发的时间范围来过滤满足的时间分区,在对应的分区下逐级查询当前可见的 MemSegment、LastSegment 和 EntitySegment。此时各结构中每个 Block 内部的数据均按照设备号和时间戳升序排列。在返回给上层执行层前,这些 Block 会再经过归并排序来处理 Block 之间的重复数据,最终将经过处理的 Block 组织成合理的数据格式再返回给上层。查询的逻辑如下:

3. 数据目录结构说明

时序数据存储于数据库数据路径下的tsdb目录中。该目录默认包含 4 个以vg_为前缀的 VGroup 子目录,每个 VGroup 内部会按库 ID 与分区时间对数据进行分层分组管理。各 VGroup 下常见的文件如下:

3.1 版本控制文件

文件名:CURRENTTSVERSION-*

版本控制文件每个 VGroup 路径下有一组,核心功能是记录文件层级的所有变更,例如 Flush 操作新增的 LastSegment、Compact 操作导致的 LastSegment 新增或减少等。这些变更会通过以 TSVERSION-* 为命名格式的文件,以追加写方式实时记录,且记录时机严格限定在所有更新文件完成写入并 Sync 成功之后,确保数据一致性。

CURRENT 文件用于标记当前生效的 TSVERSION-* 文件(即当前变更由哪个版本文件记录),其核心目的是保障系统宕机或正常退出后重启时,能准确重建文件层级,避免重启恢复过程中出现数据丢失或读取错误数据的问题。

系统重启时,会读取所有旧 TSVERSION-* 文件中的变更记录并合并为一条完整记录,写入新的 TSVERSION-*文件(这也是该类文件后缀带有文件号的原因)。若重启成功,CURRENT 文件会更新为最新的 TSVERSION-* 文件标识,并清理旧版本文件,从而规避重启过程中再次发生断电或宕机时,后续重启出现异常的场景。

3.2 设备 Count 统计文件

文件名:entity_count.item

entity_count.item文件用于记录分区下各设备的落盘数据条数。当count查询的时间范围覆盖整个时间分区,会直接读取分区下该文件,取统计结果并返回,无需遍历全量数据,大幅提升查询效率。

3.3 删除记录文件

文件名:partition_del.item

partition_del.item文件用于记录各设备的删除信息,执行删除操作时会同步更新该文件内容。文件中会明确记录被删除设备的 ID、OSN 范围及时间戳范围,查询数据时,系统会先从查询范围中剔除该文件所记录的删除范围,从而间接实现数据删除的效果。

3.4 Last 文件

文件名:last.ver-*

Last 文件是一种自索引、不可修改的持久化文件。它通常通过两种方式生成:一是将内存中的数据通过 Flush 操作刷盘;二是在 Compact 过程中,将那些行数未达到 EntitySegment 合并阈值的设备数据回写而成。

3.5 Block 文件

文件名:block.veragg.verheader.eheader.b

这四个文件共同构成 EntitySegment,各文件功能及关联如下:

block.ver:以 Block 为单位存储原始数据,是数据的核心载体。

agg.ver:记录每个 Block 的聚合信息,与 block.ver 中的 Block 一一对应,用于快速获取 Block 级聚合结果。

header.b:包含两部分关键信息:一是各 Block 的部分聚合信息(支持查询时快速过滤);二是设备 ID、该设备上一个写入 Block 的 BlockID 及对应文件偏移,整体呈现类似链表的结构,便于追溯数据写入顺序。

header.e:记录设备最后一个写入 Block 的 BlockID,以及该设备的累计写入条数等核心统计信息。

除了header.e外,其余三个文件在 Compact 时均以追加写的方式写入。header.e会重写一份。

结语

KaiwuDB 3.0 时序存储模块通过深度适配物联网 AIoT 场景的架构设计,充分满足了海量时序数据高吞吐写入、极速查询、可靠存储等核心需求,为物联网核心系统提供了稳定、高效、易运维的数据管理支撑。其多模融合能力与自主可控特性,不仅实现了 AIoT 场景下的多样化数据处理,也为关键行业核心系统的数字化转型提供了可靠保障。未来,随着物联网技术的持续演进,KaiwuDB 将继续深耕时序数据处理领域,推出更多针对性优化功能,助力企业释放数据价值。