← 返回博客
技术分享

宽表元数据膨胀怎么解?Doris Segment V3 对比 Parquet、Lance

陈明雨,飞轮科技技术副总裁 · 2026/7/14
SelectDB 微信公众号
SelectDB 公众号
获取技术干货和产品动态

列式存储的优势很直接:查询用到哪些列,就只读取哪些列。

假设一张用户行为表有 3000 列,而查询只需要 user_idevent_time,理论上,其余 2998 列都不应产生明显开销。

SELECT user_id, event_time
FROM events
WHERE dt = '2026-04-20'
LIMIT 1000;

这正是 Parquet、ORC、Apache Doris 和 ClickHouse 等列式系统适合分析场景的原因。分析表往往很宽,但单条 SQL 通常只涉及其中一小部分字段。

不过,系统在读取这两列之前,必须先回答一个问题:这两列的数据究竟存放在文件的什么位置?

为此,列式文件会在末尾保存一份元数据。它记录了文件中有哪些列、每列的数据位于哪里、使用了什么编码和压缩方式,以及最小值、最大值、索引位置等信息。这段元数据通常被称为 Footer。可以把它理解成文件的目录

查询先读取目录,找到目标列,再去读取真正的数据。

1-segment V3.png

这种设计长期以来都非常有效,因为它隐含了一个前提:Footer 很小。

十多年前,一张分析表通常只有几十列,一个文件中也只有少量数据分组。Footer 往往只有几十 KB。相比几百 MB 或几 GB 的数据,这点成本几乎可以忽略。

但今天的表已经发生了变化。尤其进入 Agent 时代后,数据规模的增长不再只体现在行数上,字段数量和结构复杂度也在快速上升

当文件目录开始拖慢查询

过去,宽表主要出现在 CDP 和用户画像场景。不同业务团队会不断增加新的标签和属性,例如用户等级、活跃度、消费偏好、风险评分、营销标签和推荐特征。几年积累下来,一张画像表很容易达到两三千列。

机器学习特征平台进一步推高了列数。除了普通业务字段,表中还会保存大量统计特征、时间窗口特征和高维向量。一个 768 维或 1024 维的 Embedding,再叠加其他训练特征,列数很快就会超过一千。

到了 Agent 时代,这个问题更加突出。一次 Agent 执行可能包含模型调用、工具调用、知识库检索、重试、评估和子 Agent 协作。为了排查失败、分析成本和评估效果,系统需要记录大量 Trace 和事件字段。不同 Agent、模型和工具又会持续引入新的 JSON 路径,使可观测数据快速演变成高维、稀疏的宽表。

排查一次失败时,查询可能只关心模型名称、工具耗时和错误类型,但底层文件中还保存着 Prompt、Token、检索结果和评估分数等大量字段。

Agent Trace 本质上也是一类不断变化的半结构化数据,通常以 JSON 形式保存。为了避免查询时反复解析整段 JSON,现代分析系统会按路径将字段拆成可以独立读取的子列。

例如,一段 JSON:

{
  "user": {
    "name": "Alice",
    "location": {
      "country": "FI",
      "city": "Helsinki"
    }
  },
  "device": {
    "os": "iOS",
    "version": "18"
  }
}

在存储层可能被拆成:

user.name
user.location.country
user.location.city
device.os
device.version

这些子列对应的是 JSON 路径,而不是每条记录都生成一套新列。但随着不同 Agent 和工具不断带来新路径,整体列数仍会持续增长。

当 JSON 结构足够复杂时,一个字段就可能展开成数百甚至数千个子列。对存储引擎来说,这些子列和普通列没有本质区别,每个子列都需要自己的位置、编码、统计信息和索引描述。

于是,原本几十 KB 的 Footer 开始增长到数 MB,甚至数十 MB。这时,前面的查询虽然只读取两列,真正的数据可能只有几十 KB,但系统在读取数据之前,却要先处理一个更大的 Footer 。

问题也随之出现。

大 Footer 会同时增加三类成本。首先,在对象存储上,它可能需要多次范围读取,拉长查询启动时间;其次,Thrift、Protobuf 等格式通常需要顺序解析,即使只查询两列,也可能经过大量无关元数据;最后,解析器还会为这些列创建内存对象。当一次查询同时打开大量文件或 Segment 时,I/O、CPU 和内存开销会被进一步放大。

于是,数据已经按需读取,元数据却仍然需要全量处理。这就是宽表场景中的元数据膨胀

Footer 为什么会快速膨胀

Footer 中保存的内容比“列在哪里”要复杂得多。它通常还包括 Schema、列类型、嵌套关系、编码方式、压缩方式、空值数量、最小值和最大值,以及 Bloom Filter、Zone Map、Bitmap 等索引的位置。

这些信息大多需要为每列保存一份。为了方便并行读取和跳过无关数据,列式文件通常还会把一批行划分成多个 Row Group,也就是数据分组。很多列级元数据会在每个 Row Group 中重复保存。如果有 C 列、R 个 Row Group,许多元数据项的数量会接近 C × R。列数和 Row Group 同时增长时,Footer 就会快速膨胀。

1-footer快速膨胀.png

因此,一旦列数增长到几千,行组增加到几十甚至上百,元数据就会迅速膨胀。

更麻烦的是,Footer 中的一些字段并不总是必要。例如,嵌套列的完整路径往往可以从 Schema 推导出来,却仍然会在每列中重复保存;此外,Footer 中还存在一些可推导或存储过宽的字段,例如,一些固定长度类型的统计信息仍使用通用二进制形式保存,某些偏移量也使用了超出实际需要的位宽。单个字段看起来不大,但乘上数千列和多个 Row Group 后,也会形成明显开销。

要让查询真正只为目标列付出元数据成本,思路无非有三种:让 Footer 更容易定位,把重型列元数据搬出 Footer,或者直接删掉其中不必要的内容。Parquet、Lance 和 Doris 的差别,主要在于三种方法的取舍,以及它们能改动到什么程度

三种格式,三种约束

2-三种对比表格.png

Parquet:兼容生态优先

Parquet 最大的约束不是技术,而是生态。Spark、Trino、DuckDB、ClickHouse、Iceberg 等系统都有自己的 Parquet Reader,因此 Footer 格式不能直接推倒重来。

任何 Footer 格式的调整,都必须保证已有 Reader 仍然能够正常读取文件。因此,最新的 FlatBuffer Footer 设计主要做了三件事:

  • 引入支持随机访问的 FlatBuffer 元数据,减少传统 Thrift 顺序解析带来的开销;
  • 裁剪冗余字段、压缩统计信息,将 OffsetIndex、ColumnIndex 等重量级元数据移出 Footer;
  • 新的 FlatBuffer 元数据不会直接替换原有 Thrift Footer,而是作为兼容扩展嵌入其中。旧 Reader 会自动忽略这部分内容,新 Reader 则可以直接利用新的元数据布局。

3-parquet.png

Parquet 的选择不是彻底重构,而是在保证新旧 Reader 兼容的前提下,尽可能降低集中式页脚的成本。

Lance:从文件结构重新设计

与 Parquet 不同,Lance 没有庞大的历史读取器生态,也不要求单个文件内部承载完整的数据库事务和索引体系。因此,它从文件结构开始重新设计:Lance Footer 只有约 40 字节,仅保存几个关键偏移信息,不再存放每列元数据。

每列元数据独立存储,查询时只读取目标列对应的 Metadata。Lance 也不再采用传统 Row Group,而是以 Page 作为读取单元,进一步避免元数据随着列数和数据分组同时增长。

4-lance.jpeg

这种设计很彻底,但前提是文件本身更像轻量列存容器,Schema 演进、事务和部分索引能力由上层负责。它适合 ML 和多模态数据,却不能直接照搬到完整 OLAP 引擎中,需要在其他层补回大量能力。

Doris Segment V3:保留 OLAP 能力的前提下改造

Doris 没有 Parquet 那样的开放生态兼容压力,但也不能像 Lance 一样把 Segment 简化成轻量容器。Segment 还需要承载主键更新、倒排索引、ANN Index 和 Variant 等完整 OLAP 能力。因此,设计重点不是重做一个最简单的文件格式,而是在控制改造范围的同时,把真正拖慢查询的部分移出去

5-doris segment v3.jpeg

第一步:将列元数据移出 Footer

Segment V2(Doris 4.1 版本之前) 中每列的 ColumnMetaPB 都直接内联在 Segment Footer 中,用于记录列位置、编码、统计信息和索引位置。

message SegmentFooterPB {
    optional uint32 version = 1;
    repeated ColumnMetaPB columns = 2;
    optional uint32 num_rows = 3;
}

普通表中问题不大,真正把问题放大的,是 Variant

Doris 会按照 JSON 路径自动拆分 Variant 字段,每个路径都会变成独立子列,也拥有自己的一份 ColumnMetaPB。一个复杂 JSON 展开之后,很容易生成几千个子列,也意味着 Footer 中需要保存几千份列元数据。

更麻烦的是,查询时,读取器不仅需要解析整个 Footer,还需要遍历所有子列路径,才能找到 user.name 对应的那一列。于是,Segment Footer 越来越大,路径查找也越来越慢。

SELECT properties['user']['name']
FROM events;

Segment V3 并没有推翻整个文件格式,第一步是把每列那份最重的详细说明从 Footer 中搬出去,只在 Footer 中留下轻量目录。这个新的独立区域被称为 Column Meta Region,简称 CMR,其中保存的就是各列的 ColumnMetaPB。Segment Footer 仅保留一个轻量级目录,记录每列元数据的位置和长度。文件布局变成:

┌──────────────────────────────────┐
│ Data + Index Pages               │   ← unchanged
├──────────────────────────────────┤
│ Column Meta Region (CMR)         │   ← V3 new: all columns' ColumnMetaPB concatenated
│   ColumnMetaPB(col_id=0) bytes   │
│   ColumnMetaPB(col_id=1) bytes   │
│   ...                            │
├──────────────────────────────────┤
│ SegmentFooterPB (serialized PB)  │
│   columns = []  // cleared       │
│   col_meta_region_start = ...    │
│   column_meta_entries = [...]    │
│   version = V3                   │
├──────────────────────────────────┤
│ uint32 footer size + crc32       │
│ "D0R1" magic                     │
└──────────────────────────────────┘

查询时,读取器先解析很小的 Footer,根据目录定位目标列,再通过一次范围读取加载对应的 ColumnMetaPB如果查询只涉及两列,就只解析两列。其余几千列的元数据不会进入内存,也不会产生对象创建和反序列化开销。相比 Segment V2,Segment V3 将主要列元数据的读取方式,从打开文件时全量解析改成了按需加载

相比 Lance 每列维护独立 Metadata,Doris 选择了连续 CMR 区域加目录的方式。这并不是技术上的限制,而是为了保留现有 Segment 布局以及各类 OLAP 功能,在改动最小的前提下获得最大的收益。

此外,Doris 使用稳定的 unique_id,而不是容易随 Schema 变化的列序号。即使表长期经历加列、删列和重命名,新旧 Segment 仍能正确对应同一个业务字段。这是 OLAP 场景长期 Schema 演进所必需的能力。

第二步:为 Variant 增加路径索引

把列元数据搬出 Footer 后,普通宽表的问题基本解决了,但 Variant 仍有一个瓶颈:一个 JSON 字段可能展开出数千条路径,而查询通常只访问其中一两条。为此,V3 从两个方面优化 Variant 元数据组织。

6-variant 增加索引.jpeg

V3 将不需要独立查询的稀疏子列和 DocValue 信息折叠回根列,并为常规路径建立有序索引。查询 variant['user']['name'] 时,系统先从路径索引定位目标子列,再从 CMR 中加载对应元数据,不再逐个扫描全部路径。该索引还可以用于前缀判断,例如快速确认某个 Segment 是否包含 user.location.*

按需加载会在首次访问时增加一次范围读取。Doris 通过 ColumnReaderCache、一次初始化和原有缓存通道摊薄这部分成本,热点列后续通常可以直接复用已构建的读取器。

极宽表下,V3 带来了什么变化

在 Apache Doris 4.x 文档给出的一组极端宽表测试中,共有 10000 个 Segment,每个 Segment 包含 7000 列。V2 的 Segment 打开时间为 65 秒,内存占用约 60 GB;V3 分别降至 4 秒和不足 1 GB

这组结果主要反映 Segment 打开和元数据加载阶段,不代表所有查询都能获得同样倍数的端到端提升。

7-对比性能.png

这种差异主要来自两种完全不同的读取路径。

  • 在 V2 中,打开 Segment 就需要读取完整页脚,解析 7000 列的 ColumnMetaPB,并为全部列创建内存对象。即使查询最终只访问少量列,这些工作也已经发生。
  • V3 打开 Segment 时只读取轻量目录,真正的列元数据在查询访问时再按需加载。如果查询只使用 5 列,其余 6995 列不会进入解析和对象创建流程。

当然,这是一组极端宽表测试。如果一张表只有几十列,也没有复杂 Variant,V2 Footer 本来就不大,V3 不会带来同等幅度的提升。Segment V3 主要解决的是数千列宽表、大量 Variant 子路径、Segment 数量多,以及文件打开阶段内存压力高的场景

不同约束下的不同选择

Parquet、Lance 和 Doris 看似都在缩小 Footer,实际面对的是不同约束。Parquet 首先要保证开放生态兼容,Lance 可以围绕轻量容器从头设计,而 Doris 必须保留完整 OLAP 能力和 Variant 原生查询。

因此,Parquet 主要优化 Footer 编码和兼容升级,Lance 将列元数据彻底拆分,Doris 则把 ColumnMetaPB 外置到 CMR,并为 Variant 增加路径索引。三者没有绝对优劣,只是在各自边界内选择了更合适的方案。

8-不同选择方案.png

体验 Segment V3

Segment V3 随 Apache Doris 4.1 发布,并成为新表的默认存储格式。如果你的工作负载包含数千列、复杂 Variant 路径,或者经常受到 Segment 打开延迟和内存占用影响,可以重点评估 Segment V3。

  • 参考文档Apache Doris 4.x · 存储格式,包含 CMR 区域布局、Variant 路径索引和 V2 兼容机制。
  • Apache Doris 4.1 下载:新表默认使用 Segment V3,窄表场景仍可通过 storage_format 使用 V2。
  • 体验 SelectDB:SelectDB 是基于 Apache Doris 打造的企业级实时数据仓库,提供更易用、更稳定的云服务与企业级能力