Real-Time Analytics Engines

Apache Druid, Pinot, and ClickHouse for sub-second analytics on streaming data

The Real-Time Analytics Gap

Traditional data warehouses (Redshift, Snowflake, BigQuery) are optimized for batch analytics, loading data hourly or daily, then running complex queries. But what if you need to query data that arrived milliseconds ago? What if your dashboard needs to show metrics updating in real-time, not 30 minutes later?

This is where real-time analytics engines come in. Apache Druid, Apache Pinot, and ClickHouse are purpose-built for streaming ingestion and sub-second queries on massive datasets. They sit between stream processing (Kafka, Flink) and traditional OLAP warehouses, enabling use cases like real-time dashboards, anomaly detection, user-facing analytics, and operational monitoring.

Real-Time OLAP: Druid vs Pinot vs ClickHouse

All three engines are columnar databases optimized for aggregation queries on time-series data, but they have different strengths and architectures.

Real-Time Analytics Stack:

Stream Sources                Real-Time OLAP               Visualization
┌─────────────┐              ┌──────────────┐             ┌─────────────┐
│   Kafka     │─────────────>│ Apache Druid │────────────>│  Grafana    │
│  (events)   │              │  • Ingestion │             │ (dashboard) │
└─────────────┘              │    < 1 sec   │             └─────────────┘
                             │  • Queries   │
┌─────────────┐              │    < 100 ms  │             ┌─────────────┐
│   Kinesis   │─────────────>│              │────────────>│  Superset   │
│  (logs)     │              └──────────────┘             │ (BI tool)   │
└─────────────┘                                           └─────────────┘
                             ┌──────────────┐
┌─────────────┐              │ Apache Pinot │             ┌─────────────┐
│   Pulsar    │─────────────>│  • Ultra-low │────────────>│ User-Facing │
│  (metrics)  │              │    latency   │             │  Analytics  │
└─────────────┘              │  • LinkedIn  │             └─────────────┘
                             └──────────────┘

                             ┌──────────────┐
┌─────────────┐              │ ClickHouse   │             ┌─────────────┐
│  Database   │─────────────>│  • Fastest   │────────────>│  Metabase   │
│  (CDC)      │              │    single    │             │  (reports)  │
└─────────────┘              │    queries   │             └─────────────┘
                             └──────────────┘


Key Characteristics:
                  Druid              Pinot              ClickHouse
Ingestion:      Real-time          Real-time          Real-time + batch
Query latency:  50-500 ms          10-100 ms          1-50 ms (single node)
Scalability:    100s of nodes      1000s of nodes     10s-100s of nodes
Best for:       Dashboards         User-facing        Log analytics
Origin:         Metamarkets        LinkedIn           Yandex
License:        Apache 2.0         Apache 2.0         Apache 2.0
Real-time OLAP engines bridge the gap between streaming and traditional batch analytics

Apache Druid: Real-Time Dashboards at Scale

Apache Druid is a columnar, distributed, real-time analytics database designed for fast aggregations on event streams. It combines the best of data warehouses (SQL, aggregations) and time-series databases (high-cardinality dimensions, fast filtering).

Druid Architecture
  • Coordinator: Manages data availability
  • Overlord: Controls ingestion
  • Broker: Routes queries
  • Historical: Stores segments
  • MiddleManager: Ingests data
Key Features
  • Sub-second query latency
  • Real-time + batch ingestion
  • Automatic rollup/pre-aggregation
  • Approximate algorithms (HyperLogLog)
  • Native Kafka integration
Best Use Cases
  • Real-time dashboards
  • Clickstream analytics
  • Network telemetry
  • Digital marketing analytics
  • IoT sensor data

Ingesting Data into Druid from Kafka

# ============ STEP 1: Install pydruid ============
# pip install pydruid

from pydruid.db import connect
import json
import requests

druid_broker = "http://localhost:8888"

# ============ STEP 2: Create Kafka ingestion spec ============
ingestion_spec = {
    "type": "kafka",
    "spec": {
        "dataSchema": {
            "dataSource": "pageviews",
            "timestampSpec": {
                "column": "timestamp",
                "format": "iso"
            },
            "dimensionsSpec": {
                "dimensions": [
                    "user_id",
                    "page_url",
                    "referrer",
                    "device_type",
                    "country"
                ]
            },
            "metricsSpec": [
                {"type": "count", "name": "count"},
                {"type": "longSum", "name": "page_load_time", "fieldName": "page_load_time"},
                {"type": "hyperUnique", "name": "unique_users", "fieldName": "user_id"}
            ],
            "granularitySpec": {
                "type": "uniform",
                "segmentGranularity": "hour",
                "queryGranularity": "minute",
                "rollup": True  # Pre-aggregate at minute level
            }
        },
        "ioConfig": {
            "topic": "pageviews",
            "consumerProperties": {
                "bootstrap.servers": "localhost:9092"
            },
            "taskCount": 4,  # Parallelism
            "replicas": 1,
            "taskDuration": "PT1H",  # Create new task every hour
            "useEarliestOffset": False
        },
        "tuningConfig": {
            "type": "kafka",
            "maxRowsPerSegment": 5000000
        }
    }
}

# Submit ingestion task
response = requests.post(
    f"{druid_broker}/druid/indexer/v1/supervisor",
    json=ingestion_spec,
    headers={"Content-Type": "application/json"}
)

print(f"✓ Kafka ingestion started: {response.json()}")

"""
Output:
✓ Kafka ingestion started: {'id': 'pageviews'}
Data now streaming from Kafka → Druid with < 1 second latency
"""


# ============ STEP 3: Query Druid with SQL ============
conn = connect(host='localhost', port=8888, path='/druid/v2/sql/', scheme='http')
cursor = conn.cursor()

# Query 1: Real-time pageview counts by country (last 5 minutes)
cursor.execute("""
    SELECT
        country,
        COUNT(*) as pageviews,
        APPROX_COUNT_DISTINCT_DS_HLL(user_id) as unique_users,
        AVG(page_load_time) as avg_load_time
    FROM pageviews
    WHERE __time > CURRENT_TIMESTAMP - INTERVAL '5' MINUTE
    GROUP BY country
    ORDER BY pageviews DESC
    LIMIT 10
""")

print("\n📊 Top Countries (Last 5 minutes):")
for row in cursor.fetchall():
    country, views, users, load_time = row
    print(f"  {country:15} {views:>8,} views  |  {users:>6,} users  |  {load_time:>5.0f}ms")

"""
Output:
📊 Top Countries (Last 5 minutes):
  United States    45,231 views  |  8,912 users  |   342ms
  United Kingdom   12,456 views  |  2,341 users  |   389ms
  Germany           9,876 views  |  1,892 users  |   301ms
  France            7,234 views  |  1,456 users  |   412ms
  Canada            6,123 views  |  1,234 users  |   298ms

Query time: 47ms
"""


# Query 2: Hourly trend (last 24 hours)
cursor.execute("""
    SELECT
        TIME_FLOOR(__time, 'PT1H') as hour,
        COUNT(*) as pageviews,
        APPROX_COUNT_DISTINCT_DS_HLL(user_id) as unique_users
    FROM pageviews
    WHERE __time > CURRENT_TIMESTAMP - INTERVAL '24' HOUR
    GROUP BY TIME_FLOOR(__time, 'PT1H')
    ORDER BY hour
""")

print("\n📈 Hourly Pageview Trend:")
for row in cursor.fetchall():
    hour, views, users = row
    print(f"  {hour}  {views:>8,} views  ({users:>6,} unique users)")

"""
Output:
📈 Hourly Pageview Trend:
  2024-01-20 00:00:00    234,567 views  ( 45,231 unique users)
  2024-01-20 01:00:00    198,432 views  ( 38,912 unique users)
  2024-01-20 02:00:00    156,789 views  ( 31,234 unique users)
  [... 21 more hours ...]

Query time: 89ms
"""


# Query 3: Top referrers for specific page (real-time)
cursor.execute("""
    SELECT
        referrer,
        COUNT(*) as visits,
        AVG(page_load_time) as avg_load_time
    FROM pageviews
    WHERE page_url = '/product/laptop-pro'
      AND __time > CURRENT_TIMESTAMP - INTERVAL '10' MINUTE
    GROUP BY referrer
    ORDER BY visits DESC
    LIMIT 5
""")

print("\n🔗 Top Referrers to /product/laptop-pro (Last 10 min):")
for row in cursor.fetchall():
    referrer, visits, load_time = row
    print(f"  {visits:>5} visits from {referrer} (avg {load_time:.0f}ms load time)")

"""
Output:
🔗 Top Referrers to /product/laptop-pro (Last 10 min):
  1,234 visits from google.com (avg 321ms load time)
    892 visits from facebook.com (avg 398ms load time)
    567 visits from twitter.com (avg 276ms load time)
    234 visits from reddit.com (avg 412ms load time)
    123 visits from direct (avg 298ms load time)

Query time: 32ms
"""
Result: Real-time analytics on streaming Kafka data with sub-100ms query latency

Apache Pinot: Ultra-Low Latency for User-Facing Analytics

Apache Pinot is LinkedIn's real-time distributed OLAP datastore, designed for ultra-low latency queries on massive datasets. It powers LinkedIn's "Who Viewed Your Profile" and Uber's real-time pricing dashboards.

Pinot's Secret Sauce
  • Star-Tree Index: Pre-computed aggregations for common queries
  • Smart routing: Queries only hit relevant servers
  • Upserts: Update existing records (unique to Pinot)
  • Multi-stage query: Complex joins without data movement
When to Use Pinot
  • User-facing analytics (SLA < 100ms)
  • High-cardinality data (millions of users)
  • Massive scale (1000+ nodes at LinkedIn)
  • Real-time updates required

Setting Up Pinot and Querying Data

# ============ STEP 1: Install Pinot client ============
# pip install pinotdb

from pinotdb import connect

# Connect to Pinot broker
conn = connect(host='localhost', port=8099, path='/query/sql', scheme='http')
cursor = conn.cursor()


# ============ STEP 2: Create table schema ============
# Table configuration for ride-sharing app (like Uber)
table_config = {
    "tableName": "rides",
    "tableType": "REALTIME",
    "segmentsConfig": {
        "timeColumnName": "ride_timestamp",
        "timeType": "MILLISECONDS",
        "replication": 3,
        "retentionTimeUnit": "DAYS",
        "retentionTimeValue": 7
    },
    "tableIndexConfig": {
        "loadMode": "MMAP",
        "invertedIndexColumns": ["driver_id", "rider_id", "city"],
        "noDictionaryColumns": ["ride_id"],
        "starTreeIndexConfigs": [{
            "dimensionsSplitOrder": ["city", "vehicle_type"],
            "skipStarNodeCreationForDimensions": [],
            "functionColumnPairs": [
                "COUNT__*",
                "SUM__fare_amount",
                "MAX__fare_amount"
            ],
            "maxLeafRecords": 10000
        }]
    },
    "ingestionConfig": {
        "streamIngestionConfig": {
            "streamConfigMaps": [{
                "realtime.segment.flush.threshold.rows": 50000,
                "stream.kafka.topic.name": "ride-events",
                "stream.kafka.broker.list": "localhost:9092",
                "stream.kafka.consumer.type": "lowLevel"
            }]
        }
    }
}

# Note: In production, submit this via Pinot Controller REST API
print("✓ Table 'rides' configured with Star-Tree index for fast aggregations")


# ============ STEP 3: Query real-time ride data ============
# Query 1: Current active rides by city (last 5 minutes)
cursor.execute("""
    SELECT
        city,
        COUNT(*) as active_rides,
        COUNT(DISTINCT driver_id) as active_drivers,
        COUNT(DISTINCT rider_id) as active_riders,
        AVG(surge_multiplier) as avg_surge
    FROM rides
    WHERE ride_timestamp > ToDateTime(now()) - 300000  -- 5 minutes in ms
      AND ride_status = 'in_progress'
    GROUP BY city
    ORDER BY active_rides DESC
    LIMIT 10
""")

print("\n🚗 Active Rides by City (Real-Time):")
for row in cursor:
    city, rides, drivers, riders, surge = row
    print(f"  {city:15} {rides:>5} rides  |  {drivers:>4} drivers  |  {riders:>4} riders  |  {surge:.2f}x surge")

"""
Output:
🚗 Active Rides by City (Real-Time):
  San Francisco   2,341 rides  |  1,892 drivers  |  2,341 riders  |  1.25x surge
  New York        1,987 rides  |  1,543 drivers  |  1,987 riders  |  1.50x surge
  Los Angeles     1,654 rides  |  1,321 drivers  |  1,654 riders  |  1.15x surge
  Chicago           892 rides  |    723 drivers  |    892 riders  |  1.00x surge
  Boston            567 rides  |    456 drivers  |    567 riders  |  1.10x surge

Query latency: 23ms ⚡ (Star-Tree index accelerated the aggregation!)
"""


# Query 2: Driver earnings in last hour (for driver app)
driver_id = 12345

cursor.execute("""
    SELECT
        COUNT(*) as completed_rides,
        SUM(fare_amount) as total_earnings,
        AVG(fare_amount) as avg_fare,
        AVG(rider_rating) as avg_rating,
        SUM(CASE WHEN surge_multiplier > 1.0 THEN 1 ELSE 0 END) as surge_rides
    FROM rides
    WHERE driver_id = ?
      AND ride_timestamp > ToDateTime(now()) - 3600000  -- 1 hour
      AND ride_status = 'completed'
""", (driver_id,))

print(f"\n💰 Driver {driver_id} Earnings (Last Hour):")
row = cursor.fetchone()
rides, earnings, avg_fare, rating, surge_rides = row
print(f"  Completed: {rides} rides")
print(f"  Earnings: ${earnings:.2f} (avg ${avg_fare:.2f}/ride)")
print(f"  Rating: {rating:.2f} ⭐")
print(f"  Surge rides: {surge_rides}/{rides}")

"""
Output:
💰 Driver 12345 Earnings (Last Hour):
  Completed: 8 rides
  Earnings: $127.50 (avg $15.94/ride)
  Rating: 4.85 ⭐
  Surge rides: 3/8

Query latency: 8ms ⚡ (Pinot's inverted index on driver_id!)
"""


# Query 3: Surge pricing heatmap (for ops dashboard)
cursor.execute("""
    SELECT
        city,
        vehicle_type,
        PERCENTILEEST(surge_multiplier, 50) as median_surge,
        PERCENTILEEST(surge_multiplier, 95) as p95_surge,
        COUNT(*) as rides
    FROM rides
    WHERE ride_timestamp > ToDateTime(now()) - 600000  -- 10 minutes
    GROUP BY city, vehicle_type
    HAVING COUNT(*) > 10
    ORDER BY p95_surge DESC
""")

print("\n🔥 Surge Pricing Heatmap:")
for row in cursor:
    city, vehicle, median, p95, rides = row
    surge_indicator = "🔴" if p95 > 2.0 else "🟡" if p95 > 1.5 else "🟢"
    print(f"  {surge_indicator} {city:15} {vehicle:10} P50={median:.2f}x  P95={p95:.2f}x  ({rides} rides)")

"""
Output:
🔥 Surge Pricing Heatmap:
  🔴 New York        UberXL     P50=1.8x  P95=2.5x  (234 rides)
  🔴 San Francisco   UberBlack  P50=1.7x  P95=2.3x  (156 rides)
  🟡 Los Angeles     UberX      P50=1.3x  P95=1.8x  (892 rides)
  🟡 Boston          UberXL     P50=1.4x  P95=1.6x  (123 rides)
  🟢 Chicago         UberX      P50=1.1x  P95=1.3x  (567 rides)

Query latency: 18ms ⚡
"""
Result: Sub-20ms queries on real-time ride data with Star-Tree indexing

Pinot Upserts: Updating Records in Real-Time

# Pinot supports upserts (unique to real-time OLAP!)
# Use case: Update order status as it progresses

# Table with upsert enabled (primary key = order_id)
upsert_table_config = {
    "tableName": "orders",
    "tableType": "REALTIME",
    "segmentsConfig": {
        "timeColumnName": "order_time",
        "replication": 2
    },
    "tableIndexConfig": {
        "loadMode": "MMAP",
        "invertedIndexColumns": ["user_id", "status"]
    },
    "ingestionConfig": {
        "upsertConfig": {
            "mode": "FULL",  # FULL or PARTIAL upsert
            "primaryKeyColumns": ["order_id"]  # Unique key
        }
    }
}

# Stream of events (same order_id, different status)
# Event 1: {"order_id": 123, "status": "pending", "order_time": 1000}
# Event 2: {"order_id": 123, "status": "confirmed", "order_time": 1050}
# Event 3: {"order_id": 123, "status": "shipped", "order_time": 1100}
# Event 4: {"order_id": 123, "status": "delivered", "order_time": 1200}

# Query always returns LATEST status per order_id
cursor.execute("""
    SELECT order_id, status, order_time
    FROM orders
    WHERE order_id = 123
""")

print("\n📦 Order Status:")
row = cursor.fetchone()
print(f"  Order {row[0]}: {row[1]} (updated at {row[2]})")

"""
Output:
📦 Order Status:
  Order 123: delivered (updated at 1200)

Note: Only the latest record exists in Pinot (old statuses overwritten)
This is MUCH faster than scanning all events to find latest status!
"""
Result: Pinot automatically maintains latest state per primary key

ClickHouse: The Fastest OLAP Database

ClickHouse is an open-source columnar database developed by Yandex (Russia's Google). It's the fastest single-node OLAP database and excels at log analytics, observability, and time-series workloads.

Speed Secrets
  • Vectorized execution: SIMD instructions
  • Compression: 10x-100x on disk
  • Sparse indexes: Skip irrelevant data
  • Specialized engines: MergeTree, ReplacingMergeTree
Best Use Cases
  • Log analytics (structured logs)
  • Observability (metrics, traces)
  • Web analytics (Yandex Metrica)
  • Time-series data
  • Real-time aggregations
Performance
  • Billions of rows scanned/second
  • 1-50ms query latency (single node)
  • 100 GB/s scan throughput
  • 10x-100x compression ratio

ClickHouse for Application Logs

# ============ STEP 1: Install ClickHouse client ============
# pip install clickhouse-driver

from clickhouse_driver import Client
from datetime import datetime, timedelta
import time

client = Client(host='localhost', port=9000)


# ============ STEP 2: Create table for application logs ============
client.execute("""
    CREATE TABLE IF NOT EXISTS app_logs (
        timestamp DateTime,
        log_level LowCardinality(String),
        service LowCardinality(String),
        message String,
        user_id UInt64,
        request_id String,
        duration_ms UInt32,
        status_code UInt16
    )
    ENGINE = MergeTree()
    PARTITION BY toYYYYMMDD(timestamp)
    ORDER BY (service, timestamp)
    TTL timestamp + INTERVAL 30 DAY  -- Auto-delete after 30 days
""")

print("✓ Table 'app_logs' created with MergeTree engine")


# ============ STEP 3: Insert sample logs (simulating real-time stream) ============
# In production: use Kafka → ClickHouse via Kafka table engine
sample_logs = [
    (datetime.now() - timedelta(seconds=i), 'INFO', 'api-gateway', 'Request received',
     12345 + i, f'req-{i}', 50 + i, 200)
    for i in range(1000)
]

# Add some errors
sample_logs.extend([
    (datetime.now() - timedelta(seconds=i), 'ERROR', 'payment-service', 'Payment failed',
     20000 + i, f'req-err-{i}', 500 + i, 500)
    for i in range(50)
])

client.execute('INSERT INTO app_logs VALUES', sample_logs)
print(f"✓ Inserted {len(sample_logs)} log records")


# ============ STEP 4: Query logs with sub-second latency ============
# Query 1: Error rate by service (last hour)
result = client.execute("""
    SELECT
        service,
        countIf(log_level = 'ERROR') as errors,
        count() as total_logs,
        round(errors / total_logs * 100, 2) as error_rate_pct
    FROM app_logs
    WHERE timestamp > now() - INTERVAL 1 HOUR
    GROUP BY service
    ORDER BY error_rate_pct DESC
""")

print("\n❌ Error Rates by Service (Last Hour):")
for service, errors, total, error_rate in result:
    indicator = "🔴" if error_rate > 5 else "🟡" if error_rate > 1 else "🟢"
    print(f"  {indicator} {service:20} {errors:>5} errors / {total:>6} logs = {error_rate}%")

"""
Output:
❌ Error Rates by Service (Last Hour):
  🔴 payment-service        50 errors /  1,050 logs = 4.76%
  🟢 api-gateway             0 errors /  1,000 logs = 0.00%

Query time: 12ms (scanned 2,050 rows in 0.012 seconds)
"""


# Query 2: 95th percentile latency by endpoint
result = client.execute("""
    SELECT
        service,
        quantile(0.95)(duration_ms) as p95_latency,
        quantile(0.99)(duration_ms) as p99_latency,
        avg(duration_ms) as avg_latency
    FROM app_logs
    WHERE timestamp > now() - INTERVAL 5 MINUTE
    GROUP BY service
""")

print("\n⏱️  Latency Percentiles (Last 5 min):")
for service, p95, p99, avg in result:
    print(f"  {service:20} P95={p95:>4.0f}ms  P99={p99:>4.0f}ms  Avg={avg:>4.0f}ms")

"""
Output:
⏱️  Latency Percentiles (Last 5 min):
  api-gateway          P95= 149ms  P99= 199ms  Avg= 125ms
  payment-service      P95= 549ms  P99= 649ms  Avg= 525ms

Query time: 8ms
"""


# Query 3: Find all logs for specific request_id (troubleshooting)
request_id = 'req-123'

result = client.execute("""
    SELECT
        timestamp,
        log_level,
        service,
        message,
        duration_ms
    FROM app_logs
    WHERE request_id = %(req_id)s
    ORDER BY timestamp
""", {'req_id': request_id})

print(f"\n🔍 Trace for request_id={request_id}:")
for ts, level, service, msg, duration in result:
    print(f"  [{ts}] {level:5} {service:20} {msg} ({duration}ms)")

"""
Output:
🔍 Trace for request_id=req-123:
  [2024-01-20 10:30:45] INFO  api-gateway          Request received (173ms)

Query time: 5ms (ClickHouse's sparse index made this instant!)
"""


# Query 4: Top 10 slowest users (last hour)
result = client.execute("""
    SELECT
        user_id,
        count() as request_count,
        avg(duration_ms) as avg_duration,
        max(duration_ms) as max_duration
    FROM app_logs
    WHERE timestamp > now() - INTERVAL 1 HOUR
      AND user_id != 0
    GROUP BY user_id
    ORDER BY avg_duration DESC
    LIMIT 10
""")

print("\n🐌 Slowest Users (Last Hour):")
for user_id, count, avg_dur, max_dur in result:
    print(f"  User {user_id:>6}: {count:>3} requests, avg {avg_dur:>5.0f}ms, max {max_dur:>5.0f}ms")

"""
Output:
🐌 Slowest Users (Last Hour):
  User  20049:  1 requests, avg   549ms, max   549ms
  User  20048:  1 requests, avg   548ms, max   548ms
  User  20047:  1 requests, avg   547ms, max   547ms
  [... more users ...]

Query time: 15ms
"""


# ============ Performance comparison ============
# Scan 1 billion rows in ClickHouse
client.execute("SELECT count() FROM app_logs")  # Assume 1B rows

start = time.time()
result = client.execute("""
    SELECT count()
    FROM app_logs
    WHERE timestamp > now() - INTERVAL 1 DAY
""")
elapsed = time.time() - start

print(f"\n⚡ ClickHouse Performance:")
print(f"  Scanned: 1,000,000,000 rows")
print(f"  Time: {elapsed:.3f} seconds")
print(f"  Throughput: {1_000_000_000 / elapsed / 1_000_000:.0f} million rows/sec")

"""
Output:
⚡ ClickHouse Performance:
  Scanned: 1,000,000,000 rows
  Time: 0.243 seconds
  Throughput: 4,115 million rows/sec

ClickHouse can scan BILLIONS of rows per second on commodity hardware!
"""
Result: Sub-50ms queries on log data with billions of rows using ClickHouse's columnar storage

ClickHouse Materialized Views: Pre-Aggregation

# Create materialized view for common aggregation (service metrics per minute)
client.execute("""
    CREATE MATERIALIZED VIEW IF NOT EXISTS service_metrics_mv
    ENGINE = SummingMergeTree()
    PARTITION BY toYYYYMMDD(minute)
    ORDER BY (service, minute)
    AS SELECT
        toStartOfMinute(timestamp) as minute,
        service,
        count() as request_count,
        sum(duration_ms) as total_duration,
        countIf(status_code >= 500) as error_count
    FROM app_logs
    GROUP BY minute, service
""")

print("✓ Materialized view created: service_metrics_mv")
print("  • Pre-aggregates logs per service per minute")
print("  • Queries hit aggregated data (1000x faster!)")


# Query the materialized view
result = client.execute("""
    SELECT
        service,
        sum(request_count) as requests,
        sum(error_count) as errors,
        round(sum(error_count) / sum(request_count) * 100, 2) as error_rate
    FROM service_metrics_mv
    WHERE minute > now() - INTERVAL 1 HOUR
    GROUP BY service
""")

print("\n📊 Service Metrics (from materialized view):")
for service, requests, errors, error_rate in result:
    print(f"  {service:20} {requests:>6} requests, {errors:>3} errors ({error_rate}%)")

"""
Output:
✓ Materialized view created: service_metrics_mv
  • Pre-aggregates logs per service per minute
  • Queries hit aggregated data (1000x faster!)

📊 Service Metrics (from materialized view):
  api-gateway           1,000 requests,   0 errors (0.00%)
  payment-service       1,050 requests,  50 errors (4.76%)

Query time: 2ms ⚡⚡⚡ (vs 12ms on raw table!)
Materialized views trade storage for speed: perfect for dashboards
"""
Result: 6x faster queries using pre-aggregated materialized views

When to Use Real-Time OLAP vs Traditional Warehouses

RequirementDruidPinotClickHouseSnowflake/Redshift
Query latency required50-500ms10-100ms1-50ms1-30 seconds
Data freshness needed< 1 second< 1 second< 5 secondsMinutes to hours
Best forDashboards, monitoringUser-facing analyticsLog analytics, observabilityComplex BI, data science
Query complexitySimple aggregationsSimple-medium aggregationsMedium-complex queriesComplex joins, window functions
Data retentionDays to weeksDays to monthsWeeks to monthsYears
Ingestion throughputMillions events/secMillions events/secMillions rows/secBatch loads (COPY)
Updates/DeletesNo (append-only)Yes (upserts)Yes (slow)Yes
Cost (relative)$$$ (compute + storage)$$$ (compute + storage)$ (self-managed)$$ (storage cheap)
Operational complexityHigh (many components)High (distributed system)Low (single binary)Very low (fully managed)
Decision Framework
  • Use Druid if you need real-time dashboards on event streams with automatic rollup
  • Use Pinot if you need ultra-low latency (<100ms) for user-facing analytics at massive scale
  • Use ClickHouse if you need the fastest queries on logs/metrics and can manage infrastructure
  • Use Snowflake/Redshift if you need complex BI queries, long data retention, and fully managed service
  • Use multiple systems: Real-time OLAP for hot data (recent), warehouse for cold data (historical)

Architecture Comparison

FeatureApache DruidApache PinotClickHouse
ArchitectureDistributed (Coordinator, Broker, Historical, etc.)Distributed (Controller, Broker, Server)Single node or sharded cluster
Storage formatColumnar segments with bitmap indexesColumnar with Star-Tree indexesColumnar with sparse primary key index
Real-time ingestionKafka, Kinesis (native)Kafka, Kinesis, Pulsar (native)Kafka via Kafka engine table
SQL supportSQL (Druid SQL)SQL (PQL + SQL)Full ANSI SQL
Approximate algorithmsHyperLogLog, Theta sketchesHyperLogLog, percentilesHyperLogLog, quantiles
Time-series optimizationsNative (designed for time-series)Yes (time-based partitioning)Yes (MergeTree + TTL)
Pre-aggregationRollup at ingestion timeStar-Tree indexesMaterialized views
Cloud offeringsImply Cloud (managed Druid)StartTree Cloud (managed Pinot)ClickHouse Cloud, Altinity.Cloud
CommunityStrong (Apache, Imply)Strong (Apache, LinkedIn, Uber)Very strong (Yandex, ClickHouse Inc.)
MaturityMature (2011, Apache TLP)Mature (2015, Apache TLP)Very mature (2016, widely adopted)

Key Takeaways

  • Real-time OLAP bridges streaming and batch analytics
  • Druid: Real-time dashboards with automatic rollup
  • Pinot: Ultra-low latency (<100ms) user-facing analytics
  • ClickHouse: Fastest queries on logs and metrics
  • Star-Tree indexes: Pre-compute aggregations (Pinot)
  • Materialized views: Trade storage for speed (ClickHouse)
  • Upserts: Update records in real-time (Pinot only)
  • Hot/cold architecture: Real-time + warehouse together
Remember: Traditional warehouses (Snowflake, Redshift) are not real-time, they're optimized for complex queries on large historical datasets with minutes-to-hours latency. Real-time OLAP engines (Druid, Pinot, ClickHouse) sacrifice some query complexity for sub-second latency on streaming data. The best architecture uses BOTH: real-time engines for recent data (last 7-30 days) feeding into warehouses for long-term storage and complex analytics. This "hot/cold" pattern gives you real-time dashboards AND comprehensive BI reports. Companies like Uber, LinkedIn, and Cloudflare run these systems at massive scale, billions of events per day, thousands of queries per second.