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 索引选择决策流程
为了帮你快速决策,我画了一张流程图:
4.5 综合建议
说了这么多,最后给几条实战建议:
- 先跑查询,再建索引:不要一开始就建所有索引。先用默认配置跑一遍典型查询,看哪些字段是瓶颈,再针对性加索引。
- 监控段大小:如果段文件膨胀超过预期,检查是不是索引建多了。我遇到过 Star-Tree 把段撑到 2GB 的情况,后来调整了
maxLeafRecords才恢复正常。 - 实时表慎用文本索引:文本索引的写入开销大,实时场景下容易造成消费延迟。如果必须用,建议把文本字段单独放在一个离线表里。
一句话总结:Schema 定义要严谨,Table 配置要匹配数据源,索引选择要按查询模式来。这三件事做好了,Pinot 的查询性能才能发挥到极致。