Mastering Apache Pinot: Real-time OLAP for Sub-Second Analytics

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

The Challenge of Real-time Analytics at Scale

Building a real-time dashboard for a platform pushing 50,000 events per second is a nightmare for standard databases. Your stakeholders expect sales totals, top-performing SKUs, and engagement metrics to refresh instantly. I have seen many teams try to force MySQL, PostgreSQL, or MongoDB into this role. They usually hit a wall.

Once a table surpasses 100 million rows, a simple GROUP BY or COUNT(DISTINCT) query in a relational database slows to a crawl. Even with aggressive indexing, performance tanking is inevitable as data volume grows. Pre-aggregating data into summary tables is a common workaround. However, this tactic fails when users need to filter by arbitrary dimensions like specific GPS coordinates, device models, or custom time ranges.

Root Cause: Why Traditional Databases Fail at OLAP

The bottleneck rarely stems from underpowered hardware. Instead, the issue lies in the architecture. PostgreSQL and its peers are built for Online Transactional Processing (OLTP). They store data in rows. This is perfect for updating a single customer’s balance but inefficient for scanning 500 million rows to calculate an average price.

Apache Pinot fills this gap as a distributed, column-oriented OLAP data store. It uses specialized indexing—including Star-tree, Bloom filters, and Range indexes—to maintain sub-second query latency on petabyte-scale datasets. While engines like Presto or Trino query data where it lives (like S3), Pinot stores data in its own highly optimized format. This makes it the go-to choice for user-facing analytics where every millisecond counts.

Comparing the Alternatives

Before committing to Pinot, I evaluated several competitors. Each has a specific niche, but they differ significantly in how they handle live data streams.

Feature Apache Pinot ClickHouse Presto / Trino
Primary Use Case User-facing real-time apps Internal BI and log analysis Federated SQL across sources
Ingestion Latency < 1 second (True real-time) Seconds (Micro-batch) High (Source dependent)
Query Latency Sub-second (p99 < 200ms) Sub-second to seconds Seconds to minutes
Storage Strategy Advanced columnar indexing Columnar MergeTree Decoupled (S3, HDFS)

The Trade-offs of Apache Pinot

Strengths

  • Blazing Speed: It is built specifically for queries that must return in under 200ms to keep a UI responsive.
  • Native Kafka Integration: Pinot treats Kafka and Kinesis as first-class citizens, making real-time ingestion seamless.
  • Horizontal Elasticity: You can scale the cluster by adding more nodes to handle increased query concurrency or storage needs.
  • Star-Tree Indexing: This unique feature allows Pinot to pre-aggregate data while still allowing users to drill down into raw details.

Weaknesses

  • Operational Complexity: Managing a cluster requires coordinating Controllers, Brokers, Servers, and Zookeeper instances.
  • Immutable Data: Pinot is designed for append-only streams. Running an UPDATE or DELETE on a specific row is not supported in the traditional sense.
  • Strict Schemas: If you need to change a data type, you often have to re-ingest your data segments.

A Practical Development Setup

Production environments usually run on Kubernetes, but Docker Compose is the fastest way to test local integrations. A functional Pinot cluster consists of several moving parts. The Controller manages the cluster state, while the Broker routes queries. The Server does the heavy lifting of storing data and executing scans. Finally, Zookeeper keeps all these components in sync.

Step-by-Step Integration with Apache Kafka

Let’s build a pipeline that streams events from Kafka directly into Pinot for immediate querying.

1. Launch the Infrastructure

Use this docker-compose.yml to spin up Pinot and Kafka together. This configuration provides a sandbox for real-time testing.

version: '3.7'
services:
  zookeeper:
    image: zookeeper:3.8
    ports:
      - "2181:2181"
  pinot-controller:
    image: apachepinot/pinot:latest
    command: "StartController -zkAddress zookeeper:2181"
    ports:
      - "9000:9000"
    depends_on:
      - zookeeper
  pinot-broker:
    image: apachepinot/pinot:latest
    command: "StartBroker -zkAddress zookeeper:2181"
    ports:
      - "8099:8099"
    depends_on:
      - pinot-controller
  pinot-server:
    image: apachepinot/pinot:latest
    command: "StartServer -zkAddress zookeeper:2181"
    depends_on:
      - pinot-broker
  kafka:
    image: bitnami/kafka:latest
    environment:
      - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
      - ALLOW_PLAINTEXT_LISTENER=yes
    ports:
      - "9092:9092"
    depends_on:
      - zookeeper

2. Generate a Data Stream

Create a topic named orders-topic. We will push a JSON message to simulate a live purchase event.

# Create the topic
docker exec -it kafka /opt/bitnami/kafka/bin/kafka-topics.sh --create --topic orders-topic --bootstrap-server localhost:9092

# Push a sample order
echo '{"order_id": 101, "product_name": "Mechanical Keyboard", "price": 150.00, "timestamp": 1672531200000}' | \
docker exec -i kafka /opt/bitnami/kafka/bin/kafka-console-producer.sh --topic orders-topic --bootstrap-server localhost:9092

3. Define the Schema

Pinot requires a schema to understand your data types. Save this as orders_schema.json. We distinguish between dimensions for filtering and metrics for calculations.

{
  "schemaName": "orders",
  "dimensionFieldSpecs": [
    {"name": "order_id", "dataType": "LONG"},
    {"name": "product_name", "dataType": "STRING"}
  ],
  "metricFieldSpecs": [
    {"name": "price", "dataType": "DOUBLE"}
  ],
  "dateTimeFieldSpecs": [{
    "name": "timestamp",
    "dataType": "LONG",
    "format": "1:MILLISECONDS:EPOCH",
    "granularity": "1:MILLISECONDS"
  }]
}

4. Configure the Real-time Table

The table configuration tells Pinot where to find the Kafka stream. Save this as orders_table.json. Note the streamConfigs section which points to our Kafka broker.

{
  "tableName": "orders",
  "tableType": "REALTIME",
  "segmentsConfig": {
    "timeColumnName": "timestamp",
    "schemaName": "orders",
    "replication": "1"
  },
  "tableIndexConfig": {
    "loadMode": "MMAP",
    "streamConfigs": {
      "streamType": "kafka",
      "stream.kafka.consumer.type": "lowlevel",
      "stream.kafka.topic.name": "orders-topic",
      "stream.kafka.decoder.class.name": "org.apache.pinot.plugin.inputformat.json.JSONMessageDecoder",
      "stream.kafka.consumer.prop.auto.offset.reset": "smallest",
      "stream.kafka.broker.list": "kafka:9092"
    }
  }
}

5. Apply the Configuration

Submit these files to the Pinot Controller to start the ingestion process:

docker exec -it pinot-controller /opt/pinot/bin/pinot-admin.sh AddTable \
  -schemaFile /path/to/orders_schema.json \
  -tableConfigFile /path/to/orders_table.json \
  -exec

Testing the Performance

Open the Pinot Query Console at http://localhost:9000. You can now execute standard SQL against the live stream. Try calculating revenue by product:

SELECT 
    product_name, 
    COUNT(*), 
    SUM(price) 
FROM orders 
GROUP BY product_name 
ORDER BY SUM(price) DESC

The results update almost the instant you push a new message to Kafka. In my production deployments, the lag between a user clicking “buy” and the data appearing in Pinot is consistently under 500ms.

The Verdict

Moving from a traditional RDBMS to Apache Pinot is a significant shift in how you handle data. However, it is a necessary move when your growth outpaces your database’s ability to aggregate. By offloading analytical heavy lifting to Pinot, you keep your transactional databases lean. If your project involves fraud detection, live leaderboards, or real-time monitoring, Pinot is an essential tool for your stack.

Share: