Apache Pinot: Hướng dẫn cài đặt OLAP database thời gian thực cho analytics dashboard dưới 100ms

Database tutorial - IT technology blog
Database tutorial - IT technology blog

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.

Share: