TDengine 存储引擎深度解析:行列格式、TDB 元数据引擎与 TSDB LSM 存储架构
2026/9/14 17:20:55 网站建设 项目流程

TDengine 存储引擎深度解析:行列格式、TDB 元数据引擎与 TSDB LSM 存储架构

【免费下载链接】TDengineHigh-performance, scalable time-series database designed for Industrial IoT (IIoT) scenarios项目地址: https://gitcode.com/GitHub_Trending/tde/TDengine

TDengine 的高写入与查询性能,源于其为时序场景量身定制的一套存储技术栈:区分 NONE/NULL 语义的自研行列格式、基于 B+Tree 的 TDB 元数据引擎,以及采用 LSM(Log-Structured Merge-Tree)结构的 TSDB 时序存储引擎。本文基于 TDengine 官方的“存储引擎”内部原理文档展开,并结合当前仓库中的源码实现,完整还原从写入请求落 WAL、内存池刷盘,到 head/data/sma/tomb/stt 各类数据文件的组织方式,帮助开发者建立对 TDengine 数据在磁盘上“长什么样”的完整认知。

为什么需要时序专属的存储算法

相较于通用型数据库,TDengine 在诞生之初就专注于时序数据场景的独特性:数据按时间天然有序、写入持续且高并发、单条记录往往只覆盖 schema 中很少的列。TDengine 利用这些特点,自主研发了针对时序数据的写入与存储算法,对数据进行预处理与压缩——既大幅提升写入速度,又显著降低存储占用,从而在大量实时数据持续涌入时仍能保持高吞吐与低延迟响应。

具体而言,这套算法体现在三个层次:

  1. 行列格式层:一套自研的行列式内存数据结构,支持 NONE、NULL、有值三态共存与稀疏数据高效处理;
  2. 元数据层(META/TDB):基于 B+Tree 的键值存储引擎,负责表、标签、索引等元信息;
  3. 时序数据层(TSDB):LSM 结构 + 文件组切分 + 多种辅助文件,负责海量时序数据本体。

行列格式:NONE、NULL 与稀疏数据的统一表示

业内已有 Apache Arrow 等标准化的行列格式库,但 TDengine 面向的场景更加聚焦、性能要求更高,因此自行实现了一套行列格式。其核心需求有三点:

  • 支持**未指定值(NONE)空值(NULL)**的区分;
  • 支持 NONE、NULL 以及有值共存的不同场景;
  • 稀疏数据和稠密数据都能高效处理。

在源码中可以直接看到三态的定义。include/common/trow.h 中定义了行级值类型:

#define TD_VTYPE_NORM 0x00U // normal val: not none, not null #define TD_VTYPE_NULL 0x01U // null val #define TD_VTYPE_NONE 0x02U // none or unknown/undefined

这与 INSERT 语义一一对应:NULL表示客户端显式写入的空值,NONE表示客户端根本没有提供该列——两者在存储与查询语义上必须区分开。

行格式:Tuple 与 Key-Value 双编码

TDengine 的行格式有两种编码,具体采用哪一种由数据特征决定

1. Tuple 编码格式

Tuple 编码面向非稀疏数据,即所有列全部有值、或只有少量 NONE 的场景。Tuple 行直接根据表 schema 提供的偏移量信息访问列数据,访问复杂度为 O(1),速度最快。其布局由时间戳主键(独立存放)、各列连续排布的字段区(flen 部分)以及末尾的 bitmap 组成。

源码中的 ASCII 布局注释与这一描述完全对应,见 include/common/trow.h:

/* * |<----------------------------- tlen ---------------------------------->| * |<--------- flen ------------->|<-- blen -->| | * +---------------------------------+-------------+-----------------------+ * | first part | bitmap | second part | * +---------------------------------+-------------+-----------------------+ */

行头STSRow结构(include/common/trow.h)中有一个statis位,当行中存在 null 或 none 时置 1,让读取路径可以快速跳过 bitmap 检查。

2. Key-Value 编码格式

Key-Value 编码专门用于稀疏数据:schema 中定义了数千列,但实际有值的列很少。此时若用 Tuple 编码会为海量未指定列预留空间,造成极大浪费;Key-Value 编码只记录“有值”的列,通过offset 数组索引各列的值——访问速度比 Tuple 直接寻址略慢,但显著减少空间占用。

仓库源码印证了这套机制,并且比文档描述走得更远。include/common/trow.h 中 KV 行的索引项为{colId, offset}对:

typedef struct { col_id_t colId; uint32_t offset; } SKvRowIdx; typedef struct { uint16_t ncols; SKvRowIdx cidx[]; } SKvRow;

而是否选择 KV 行,由两个比例阈值决定(include/common/trow.h 与 L197):

#define KvConvertRatio (0.9f) #define isSelectKVRow(klen, tlen) ((klen) < ((tlen)*KvConvertRatio))

即 KV 行编码后的长度小于 Tuple 行(tlen)的一定比例时才启用 KV 编码——这正是“flag 选项优化空间占用”思想在实现中的体现:不是简单的“稀疏就 KV”,而是按实际编码长度对比决策,取空间更优者。

列格式:bitmap + 数据数组 + offset 数组

列格式用于列式存储路径。对于定长数据,列本质上是一个数组,但由于 NONE、NULL、有值三种状态并存,列还需要一个bitmap标识每个行位置的状态;对于变长数据(如 binary、nchar、JSON),除了数据数组外还有一个offset 数组索引每个值在数据区的起始位置,单个值的长度由相邻两个 offset 之差得到。

在 include/common/tdatablock.h 中可以验证这两种列结构:定长列的空值判断基于 bitmap 宏colDataIsNull_f(每 8 个行共享 1 字节,见 L39-L44),变长列则以varmeta.offset[row] == -1作为空值标记(L72-L73),取值统一走colDataGetData宏按类型分流(L80-L81)。这与文档中“变长列 = 数据数组 + offset 数组”的描述一一对应。

vnode:TDengine 数据存储的基本单元

vnode 存储架构

vnode 是 TDengine 中数据存储、查询与备份的基本单元。每个 vnode 中存放着部分表的元数据信息,以及属于这些表的全部时序数据;表在 vnode 上的分布由一致性哈希决定,因此每个 vnode 都可以被看作一个单机数据库。

vnode 的存储由三部分组成:

  • WAL 文件的存储;
  • 元数据的存储(META,底层是 TDB 引擎);
  • 时序数据的存储(TSDB,LSM 结构)。

写入流程为:vnode 收到写入请求 →预处理(确保多副本间数据一致,保障安全性与一致性)→ 写入WAL保证持久性 → 写入vnode 内存池→ 内存池占用达到阈值后,后台线程将数据刷新到硬盘(META 与 TSDB)→ 标记内存中对应的 WAL 编号为已落盘。由于 TSDB 采用 LSM 结构,在打开数据库的“多表低频”参数时,后台还会对 TSDB 数据文件执行合并,减少文件数量并提高查询性能。

从源码结构看,这一链条对应 source/dnode/vnode/src/vnd/ 下的vnodeTxn.cvnodeTxnWalMgr.cvnodeBufPool.c(内存池)、vnodeCommit.c等文件,WAL 读写由 source/libs/wal/src/ 中的walWrite.cwalRead.cwalTxn.c实现。vnode 还通过vnodeHash.c承载一致性哈希相关的表分布逻辑。

元数据的存储:TDB B+Tree 引擎

vnode 中的元数据主要包括:超级表名称、超级表 schema 定义、标签 schema 定义、子表名称、子表标签信息以及标签索引等。由于元数据读多写少,TDengine 选择 B+Tree 作为存储结构——其查询性能高、插入删除稳定,非常适合这类负载。

元数据的写入过程是:META 模块收到写入请求后生成多个 Key-Value 对,存入底层的TDB 存储引擎。TDB 是 TDengine 自研的 B+Tree 引擎,由三部分组成:

  1. 内置 Cache(页缓存);
  2. TDB 存储主文件
  3. TDB 日志文件

数据写入 TDB 时先写入 Cache;Cache 内存不足时,系统会向 vnode 内存池申请额外内存。若写入涉及已有数据页的更改,系统会在修改前先把未更改的数据页写入 TDB 日志文件作为备份——这样在断电等故障发生时,可以通过日志回滚到原始数据,保证数据更新的原子性与完整性。

对应到源码,TDB 引擎位于 source/libs/tdb/:tdbBtree.c 实现 B+Tree 核心,tdbPage.c 与 tdbPager.c 管理页与页分配,tdbPCache.c 实现内置页缓存,tdbTxn.c 负责事务与日志恢复语义。source/libs/tdb/test/下还有tdbPageFlushTest.cpptdbPageRecycleTest.cpp等针对页刷盘与回收的单元测试。

多 B+Tree 与根索引页

由于元数据查询需求多样化,vnode 内部会创建多个 B+Tree存放不同维度的索引(如按表 ID、按标签、按名称等),这些树都存储在同一个共享存储文件中,并通过一个根页编号为 1 的“索引 B+Tree”来索引各个 B+Tree 的根页编号:

对于变长 Key/Value,TDB 有两个关键工程手段:

  • 溢出页(overflow page):当 Key 或 Value 超过文件页大小时,超出部分存入溢出页;
  • 非溢出页中 Key/Value 长度受限:以此控制 B+Tree 的扇出度不低于 4,从而压低树的高度、控制查询路径长度。

source/libs/tdb/test/tdbExOVFLTest.cpp即为针对 Key/Value 溢出场景的测试用例,tdbBtreeToStackTest.cpp验证 B+Tree 与栈式遍历的一致性。

时序数据的存储:LSM 与 MemTable

若用传统 B+Tree 存储海量时序数据,随着数据增长树高会迅速上升,查询与写入性能急剧下降直至引擎不可用。因此 TDengine 用LSM 存储结构处理时序数据:写入以日志方式顺序追加,写性能优异;后台通过合并减少空间占用、提升查询效率。

MemTable:红黑树 + SkipList 组合索引

内存中的 MemTable 采用Red-Black Tree 与 SkipList 相结合的索引方式:

  • 不同表之间的索引存在 Red-Black Tree 中:自平衡二叉树通过着色与旋转保持平衡,查询/插入/删除均为 O(log n);
  • 同一张表内部的数据索引存在 SkipList 中:基于多级索引的有序链表,操作同样 O(log n),能快速定位特定时间范围的数据。

这种“表级用平衡树、表内时间序列用跳表”的组合,恰好匹配时序数据“多表并存、单表内时间连续”的访问模式:先 O(log n) 找到表,再在表内按时间区间快速扫描。

从源码结构看,MemTable 的实现位于 source/dnode/vnode/src/tsdb/tsdbMemTable.c,其中tsdbTbDataIterCreate/Open/Next等函数围绕“按 (ts, version) 行键顺序遍历表内数据”的迭代器展开(tsdbMemTable.c)。

按 (ts, version) 排序与文件组切分

TSDB 中无论是在内存中还是数据文件里,数据都按(ts, version)元组排序——version 用于多副本场景下以版本号解决写冲突与乱序提交。为组织这些按时间排序的数据,TSDB 将数据文件按时间范围切分为多个数据文件组(FSet),每个文件组覆盖一段时间范围,保证数据的连续性与完整性,也便于分片分区。

查询时根据查询条件的时间范围可以直接计算出文件组编号,快速定位目标文件组,避免扫描无关文件——这是时序数据库相比通用数据库在点查/范围查上天然占优的关键设计之一。

数据文件组的五种文件

每个文件组内包含五类文件,各司其职:

1. head 文件(BRIN 索引)

head 文件是 data 文件的BRIN(Block Range Index,块范围索引),记录每个数据块的时间范围、偏移量(offset)与长度。查询引擎据此高效过滤出时间范围相交的数据块并直接取得其位置信息。

head 文件内部由多个 BRIN 记录块及其索引构成,记录块采用列存压缩方式存储,大幅减少空间占用同时保持高查询性能:

2. data 文件(数据本体)

data 文件存储真实时序数据,数据以数据块为单位组织,每个块内含一定量数据的列式存储。不同数据类型与压缩配置对应不同的压缩算法,以压缩存储空间、提升传输效率。与 stt 文件不同,data 文件中每个数据块独立存储,代表一张表在特定时间范围内的数据——块级独立使得按表+时间范围的管理与查询更灵活,也是 LSM 后台合并的基本操作单元。

3. sma 文件(预计算)

sma 文件存储各数据块中每列的预计算结果,包括 sum、min、max 等统计信息(即 TDengine 的超级表预聚合)。执行聚合类查询时,查询引擎可直接利用这些预计算结果,避免读取原始数据,显著降低聚合查询的 I/O 与计算开销。vnode 侧对应的提交与聚合逻辑见source/dnode/vnode/src/sma/smaCommit.csmaRollup.c等)。

4. tomb 文件(删除标记)

tomb 文件存储删除记录,记录结构为(suid, uid, start_timestamp, end_timestamp, version)元组:超级表 ID、子表 ID、删除时间区间与版本号。LSM 中删除并不物理抹除数据,而是以标记叠加的方式表达,查询时据此过滤,后台合并时才能真正丢弃。

5. stt 文件(碎片暂存与合并)

stt 文件服务于“落盘时产生的碎片数据”,在不同场景下有不同策略:

  • 少表高频场景:系统只维护一个stt 文件,专门存放每次数据落盘后剩余的碎片。下一次落盘时,碎片与内存中的新数据合并成更大的数据块,再一并写入 data 文件——避免数据文件碎片化,保证连续性与高效性;
  • 多表低频场景:建议配置多个stt 文件。此时单张表每次落盘的数据量不大,但同一超级表下所有子表累积的数据量可观,通过跨表合并生成大块数据,减少碎片,提升写入效率与查询性能。

TSDB 侧的文件读写与合并实现集中在 source/dnode/vnode/src/tsdb/ 目录:tsdbFSetRW.c/tsdbFSetRAW.c负责文件组读写,tsdbSttFileRW.c专门处理 stt 文件,tsdbMerge.c/tsdbMergeTree.c实现后台合并,tsdbSnapshot.c/tsdbSnapshotMEDIUM.c支持不同规模快照,tsdbRetention.c处理数据保留策略,tsdbRepair.c提供文件修复能力,并有 source/dnode/vnode/test/ 下tsdbRepairTest.cpptsdbSmaTest.cpptsdbCacheTest.cpp等配套测试。

总结:三层结构如何共同支撑高性能

TDengine 存储引擎的设计可以归纳为一条主线:让每一层结构都精准匹配时序数据的访问特征

层次结构选择匹配的场景特征仓库实现位置
行列格式Tuple / Key-Value 双行编码 + 定长/变长双列编码NONE/NULL/有值三态、稀疏与稠密混合include/common/trow.h、include/common/tdatablock.h
元数据共享文件上的多棵 B+Tree(TDB),页级日志保证原子性读多写少、查询维度多样source/libs/tdb/
时序数据LSM + MemTable(RBT+SkipList) + 按时间切分的文件组(head/data/sma/tomb/stt)海量顺序写入、按时间范围查询、跨表合并source/dnode/vnode/src/tsdb/

WAL 先落盘保证持久性、内存池阈值触发后台刷盘保证吞吐、(ts, version) 全局有序保证多副本一致性、BRIN + 预计算保证查询只读必要的数据块、stt 文件按场景合并碎片保证块密度——这些机制环环相扣,构成了 TDengine 在工业物联网等 IIoT 场景下高写入、低延迟查询的底层基础。

【免费下载链接】TDengineHigh-performance, scalable time-series database designed for Industrial IoT (IIoT) scenarios项目地址: https://gitcode.com/GitHub_Trending/tde/TDengine

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询