Post

一个时序元数据引擎的实现复盘:从双层 MemTable 到流式合并

一个时序元数据引擎的实现复盘:从双层 MemTable 到流式合并

沉淀一下从零实现 MetaStorage 元数据存储内核的心得体会,记录从 0 到 1 的开发全历程

MetaStorage 不是一个追求通用性的数据库内核,而是一套面向 “时序数据文件元数据检索” 场景、参考 LSM Tree 思想深度定制的存储引擎。

在项目初期,我们也曾尝试过 MySQL、DuckDB 等成熟方案,但在面对 PB 级元数据压力和严酷的查询性能要求时,通用组件的短板逐渐显现。下表简单对比了三者的差异:

Key MySQL DuckDB MetaStorage (本项目)
定位 通用型关系数据库 (OLTP) 嵌入式分析型数据库 (OLAP) 时序 DB 元数据专用存储引擎
PB 存储性能 分库分表运维成本高 内存消耗严重 数据可按照垂直&水平双维度切分
时间范围查询 慢查询性能受限 查询速度受写入事务影响 借助时间分区剪枝 (Pruning),PB 级检索秒级响应
数据变更/并发 高并发写入吞吐受限 数据更新时性能衰减明显 LSM Tree 天生适配高频追加与合并,无锁写入
可定制与观测 二开成本高昂 内部实现较为黑盒 全链路可观测,深度定制 SLO 监控指标

该项目的目标很明确,主要解决的是 “时序数据库有海量的底层数据文件 (PB 级), 查询要按时间范围、命名空间等条件快速拿到文件元数据” 这类问题,主要有以下几点:

  • 根据时间范围,从需要访问的海量元数据中,快速筛一组选到需要访问的数据
  • 支持数据的垂直和水平扩展,避免单点数据压力
  • 支持数据多版本并发 Insert 更新,避免数据写入影响查询速度

就我们的场景而言,每一条元数据记录的是时序数据库底层文件的 MetaData,具体的包括 file_id, object store path, start_time, end_time 等等。

为了支持元数据的多版本写入与查询可见性控制,我们在上述 MetaData 基础上新增了 version, operation, is_deleted 等字段。

从结果上看,MetaStorage 在存储、合并、MemTable、流式合并这些方向上已经形成了一套比较完整的工程实践。当然,目前它也有一个非常清晰的限制:“元数据 Schema 目前还是 hard code 的,还没有走到可扩展 Schema 的那一步”。

为什么参考 LSM 设计概念

现在很多大数据系统,底层都不是把数据成一条条记录去直接检索,而是先落成文件,再围绕文件做组织和查询。业务真正需要频繁访问的,往往不是文件内容本身,而是这些文件的元数据信息,比如:

  • 文件属于哪个 namespace、哪个 table
  • 文件覆盖的时间范围
  • 文件大小、行数、对象存储位置
  • 文件是否已经过期、是否被更高版本覆盖

MetaStorage 本质上就是想把 “文件元数据” 这件事单独做成一层存储引擎,之所以参考 LSM Tree,是因为这个问题天然就带有几个很典型的特征:

  • 删除,写入频繁:适合批量写,批量删。将删除操作修改为“高版本的插入操作 + delete tomb 机制”
  • 查询经常带时间范围:可自定义时间分区降低查询成本
  • 历史数据会持续累积:需要后台不断合并 merge

所以最后落成的形态,就是 MemTable + L0 + L1 + 后台 Compaction 这一套结构。

为什么没有自己做 SSTable,而是直接用了 Arrow / Parquet / DataFusion

这个项目里一个很重要的取舍是:没有从零去实现一套复杂的底层 SSTable,而是直接建立在 Arrow、Parquet、DataFusion 这套生态上。

这是一个非常现实的选择:

  • 自己开发 SSTable 意味着要自己处理文件格式、过滤、序列化、读取计划、bloom-filter,工程成本也非常高。
  • 我们已经在二开 TimeSeries DB 内核,它使用 DataFusion 支持 SQL 查询能力。我们选择 DataFusion 的技术成本较低。

有了这套基础设施,很多事情就不用从头发明一遍了。项目里查询 MemTable 时直接走 DataFusion SQL,读取磁盘文件时也可以直接复用 Parquet reader;序列化存储 Parquet 文件时,还能直接配置 bloom filter、字典编码、排序策略和 page 级统计信息。

换句话说,这个项目更像是在 Arrow / Parquet / DataFusion 之上搭一套 LSM 风格的数据组织方式,而不是完整的重新造一套底层文件的轮子。

架构总览

flowchart TB
    Client["Write / Query Requests"] --> Engine["MetaStorage"]

    Engine --> Mem["MemTable"]
    Mem --> Mutable["MutableMemTable<br/>active batch + frozen batches"]
    Mem --> Immutable["ImmutableMemTable<br/>partition snapshots"]

    Immutable --> Serializer["ParquetSerializer"]
    Serializer --> L0["L0 Parquet Files<br/>time ranges may overlap"]

    Engine --> Query["Query Path"]
    Query --> ReadMem["MemTable Query"]
    Query --> ReadL0["L0 Query"]
    Query --> ReadL1["L1 Query"]

    ReadMem --> GlobalDedup["Global Version Dedup<br/>+ tombstone/to_delete filtering"]
    ReadL0 --> GlobalDedup
    ReadL1 --> GlobalDedup

    Compactor["Compactor"] --> L0
    Compactor --> Partition["Partition by time window"]
    Partition --> Merge{"Existing L1 file?"}
    Merge -->|"No"| NewL1["Create new L1"]
    Merge -->|"Small L1"| InMemory["In-memory merge"]
    Merge -->|"Large L1"| Streaming["Streaming merge"]
    InMemory --> NewL1
    Streaming --> NewL1

    L0 --> Manifest["Manifest"]
    NewL1 --> Manifest
    Manifest --> Managers["ManifestManager / ParquetManager<br/>atomic snapshot + lazy upload/delete"]

如果按职责来拆,可以概括成下面几层:

  • MetaStorage:统一对外接口,负责把写入、查询、合并几条主链路串起来。
  • MemTable:承接内存写入,并负责把写入流量分层缓冲。
  • L0:磁盘上的原始地层,允许时间范围重叠。
  • L1:按时间分区组织的稳定层,适合长期查询,可按照小时,天级别配置时间分区。
  • Compactor:后台持续整理 L0,产出新的 L1。
  • Manifest:记录底层 parquets 文件列表,版本号。
  • ManifestManager / ParquetManager:处理 manifest 和 parquet 文件的落盘、文件上传和延迟删除。

双层 MemTable 的设计

存储引擎采用双层 MemTable,并不只是为了让架构看起来更像 LSM Tree。更现实的原因其实是为了提升写入性能,降低写入操作对查询的性能影响。

如果只维护一个不断膨胀的内存表,那么每次写入、去重、整理都会越来越越重,查询也更容易被热点写入影响。于是最后我们采用了双层 MemTable 的方式,把内存层拆成两部分:

  • MutableMemTable:负责当前热点写入。
  • ImmutableMemTable:负责接住一批已经相对稳定的数据,等待刷盘。

MutableMemTable: 热点写入缓冲

MutableMemTable 里又拆成了:

  • active_batch
  • frozen_batches

新的记录先和 active_batch 合并,然后基于 id + version 做一轮局部 compact。整理之后:

  • 满批次的 RecordBatch 进入 frozen_batches
  • 不满的尾批次继续留在 active_batch

这个设计带来的收益很直接:

  1. 热点写入始终只在很小的一块数据上发生。
  2. 稳定 batch 可以直接作为后续 Flush 的输入。
  3. 在真正落盘之前,内存层已经提前做了一轮局部去重。

所以双层 MemTable 这件事,一方面确实是在借 LSM 的思路,另一方面更重要的是,它确实提升了写入路径的稳定性和吞吐。

ImmutableMemTable: 从写缓冲到刷盘缓冲

MutableMemTable 中冻结 batch 的总量达到阈值以后,这些 batch 会整体移动到 ImmutableMemTable

ImmutableMemTable 不是简单的“第二个内存表”,它更像是一个只读缓冲层:

  • 内部按 partition 维护只读 batch 集合。
  • 达到分区数量上限时触发 finalize。
  • 或者超过一定时间间隔触发 finalize。

一旦 finalize,所有数据会被合并、排序、序列化成新的 L0 Parquet 文件,并注册到 Manifest 里。这一步和经典 LSM Tree 里的 MemTable flush 非常像,只不过这里落下的不是自定义 SSTable,而是 Parquet。

L1 层时间分区的设计

存储引擎的另一个很关键的设计,就是 L1 级别的时间分区。这是基于实际的业务场景 + 数据分布特点思考到的设计方案,我们几乎 100% 的用户在查询时序数据时都会指定时间范围,而且这个时间范围有时候会非常短,有时候又会很长。

比如有的查询只看最近 10 分钟,有的查询看 8 小时、24 小时,甚至更长。如果不做时间分区,或者把分区做得很大,比如一个文件直接覆盖一整天甚至更久,那么查询 10 分钟数据的时候,DataFusion 仍然得去加载一个很大的 Parquet 文件,哪怕最终只命中其中很小一部分数据,整体 overhead 也会很高。

所以这里的设计重点不是“按时间分区看起来很标准”,而是:

  • 让短时间范围查询尽量只碰到少量文件。
  • 让长时间范围查询依然可以通过多文件并行完成。
  • 把大文件读取的固定成本切小。

当前实现里,partition_duration 是可配置的,默认情况下我们可以直接按照天进行分区,相同时间段内的数据进行多版本合并也非常容易。

查询链路

查询这条链路,实际上做过多轮调整。真正难的点不在于“能查到数据”,而在于“多层数据一起查时,结果是不是全局正确”。因为同一条记录可能同时存在于:

  • MutableMemTable
  • ImmutableMemTable
  • L0
  • L1

而且不同层中可能保留着同一条记录的不同版本,甚至包含 tombstone 标记。所以最后我们把查询链路收敛成了下面这个思路:

  1. 并发查询 MemTable、L0、L1 三层。
  2. 先拿到所有候选结果
  3. 在引擎层做全局去重,只保留最新数据版本。
  4. 再处理 tombstone

这里有一个细节很重要:删除标记不能过早过滤。因为如果上层 tombstone 还没来得及覆盖掉下层旧版本,就把 tombstone 丢掉了,查询结果就会出错。

这块之所以最后能稳定下来,其实离不开 DataFusion。文件内过滤、MemTable SQL 查询、RecordBatch 合并这些事情都能直接复用成熟能力,不需要自己在底层文件格式上做太多额外工作。

Compaction OOM 陷阱 🪤

Compaction 是这套引擎里最像 “系统工程” 的部分。

L0 文件一多,系统就不能一直堆在那里不管,必须有后台线程持续把它们整理成更稳定的 L1 文件。整体流程大致如下:

flowchart LR
    L0Files["L0 files reach threshold"] --> ReadL0["Concurrent read L0 files"]
    ReadL0 --> KeepTombstone["Deduplicate L0<br/>but keep tombstones"]
    KeepTombstone --> Split["Partition by time window"]
    Split --> EndPoint1(("..⬇️.."))
flowchart LR
    StartPoint2(("..⬆️..")) --> PickL1["Find latest L1 for each partition"]
    PickL1 --> Decision{"L1 row count >= threshold?"}
    Decision -->|"No"| InMem["In-memory merge"]
    Decision -->|"Yes"| Stream["Streaming merge"]
    %% ... 后续逻辑

朴素的 Compaction 初始设计

最早的思路并不复杂:

  • 把 L0 读出来。
  • 按时间窗口做分区。
  • 找到对应分区的 L1。
  • 直接在内存里 merge。
  • 去重、过滤、写回新的 L1。

在数据量还不大的时候,这样做完全能工作,而且路径简单。

PB 级数据对 Compaction 的考验

真正的问题出现在数据量继续增大之后。

当某些 L1 文件已经足够大时,如果仍然按“整文件读入内存再合并”的方式处理,内存压力会非常明显,最后直接把流式合并这条链路逼出来了。换句话说,StreamingMerger 不是一开始拍脑袋设计出来的,而是后来在大数据量场景为了应对 OOM 的问题,才进行的优雅优化。

流式合并的设计经验

最后落实下来的 StreamingMerger,它的核心思路是:

  1. 先把 L0 记录按 end_time 字段排序。
  2. L1 不整文件读入,而是针对大的 parquet 进行多 batch 读取,多 batch 合并。
  3. 每一轮根据当前 L1 批次的时间上边界,取出对应范围内的 L0 记录。
  4. 把这部分 L0 和当前这部分 L1 合并、去重。
  5. 结果按配置的 row group 大小持续写出。

这样做之后,系统不再需要把整个大 L1 一次性塞进内存,合并和写出可以边读边做,内存占用就稳定了很多。

另外,我们需要在合并的过程中去处理 tombstone 标记。

在读取多个 L0 文件并准备下沉到 L1 之前,系统会先对 L0 做一次去重,但这里保留 tombstone,不会提前删除。原因很简单:

  • 上层的删除版本必须继续往下传播。
  • 否则下层旧版本会失去被覆盖的机会。

而到了 L0 与 L1 真正完成合并之后,系统才会执行 deduplicate_and_purge,把已经可以安全清理的逻辑删除和过期记录物理丢弃。这个顺序听起来只是实现细节,但实际上决定了整个多层可见性语义是否正确。

存在感不强,但是很重要的 Manifest

除了写入和查询主链路之外,这个项目还有一个“平时不显眼,但不能没有”的基础设施 — Manifest

Manifest 负责维护当前有哪些 L0、L1 文件,它们的层级、大小、行数、时间范围和删除状态是什么,在我们的元数据引擎关闭之后,可以通过读取 Manifest 完成底层数据文件校验,重新启动,完成状态恢复。

另外我们的 Manifest 在更新时,采用原子更新的策略,磁盘上旧的 parquet 文件在被更新后,不会立刻删除,而是先在 Manifest 中标记,再交给 ParquetManager 延迟处理。

这个设计看起来保守,但非常有必要。因为 Compaction 本身就是“新文件生成、旧文件失效”的切换过程,延迟删除相当于给这段切换留了安全窗口,可以避免文件状态变更时序不一致的风险。

总结

我们不能把 MetaStorage 作为一个通用数据库去看,它当然还远远没有走到那个阶段;但如果把它放回到“时序文件元数据存储与检索”这个问题看,这个项目已经把一条很完整的主链路做出来了。

这里面有双层 MemTable,有 L0 / L1 分层,有基于时间范围的分区,有多版本标记和 tombstone 语义,也有从 OOM 问题里迭代出来的流式合并;再往下,还有 Manifest、延迟删除、benchmark 驱动迭代这些更偏工程落地的部分。

所以对 MetaStorage 更准确的描述不是 “实现了一个 LSM Tree”,而是:围绕时序文件元数据场景,做出一套参考 LSM 思想、建立在 Arrow / Parquet / DataFusion 之上的专用存储引擎。

它现在最值得继续推进的方向也很明确:把固定的 Schema 逐步抽象出来,只有把这些能力抽象出来,这套引擎才会真正从“面向当前时序元数据场景的实现”走向“可以复用的框架”。让这套已经在存储、查询、合并上打磨过多轮的能力,未来可以服务更广一点的文件元数据场景。

This post is licensed under CC BY 4.0 by the author.