Apache Pinotが本当に必要なのはどんな時か?
インストールの話をする前に、実際の体験談を一つ紹介したい。昨年、私はあるECプラットフォームのアナリティクスシステムを担当していた。最初はMySQL単体を使っていて、「日別売上」ダッシュボードのクエリは3〜5秒で問題なかった。ところがデータが5000万行を超えた頃、その数字が30〜40秒になってしまった。ユーザーからのクレームは絶えず、PMからはデッドライン通告が来て、早急に解決策を見つける必要があった。
データベースの移行も検討した。以前、MySQLからPostgreSQLへの100GBのマイグレーションを経験したことがあり、計画に3日、実行に1日かかった。ただ今回はPostgreSQLも解決策にはならなかった。問題の本質はOLAP(オンライン分析処理)であり、OLTPではないからだ。そこで出会ったのがApache Pinotだった。
Apache Pinotは、LinkedInが開発し2015年にオープンソース化した分散OLAPデータストアだ。LinkedIn、Uber、Walmartがユーザー向けアナリティクス——エンドユーザーが直接参照するダッシュボードで1秒以上の待機が許されない場面——に利用している。
なぜPinotは速いのか?アーキテクチャが根本的に異なるからだ:
- カラム型ストレージ:行全体ではなく必要な列だけを読み込むため、I/Oを大幅に削減
- Star-treeインデックス:ディメンションごとにデータを事前集計し、GROUP BYクエリを10〜100倍高速化
- リアルタイム+バッチインジェスト:KafkaやCSV/JSON/Parquetファイルからデータを取り込み可能
- 水平スケーリング:ダウンタイムなしでサーバーを追加可能
10億行のデータに対して100ms以下でクエリ結果を返すダッシュボード?それこそまさにPinotが解くために設計された問題だ。
Apache Pinotのインストール
Dockerでインストール(開発・テスト環境に推奨)
ローカルの開発・テスト環境では、Dockerの方がはるかに手軽だ——Javaを別途インストールする必要がなく、classpathの設定も不要。DockerとDocker Composeがすでに用意されている前提で、docker-compose.ymlファイルを作成する:
version: '3.7'
services:
zookeeper:
image: zookeeper:3.5.6
hostname: zookeeper
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
pinot-controller:
image: apachepinot/pinot:1.2.0
command: "StartController -zkAddress zookeeper:2181"
restart: unless-stopped
ports:
- "9000:9000"
depends_on:
- zookeeper
pinot-broker:
image: apachepinot/pinot:1.2.0
command: "StartBroker -zkAddress zookeeper:2181"
restart: unless-stopped
ports:
- "8099:8099"
depends_on:
- pinot-controller
pinot-server:
image: apachepinot/pinot:1.2.0
command: "StartServer -zkAddress zookeeper:2181"
restart: unless-stopped
ports:
- "8098:8098"
depends_on:
- pinot-controller
docker-compose up -d
# コンテナの起動状態を確認
docker-compose ps
Controllerが完全に起動するまで30〜60秒ほど待つ。その後ブラウザでhttp://localhost:9000を開くと、Pinot UIが表示される。
バイナリでインストール(本番環境)
# Pinot 1.2.0 をダウンロード
wget https://downloads.apache.org/pinot/apache-pinot-1.2.0/apache-pinot-1.2.0-bin.tar.gz
tar -xvf apache-pinot-1.2.0-bin.tar.gz
cd apache-pinot-1.2.0-bin
# ZooKeeperを起動
bin/pinot-admin.sh StartZookeeper -zkPort 2181 &
# Controllerを起動
bin/pinot-admin.sh StartController \
-zkAddress localhost:2181 \
-controllerPort 9000 &
# Brokerを起動
bin/pinot-admin.sh StartBroker -zkAddress localhost:2181 &
# Serverを起動
bin/pinot-admin.sh StartServer -zkAddress localhost:2181 &
詳細設定
Pinotを動かすには3つのものが必要だ:Schema(データ構造)、Table Config(ストレージとインデックスの設定)、そしてData Ingestion(データの取り込み方法)。以下ではECサイトの注文データセットを使って説明する——イメージしやすく、実際のユースケースに近い内容だ。
ステップ1:スキーマの作成
スキーマは3種類のカラムでデータ構造を定義する:
- dimensionFieldSpecs:フィルタリング/グルーピングに使用(category、user_idなど)
- metricFieldSpecs:集計に使用(revenue、countなど)
- dateTimeFieldSpecs:時刻カラム——必須項目
orders_schema.jsonファイルを作成する:
{
"schemaName": "orders",
"dimensionFieldSpecs": [
{"name": "order_id", "dataType": "STRING"},
{"name": "user_id", "dataType": "STRING"},
{"name": "product_category", "dataType": "STRING"},
{"name": "status", "dataType": "STRING"},
{"name": "payment_method", "dataType": "STRING"}
],
"metricFieldSpecs": [
{"name": "revenue", "dataType": "DOUBLE"},
{"name": "quantity", "dataType": "INT"},
{"name": "discount", "dataType": "DOUBLE"}
],
"dateTimeFieldSpecs": [
{
"name": "order_timestamp",
"dataType": "TIMESTAMP",
"format": "1:MILLISECONDS:EPOCH",
"granularity": "1:MILLISECONDS"
}
]
}
curl -X POST \
"http://localhost:9000/schemas" \
-H "Content-Type: application/json" \
-d @orders_schema.json
ステップ2:インデックス付きテーブル設定の作成
インデックス設定こそ、Pinotがリレーショナルデータベースと根本的に異なる点だ。同じデータ、同じクエリでも——インデックスが適切なら50ms、不適切なら5秒になる。orders_table.jsonファイルを作成する:
{
"tableName": "orders",
"tableType": "OFFLINE",
"segmentsConfig": {
"timeColumnName": "order_timestamp",
"timeType": "MILLISECONDS",
"replication": "1",
"schemaName": "orders"
},
"tableIndexConfig": {
"loadMode": "MMAP",
"invertedIndexColumns": ["product_category", "status", "payment_method"],
"starTreeIndexConfigs": [
{
"dimensionsSplitOrder": ["product_category", "status"],
"skipStarNodeCreationForDimensions": [],
"functionColumnPairs": ["SUM__revenue", "COUNT__*"],
"maxLeafRecords": 10000
}
],
"rangeIndexColumns": ["revenue", "order_timestamp"]
},
"tenants": {
"broker": "DefaultTenant",
"server": "DefaultTenant"
},
"metadata": {}
}
最もよく使われる3種類のインデックスで、それぞれ異なるクエリパターンに対応する:
- invertedIndexColumns:完全一致の検索が非常に高速——
WHERE status = 'COMPLETED'はO(1)で実行 - starTreeIndexConfigs:ディメンションごとに事前集計を行い、GROUP BYクエリを通常のスキャンと比べて10〜100倍高速化
- rangeIndexColumns:範囲クエリを高速化——
WHERE revenue BETWEEN 500000 AND 2000000
curl -X POST \
"http://localhost:9000/tables" \
-H "Content-Type: application/json" \
-d @orders_table.json
ステップ3:データの取り込み(バッチインジェスト)
サンプルCSVファイルsample_orders.csvを作成する:
order_id,user_id,product_category,status,payment_method,revenue,quantity,discount,order_timestamp
ORD001,USER100,Electronics,COMPLETED,CREDIT_CARD,1500000,1,0,1720310400000
ORD002,USER101,Fashion,COMPLETED,BANK_TRANSFER,350000,2,50000,1720310460000
ORD003,USER102,Electronics,PENDING,MOMO,2200000,1,100000,1720310520000
ORD004,USER103,Food,COMPLETED,CASH,85000,3,0,1720310580000
インジェストジョブの仕様ファイルingestion_job.yamlを作成する:
executionFrameworkSpec:
name: 'standalone'
segmentGenerationJobRunnerClassName: 'org.apache.pinot.plugin.ingestion.batch.standalone.SegmentGenerationJobRunner'
segmentTarPushJobRunnerClassName: 'org.apache.pinot.plugin.ingestion.batch.standalone.SegmentTarPushJobRunner'
jobType: SegmentCreationAndTarPush
inputDirURI: '/tmp/pinot-data/input'
outputDirURI: '/tmp/pinot-data/output'
overwriteOutput: true
pinotFSSpecs:
- scheme: file
className: org.apache.pinot.spi.filesystem.LocalPinotFS
recordReaderSpec:
dataFormat: 'csv'
className: 'org.apache.pinot.plugin.inputformat.csv.CSVRecordReader'
configClassName: 'org.apache.pinot.plugin.inputformat.csv.CSVRecordReaderConfig'
tableSpec:
tableName: 'orders'
schemaURI: 'http://localhost:9000/schemas/orders'
tableConfigURI: 'http://localhost:9000/tables/orders'
pinotClusterSpecs:
- controllerURI: 'http://localhost:9000'
# CSVを正しいディレクトリにコピー
mkdir -p /tmp/pinot-data/input
cp sample_orders.csv /tmp/pinot-data/input/
# インジェストを実行
bin/pinot-admin.sh LaunchDataIngestionJob \
-jobSpecFile ingestion_job.yaml
動作確認とモニタリング
SQLクエリのテスト実行
PinotはSQL標準に対応している。http://localhost:9000でPinot UIを開き、Query Consoleタブで以下を試してみよう:
-- カテゴリ別売上
SELECT
product_category,
SUM(revenue) AS total_revenue,
COUNT(*) AS order_count,
AVG(revenue) AS avg_order_value
FROM orders
WHERE status = 'COMPLETED'
GROUP BY product_category
ORDER BY total_revenue DESC
LIMIT 10
REST API経由でクエリする場合:
curl -X POST \
"http://localhost:8099/query/sql" \
-H "Content-Type: application/json" \
-d '{
"sql": "SELECT product_category, SUM(revenue) as total FROM orders GROUP BY product_category LIMIT 10"
}'
レスポンスにはtimeUsedMsフィールドが含まれており、これが実際のクエリ時間だ。Star-treeインデックスが正しく設定されていれば、数百万行のデータでもこの値は通常50ms以下になる。
Pythonによるモニタリング
import requests
def query_pinot(sql: str, broker_url: str = "http://localhost:8099") -> dict:
response = requests.post(
f"{broker_url}/query/sql",
headers={"Content-Type": "application/json"},
json={"sql": sql},
timeout=10
)
result = response.json()
time_ms = result.get("timeUsedMs", 0)
rows = result.get("resultTable", {}).get("rows", [])
print(f"Query time: {time_ms}ms | Rows returned: {len(rows)}")
return result
# 実際に計測する
result = query_pinot("""
SELECT
status,
COUNT(*) AS cnt,
SUM(revenue) AS total_revenue
FROM orders
GROUP BY status
ORDER BY cnt DESC
""")
# 結果を表示
columns = result["resultTable"]["dataSchema"]["columnNames"]
for row in result["resultTable"]["rows"]:
print(dict(zip(columns, row)))
クラスターヘルスの確認
# ヘルスチェック — クラスターが正常なら "OK" を返す
curl http://localhost:9000/health
# 作成済みテーブルの一覧
curl http://localhost:9000/tables
# ordersテーブルのセグメント情報
curl http://localhost:9000/segments/orders
# ブローカーのメトリクス(Prometheusフォーマット)
curl http://localhost:8099/metrics | grep pinot_broker
エラーが発生した場合は、ログを確認してデバッグする:
# Docker
docker logs pinot-controller --tail 50
docker logs pinot-server --tail 50
# バイナリ
tail -f logs/pinot-controller.log
tail -f logs/pinot-server.log
初心者が最もよく陥るミスは、star-treeインデックスの設定を忘れることだ。なくてもPinotは動くが、データ量が増えるとクエリ時間が急増する。期待より遅いと感じたら、tableIndexConfigを見直し、functionColumnPairsに適切なdimension/metricのペアを追加しよう。
MySQLとPostgreSQLはOLTPが得意——トランザクション、行単位のupdate・deleteなど。PinotはOLAPが得意——大規模データへの高速読み取りで、書き込みスループットは重視しない。問題が異なれば、使うツールも異なる。今回の設定で、自分のデータを実際に試してみてほしい——同じ5000万行でも、MySQLとPinotの差は30秒から1秒以下になることが多い。

