3. 数据模型与表结构

好,咱们今天聊点实在的。Pinot 里最核心的三个概念——Table、Schema、Segment,说白了就是「数据怎么存、长什么样、怎么切」。我刚开始接触 Pinot 时,也绕了不少弯路,尤其是离线表和实时表的设计差异,踩过坑才知道疼。

先给你吃颗定心丸:这三个概念搞明白了,后面查询优化、运维调优都会顺很多。咱们一个一个拆。

Table:逻辑上的数据容器

Table 在 Pinot 里是个逻辑概念。它不存数据,只负责把 Schema 和 Segment 组织起来。你可以把它理解成一个「数据目录」——告诉系统数据从哪来、怎么查。

每个 Table 必须绑定一个 Schema。没有 Schema 的 Table,就像没有菜谱的厨房,没法干活。

Table 分两种:离线表(Offline Table)和实时表(Realtime Table)。还有一种混合表(Hybrid Table),其实是离线表和实时表的组合,底层还是两个独立的 Table。

关键点:Table 不存数据,只存元数据。数据全在 Segment 里。

Schema:数据的「身份证」

Schema 定义了字段名、类型、是否索引、是否可空。我习惯把 Schema 比作数据库的 DDL,但比 DDL 更轻量。

一个典型的 Schema 长这样:

{
  "schemaName": "user_events",
  "dimensionFieldSpecs": [
    {"name": "user_id", "dataType": "STRING"},
    {"name": "event_type", "dataType": "STRING"},
    {"name": "city", "dataType": "STRING"}
  ],
  "metricFieldSpecs": [
    {"name": "duration", "dataType": "DOUBLE"},
    {"name": "revenue", "dataType": "DOUBLE"}
  ],
  "timeFieldSpec": {
    "incomingGranularity": "MILLISECONDS",
    "name": "event_ts",
    "dataType": "LONG",
    "timeType": "MILLISECONDS"
  }
}

注意几个细节:

  • 维度字段(Dimension):用于过滤、分组、排序。我建议把高基数字段(如 user_id)设为维度,但要注意内存占用。
  • 度量字段(Metric):用于聚合计算。Pinot 对度量字段做了预聚合优化,查询时直接拿结果,不用扫全量数据。
  • 时间字段(Time):每个 Table 必须有且只有一个时间字段。这是 Pinot 做分区、淘汰、时间范围查询的基础。

我的经验:时间字段的粒度别选太细。秒级够用了,毫秒级会浪费存储。我曾经有个表用了纳秒级时间戳,Segment 数量暴涨,查询性能反而下降。

Segment:数据的最小存储单元

Segment 是 Pinot 真正存数据的地方。每个 Segment 是一个不可变的数据文件,包含索引、字典、数据块。

你可以把 Segment 想象成「数据切片」——一张大表被切成若干小段,每段独立存储、独立索引、独立查询。

Segment 的内部结构大致如下:

Segment
├── 元数据(Metadata)
│   ├── Schema 快照
│   ├── 列统计信息(min/max/null count)
│   └── 索引信息
├── 字典(Dictionary)
│   ├── 正向索引(Forward Index)
│   └── 倒排索引(Inverted Index)
├── 数据块(Data Blocks)
│   ├── 列式存储(Columnar Store)
│   └── 行式存储(Row Store,用于 Star-Tree)
└── 索引文件
    ├── 范围索引(Range Index)
    ├── 文本索引(Text Index)
    └── 地理索引(Geospatial Index)

Segment 的不可变性很重要。离线表的数据一次性写入,生成 Segment 后不再修改。实时表的数据先写入内存中的 Mutable Segment,达到阈值后「翻转」成 Immutable Segment。

注意:Segment 数量不是越多越好。太多 Segment 会导致查询时合并开销大。我见过有人把 Segment 数搞到几千个,查询延迟直接飙到秒级。合理控制 Segment 大小在 100MB~500MB 之间比较稳妥。

离线表 vs 实时表:设计差异

离线表和实时表的核心区别在于:数据写入方式和数据可见性。

对比维度 离线表 实时表
数据来源 批量导入(HDFS、S3、本地文件) 流式写入(Kafka、Pulsar)
Segment 生成 外部生成,一次性推送 内部生成,自动翻转
数据可见性 导入完成后立即可见 有延迟(通常几秒到几十秒)
数据更新 全量覆盖或增量追加 仅追加,不支持更新
存储成本 较低(批量压缩) 较高(需要内存缓冲)
典型场景 历史数据、T+1 报表 实时监控、在线分析

离线表的设计思路是「批处理」。数据先在外面处理好,生成 Segment 后推给 Pinot。这种方式适合数据量大、对实时性要求不高的场景。

实时表的设计思路是「流处理」。数据从 Kafka 直接流入 Pinot,先写内存,再落盘。这种方式适合需要秒级数据可见性的场景。

我举个例子:

  • 离线表:每天凌晨跑 Spark 任务,把昨天的用户行为数据生成 Segment,推给 Pinot。查询时能看到昨天的全量数据。
  • 实时表:用户点击事件实时写入 Kafka,Pinot 消费后几秒内就能查到。适合做实时大屏、异常检测。

混合表:离线+实时的组合拳

混合表不是新概念,而是把离线表和实时表「拼」在一起。查询时自动合并两个表的结果。

为什么需要混合表?

  • 离线表数据全,但更新慢(T+1)。
  • 实时表数据新,但只保留最近几小时或几天。

混合表让查询既能拿到历史全量数据,又能看到最新数据。我做过一个电商大屏项目,就是用混合表——离线表存过去 30 天的订单,实时表存当天的订单,查询时自动合并。

设计要点:混合表的时间字段必须一致。离线表和实时表的时间范围不能重叠,否则数据会重复。我建议离线表覆盖「昨天及之前」,实时表覆盖「今天及之后」。

SVG 图:Table、Schema、Segment 关系

下面这张图帮你理清三个概念的关系:

Table、Schema、Segment 关系图 Table(逻辑容器) Schema(数据定义) Segment(数据单元) 绑定 包含 Segment 内部结构 元数据(Schema 快照 + 统计信息) 字典 + 正向/倒排索引 列式数据块 + 索引文件 说明:Table 是逻辑容器,绑定 Schema 定义数据结构, 包含多个 Segment 存储实际数据。Segment 内部有元数据、索引、数据块。

离线表与实时表的设计差异:深入一点

上面表格列了基本差异,我再补充几个实战中容易忽略的点。

1. Segment 生成策略

离线表的 Segment 在外部生成。你可以用 Spark、MapReduce 或 Pinot 自带的工具。生成时可以做全局排序、预聚合、建索引。我习惯在生成阶段就把数据按时间分区,这样查询时能快速裁剪。

实时表的 Segment 在 Pinot 内部自动生成。数据先写入 Mutable Segment(内存),达到以下任一条件时翻转:

  • 行数达到阈值(默认 5,000,000 行)
  • 时间达到阈值(默认 1 小时)
  • 内存达到阈值(默认 500MB)

翻转后生成 Immutable Segment,落盘到文件系统。这个过程对查询是透明的,但翻转期间会有短暂的资源消耗。

避坑指南:我曾经把实时表的翻转阈值设得太小(100 万行),结果 Segment 数量暴增,查询时合并开销巨大。后来调到 500 万行,性能好了很多。但也不能太大,否则内存压力大。建议根据数据量和查询模式做压测。

2. 数据更新与删除

离线表支持全量覆盖和增量追加。全量覆盖就是重新生成 Segment 替换旧的。增量追加是追加新 Segment,旧数据不变。

实时表只支持追加,不支持更新和删除。如果你需要更新实时数据,得用 Upsert 表(Pinot 0.11 之后支持)。Upsert 表通过主键去重,但性能开销较大,不建议高频使用。

3. 存储与计算资源

离线表对存储友好。数据批量压缩,Segment 大小可控。查询时直接从文件系统读取,不占用内存。

实时表需要内存缓冲。每个实时表至少有一个 Mutable Segment 在内存中。如果实时表很多,内存消耗会很大。我建议实时表数量控制在 10 个以内,每个表的分区数不要超过 8。

总结一下

Table 是逻辑容器,Schema 是数据结构定义,Segment 是物理存储单元。离线表适合历史数据,实时表适合流式数据。混合表是两者的组合,适合需要「历史+实时」的场景。

设计表结构时,先想清楚数据来源和查询需求。离线表注重存储效率和查询性能,实时表注重写入速度和数据可见性。没有银弹,只有权衡。