Khi nào bạn thực sự cần Apache Pinot?
Trước khi nói về cài đặt, mình muốn kể một câu chuyện thực tế. Năm ngoái mình phụ trách hệ thống analytics cho một e-commerce platform. Lúc đầu dùng MySQL thuần, query dashboard “doanh số theo ngày” chạy 3-5 giây — ổn. Nhưng khi data lên 50 triệu rows, con số đó thành 30-40 giây. User complaint liên tục, PM deadline sát, mình phải tìm giải pháp ngay.
Mình từng cân nhắc migrate database. Bản thân đã từng trải qua lần migrate 100GB từ MySQL sang PostgreSQL — mất 3 ngày planning và 1 ngày thực thi. Nhưng lần này PostgreSQL cũng không phải câu trả lời, vì bài toán cốt lõi là OLAP (Online Analytical Processing), không phải OLTP. Đó là lúc mình gặp Apache Pinot.
Apache Pinot là distributed OLAP datastore do LinkedIn phát triển và open-source năm 2015. LinkedIn, Uber, Walmart đang dùng nó cho user-facing analytics — những dashboard end user nhìn trực tiếp, không chấp nhận chờ quá 1 giây.
Sở dĩ Pinot nhanh hơn? Kiến trúc hoàn toàn khác:
- Columnar storage: chỉ đọc cột cần thiết thay vì cả row, tiết kiệm I/O đáng kể
- Star-tree index: pre-aggregate data theo dimension, GROUP BY query nhanh gấp 10-100x
- Real-time + batch ingestion: nhận data từ Kafka hoặc file CSV/JSON/Parquet
- Scale ngang: thêm server mà không cần downtime
Dashboard query 1 tỷ rows mà cần trả kết quả dưới 100ms? Đó đúng là bài toán Pinot được thiết kế để giải.
Cài đặt Apache Pinot
Cài đặt bằng Docker (khuyến nghị cho dev/test)
Cho dev/test local, Docker tiện hơn hẳn — không cần cài Java riêng, không cần config classpath. Giả sử Docker và Docker Compose đã có, tạo file 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
# Kiểm tra containers đã chạy
docker-compose ps
Đợi khoảng 30-60 giây để Controller khởi động hoàn toàn. Sau đó mở browser vào http://localhost:9000 — bạn sẽ thấy Pinot UI.
Cài đặt bằng binary (production)
# Tải 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
# Khởi động ZooKeeper
bin/pinot-admin.sh StartZookeeper -zkPort 2181 &
# Khởi động Controller
bin/pinot-admin.sh StartController \
-zkAddress localhost:2181 \
-controllerPort 9000 &
# Khởi động Broker
bin/pinot-admin.sh StartBroker -zkAddress localhost:2181 &
# Khởi động Server
bin/pinot-admin.sh StartServer -zkAddress localhost:2181 &
Cấu hình chi tiết
Pinot cần 3 thứ để chạy: Schema (cấu trúc data), Table Config (cấu hình storage và index), và Data Ingestion (cách nạp data vào). Dưới đây dùng dataset đơn hàng e-commerce — dễ hình dung, gần với thực tế.
Bước 1: Tạo Schema
Schema định nghĩa cấu trúc data với 3 loại column:
- dimensionFieldSpecs: để filter/group by (category, user_id…)
- metricFieldSpecs: để aggregate (revenue, count…)
- dateTimeFieldSpecs: cột thời gian — bắt buộc phải có
Tạo file 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
Bước 2: Tạo Table Config với Index
Index config là nơi Pinot khác biệt hoàn toàn so với relational DB. Cùng data, cùng query — nhưng index đúng thì 50ms, index sai thì 5 giây. Tạo file 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": {}
}
Ba loại index hay dùng nhất, mỗi loại phục vụ một kiểu query khác nhau:
- invertedIndexColumns: tìm exact match cực nhanh —
WHERE status = 'COMPLETED'chạy O(1) - starTreeIndexConfigs: pre-aggregate theo dimension trước, GROUP BY query nhanh gấp 10-100x so với scan thông thường
- rangeIndexColumns: range query nhanh —
WHERE revenue BETWEEN 500000 AND 2000000
curl -X POST \
"http://localhost:9000/tables" \
-H "Content-Type: application/json" \
-d @orders_table.json
Bước 3: Nạp dữ liệu (Batch Ingestion)
Tạo file CSV mẫu 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
Tạo ingestion job spec 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'
# Copy CSV vào đúng thư mục
mkdir -p /tmp/pinot-data/input
cp sample_orders.csv /tmp/pinot-data/input/
# Chạy ingestion
bin/pinot-admin.sh LaunchDataIngestionJob \
-jobSpecFile ingestion_job.yaml
Kiểm tra và Monitoring
Chạy thử query SQL
Pinot hỗ trợ SQL chuẩn. Mở Pinot UI tại http://localhost:9000, vào tab Query Console và thử:
-- Doanh thu theo category
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
Hoặc query qua 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"
}'
Response có field timeUsedMs — đây là query time thực tế. Star-tree index cấu hình đúng thì con số này thường dưới 50ms, dù data có hàng triệu rows.
Monitoring bằng 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
# Đo thực tế
result = query_pinot("""
SELECT
status,
COUNT(*) AS cnt,
SUM(revenue) AS total_revenue
FROM orders
GROUP BY status
ORDER BY cnt DESC
""")
# In kết quả
columns = result["resultTable"]["dataSchema"]["columnNames"]
for row in result["resultTable"]["rows"]:
print(dict(zip(columns, row)))
Kiểm tra cluster health
# Health check — trả về "OK" nếu cluster ổn
curl http://localhost:9000/health
# Danh sách tables đã tạo
curl http://localhost:9000/tables
# Thông tin segments của table orders
curl http://localhost:9000/segments/orders
# Metrics của broker (Prometheus format)
curl http://localhost:8099/metrics | grep pinot_broker
Nếu gặp lỗi, check log để debug:
# Docker
docker logs pinot-controller --tail 50
docker logs pinot-server --tail 50
# Binary
tail -f logs/pinot-controller.log
tail -f logs/pinot-server.log
Lỗi phổ biến nhất với người mới là quên cấu hình star-tree index. Không có nó, Pinot vẫn chạy — nhưng khi data lớn thì query time tăng vọt. Thấy chậm hơn kỳ vọng? Kiểm tra lại tableIndexConfig và thêm đúng cặp dimension/metric vào functionColumnPairs.
MySQL và PostgreSQL giỏi OLTP — transaction, update, delete theo từng row. Pinot giỏi OLAP — đọc nhanh trên data lớn, không bận tâm write throughput. Hai bài toán khác nhau, hai công cụ khác nhau. Với setup trên, bạn có thể đo thực tế ngay trên data của mình — trên cùng 50 triệu rows, chênh lệch giữa MySQL và Pinot thường là 30 giây xuống còn dưới 1 giây.

