4、数据模型设计:Schema定义、Table配置与索引选择

数据模型设计,说白了就是决定数据怎么存、怎么查。Pinot 的灵活度很高,但灵活也意味着坑多。我刚开始用 Pinot 时,就因为在 Schema 里少配了一个字段类型,导致整个离线导入任务跑了两小时然后挂掉……嗯,从那以后我再也不敢轻视这一步了。

这一章我们聚焦三个核心问题:Schema 怎么定义、Table 怎么配(离线/实时)、索引怎么选。你想想看,这三件事决定了你的查询能不能跑进秒级。

4.1 Schema 定义:字段的“身份证”

Schema 就是 Pinot 对数据的理解方式。每个字段都要说清楚:叫什么、是什么类型、能不能做聚合、能不能做过滤。

核心原则:Schema 一旦上线,修改成本极高。尤其是维度字段的类型,改起来要重刷数据。所以,设计时多想一步。

4.1.1 字段类型与维度/度量划分

Pinot 的字段分三类:

类型 说明 常见例子
DIMENSION 维度字段,用于分组、过滤 用户ID、城市、商品类目
METRIC 度量字段,用于聚合计算 订单金额、点击次数
DATE_TIME 时间字段,用于时间范围查询 事件时间、下单时间

我个人习惯把 所有可能用于 WHERE 或 GROUP BY 的字段都设为 DIMENSION,哪怕它看起来像数字(比如年龄、星级)。因为 Pinot 对维度字段做了字典编码,压缩比高,查询也快。

小技巧:如果你不确定某个字段是 DIMENSION 还是 METRIC,就问自己一个问题——用户会不会拿它做 SUM 或 AVG?如果会,就是 METRIC;如果只是用来分组或过滤,就是 DIMENSION。

4.1.2 Schema 示例

下面是一个电商订单场景的 Schema 定义:

{
  "schemaName": "order_events",
  "dimensionFieldSpecs": [
    {"name": "order_id", "dataType": "STRING"},
    {"name": "user_id", "dataType": "STRING"},
    {"name": "city", "dataType": "STRING"},
    {"name": "category", "dataType": "STRING"},
    {"name": "payment_method", "dataType": "STRING"}
  ],
  "metricFieldSpecs": [
    {"name": "order_amount", "dataType": "DOUBLE"},
    {"name": "discount", "dataType": "DOUBLE"}
  ],
  "dateTimeFieldSpecs": [
    {
      "name": "event_time",
      "dataType": "LONG",
      "format": "1:MILLISECONDS:EPOCH",
      "granularity": "1:MILLISECONDS"
    }
  ]
}

注意 dateTimeFieldSpecs 里的 format 和 granularity。格式是 时间单位:时间格式:起点。我见过有人把 MILLISECONDS 写成 MILLISECOND,结果 Pinot 解析失败,查了半天……

4.2 Table 配置:离线与实时的“双面人生”

Table 配置是 Schema 的“运行环境”。你要告诉 Pinot:数据从哪来、怎么分区、怎么复制、用哪个索引。

Pinot 支持两种表类型:OFFLINE(离线批导入)和 REALTIME(实时流摄入)。

4.2.1 OFFLINE 表配置

离线表适合 T+1 的批量数据,比如昨天的订单汇总。配置要点:

  • tableType:固定为 OFFLINE
  • segmentsConfig:控制段的大小、时间阈值
  • routing:建议用 PARTITION 路由,减少跨节点扫描
{
  "tableName": "order_events_OFFLINE",
  "tableType": "OFFLINE",
  "segmentsConfig": {
    "replication": "2",
    "timeColumnName": "event_time",
    "timeType": "MILLISECONDS",
    "segmentPushType": "APPEND",
    "segmentAssignmentStrategy": "BalanceNumSegmentAssignmentStrategy"
  },
  "routing": {
    "routingTableBuilderName": "PartitionAwareRoutingTableBuilder"
  }
}

避坑提醒:我曾经把 segmentPushType 配成了 REFRESH,结果每次导入都覆盖全量数据,历史数据全丢了。如果你只是想追加新数据,一定要用 APPEND。

4.2.2 REALTIME 表配置

实时表用于流式数据,比如用户点击流、交易流水。配置比离线表复杂,多了 ingestionConfig 和 metadata 部分。

{
  "tableName": "order_events_REALTIME",
  "tableType": "REALTIME",
  "segmentsConfig": {
    "replication": "2",
    "timeColumnName": "event_time",
    "timeType": "MILLISECONDS",
    "segmentPushType": "APPEND",
    "realtime": {
      "realtimeSegmentVersion": "v3"
    }
  },
  "ingestionConfig": {
    "streamIngestionConfig": {
      "streamConfigMaps": [{
        "streamType": "kafka",
        "streamConfigs": {
          "topicName": "order_events_topic",
          "bootstrap.servers": "localhost:9092",
          "decoder.class.name": "org.apache.pinot.plugin.inputformat.json.JSONMessageDecoder"
        }
      }]
    }
  }
}

实时表的核心是 消费-索引-服务 的流水线。数据从 Kafka 拉下来,先写内存,再刷盘成段。这里有个关键参数:realtimeSegmentVersion,我建议用 v3,性能更好。

4.3 索引类型选择:查询加速的“三把刀”

Pinot 的索引不是越多越好。每个索引都有代价:存储、写入速度、内存占用。你想想看,如果一张表有 50 个字段,每个字段都建倒排索引,那存储可能比数据本身还大。

我总结了三类最常用的索引:

4.3.1 倒排索引(Inverted Index)

适合 高基数的等值过滤,比如按 user_id 查某个用户的所有订单。原理是维护一个“值→文档ID”的映射表。

  • 适用场景:WHERE user_id = 'abc123'
  • 优点:过滤极快,毫秒级返回
  • 缺点:存储开销大,尤其是字符串字段

我的经验:对于基数超过 100 万的维度字段,倒排索引几乎是必须的。但如果是性别这种只有 2-3 个值的字段,倒排索引反而浪费,因为 Pinot 的字典编码已经够快了。

4.3.2 Star-Tree 索引

Star-Tree 是 Pinot 的“杀手锏”。它预聚合了数据,把多维聚合查询的耗时从秒级降到毫秒级。

原理很简单:提前按你指定的维度组合算好 SUM、COUNT、AVG,查询时直接读结果,不用扫描原始数据。

{
  "indexingConfig": {
    "starTreeIndexConfigs": [{
      "dimensionsSplitOrder": ["city", "category"],
      "functionColumnPairs": ["SUM__order_amount", "COUNT__order_id"],
      "maxLeafRecords": 100
    }]
  }
}

这里要注意 dimensionsSplitOrder 的顺序。我建议把 过滤频率最高的维度放前面。比如你的查询经常按城市过滤,就把 city 放第一个。

避坑提醒:Star-Tree 不是万能的。如果你的查询维度组合变化很大(比如今天按城市+类目,明天按用户+时间),Star-Tree 的预聚合可能覆盖不全,反而浪费存储。我曾经在一个广告场景里给 20 个维度建了 Star-Tree,结果段大小膨胀了 5 倍,查询却没快多少……后来只保留了 Top-3 维度组合,效果才好起来。

4.3.3 文本索引(Text Index)

适合 全文搜索 场景,比如搜索商品名称、日志内容。Pinot 使用 Lucene 作为文本索引引擎。

  • 适用场景:WHERE text_match(description, '手机')
  • 优点:支持模糊匹配、分词搜索
  • 缺点:写入慢,索引构建开销大
{
  "fieldConfigList": [{
    "name": "description",
    "encodingType": "RAW",
    "indexType": "TEXT"
  }]
}

文本索引的字段必须用 RAW 编码,不能用字典编码。因为字典编码会把字符串映射成整数 ID,而文本索引需要原始字符串来做分词。

4.4 索引选择决策流程

为了帮你快速决策,我画了一张流程图:

索引选择决策流程 查询需求分析 是否高频等值过滤? 是 倒排索引 否 是否多维聚合查询? 是 Star-Tree 索引 否 是否全文搜索? 是 文本索引 否 默认字典编码即可 💡 提示:索引可以组合使用 例如:倒排索引 + Star-Tree 同时启用,过滤和聚合都能加速

4.5 综合建议

说了这么多,最后给几条实战建议:

  • 先跑查询,再建索引:不要一开始就建所有索引。先用默认配置跑一遍典型查询,看哪些字段是瓶颈,再针对性加索引。
  • 监控段大小:如果段文件膨胀超过预期,检查是不是索引建多了。我遇到过 Star-Tree 把段撑到 2GB 的情况,后来调整了 maxLeafRecords 才恢复正常。
  • 实时表慎用文本索引:文本索引的写入开销大,实时场景下容易造成消费延迟。如果必须用,建议把文本字段单独放在一个离线表里。

一句话总结:Schema 定义要严谨,Table 配置要匹配数据源,索引选择要按查询模式来。这三件事做好了,Pinot 的查询性能才能发挥到极致。