Hotdry.

Article

NanoTDB Append-Only 存储引擎与 LSM 树索引策略

深入解析 NanoTDB 的 Append-Only 存储架构、WAL 机制、LSM 树分层思想与时序数据的索引设计,提供可落地的配置参数与工程权衡。

2026-05-15systems

NanoTDB 是一款专为树莓派、边缘节点和资源受限主机设计的嵌入式时序数据库,其核心存储引擎采用了 Append-Only 设计哲学,通过 WAL(Write-Ahead Log)+ 分区数据文件的二层结构实现了 crash-safe 的数据持久化。本文从架构设计、LSM 树思想借鉴、索引策略三个维度,完整解析该引擎的工程实现与可调参数。

Append-Only 架构的核心设计

NanoTDB 的存储布局遵循极简原则,所有数据均以纯文件形式存在于单一根目录下,每个 Database 由三个明确的存储层构成:

层级 文件 职责
WAL 层 <db>.wal 记录每一条样本,保证 crash-safe
Catalog 层 catalog.json 指标名 ↔ MetricID 的映射注册表
数据文件层 data-<partition>.dat 从内存页压缩 flush 后的不可变数据

这种分层设计的核心收益在于写路径的确定性:所有新样本首先 append 到 WAL,随后进入当日内存页;当页满时,压缩并写入数据文件,此时 WAL 可以安全截断(replay 不再需要)。整个过程无随机写、无 in-place 更新,这是 Append-Only 架构区别于传统 BTree 类数据库的根本差异。

从工程视角看,Append-Only 带来了三个实质性优势:顺序写最大化磁盘吞吐、利用 copy-on-write 特性天然支持 MVCC、以及数据文件一旦落盘即为只读状态,简化了并发读与 compaction 的协调。

WAL 的紧凑编码与 crash-safe 保障

NanoTDB 的 WAL 采用了紧凑的 v2 编码格式,以可变长整数(uvarint)作为长度前缀,随后跟随固定布局的 payload:

[uvarint: payload_len] [2-byte MetricID] [3-byte TS delta] [1-byte flags] [+8-byte baseline] [+var name+vtype] [+4-byte value]

热路径(已知指标且在同一时间基线内)仅需 11 字节即可记录一条样本。新 baseline 的触发阈值为 2²⁴ 纳秒(约 16.7 毫秒),典型传感器数据流在数百秒后才需要重置基线,大幅压缩了存储开销。

关键可配置参数

  • wal.max_segment_size:默认 64 MiB,决定 WAL 重置前的最大尺寸。资源受限设备建议降至 16–32 MiB 以控制单次 fsync 的延迟。
  • wal.fsync_policy:默认 segment,仅在页 flush 后执行 fsync;写入密集场景可设为 always 以消除重启时的 replay 窗口,但会显著影响吞吐量。

WAL 的 replay 逻辑在引擎启动时执行:读取 WAL 内容,依据 catalog 恢复已知指标的 ValueType,并将数据填充到对应日期的内存页中。完成 replay 后引擎即进入可接受新写入的状态。

LSM 树思想在 NanoTDB 中的借鉴

LSM 树(Log-Structured Merge Tree)的核心思想是将随机写转化为顺序写,并通过多层合并保持读效率。NanoTDB 虽非完整意义上的 LSM 树实现,但其存储层次与 LSM 思想高度契合:

写路径AddLine → WAL append → 内存页(等价于 memtable)→ 页满 flush → 压缩数据文件(等价于 SSTable)

内存页设计:内存页以 UTC 日期为分桶键,每页内部采用 MetricID、时间戳、值交叉存储(interleaved format),并按时间戳排序。当页满时,S2 压缩算法将页负载压缩后追加到对应日期的 data-YYYY-MM-DD.dat 文件中。

数据文件的类 SSTable 结构

Frame = PageHeader(18 bytes) + uvarint(compressed_len) + S2-compressed payload + CRC32(4 bytes)

PageHeader 包含该帧的时间范围和 MetricID 范围元数据,查询时无需解压即可跳过不相关帧。这一设计与 LSM 树中每个 SSTable 携带 min/max 时间戳元数据以便剪枝的做法一致。

与经典 LSM 的差异:NanoTDB 摈弃了多级 compaction 机制。所有数据文件按分区(天 / 月 / 年)天然隔离,老分区只读不合并,新数据始终写入当日页。这意味着它更接近于「分区 WAL + 固定时间窗口」的简化 LSM,而非 RocksDB 式的层级合并。

时间分区与索引策略

NanoTDB 的索引体系围绕三个核心维度构建:

1. MetricID 倒排索引:catalog.json 维护指标名到 uint16 MetricID 的映射。MetricID 在每个 Database 内全局唯一(最多 65535 个指标),查询时通过 catalog 解析得到 ID,随后在数据文件中定位对应记录。该映射在 WAL replay 时被缓存到内存,实现 O (1) 的指标名查找。

2. 时间分区索引:每个数据文件以 UTC 日期(或月 / 年)为粒度命名,查询 QueryRange 时直接根据时间范围映射到目标分区文件列表。例如,查询 2024-03-15 至 2024-03-20 的数据,只需打开 6 个 data-YYYY-MM-DD.dat 文件而非扫描全量数据。分区粒度可通过 manifest.toml 中的 partition 字段配置:

[retention]
partition = "day"   # 默认,按天分桶
# partition = "month" # 按月,适合低频查询
# partition = "year"  # 按年,适合长期归档

3. 页帧级别元数据:每个 .dat 文件由若干页帧组成,每个帧头包含该帧覆盖的时间范围和 MetricID 范围。QueryRange 在打开文件后仅扫描帧头即可跳过不在查询窗口内的帧,无需全量解压。这一机制等价于 LSM 树中的 Block-level 布隆过滤器,只是 NanoTDB 使用精确的 min/max 时间戳而非概率过滤。

Rollup 与数据老化

NanoTDB 内置的 rollup 机制是时序数据库保留策略的核心组件。rollup job 在源数据库的 manifest.toml 中定义,周期性地从原始指标聚合生成 min/max/sum/avg/count 等降采样数据:

[rollups]
enabled = true
default_grace = "5m"

[[rollups.jobs]]
id = "temp_1h"
source_metric = "temp.out_dry"
interval = "1h"
aggregates = ["min", "max", "sum", "avg", "count"]
destination_db = "sensors_rollup_1h"
destination_metric_prefix = "temp.out_dry"

Rollup 的 checkpoint 机制确保即使引擎异常重启,也不会丢失已处理窗口或重复处理未完成窗口。目标数据库建议使用更粗粒度的分区(如 month),以避免产生大量小文件。

从 LSM 树视角看,rollup 本质上是将热数据(高分辨率原始点)向下层冷数据(低分辨率聚合点)转化的 compaction 过程。原始数据的保留周期由源数据库的 manifest 配置控制,达到阈值后分区文件可直接删除,无需复杂的逐点 GC。

配置建议与工程权衡

针对不同硬件与工作负载,以下参数组合可作为起点:

资源受限设备(Pi / 边缘网关)

[wal]
max_segment_size = 16777216     # 16 MiB,降低单次 fsync 延迟
fsync_policy = "segment"        # 默认即可

[manifest_defaults]
partition = "day"               # 天级分区,平衡查询粒度与文件数量

[retention]
# 嵌入式场景建议保留 7–30 天原始数据,超出后依赖 rollup

吞吐量优先场景

[durability]
profile = "throughput"          # 跳过数据文件和 catalog 的 fsync
# 注意:这会引入约一个 WAL segment 的数据丢失风险

[wal]
fsync_policy = "segment"        # 仅在页 flush 后同步

严格持久化场景

[durability]
profile = "strict"             # 所有写入均 fsync 落地

[wal]
fsync_policy = "always"        # 每条样本立即持久化

从 LSM 树的设计空间看,NanoTDB 选择了一条介于「纯 WAL 日志」与「完整多级 LSM」之间的路径:以时间分区替代动态 compaction,以 Append-Only 避免随机更新。这种设计在写入密集、查询以近期窗口为主的 IoT 场景中表现优异,但也意味着长期范围查询的性能取决于是否命中 rollup 后的冷数据层。对于需要极低延迟全量历史扫描的场景,建议在 rollup 目标数据库上使用更粗粒度的分区策略或外部列式存储(如 Parquet)进行归档。


资料来源NanoTDB 官方 GitHub 仓库

systems

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com