TL;DR β Real-Time Analytics in Plain English
- What real-time analytics actually IS β fresh-data OLAP queries answered in under a second, even when the underlying dataset is updating millions of times per second and thousands of users are querying simultaneously
- Why OLTP databases (Postgres, MySQL) and batch OLAP warehouses (Snowflake, BigQuery) both fail at this problem β and the specific, mathematical reason each one breaks
- The three database families and where each one lives on the latency-vs-freshness spectrum
- Why columnar storage is the single most important building block β the I/O physics that make it 10β100Γ faster for analytical scans
- How Apache Druid, Apache Pinot, and ClickHouse slice data into segments by time so a query touching "last 24 hours" only reads 1% of the total data
- The four hard requirements that must be satisfied simultaneously: freshness, sub-second query latency, ad-hoc OLAP queries, and high concurrency β and why doing all four at once is genuinely hard
Real-time analytics is the discipline of answering complex analytical questions β "how many ad impressions per campaign in the last 15 minutes, broken down by country and device type?" β in under a second, over a dataset that is being updated millions of times per second, while thousands of concurrent users are asking different questions simultaneously. That four-way combination β fresh data + ad-hoc queries + sub-second latency + high concurrency, all at once β is what makes this hard. OLTP databases (Postgres, MySQL) are optimized for single-row lookups, not column scans. Batch OLAP warehouses (Snowflake, BigQuery) are optimized for big scans, but not for sub-second latency under high concurrency. Real-time OLAP engines (Apache Druid, Apache Pinot, ClickHouse, StarRocks, Apache Doris) are built for exactly this gap: columnar storage + indexed dimensions + time-based segment partitioning = sub-second queries over billions of rows with fresh data.
Traditional analytics is a 9 AM email of yesterday's numbers. Batch OLAP (Snowflake, BigQuery) improved on that β you can now run the same query in 12 seconds instead of 8 minutes, but freshness is still measured in minutes-to-hours. Real-time analytics takes the next leap: answering analytical queries in under one second over data that is seconds or milliseconds old. The hard part is not the speed alone β it is the simultaneous combination. Fresh data requires continuous ingestion pipelines. Sub-second latency requires columnar storage, inverted indexes, and aggressive pre-computation. Ad-hoc queries (you don't know the question in advance) mean you can't just cache one fixed answer. High concurrency (500β5,000 dashboard users at the same time) means the engine must serve many queries in parallel without melting. Getting all four at once is what specialized real-time OLAP engines are built for.
Think of databases as a spectrum of trade-offs between write speed, query latency, and scan throughput. OLTP databases (Postgres, MySQL, Oracle) optimize for fast single-row reads and writes with ACID transactions β perfect for "look up row 42" and "update row 42 in a transaction", terrible for "scan 2 billion rows and group-by campaign ID". Batch OLAP databases (Snowflake, BigQuery, Amazon Redshift) optimize for scanning huge datasets column-by-column, can process trillions of rows per query, but run queries in seconds-to-minutes and cost money per scan. Real-time OLAP engines (Apache Druid, Apache Pinot, ClickHouse, StarRocks, Apache Doris) sit in a specific sweet spot: columnar storage like batch OLAP, but with time-partitioned segments, inverted indexes on dimensions, and pre-aggregated rollups β enabling sub-second query latency while ingesting fresh data in real time.
You already know Kafka (events flow in) and stream processing (Flink/Kafka Streams compute over those events). Real-time analytics is the next layer: the query interface that lets humans ask questions about what those events mean right now. Events land in Kafka β stream processors optionally enrich/clean them β real-time OLAP engine ingests the stream directly (Druid and Pinot have native Kafka consumers) β dashboards and APIs query the engine at sub-second speed. The engines are also typically coupled with a batch layer for historical data: old segments (months ago) are stored cheaply in object storage (S3, GCS), while recent hot segments stay in fast local storage for low-latency access. This hot/cold architecture is why real-time OLAP can be cheap at scale despite serving very fast queries.
Why You Need This β The Live Dashboard Problem
Before diving into how real-time analytics works, let's prove β concretely β that the tools most engineers reach for first (Postgres, then Snowflake) hit hard walls at dashboard scale. Understanding exactly where each tool breaks makes every design decision in the rest of the page obvious.
The Story: An Ad-Tech Dashboard That Brought Down the Database
Imagine an ad-tech company. They run display advertising campaigns β banner ads, video ads β for thousands of advertisers. Each advertiser wants a live dashboard: impressions served, clicks, click-through rate, spend, broken down by country, device type, and ad creative, updated every 30 seconds.
The engineering team starts with what they know: Postgres. They have an impressions table with six months of data β roughly 200 billion rows. Each dashboard page load fires a query like:
On Postgres, that query takes 8 minutes. Why? Postgres is a row-oriented database β the impressions table stores each row's columns together. To compute the sum of "clicked" across 200 billion rows, Postgres must read every column of every row to get to the "clicked" column, even though it only needs two columns out of the fifteen in the table. That's roughly 15Γ more data read from disk than actually needed.
Migration 1: Snowflake β Faster, But a New Problem Emerges
The team migrates to Snowflake, a batch OLAP warehouse. Snowflake is columnar β it stores each column separately, so the same query now reads only the columns it needs. The same query drops from 8 minutes to 12 seconds. That's a 40Γ improvement for batch reporting. Problem solved?
Not quite. Two new problems appear when you use Snowflake for a live dashboard:
-
Latency: 12 seconds feels acceptable in a BI report. But a dashboard that refreshes every 30 seconds and takes 12 seconds per query means the UI is showing stale numbers more than a third of the time. For live operations β campaign managers watching a new ad launch in real time β 12 seconds feels broken.
-
Cost explosion: Snowflake charges per compute-second of warehouse usage. 500 concurrent users Γ 12-second queries Γ 30-second refresh cycle = a warehouse that runs almost continuously, full throttle. The monthly Snowflake bill for 500 live dashboard users can reach tens of thousands of dollars for this query volume β an order of magnitude more than a purpose-built OLAP engine would cost for the same workload.
Migration 2: Apache Druid β The Right Tool
The team migrates to Apache Druid. Same data, same query. Results: ~200 ms. The same 500 users query simultaneously β Druid fans the query out across segment-level parallelism and answers everyone within the 30-second refresh window with capacity to spare. At this query volume, the compute cost is a small fraction of the Snowflake bill.
How does Druid do this? Three mechanisms work together, all explained in depth later in this page:
-
Columnar storage: like Snowflake, reads only the columns it needs β 10β50Γ less data than row-oriented Postgres.
-
Time-partitioned segments: 200 billion rows split into hourly segments. A "last 24 hours" filter only reads the 24 most recent hour-segments β roughly 1% of total storage. Druid skips the other 99%.
-
Inverted indexes on dimensions: each segment has a bitmap index on country, device_type, campaign_id. Filtering to "country = US" means reading a compressed bitmap, not scanning rows.
The chart maps out the fundamental trade-off landscape. OLTP databases (top-left) handle small-scale transactional queries well but collapse the moment you ask them to scan billions of rows at high concurrency β they simply aren't built for it. Batch OLAP warehouses (middle) are much better at large scans but live in a latency band of seconds-to-minutes and become expensive when hundreds of users are running concurrent queries. Real-time OLAP engines (bottom-right) occupy the sweet spot that nobody else covers: sub-second query latency and high concurrency and fresh data.
Why These Numbers Matter in Practice
There's a well-known study by Google on page load time and user abandonment β the numbers shift with device and era, but the principle is consistent: users start abandoning interactive UIs when response time crosses about one second. The same psychological threshold applies to dashboards. A live ops dashboard that takes 12 seconds per query doesn't feel live β it feels sluggish and users stop trusting it. A dashboard that responds in 200 ms feels instant and operators stay engaged with it.
Mental Model β The Three Database Families
Every database you'll encounter optimizes for a specific workload. The mistake most engineers make is choosing a database they already know β often a relational database β and then fighting its limits when the workload changes. The right mental model: there are three fundamentally different optimization targets, and each leads to a different family of databases with different internal designs.
The Library Analogy
Imagine three different kinds of libraries. The first is a public library β it's great for finding one specific book quickly (the librarian knows exactly which shelf it's on), but if you asked "sum up every book's page count across 50,000 books", it would take all day because you'd have to open every single book. The second is a university research archive β it's organized by topic, optimized for reading huge volumes of material from one subject area, but retrieving a specific page from a specific book takes longer because the organization favors bulk access over quick point lookups. The third is a live news ticker β everything published in the last 24 hours is instantly accessible, sorted by time, and you can query it in real time while new stories are still coming in.
OLTP databases are the public library. Batch OLAP warehouses are the research archive. Real-time OLAP engines are the live news ticker. Each design is correct β for its intended use case.
The three-column chart makes the gaps concrete. OLTP databases have instant freshness (writes are immediately visible) but terrible analytical scan latency because row-oriented storage forces reading every column to find the one you need. Batch OLAP warehouses solve the scan problem with columnar storage but trade off freshness (data loads run in batches) and concurrency (every concurrent query spins up compute, which costs money). Real-time OLAP engines are the only family that simultaneously hits all four requirements: columnar storage for scan efficiency, indexing for filter performance, time-partitioned segments for freshness and locality, and a query routing architecture that scales concurrency via parallelism rather than by throwing more compute at each individual query.
The One Trade-off Real-Time OLAP Makes
Nothing comes free. Real-time OLAP engines make one significant trade-off to achieve their performance: they are poor at writes and updates. An OLTP database can update a single row in microseconds. Real-time OLAP engines store data in immutable segments β once a segment is written and indexed, you can't cheaply update individual rows. Corrections require compaction jobs that rewrite segments. This is fine for event-based workloads (ad impressions, clickstreams, sensor readings, application logs β data that arrives and is never modified), and it's a serious problem for transactional workloads (bank balances, inventory counts β data that must be updated in place).
Core Concepts β The Real-Time Analytics Vocabulary
Real-time analytics comes with a vocabulary that can feel overwhelming at first β segment, rollup, HyperLogLog, deep storage, broker, historical node. Most of these terms are just names for ideas you can reason about from first principles. This section gives you the plain English warmup for each term before the jargon label, so you're never reading a sentence and thinking "I know all the words except the important one."
The map organizes terms into four clusters: Storage Layer (how data is laid out on disk), Query Layer (the software components that receive and route queries), Ingestion Layer (how data gets into the engine), and Approximation Layer (how to trade a tiny bit of accuracy for massive performance gains on high-cardinality queries). Let's walk through the most important terms in each cluster.
Storage Layer Terms
-
Columnar storage β Instead of storing each row's columns together ("row A: col1, col2, col3; row B: col1, col2, col3β¦"), each column's values are stored together ("col1: all values; col2: all valuesβ¦"). This means an analytical query that reads only two columns out of fifteen only loads 2/15ths of the data. Section 5 gives the full physics explanation.
-
Segment β Think of a segment as a single chapter in a book. The book (your entire dataset) is divided into chapters (segments), each covering a specific time range. A segment is an immutable file package containing columnar data + indexes. Most real-time OLAP engines (Druid, Pinot) use segments as their fundamental storage unit. ClickHouse calls them "parts" but the concept is identical.
-
Time-based partitioning β Data is organized by time β each segment covers a specific time window (hourly, daily, or custom). WHY does this matter? Almost all analytical queries include a time filter ("last 7 days", "this month", "yesterday"). If data is partitioned by time, the engine can completely skip segments outside the query's time range without reading a single byte from them.
-
Deep storage β The long-term home for segments that have aged out of the hot tier. Typically S3, GCS, or Azure Blob Storage. Old segments are archived here and only pulled into fast local storage when queried.
-
Hot-cold tier β Recent data lives on fast local disks (hot tier), old data lives on cheap object storage (cold tier). WHY this matters: most dashboard queries ask about recent data ("last 24 hours", "this week"). Hot-cold tiering keeps the data you query most on fast storage and moves old data to storage that costs 20β50Γ less per GB.
Query Layer Terms
-
Broker β The front door for queries. When your application fires a SQL query, it hits the broker first. The broker looks at the time range and filters, figures out which segments are relevant, fans the query out to the nodes holding those segments in parallel, and merges the partial results. In Druid this role is literally called "Broker"; in Pinot it's also called a "Broker"; in ClickHouse there is no named broker β any node can route queries.
-
Historical node β The worker nodes that actually hold segments and execute scans. In Druid's architecture: Broker routes, Historical nodes execute. The broker is a thin router; the historical nodes do the heavy computational lifting.
-
Pre-aggregation / rollup β At ingestion time, if many events share the same values for key dimensions (same campaign_id, same country, same hour), the engine can merge them into one row with a count. A billion impressions might compact to a million pre-aggregated rows. At query time, GROUP BY reads a million rows instead of a billion β 1000Γ less data. This is called a rollup. The trade-off: you can't recover original individual rows after rollup, so you need to decide which granularity to preserve.
-
Materialized view β A pre-computed query result stored physically. Instead of running "impressions per campaign per hour" from scratch on every request, the engine pre-computes it and stores it as a physical table. Queries read the materialized view (fast) instead of the raw data (slow). Updated incrementally as new data arrives.
Ingestion Layer Terms
-
Freshness lag β The time between "this event happened" and "this event is visible in dashboard queries." Composed of three parts: how long before the event is emitted to Kafka, how long the stream processing pipeline takes to enrich it, and how long the OLAP engine takes to index and serve the new segment. Minimizing freshness lag is the core engineering challenge in real-time analytics ingestion pipelines.
-
Ingestion latency vs query latency β Two different things that are easy to confuse. Ingestion latency is how long data takes to enter and become queryable in the engine. Query latency is how long a query takes to execute. You can have low query latency (200 ms queries) but high ingestion latency (data is 2 minutes old) β or vice versa. Both matter for different reasons.
Approximation Layer Terms
-
Cardinality β How many distinct values a column has. "Country" has ~200 distinct values β low cardinality, easy to group by. "User ID" might have hundreds of millions of distinct values β high cardinality, very expensive to compute exact counts like "how many unique users saw this ad."
-
HyperLogLog β A clever algorithm that estimates "how many distinct values are there?" using a tiny fixed-size summary (a few kilobytes), with ~1-2% error. WHY this matters: exact distinct-count queries ("how many unique users?") require storing every unique value seen so far β which is prohibitively expensive at high cardinality. HyperLogLog achieves nearly the same answer with a summary that's thousands of times smaller, enabling sub-second distinct-count queries over billions of rows.
-
Data sketch / theta sketch β A general term for compact probabilistic summaries. HyperLogLog is a sketch for distinct counts. A theta sketch enables set operations on approximate counts β "how many users saw campaign A and campaign B?" β enabling audience overlap analysis at scale. Apache DataSketches is the library most engines use.
Columnar Storage β Why It's Foundational
If you understand only one concept from this entire page, make it columnar storage. Every speed advantage that real-time OLAP engines have over Postgres β and most of the advantage they have over Snowflake β traces back to this single design decision about how bytes are physically arranged on disk.
Row-Oriented vs. Columnar: The Physical Byte Layout
Imagine a table with four columns: user_id, campaign_id, country, and clicked. It has five rows. In a row-oriented database (Postgres, MySQL), each row is stored together as a contiguous block of bytes:
Notice the red "clicked" cells. They are scattered throughout the file β every four adjacent cells belong to the same row, so to read just the "clicked" column you must seek to each row's location and extract its "clicked" byte. With 200 billion rows, the I/O is dominated by reading three columns you don't need for every one column you do.
Now look at the same data in columnar storage:
In columnar storage, each column is a separate file. To compute SUM(clicked), the engine opens exactly one file β clicked.col β reads its five bytes, and sums them. The user_id, campaign_id, and country files are never opened. With 200 billion rows and 15 columns, this means reading 1/15th of the data that a row-oriented query would read. On a table where each row is 200 bytes and you only need a 4-byte integer column, you read 4/200 = 2% of the bytes. That's the 50Γ I/O reduction that makes columnar scanning fast.
Compression: Columnar Data Compresses Far Better Than Row Data
The second big advantage is compression. Look at the country.col column above: it contains the values US, DE, US, US, GB. Notice they repeat. In fact, analytical data tends to have lots of repetition within a column β many rows have the same country, the same campaign ID, the same device type. When similar values are stored together, compression algorithms like run-length encoding (RLE) can represent them far more compactly.
In a row-oriented layout, each row's bytes are different β user ID, campaign ID, country, timestamp, and metric values are all interleaved. There's little repetition in the byte sequence, so compression barely helps. In columnar layout, a column of 10 million rows where 60% of values are "US" might compress from 20 MB to under 1 MB β a 20Γ compression ratio just from the locality of similar values.
SIMD: CPU-Level Acceleration for Dense Arrays
There's a third advantage that often goes unmentioned: SIMD (Single Instruction, Multiple Data). Modern CPUs can operate on 4, 8, or 16 integers simultaneously in a single instruction. This is called vectorization. For this to work efficiently, the data must be a dense, contiguous array of the same type.
Columnar storage produces exactly this: the clicked.col file is a dense array of integers. The CPU can sum 8 values per instruction, making the column scan up to 8Γ faster than equivalent row-oriented code. In row-oriented storage, the "clicked" values are scattered across rows, so the CPU can't vectorize the scan β each value requires a separate load, offset, and operation.
The Trade-off: Columnar Storage Is Slow for Writes
Nothing comes free. Columnar storage has one significant weakness: it is slow for individual row writes and updates. In a row-oriented database, inserting a new row means appending one contiguous block of bytes. In columnar storage, a new row means appending to N different column files β one write per column β and potentially invalidating existing indexes. For a table with 50 columns, a single row insert touches 50 column files.
Real-time OLAP engines solve this by batching writes: instead of inserting rows one at a time, events are accumulated in a small in-memory buffer, then flushed as a complete segment to disk. The segment is immutable once written β no row-level updates. Corrections require rewriting whole segments via background compaction jobs. This is exactly the right trade-off for event-based workloads where data arrives fast, in bulk, and is never modified after the fact.
Segments and Time-Based Partitioning β How Druid and Pinot Slice the Data
Columnar storage explains why real-time OLAP can scan data efficiently. But it doesn't explain how a system with 200 billion rows can return a query in 200 milliseconds β even columnar storage has limits, and scanning 200 billion rows column-by-column takes seconds, not milliseconds. The second key mechanism is time-based segment partitioning: slicing the data into time-bounded segments so that most queries only touch a tiny fraction of the total data.
What a Segment Is
Think of a book that's been printed in daily volumes. Volume 1 covers January 1st. Volume 2 covers January 2nd. Each volume is a self-contained unit β it has its own index, its own page numbering, its own table of contents. To answer "what happened on January 15th?", you pull Volume 15 off the shelf and never open the other 364 volumes.
A segment in Apache Druid or Apache Pinot is exactly this. It is a self-contained, immutable file package containing:
-
Columnar data files β one file per column, containing the column's values for events in this time bucket
-
Bitmap (inverted) indexes β for each dimension column (country, campaign_id, device_type), a compact bitmap that maps "country=US" to a set of row positions in this segment. Looking up "US" events skips rows without touching any data files.
-
Metadata β the time range this segment covers, the datasource name, the version, min/max values per column (used for further pruning)
Segments are typically 100 MB to 1 GB in size. Each segment covers a specific time window β hourly segments are common for high-volume streams (one segment = one hour of events), daily segments for lower-volume data. A six-month dataset with hourly segments has 4,380 segments (6 months Γ 30 days Γ 24 hours). ClickHouse calls its equivalent unit a "part" β same concept, different name.
Segment Pruning: Skipping 99% of the Data
The key insight is segment pruning. When your query includes a time filter β and almost every analytical query does β the broker doesn't need to scan all 4,380 segments. It looks at each segment's metadata and skips any segment whose time range doesn't overlap with the query's time window.
A query for "last 24 hours" in a dataset with 6 months of hourly segments touches exactly 24 segments out of 4,380. That's 0.5% of the data β and with columnar storage inside each of those 24 segments reading only the needed columns, the total I/O might be less than 1% of what a full table scan would require. This is why 200-millisecond queries on 200-billion-row datasets are possible: the engine physically reads a tiny fraction of the stored data.
The diagram makes the math visual. A six-month dataset with hourly segments has roughly 4,380 segments total. A "last 24 hours" query causes the broker to examine each segment's time metadata, skip the 4,356 segments outside the window, and dispatch scan tasks to only the 24 segments within the window. Even if each of those 24 segments is 500 MB on disk, the total I/O is 12 GB β and with columnar storage reading only the needed columns, the actual bytes read might be under 1 GB. That is physically why sub-second queries on 200-billion-row datasets are possible.
Query Routing: How the Broker Dispatches to Segments
The broker is the coordinator that makes segment pruning work at query time. Here's the step-by-step flow for a dashboard query:
The diagram shows the five-step flow. The broker receives the query, consults its in-memory segment metadata index to determine which 24 of 4,380 segments overlap "last 24 hours," and fans the query out to the three historical nodes that hold those 24 segments (typically 8 segments per node, distributed by time range). Each historical node scans its assigned segments in parallel β wall-clock time is bounded by the slowest node, not the sum of all nodes. The broker collects three partial aggregation results and merges them into the final answer. The entire round trip takes around 200 milliseconds: ~5 ms broker overhead, ~140 ms for the parallel segment scans, ~10 ms for the merge.
Why Immutability Is a Feature, Not a Limitation
Once a segment is written and indexed, it is never modified. This might sound like a limitation β what if the data is wrong? β but immutability is what makes the entire architecture reliable and scalable:
-
Replication without coordination β A segment can be copied to 3 historical nodes for fault tolerance. Since it's immutable, all three copies are always identical. No locking, no conflict resolution, no distributed write protocols.
-
Replay and backfill β If data pipeline logic changes, old segments can be discarded and reprocessed from the original Kafka event log to produce new segments. The Kafka log is the source of truth; segments are derived data that can always be rebuilt.
-
Compaction β Small segments from streaming ingestion (e.g., one per 15-minute batch) are periodically merged by background compaction jobs into larger, more efficient segments. Compaction produces a new segment and atomically replaces the old ones β no downtime, no query interruption.
-
Cache coherence β Historical nodes cache segments in memory. Since a segment never changes, a cached segment is always valid. Cache invalidation is only needed when a new segment version is compacted to replace an old one.
Indexing in Real-Time OLAP β Beyond Just B-Trees
An index is a helper data structure that lets a database find rows without scanning the whole table β a shortcut, basically. The classic shortcut you've seen in Postgres or MySQL is the B-tree index: a tree of sorted keys that finds a single row in a few hops. B-trees are excellent for point queries like "give me the row where user_id = 12345." But real-time OLAP workloads look nothing like that. A real OLAP query sounds like: "how many ad impressions happened in the last hour, grouped by country and device type, filtered to campaigns where the advertiser is in the retail category?" That query touches hundreds of millions of rows, filters across multiple columns simultaneously, and needs to finish in under 200 ms.
B-trees don't help here. For a B-tree to accelerate a filter, the filter must align with the index key β and when you're filtering on three columns simultaneously, a single B-tree helps with one and the engine falls back to a scan for the rest. Real-time OLAP engines instead build a portfolio of specialized indexes, each designed for a specific shape of analytical query. Understanding each one tells you why these engines are fast in ways a traditional RDBMS simply cannot be.
Index Type 1: Bitmap Index β One Bit Per Row Per Value
Imagine you have a column called country with five possible values: US, UK, DE, FR, JP. A bitmap index for this column creates five separate bit arrays β one per distinct value. Each bit array has one bit per row. If row 7 has country = US, then bit 7 of the US bitmap is 1 and bits 7 of all other country bitmaps are 0.
The power comes when you combine filters. Say you want: country = US AND device_type = mobile. You fetch the US bitmap and the mobile bitmap, then do a single bitwise AND operation. On modern CPUs, a single instruction processes 64 bits at a time (or 256β512 bits with AVX). For 1 billion rows, each bitmap is 1 billion bits = 125 MB. A bitwise AND across 125 MB happens in milliseconds. That's how Druid answers multi-dimension filters on 1B rows in under 100 ms.
The diagram shows the key insight: a bitmap index trades storage (a few hundred MB per low-cardinality column) for the ability to reduce a multi-filter query to a few bitwise operations. The math is what makes it fast β modern CPUs execute bitwise AND on 64 bits per clock cycle, and with SIMD (AVX-512) that's 512 bits per cycle. Filtering 1 billion rows with bitmap AND takes single-digit milliseconds. The critical limitation: bitmap indexes only work well for low-cardinality columns (a small number of distinct values). A column like user_id with 100 million distinct values would need 100 million separate bitmaps β absurd. For high-cardinality columns, you need a different approach.
Index Type 2: Inverted Index β Value β List of Rows
An inverted index flips the relationship: instead of one bit per row per value, it stores a mapping from each value to the set of rows containing that value. Think of it like the index at the back of a textbook β "see page 42, 87, 203" β except the "pages" are row IDs.
Apache Pinot uses inverted indexes for medium-cardinality dimension columns (think hundreds to tens of thousands of distinct values). When a query filters on campaign_id = 'abc123', Pinot looks up 'abc123' in the inverted index and gets back a compact list of row IDs β no scan needed. Combined with Pinot's star-tree index (see below), this gives Pinot some of the lowest per-query latencies of any engine at sub-100 ms for user-facing dashboards.
The inverted index is essentially the same data structure that makes Google Search fast β a word-to-document mapping. In OLAP, it's a value-to-row-ID mapping. The query "where campaign_id = 'camp_abc'" fetches the posting list for 'camp_abc' in O(1) time, then reads only those specific rows from the columnar data. Compared to scanning the entire column, this can skip 99% of rows for high-selectivity filters.
Index Type 3: Forward Index β Dictionary Encoding
The forward index isn't a filter accelerator β it's a storage optimizer. Both Druid and Pinot store string columns with dictionary encoding: each distinct value is assigned a small integer ID, and the column stores the integers rather than the strings. "United States" might be stored as integer 3, "Germany" as 7, and so on. Since the dictionary typically has thousands of entries while the column has billions of rows, you're storing 4-byte integers instead of 13-byte strings β a 3Γ storage reduction for typical country/city strings, even more for longer strings like campaign names.
Dictionary encoding also makes bitmap and inverted indexes cheaper: instead of comparing variable-length strings, comparisons operate on fixed-width integers, which CPUs are extremely efficient at processing.
Index Type 4: Star-Tree Index (Pinot) β Pre-Computed Multi-Dimensional Rollups
The star-tree index is Pinot's secret weapon for the most demanding aggregation queries. The idea: instead of computing GROUP BY country, device_type, campaign_id at query time by scanning billions of rows, pre-compute all combinations of those dimensions at ingest time and store the results in a tree structure.
Think of it as a materialized OLAP cube embedded inside the index. The tree has one node per combination of dimension values. A query for "total impressions by country" climbs to the country-level nodes and reads pre-aggregated counts β no row scanning at all. For dashboards with known dimension sets (which is most production use cases), this can reduce query latency from hundreds of milliseconds to single-digit milliseconds.
The star-tree index is what makes Pinot the top choice for user-facing dashboards β it trades index build time and storage for near-instant query answers on known aggregation patterns. LinkedIn uses it to power analytics dashboards that return in under 100 ms even at millions of rows per second ingest. The trade-off: star-tree indexes must be defined at table creation time for specific dimension sets. If your query asks for a combination the star-tree wasn't built for, it falls back to a raw columnar scan.
Index Type 5: Primary Key (Sparse) Index β ClickHouse MergeTree
ClickHouse takes a different approach. Its sparse primary index stores data physically sorted by the primary key (e.g., (event_date, campaign_id)) and maintains one index entry per ~8,192 rows (a "granule"). A query filtering on the primary key identifies which granules to read, then ClickHouse reads only those granules β skipping the rest entirely. Because data is sorted, related rows are physically contiguous on disk, maximizing both compression ratios and cache utilization.
Choosing the Right Index β Quick Guide
Use: Bitmap index (Druid default, ClickHouse BitmapIndex)
Best for: gender, device_type, status codes, boolean flags, country (200 countries). Each distinct value gets its own bitmap. Multi-filter queries become bitwise AND/OR operations β incredibly fast. Storage is one bit per row per distinct value.
Why NOT for high-cardinality: 100M user_ids Γ 125 MB per bitmap = 12.5 TB of indexes. Absurd.
Use: Inverted index (Pinot, ClickHouse InvertedIndex)
Best for: campaign_id, ad_group_id, product SKUs, city names. Maps each value to a posting list of row IDs. Lookups are O(1) dictionary lookup + O(k) posting-list read. Most efficient when query selectivity is high (the filter matches a small fraction of rows).
Use: Range index / sorted primary key (ClickHouse MergeTree, Druid range index)
Best for: user_id, session_id, order_id, IP addresses. Bitmap and inverted indexes don't scale to millions of distinct values. Instead, sort the data by the high-cardinality column and use range filters β the physical sort order allows binary search to find the right block, then a scan of only the matching range.
Druid also supports Bloom filters on segment metadata to quickly skip entire segments that can't contain a given value.
Use: Star-tree index (Pinot only)
Best for: dashboards where the GROUP BY dimensions are known in advance (country + device_type + hour, for example). Pre-computes all rollup combinations at ingest time. Queries hit pre-aggregated nodes instead of raw rows β often 100β1000Γ faster than a columnar scan for the same query. Must be configured explicitly per table with the chosen dimensions.
Pre-Aggregation & Rollups β Trade Storage for Query Speed
Even with the best columnar storage and indexes, scanning a billion rows per query has a cost. At some point, raw-row scanning doesn't scale β not because the hardware is too slow, but because you have thousands of simultaneous users, each triggering a billion-row scan. If each scan takes 200 ms and you have 2,000 concurrent users, the math works out: you need enough hardware to run 2,000 scans in parallel. That's expensive.
The alternative is pre-aggregation: instead of computing the answer to "how many impressions per campaign per hour" at query time, you compute it once at ingest time and store the result. When a query comes in, it reads the pre-aggregated summary β milliseconds, not a billion-row scan. This is the same insight behind database materialized views, but done in real time as events arrive.
Rollup Tables β Collapsing Billions of Rows to Thousands
A rollup at ingest time works like this: as events arrive, the engine groups them by their dimension columns (hour, country, campaign_id) and accumulates metric columns (count, sum_revenue, sum_clicks). When you flush the segment to disk, each unique combination of dimension values becomes a single row with the accumulated metrics.
Concrete example: an ad-tech platform receives 1 billion click events per day. Without rollup, that's 1B rows/day in storage. With hourly rollup by (campaign_id, country): there are 10,000 campaigns Γ 200 countries Γ 24 hours = 48 million combinations in theory, but in practice each campaign is only active in a few countries, so the actual row count is around 2β5 million rows/day. That's a 200β500Γ reduction in storage and a 200β500Γ speedup for any query that aligns with the rollup dimensions.
The before/after contrast is stark. Raw events give you perfect fidelity β you can answer any question. But at 1 billion rows per day, storing three years of history means over a trillion rows. Rollup collapses that to ~1 billion rows for the same history, and queries run 300Γ faster because they're scanning fewer rows. The loss: you can no longer drill down to an individual event. This trade-off is why the decision to roll up must be made at table design time, not after the fact.
Materialized Views β On-the-Fly Rollup for ClickHouse and StarRocks
ClickHouse and StarRocks take a different approach: rather than rolling up at ingest time (before the data hits storage), they support materialized views that maintain rollup summaries alongside the raw data. When a new event arrives in the base table, ClickHouse simultaneously writes the raw row AND updates the appropriate aggregation row in the materialized view β all in the same write path.
This is the best of both worlds: you keep full raw event history for drill-down analysis AND have pre-computed rollups for fast dashboard queries. The cost: roughly double the storage (raw + aggregated). ClickHouse's SummingMergeTree and AggregatingMergeTree engines are specifically designed for this β they automatically merge partial aggregate states during background compaction, so intermediate states never grow unbounded.
The key insight in this code: countState() and sumState() store partial aggregate states (binary blobs that can be merged later) rather than final values. When you query with countMerge() and sumMerge(), ClickHouse combines those partial states across all rows that share the same key. This allows ClickHouse to merge segments in the background without losing accuracy β if two segments both have rows for (hour=10, campaign_id=abc, country=US), the merge combines their partial states rather than double-counting.
Approximate Queries β Trading 1% Accuracy for 10,000Γ Less Memory
Some metrics are genuinely expensive to compute exactly, even with rollups. The canonical example: unique users. "How many distinct users saw this campaign in the last 30 days?" This requires knowing which user IDs are unique β and a naive set of all user IDs for a 1B-event stream can consume 100 GB of memory for an exact count.
The solution: HyperLogLog (HLL). The algorithm uses a clever hashing trick: instead of storing every unique user ID, it stores a small summary structure (2β16 KB) that lets you estimate the count of distinct IDs with ~1β2% relative error. For a billion-row stream, the exact count takes 100 GB; HLL gives 99% accuracy with 16 KB. For a dashboard showing "1.2M unique users" (which rounds to the nearest thousand anyway), a 1% error is invisible.
The HLL diagram reveals the key property that makes it practical for OLAP: mergeability. You can pre-compute an HLL sketch per segment (say, one per hour of data) and merge multiple sketches together at query time. The merged sketch still fits in 1.5 KB and still gives ~1% error. This means an HLL "unique users" metric can be rolled up just like a sum or count β stored per-segment, merged at query time. Druid, Pinot, and ClickHouse all support HLL (Druid calls them HLLSketchBuild, Pinot uses ThetaSketch, ClickHouse has built-in uniqHLL12()).
Beyond HLL, two other approximate data structures are common in real-time OLAP: Theta sketches for set intersection/difference queries ("users who saw ad A but NOT ad B"), and T-digest for percentile estimation (p95/p99 query latency over billions of observations).
Ingestion β Streaming, Batch, and the Lambda Heritage
All of the query speed in the world is worthless if data doesn't get into the engine quickly and reliably. Ingestion is the process of moving events from their source (usually Kafka or S3) into queryable segments inside the OLAP engine. Real-time OLAP engines support two very different ingestion paths β streaming and batch β and most production deployments use both simultaneously. Understanding why both exist, and how they interact, is essential to designing a reliable real-time analytics system.
Streaming Ingestion β Seconds from Event to Query
In streaming ingestion, the OLAP engine runs a continuous consumer that reads from Kafka topic partitions. As events arrive, the engine accumulates them into an in-memory buffer (Druid calls this a "real-time task"; Pinot calls it a "consuming segment"). Every few minutes (configurable β typically 5β30 minutes), the engine seals the current buffer into a segment on disk, makes it queryable, and starts a fresh buffer.
The result: events are typically visible to queries within 1β10 seconds of being produced to Kafka. This is the "real-time" in real-time analytics β for a campaign manager watching a new ad launch, they see impression counts updating every few seconds, not every hour.
Batch Ingestion β Efficient Historical Loading
Streaming ingestion handles the current firehose, but what about history? If you're launching a new real-time analytics system on data that's been collecting in S3 for two years, you can't wait for the streaming path to process that backlog β it would take months. That's where batch ingestion comes in.
Batch ingestion reads from large files in object storage (S3, GCS, HDFS), processes them in parallel (Druid can spin up hundreds of parallel ingestion tasks), and produces optimally-sized segments. Because batch ingestion processes files that already exist (rather than a real-time stream), it can sort the data by time, apply rollup more efficiently, and build indexes more effectively β producing better-compressed, better-indexed segments than streaming ingestion can. Batch is also how you handle data corrections: if an upstream system sends a corrected version of yesterday's data, you batch-ingest the correction and the bad segments are replaced.
The dual-path architecture is production-critical for reliability. Streaming ingestion can fail mid-segment (the Kafka consumer crashes, a segment build fails), and you need the ability to replay from Kafka offsets. Batch ingestion handles this naturally β you just re-run the job against the same S3 files. Having both paths also lets you bootstrap a new table: stream from now, backfill with batch, and the system stitches the two together seamlessly.
Segment Compaction β Merging Small Streaming Segments
Streaming ingestion creates many small segments β each buffer flush produces a new segment, so if you flush every 10 minutes you generate 6 segments per hour. Over time, the cluster accumulates thousands of tiny segments, and query performance degrades because each segment requires separate I/O. The fix: segment compaction.
Druid's Coordinator and Pinot's Minion both run compaction jobs that merge small recent segments into larger optimally-sized ones (typically 300 MBβ1 GB each). Compaction happens in the background and doesn't block queries β the engine serves queries from the old small segments until the merged segment is ready, then atomically swaps them. The merged segment is better compressed (because the sorter has access to more data, finding better compression opportunities) and faster to query (fewer files = fewer I/O seeks).
Schema Evolution β Adding Columns Without Rebuilding History
Production systems evolve. You add a new tracking dimension β say, "ab_test_variant" β six months after the table was created. With traditional databases, adding a column to a 1B-row table means an ALTER TABLE that touches every row. Real-time OLAP engines handle this more gracefully: new columns are added to the schema, and from that moment forward new segments include the column. Old segments that pre-date the column simply return NULL for that column. No backfill required β the engine treats a missing column in an old segment as implicitly null. If you need historical values, you batch-ingest a corrected version of old data with the new column populated.
Query Engine Architecture β Broker, Coordinator, Historical
When a user fires a query against an Apache Druid or Pinot cluster, that query doesn't hit a single machine. It fans out across a cluster of nodes, each responsible for a subset of the segments, and the results are merged before being returned. Understanding this architecture tells you why real-time OLAP engines can answer billion-row queries in under 200 ms β they decompose the problem into thousands of sub-problems that run in parallel across hundreds of cores.
Druid's Node Topology
Druid splits its work across five different kinds of servers, each with one focused job. Think of it like a restaurant kitchen: somebody takes orders, somebody cooks, somebody plates, somebody manages the storeroom, somebody schedules deliveries. Below are the five "stations" and what each does:
-
Broker β the query entry point. Every client query hits a Broker first. The Broker understands the segment timeline (which segments cover which time ranges) and knows which Historical and Real-Time nodes hold which segments. It rewrites the query into sub-queries, fans them out in parallel, collects partial results, merges them, and returns the final answer. Brokers are stateless and horizontally scalable β add more Brokers to handle more concurrent queries.
-
Historical β the workhorse for segments older than the streaming window. Historical nodes download segments from deep storage (S3/HDFS/GCS) to local disk, keep them in memory-mapped files, and serve queries against them. A Historical node might hold 50β500 segments, each representing a few hours of data. Multiple Historicals answer the Broker's sub-queries in parallel β this is where segment-level parallelism lives.
-
Middle Manager / Real-Time Tasks β handles streaming ingestion. Middle Managers spin up "tasks" that each consume one or more Kafka topic partitions, build segments in memory, and periodically publish them to deep storage. Once published, a Historical node downloads the segment and takes over query responsibility.
-
Coordinator β the cluster's control plane. The Coordinator watches which segments exist (via metadata in MySQL/PostgreSQL), decides which Historical nodes should hold which segments (load balancing, replication), and orchestrates segment movement. It doesn't touch the query path.
-
Overlord β manages ingestion tasks. Works with Middle Managers to start/stop/monitor streaming and batch ingestion tasks. Think of it as a job scheduler specifically for the data-in side.
The topology diagram shows the fundamental principle: the Broker is a traffic director, not a compute node. It holds no segment data. Its job is to know "which segments touch the time range this query needs, and which Historical nodes hold those segments?" It then fires sub-queries to all relevant nodes simultaneously. If a query touches 600 segments spread across 30 Historical nodes, all 30 nodes work in parallel β the wall-clock time is determined by the slowest single node, not the sum of all nodes. That's why adding more Historical nodes directly reduces query latency for large queries.
Tail Latency and Speculative Execution
The fanout model has a well-known weakness: tail latency amplification. If a query fans out to 200 segment-level sub-queries, the query's total latency equals the slowest sub-query. Even if 199 sub-queries finish in 50 ms, one slow sub-query (perhaps because the segment is cold on disk and needs a page-fault load) can push total latency to 500 ms. At scale, this slow outlier is nearly guaranteed to appear on every query.
The mitigation is speculative execution: when a sub-query takes longer than expected (say, the 80th percentile threshold), the Broker sends a duplicate request to a replica of the same segment on a different node. Whichever responds first wins; the other is cancelled. This trades a small amount of extra load for a large reduction in tail latency β the p99 can drop from 500 ms to 100 ms with speculative execution enabled.
The Big 5 Real-Time OLAP Engines β Comparison
Five engines dominate real-time OLAP in production. They all share columnar storage, time partitioning, and ingestion from Kafka β so the surface-level story looks similar. But they make very different design choices for their target workloads, and picking the wrong one for your use case costs months of migration. Here's a precise breakdown of each, what they're optimized for, and when to choose them.
| Engine | Origin | License | Primary Strength | Notable Weakness | Choose When |
|---|---|---|---|---|---|
| Apache Druid | MetaMarkets 2011, ASF 2018, top-level 2019 | Apache 2.0 | Mature, multi-tenant, flexible time-series | Complex ops; joins are expensive | Multi-tenant SaaS analytics, ad-tech, event exploration |
| Apache Pinot | LinkedIn 2013, open-sourced 2015, ASF Incubator 2018, top-level 2021 | Apache 2.0 | Lowest latency, star-tree index, user-facing | Complex ad-hoc joins; operational complexity | User-facing dashboards requiring <100 ms at high concurrency |
| ClickHouse | Yandex ~2009, production 2012, open-sourced 2016 | Apache 2.0 | Fastest raw scan throughput; simple ops; huge community | Native streaming ingest less mature; real-time latency ~seconds | High-throughput analytics where simplicity > ultra-low latency |
| StarRocks | Forked from Doris 2020, open-sourced | Apache 2.0 (re-licensed from Elastic License 2.0 in 2023) | Strong join support; lakehouse integration (S3 tables) | Younger community; fewer managed cloud options | Lakehouse analytics, complex joins on real-time + historical data |
| Apache Doris | Baidu Palo, open-sourced 2017, ASF Incubator 2018, top-level 2022 | Apache 2.0 | Full MPP SQL, broad analytical coverage | Lower global adoption outside Chinese tech ecosystem | General real-time analytics; strong in Chinese tech companies |
Engine Deep Dives
Apache Druid β The Battle-Tested Generalist
Druid is the elder statesman of real-time OLAP. It was built in 2011 at MetaMarkets to power ad-tech analytics dashboards β exactly the workload that modern real-time analytics engines are designed for. Open-sourced in 2012, donated to the Apache Software Foundation in 2018, and graduated as an Apache top-level project in 2019. The core architecture (Lambda-heritage streaming + historical tiers, Broker scatter-gather, segment-level bitmap indexes) has proven itself at companies including Airbnb, Netflix, and many large ad platforms.
Why Druid is still the default for multi-tenant analytics: its query isolation model lets many tenants share a cluster without one runaway query starving others. The Broker intelligently routes queries to minimize cross-segment reads, and the Coordinator's segment placement policy keeps tenant data co-located to reduce cross-node fanout. The built-in native query API alongside modern SQL (via Calcite) lets advanced users squeeze out extra performance when needed.
Druid's weaknesses are real: join support is historically poor (Druid is designed for denormalized fact tables, not normalized star schemas). Operational complexity is high β you manage Broker, Historical, Middle Manager, Coordinator, Overlord, plus ZooKeeper and a metadata database. Managed Druid (via Imply.io or Apache Druid on Kubernetes) reduces this burden but adds cost.
Apache Pinot β The Latency Champion
Pinot was built at LinkedIn in 2013 to power live analytics for LinkedIn's feed β a context where millions of users simultaneously see analytics about their posts, connections, and ad performance. That origin shapes everything about Pinot's design: sub-100 ms latency at thousands of concurrent queries is the north star, not raw throughput or query flexibility.
Pinot's star-tree index is its most distinctive capability β the ability to pre-aggregate known dimension combinations at ingest time and resolve those aggregations with near-zero compute at query time. For dashboards where you know the GROUP BY dimensions (which is most production dashboards), this transforms 200 ms queries into 5β20 ms queries. LinkedIn runs Pinot at very high QPS across many clusters, with member-facing products like "Who's Viewed Your Profile" answering thousands of queries per second at tens-of-milliseconds latencies.
Pinot's weakness: anything that requires joining two large tables is a challenge. Pinot supports lookup joins (one small dimension table joined to a large fact table), but complex multi-table joins at scale require pushing join logic to the application layer or preprocessing the data before ingestion. If your workload requires SQL joins across multiple large real-time tables, StarRocks or Druid (with a broadcast-join workaround) is a better choice.
ClickHouse β The Raw Speed Specialist
ClickHouse was built inside Yandex starting around 2009, primarily to power Yandex.Metrica, one of the world's largest web analytics platforms. ClickHouse went into production at Yandex in 2012, was open-sourced under Apache 2.0 in 2016, and was spun out as ClickHouse Inc. in September 2021. It has since become one of the fastest-growing real-time OLAP engines by community adoption, largely because it is genuinely one of the fastest columnar scanners available and one of the easiest to operate.
Where Druid and Pinot have complex multi-node topologies, ClickHouse runs as a small cluster (or even a single node for moderate scale) with fewer moving parts. The MergeTree storage engine, sparse primary key index, and vectorized query execution are architectural choices that maximize raw CPU throughput β ClickHouse benchmarks frequently show it outperforming Druid and Pinot on pure scan-and-aggregate workloads, though the exact margin depends heavily on schema, indexes, and query pattern.
ClickHouse also has the richest SQL dialect of the five engines β hundreds of built-in functions, window functions, full JOIN support, arrays as first-class types, and a particularly capable time-series function library. This makes it the easiest to adopt for teams coming from traditional SQL databases.
ClickHouse's trade-off: native streaming ingestion (from Kafka) is less mature than Druid and Pinot. The MaterializedView approach works well but requires more manual configuration. Real-time data freshness typically lags by several seconds to a minute rather than 1β3 seconds. For workloads that need true sub-second freshness at very high Kafka ingest rates, Druid or Pinot is more battle-tested. For workloads that prioritize query speed and operational simplicity over ultra-low ingest latency, ClickHouse wins almost every time.
StarRocks β The Join-Capable Lakehouse Engine
StarRocks (formerly DorisDB) was forked from Apache Doris by the original Doris engineering team in 2020, with the goal of building a more performant MPP engine β short for Massively Parallel Processing, an architecture where a query is split into pieces that run simultaneously on many nodes and the results are stitched back together. The key differentiator: StarRocks treats joins as a first-class operation and has invested heavily in multi-table query optimization β colocated joins (data for joined tables stored on the same nodes), broadcast joins for small dimension tables, and runtime-adaptive join strategies.
StarRocks also has the best lakehouse integration story of the five: it can query S3 Parquet files directly (via external catalogs) without ingesting them into its own storage, making it a natural fit for architectures where data lives in a data lakehouse (Apache Iceberg, Delta Lake, Apache Hudi) and you want real-time analytics without full data duplication.
When to choose StarRocks: your analytics require joining multiple large real-time tables (e.g., joining impressions + clicks + user_profile + campaign in a single query), or you're building a lakehouse stack where the same data serves both batch processing (Spark) and real-time dashboards. The community is growing rapidly, particularly in Asia, and ClickHouse's simpler operational story makes it a stronger choice for pure analytics-only workloads.
Apache Doris β The Broad SQL OLAP Platform
Apache Doris (originally Baidu Palo) was open-sourced in 2017, donated to the Apache Software Foundation Incubator in July 2018, and graduated as a top-level Apache project in June 2022. It's a widely-used real-time OLAP engine in the Chinese internet ecosystem β Baidu, TikTok, Tencent, NetEase, and hundreds of others run Doris at scale. Like StarRocks, it's a full MPP SQL engine that supports complex joins, materialized views, and direct Kafka ingestion.
Doris has been broadening its scope to cover more of the analytics stack: it supports both real-time streaming ingestion and batch loading, has a growing ecosystem of integrations with Flink and Spark, and is working toward lakehouse compatibility. The SQL compatibility is strong β Doris is largely MySQL-compatible, which lowers the learning curve for teams already familiar with MySQL.
When to choose Doris: you're building in an ecosystem where Doris has existing support and tooling (common in Chinese tech), or you want a single SQL platform that covers real-time analytics and broader OLAP workloads without managing separate engines. For teams outside this ecosystem, ClickHouse or StarRocks will have larger communities and more available documentation in English.
The positioning chart reveals the fundamental trade-off: lower latency correlates with less join flexibility. Pinot achieves the lowest latency by pre-computing aggregations (star-tree) and restricting joins to simple lookup patterns. ClickHouse sits in the pragmatic middle β not the absolute fastest, but the best combination of query speed, join capability, and operational simplicity. StarRocks and Doris sacrifice some latency for much richer SQL join semantics, making them right for complex analytical queries but less suited for user-facing dashboards where every millisecond counts.
The decision tree distills the positioning chart into actionable choices. For most teams starting from scratch on a real-time analytics problem, ClickHouse is the pragmatic default: easiest to set up, fastest raw scans, excellent SQL, growing managed offerings (ClickHouse Cloud). Move to Pinot if you need sub-100 ms user-facing dashboards with known aggregation patterns. Move to Druid for multi-tenant SaaS analytics with isolation requirements. Move to StarRocks or Doris when complex joins across multiple real-time tables are non-negotiable.
Storage Tiers β Hot, Warm, Cold, and Deep
Real-time analytics data has a freshness decay curve. Yesterday's data is queried constantly; data from three months ago is queried occasionally; data from two years ago might be queried once a quarter for a compliance report. Storing all of this at the same cost and speed is wasteful β you'd either overspend on fast storage for old data nobody queries, or you'd suffer slow queries on recent data because you put everything on cheap storage. The solution: storage tiering, where data migrates from fast expensive storage to slow cheap storage as it ages.
The Three Tiers (Plus Deep Storage)
Most production real-time OLAP deployments use three tiers, with a fourth "deep storage" layer as the archive:
-
Hot tier β NVMe SSD, last few hours to days. The most recently-ingested segments live here. All data is in memory-mapped files on local NVMe SSDs. Queries hit segments that are largely already in the OS page cache, so effective read bandwidth is often in the tens of GB/s. Query latency for hot-tier data is in the 10β100 ms range. Cost: highest (NVMe SSDs on server-grade hardware in the cloud run ~$0.08β0.20/GB/month depending on cloud provider and instance type).
-
Warm tier β HDD or cheaper SSD, last few weeks to months. Segments that have aged out of the hot tier migrate here. Queries still complete at acceptable speed (1β5 seconds for large scans) because the data is local. Some deployments skip the warm tier entirely and go directly from hot to cold, accepting that "older" queries will be slower. The warm tier makes sense when you have a significant volume of medium-age data that is queried regularly but not in the sub-second SLA.
-
Cold tier (Deep Storage) β S3/GCS/HDFS, anything older than the warm window. All segments are uploaded to object storage as they are created β Druid and Pinot do this immediately on segment publish. The deep storage copy is the ground truth. Historical nodes cache a local copy; if a Historical node dies, it recovers by re-downloading from deep storage. Some modern engine versions (Druid 0.22+, Pinot tiered storage) support querying deep storage directly with a local cache, enabling you to answer queries over cold data without downloading the full segment β the engine fetches only the columns and pages needed for a specific query.
The pyramid shape is intentional β hot data is small in volume but high in query frequency, while cold data is vast in volume but rarely queried. The cost inversion is stark: NVMe SSD in the cloud costs roughly 6β7Γ more per GB than S3 object storage. For a 10 TB analytical dataset where 95% of queries touch only the last 3 days (300 GB), tiering means you pay NVMe rates only on 300 GB and S3 rates on 9.7 TB β a massive cost saving with no query-speed penalty for the 95% case.
Retention Policy β What to Keep and How Long
Storage tiers must be paired with a retention policy: how long do you keep raw data at each tier before discarding or archiving? A typical policy looks like this:
The retention timeline shows a layered strategy. Raw event data β which provides the highest granularity but the highest storage cost β is kept only for 30β90 days (enough for operational investigations and drill-downs). Hourly rollups (which are 200β500Γ smaller than raw data) are kept for 1β2 years, covering most trend analysis and year-over-year comparisons. Daily rollups (another 24Γ smaller than hourly) are kept indefinitely or for compliance periods (7+ years in regulated industries). This multi-layer approach means the total storage cost is dominated by the cheap cold-tier rollup data, not by the expensive hot-tier raw data.
The Cost Calculation β Why Tiering Makes Real-Time Analytics Economically Viable
Concrete math illustrates why storage tiering is essential at scale. Suppose you have 100 TB of raw event data generated per year. Without tiering, storing all of it on NVMe at $0.15/GB/month = $15,000/month just for storage. With tiering:
-
Hot tier (last 3 days of raw): ~820 GB Γ $0.15 = $123/month
-
Warm tier (90 days of raw + 2yr hourly rollups): 24 TB raw + 1 TB rollup Γ $0.04 = ~$1,000/month
-
Cold/Deep tier (3yr daily rollups + full raw archive): 300 GB rollup + 100 TB archive Γ $0.023 = ~$2,300/month
Total with tiering: ~$3,400/month versus $15,000/month flat NVMe β a 4.4Γ cost reduction. And query performance for the 95% of queries that touch recent data is actually better, because hot-tier data is entirely on fast NVMe instead of being diluted across 100 TB of mixed storage.
Common Use Cases β Where Real-Time OLAP Shines
Real-time OLAP is not a universal tool. It shines brightest in a specific constellation of conditions: fresh data measured in seconds, analytical queries with multiple group-by dimensions, high concurrency, and results that users or systems act on immediately. The six use cases below hit that constellation. Understanding why each fits helps you recognize the pattern when you encounter it in the wild.
The diagram above shows how LinkedIn's "Who viewed your profile" feature works under the hood. Profile view events flow from the browser into Kafka. A stream processor enriches them with viewer metadata; a fast path ingests directly into Pinot for real-time segment writes. Pinot serves the dashboard API in under 100ms β a query like "give me all viewers of member_id=42 in the last 30 days, grouped by viewer company" executes directly on time-partitioned columnar segments. End-to-end, an event from a profile view is queryable in roughly 2β5 seconds. That is the "real-time" promise.
The matrix above distills the key dimensions that drive engine selection: concurrency pressure, freshness tolerance, query latency SLO, and cardinality. User-facing dashboards (Netflix, LinkedIn) sit at the hardest corner β very high cardinality, thousands of concurrent users, sub-100ms SLO. Internal BI tools are comparatively relaxed: fewer users, a 1-second SLO, and medium cardinality β which opens the door to StarRocks or ClickHouse with richer SQL join support.
When a product feature shows analytics to end users β not internal analysts, but real customers β the bar is brutal. Netflix's content performance UI, LinkedIn's "Who viewed your profile", Stripe's transaction analytics dashboard: these serve thousands to tens of thousands of concurrent users, each firing slightly different queries (different date ranges, different dimension filters). The latency SLO is sub-100ms because users sitting at a browser notice any pause over 200ms. Cardinality is extremely high because user_id, session_id, and content_id are all in the hundreds of millions. Apache Pinot was purpose-built for this workload at LinkedIn β its broker tier routes queries to only the segments touched by the time filter, keeping latency predictable regardless of total data size.
Observability platforms ingest enormous volumes of metric, log, and trace data β CPU, memory, request rate, error rate, latency β tagged by host, service, region, and datacenter. An engineer investigating an incident types a query like "show me p99 latency grouped by service and region for the last 15 minutes" and expects results in under a second. The engine under the hood is real-time OLAP β ClickHouse powers many modern observability stacks (used at Cloudflare, eBay, and others for log analytics), and Druid is used at several large observability deployments. (Honeycomb is a notable exception: they built a custom columnar engine called Retriever rather than adopting an off-the-shelf OLAP engine.) The freshness requirement here is tighter than dashboards β an anomaly you detect 5 minutes late may already have cascaded into a full outage. Ingestion lag must be under 30 seconds, ideally under 5 seconds.
Ad-tech is where real-time OLAP was battle-tested first. A DSP needs second-level reporting: "how many impressions and clicks did campaign 9812 get in the last 60 seconds, broken down by country and ad creative?" Advertisers pause poorly-performing creatives; publishers need live floor-price adjustments. Apache Pinot powers LinkedIn's advertising analytics and several other ad networks. Apache Druid powers ad analytics at many programmatic advertising companies. The distinguishing characteristic of ad-tech: the cardinality of campaign_id Γ creative_id Γ publisher_id is astronomical, and rollup at ingestion time (collapsing individual impression events into 1-second buckets per campaign/country/device) is almost always done to keep segment sizes manageable.
Internal operations teams β logistics, customer support, supply chain, fraud β need dashboards where the data is measured in minutes, not hours. "How many orders are stuck in 'payment pending' right now, grouped by payment gateway?" is a query that drives action: someone escalates to the gateway vendor. The concurrency here is lower (50β500 users vs. 10,000), the cardinality is medium, and the latency SLO is relaxed to around 1 second. This opens the door to StarRocks or ClickHouse with richer join support, because internal BI queries tend to involve multiple tables (orders JOIN payments JOIN customers) that would be painful to fully denormalize. The data freshness requirement β data visible within 10β30 seconds of the event β is still tighter than Snowflake can offer but relaxed compared to user-facing dashboards.
An industrial IoT deployment might have a million sensors emitting temperature, pressure, and vibration readings every 5 seconds β that is 200 million events per second at peak. Queries look like "what is the average temperature reading for sensors in Zone 3 of Plant B that exceeded 85Β°C in the last 10 minutes?" The dimensions are device_id (high cardinality), zone, plant, sensor_type, and event_time. The time dimension is always the dominant filter β most queries are "last N minutes" β which is exactly what time-partitioned segments were designed for. Apache Druid's native time-based partitioning makes it a natural fit. ClickHouse's aggressive compression on repetitive telemetry values also works well here. The main gotcha: device_id cardinality can reach hundreds of millions, making HLL sketches mandatory for any "how many unique devices reported in the last hour" query.
When you launch a new feature to 1% of users, you need real-time guardrails: "is the checkout conversion rate for experiment group B statistically significantly below the control group right now?" Catching a bad experiment early β within minutes of launch, not hours β prevents wide exposure to a buggy or harmful feature. The query pattern is: group by experiment_id and variant, compute conversion rate (events / session starts), check if the difference exceeds the guardrail threshold. The data model is relatively simple (experiment_id, variant, user_id, event_type, timestamp), the concurrency is low (experimentation pipelines run scheduled checks), and freshness needs to be within 30 seconds. Apache Druid is commonly used here because its rollup can pre-aggregate per-experiment-per-variant counts at ingestion, making the guardrail queries trivially fast.
Data Modeling for Real-Time OLAP β Star, Snowflake, Wide-Table
In traditional data warehousing (Snowflake, BigQuery, Redshift), you learn to normalize your tables into star or snowflake schemas. That advice fails hard in real-time OLAP. The reason is simple: joins are expensive, and real-time OLAP engines are optimized for single-table, segment-parallel scans. Understanding the three modeling patterns β and when each breaks β is one of the most important skills in this space.
The diagram above compares the three schemas side-by-side. A star schema has one central fact table and 3β5 dimension tables joined by foreign key. A snowflake schema normalizes the dimension tables further (dimensions have sub-dimensions). A wide table collapses everything into one massive table with 200β500 columns. In batch OLAP, star schemas are optimal because the query engine can plan efficient hash joins. In real-time OLAP, every join requires cross-segment communication that destroys query latency β so the wide table wins despite its storage cost.
Why Wide Tables Win in Real-Time OLAP
Imagine your fact table has 50 billion rows. A star schema query needs to join it against dim_campaign (10 million rows) and dim_user (500 million rows). In Druid, that means the broker must coordinate across hundreds of segment servers, shipping hundreds of millions of dimension-table rows to each server for the join, or shuffling fact-table data to co-locate with dimension data. Either way, the latency blows up from 50ms to 30+ seconds β worse than Snowflake.
The wide table takes the opposite approach: at ingestion time, you flatten every dimension attribute into the fact row. When campaign_id appears in an event, you look up campaign_name, advertiser_id, and campaign_type at ingest time and write all of them directly into the fact row. At query time, there is no join β the query engine scans a single table's segments, filters by any combination of dimensions, and returns results. This trades storage (roughly 5β10Γ larger than a normalized star schema for the same data) for sub-second query performance.
Dimension Refresh and Slowly Changing Dimensions
A user upgrades from the free plan to the premium plan. A campaign gets a new name. A country gets renamed. These are dimension changes β facts about a user, campaign, or place that change occasionally over time, not on every event. The data-warehousing world has a name for these: Slowly Changing Dimensions (SCDs). The interesting question is: when a user's segment changes from FREE to PREMIUM, what should happen to past events that already mentioned them as FREE? Two approaches exist, and only one of them works well in real-time OLAP.
The SCD diagram above shows the critical insight about slowly changing dimensions. When a user moves from segment "FREE" to segment "PREMIUM", what does that mean for historical impressions? The wide-table pattern gives you a clean answer: each event row contains the user's segment at the time of the event. Impression A happened when the user was FREE β it will always show segment=FREE. Impression B happened after the upgrade β it shows PREMIUM. This is the correct approach for most analytical questions ("what was the conversion rate among FREE users last month?") and requires zero retroactive updates to stored segments.
The alternative β retroactively updating all historical rows when a dimension changes β requires rebuilding Druid or Pinot segments, which is expensive and can cause query inconsistency during the rebuild window. Pinot supports upsert tables for mutable data (introduced around v0.7 and stabilized through subsequent releases), which is valuable for use cases where you genuinely need to update facts (e.g., order status changes). Druid added SQL-style REPLACE semantics through its multi-stage query (MSQ) engine in v25 and later. But for dimension changes, the as-of snapshot is almost always the right choice.
The Cardinality Problem β Why It Determines Everything
Before you add a dimension column to your real-time OLAP schema, ask one question: how many distinct values can this column hold? Mathematicians and database folks call this count the column's cardinality. A "country" column has cardinality ~200 (one per country in the world); a "user_id" column has cardinality in the hundreds of millions. That single number determines whether your bitmap indexes work, whether rollup compresses your data, whether your queries run in 50ms or 30 seconds, and whether your cluster costs $5,000 or $50,000 per month. Getting cardinality wrong is the single most common cause of real-time OLAP performance disasters in production.
The chart above shows the non-linear relationship between cardinality and query latency. Columns with 10β1,000 distinct values (country, device_type, status) sit in the safe zone: bitmap indexes compress to a fraction of a bit per row, and rollup dramatically reduces row count. As cardinality climbs into the millions (user_id, session_id), bitmap indexes balloon in size β a bitmap index for 100 million user_ids requires a 100-million-bit vector per segment just to represent one column β and rollup cannot compress because every row has a unique combination of dimensions. Latency climbs from milliseconds to seconds to tens of seconds.
Why Bitmap Indexes Break at High Cardinality
A bitmap index for a column works like this: for each distinct value, the engine stores a bitmask with one bit per row. If you have 200 rows and the column "country" has 5 distinct values, you get 5 bitmasks of 200 bits each β trivially small. A query filtering WHERE country = 'US' loads one 200-bit bitmask and ANDs it with other filter bitmasks in microseconds.
Now imagine "user_id" with 100 million distinct values across 1 billion rows. The engine would need 100 million bitmasks of 1 billion bits each β that's roughly 12 petabytes of index data for a single column. Even with roaring bitmaps (a compressed bitmap format), the index for high-cardinality columns becomes larger than the data itself, slower to load, and provides zero filtering benefit (nearly every row has a unique value, so no bitmap narrows the candidate set meaningfully).
Solutions for High-Cardinality Dimensions
The diagram above shows two techniques that work in practice. The bucket-then-aggregate pattern replaces a high-cardinality user_id (100 million distinct values) with a low-cardinality bucket column (user_id modulo 1,000 = 1,000 distinct values). Per-bucket event counts can then be queried in milliseconds using bitmap indexes, and the application re-aggregates bucket results. The HLL sketch approach β used when you need a distinct count (how many unique users saw this ad?) β stores a 12KB probabilistic summary instead of a full set of user IDs. HLL provides a count-distinct estimate accurate to about 2% using fixed memory regardless of the actual count, making "how many unique users this week?" a single fast scan over pre-computed sketch columns.
Joins in Real-Time OLAP β Why Druid Hates Them and ClickHouse Tolerates Them
Joins are the backbone of relational analytics. In traditional SQL, you join your orders table to your customers table to get "orders with customer name" β obvious and cheap. In real-time OLAP with billions of rows distributed across hundreds of segment servers, a join requires either broadcasting the smaller table to every server or shuffling rows of both tables across the network to co-locate matching rows. Both options are expensive. Understanding which engine handles which join type β and when to avoid joins entirely β is critical for building fast real-time OLAP systems.
The diagram above shows the two join strategies used in distributed systems. In a broadcast join, the smaller table (a dimension table like dim_campaign with 10 million rows) is sent to every segment server. Each server then performs a local join against its slice of the fact table without any cross-server communication. This is fast as long as the broadcast table fits in each server's memory β typically fine for dimension tables under 100MB. In a shuffle join, rows from both tables are redistributed across nodes by their join key hash β all rows with order_id=42 end up on the same node, from both fact tables. This enables large-to-large joins at the cost of massive network I/O, and is only practical in engines designed for it (StarRocks, Apache Doris).
Engine-by-Engine Join Support
Druid was designed from day one as a single-table scan engine. Joins were explicitly not supported until v0.18, which added lookup-based left joins β essentially the broadcast join pattern where a small dimension table is pre-loaded into every historical node. Druid v25+ added the multi-stage query (MSQ) engine, which enables SQL joins by coordinating across workers in a map-reduce style. MSQ joins are significantly slower than native single-table scans β use them only when denormalization is genuinely impossible. The production recommendation for Druid remains: denormalize at ingestion, avoid joins. If you need complex joins in Druid today, you will be fighting the grain of the engine.
Pinot's join story is similar to Druid's. The core engine is optimized for single-table queries, and the lookup join pattern (small dimension tables broadcast to all server nodes) works well for dimensions under ~100 million rows. Pinot v0.11+ introduced the multi-stage query engine (MSE) which adds proper distributed join support β fact-to-fact joins become possible at the cost of increased latency and compute. For user-facing dashboards with strict sub-100ms SLOs, Pinot's best practice is wide-table denormalization + lookup joins for small dimensions only.
ClickHouse has full SQL join support and handles broadcast joins well β when you join a large fact table against a small dimension table (up to a few hundred million rows), ClickHouse broadcasts the small table to all query nodes and performs local hash joins efficiently. Large-to-large joins (both tables with billions of rows) are slower and may require query-level tuning. ClickHouse's JOIN types include hash join (default, good for small right-side table), sort-merge join, and full join; you select via SETTINGS join_algorithm. For ClickHouse use cases, the recommendation is: broadcast join for small dimensions (fast), avoid large-to-large joins or pre-denormalize.
The query above shows ClickHouse joining a large events table against a small campaigns dimension. The join_algorithm = 'hash' setting tells ClickHouse to load the campaigns table entirely into memory on each node and perform a local hash join β the broadcast pattern. This is fast (typically under 200ms even on billions of events rows) as long as the campaigns table fits in memory per node.
StarRocks (and its sibling Apache Doris) are the real-time OLAP engines with the best join support, because they were designed as distributed MPP (massively parallel processing) databases from the start β more like a fast distributed relational database than a purpose-built OLAP engine. StarRocks supports broadcast joins, shuffle joins (for large-to-large), bucket shuffle joins (for co-located tables), and colocate joins (when both tables are partitioned by the same key on the same set of nodes β zero network shuffle required). For use cases that genuinely need complex multi-table joins at real-time speeds, StarRocks or Doris is the right choice.
When to Denormalize vs. When to Join
The decision matrix above distills the join strategy to a simple lookup by situation. The consistent theme: every row of the matrix where Druid or Pinot appear recommends either denormalization or small lookup joins. Only when you have genuinely large-to-large join requirements β and you are willing to accept higher latency (typically 500msβ2s instead of 50msβ100ms) β does StarRocks or Doris enter the picture.
Monitoring and Operating Real-Time OLAP
Running a real-time OLAP cluster in production is not a "set it and forget it" operation. The engines are complex distributed systems with many moving parts β broker routing, segment servers, ingestion pipelines, deep storage tiering. Knowing which metrics to watch, what thresholds to alert on, and how to diagnose latency regressions before users notice is the operational skill that separates a 99.9% uptime cluster from a firefighting nightmare.
The dashboard above shows the six metrics that matter most for real-time OLAP operations. Each panel has an alert threshold baked in β let's walk through the reasoning behind each one.
The Seven Operational Metrics (and Why Each One)
Query latency p50/p95/p99. This is the primary user-facing SLO. p50 tells you what the median user experiences. p99 tells you the worst 1% β for a user-facing dashboard with 10,000 users, the worst 1% is 100 users getting a bad experience every minute. Alert on growth rate, not just absolute values: if p99 was 50ms yesterday and is now 200ms, that trend needs investigation before it reaches 2,000ms. A sudden p99 jump usually means either a new query pattern, a bad segment (corrupted or oversized), a GC pause on a historical node, or concurrency saturation.
Ingestion lag. The time from an event being written to Kafka to the event being queryable in the OLAP engine. For real-time dashboards, this needs to be under 5 seconds; for anomaly detection, under 30 seconds. A lag spike to 60+ seconds usually means the ingestion pipeline hit a bottleneck: the realtime ingestion nodes are falling behind Kafka, the segment commit pipeline is slow, or a Kafka partition imbalance is starving some ingestion tasks. DLQ depth is a related metric: events that fail schema validation or size checks land in the DLQ; a growing DLQ means silent data loss.
Query error rate. A healthy cluster should have near-zero query errors (less than 0.1%). When error rate climbs above 1%, it is almost always one of: OOM (out of memory) errors on segment servers caused by queries that scan too much data, query timeouts (queries that exceed the configured timeout and are killed), or broker-to-server connectivity failures. An OOM cascade is particularly dangerous: if one server runs OOM and restarts, its segments must be re-loaded from deep storage or re-balanced from other servers, increasing load on remaining servers, potentially causing more OOMs.
Concurrent queries. Every real-time OLAP cluster has a maximum concurrent query limit configured per node (in Druid, druid.server.http.numThreads; in ClickHouse, max_concurrent_queries). When the in-flight query count approaches this limit, new queries start queueing. Queueing causes latency to grow exponentially β a queue of 10 at 100ms per query means the 10th queued query waits 1,000ms before it even starts executing. Alert when concurrency exceeds 80% of the configured maximum for more than 30 seconds.
Segment count and average segment size. Segments that are too small (under 50MB) hurt performance because the query coordinator must fan out to more servers and merge more partial results. Segments that are too large (over 1GB) hurt because a single slow disk read blocks more of a query. The sweet spot for Druid is 300β600MB per segment. If you see thousands of tiny segments (caused by high-frequency realtime commits that weren't compacted), run a compaction job.
Storage tier hit ratio. If a query touches segments in the hot tier (local SSD or memory), latency is 5β20ms per segment. If it falls to the warm or cold tier (HDD or object storage), latency is 50msβ2,000ms per segment. Target 95%+ of queries served fully from the hot tier. A drop below 90% means your hot tier is under-sized for the query access pattern β either grow the hot tier or age out data more aggressively.
Broker routing time. The broker is the entry point for all queries. If the broker is slow (routing time p99 above 10ms), it becomes a single-point bottleneck regardless of how fast the segment servers are. Broker slowness is usually caused by: too many segments to route against (high segment count), slow ZooKeeper/metadata queries, or insufficient broker CPU/memory. Scale brokers horizontally if routing time grows under load.
The latency vs. concurrency curve above shows the characteristic "hockey stick" shape of queued systems. While in-flight queries are within the cluster's thread pool capacity, latency stays flat β each query gets immediate service. Once queries start queueing (beyond 80% of max concurrent), every new query must wait for an earlier query to finish before starting. Latency grows exponentially. The critical insight: you must alert at 80% concurrent capacity, not when latency is already bad. By the time p99 latency is visibly degraded, you're already in the exponential part of the curve and user experience has suffered.
Practice Exercises β Real-Time OLAP Reasoning
These five exercises are designed to build the reasoning muscles you need for real-time OLAP in interviews and on the job. Each one maps to a concept from S13βS17. Work through them yourself before reading the solution β the struggle is where the learning happens.
You are designing the schema for a real-time OLAP table for an event stream. Each event has four dimension columns:
countryβ 200 distinct values (US, GB, DE, β¦)user_idβ 10 million distinct valuesdevice_typeβ 50 distinct values (mobile, desktop, tablet, β¦)event_timeβ continuous timestamp
For each dimension, state (a) whether to use a bitmap index, an inverted index, or no index, and (b) why. Also flag any dimension that poses a cardinality risk.
- country (200 values) β bitmap index. Low cardinality, exactly the sweet spot for bitmaps. Each bitmap is 200 bits Γ row count β trivially small. Rollup can collapse millions of events per country per hour into pre-aggregated counts. Queries filtering WHERE country = 'US' execute in microseconds using the bitmap.
- device_type (50 values) β bitmap index. Even lower cardinality than country. Same reasoning. Excellent bitmap compression and rollup potential.
- user_id (10 million values) β NO bitmap index. Flag as cardinality risk. 10 million distinct values puts this firmly in the danger zone. A bitmap index would require 10 million bitmasks β exploding index size. Instead, if you need "events per user", use a sketch column (HLL for count-distinct) or materialize per-user aggregates. Never add user_id as an indexed dimension unless absolutely necessary. If you need to filter by a specific user_id (e.g., "show me all events for user 42"), use a range/inverted index with a specific equality filter, not a bitmap.
- event_time β time-based segment partitioning, not a bitmap. event_time is continuous; bitmaps don't apply. Instead, the engine partitions data into time-granular segments (e.g., hourly Druid segments, daily Pinot segments). Queries with a time range filter automatically prune irrelevant segments β this is the primary performance mechanism for time-series queries.
Your system receives 1 billion events per day. Each event has columns: country (200 values), campaign_id (50,000 values), event_type (click or impression β 2 values), and event_time. You want to rollup events into 1-hour buckets to answer queries like "how many clicks per campaign per country per hour?"
Calculate: (a) What is the maximum number of unique rows in the rollup table per day? (b) What compression ratio does rollup provide over raw events? (c) What dimensions are safe to include in the rollup key?
(a) Maximum rollup rows per day: The rollup key is (country, campaign_id, event_type, event_hour). Distinct combinations: 200 countries Γ 50,000 campaign IDs Γ 2 event types Γ 24 hours = 480 million unique rows maximum. In practice, not every campaign runs in every country every hour, so actual rollup rows will be much less β typically 5β30% of the theoretical maximum, meaning roughly 24β144 million rows per day in the rollup table.
(b) Compression ratio: Raw events = 1 billion rows per day. Rollup rows (theoretical max) = 480 million, practical = ~50 million. Compression ratio = 1,000,000,000 / 50,000,000 = ~20Γ reduction in row count. This translates directly to faster queries and lower storage cost. The more events per dimension combination, the better rollup compresses (a campaign with 100,000 events per hour becomes 2 rows β one for clicks, one for impressions).
(c) Safe dimensions for rollup key: country (200), campaign_id (50K), event_type (2), event_hour β all are bounded cardinality and fine. What you MUST NOT add to the rollup key: user_id (10M+), session_id, or request_id. Adding user_id to the rollup key would mean one row per user per campaign per hour β that's 1 billion users Γ 50K campaigns = 50 trillion possible rows, defeating the entire purpose of rollup. If you need per-user analysis, keep raw events in a separate high-cardinality table and use HLL sketches for distinct-user counts in the rollup.
Choose between Apache Druid, Apache Pinot, ClickHouse, and StarRocks for each of the following scenarios. State your choice and the two most important reasons for it.
- Scenario A: A user-facing dashboard feature for a social platform. Expected: 10,000 concurrent users, query latency SLO of <100ms, data freshness of <5 seconds, single-table GROUP BY queries.
- Scenario B: An internal business intelligence tool for operations teams. Expected: 200 concurrent users, queries involve 3-table joins (orders + inventory + suppliers), latency SLO of <2 seconds, daily refreshed dimension tables.
- Scenario C: Infrastructure metrics and alerting. Expected: 50 million metrics per minute, 30-day retention with hourly rollups after 1 day, complex time-series queries by host/service/region, query latency <500ms.
Scenario A β Apache Pinot. Reasons: (1) Pinot was built by LinkedIn specifically for high-concurrency user-facing dashboards β its broker tier, pre-computed indexes, and realtime segments are optimized for exactly the 10,000 concurrent users Γ sub-100ms pattern. (2) Single-table GROUP BY queries with high concurrency is Pinot's core sweet spot; no joins means no penalty for Pinot's join limitations. Druid would also be acceptable but Pinot has slightly better story for user-facing latency SLOs.
Scenario B β StarRocks (or ClickHouse). Reasons: (1) Three-table joins are required β Druid and Pinot handle this poorly. StarRocks has first-class MPP distributed joins and handles multi-table SQL naturally. (2) 200 concurrent users and a 2-second SLO is relaxed enough for StarRocks's join overhead. ClickHouse is also acceptable if the tables are medium-sized and broadcast join strategy works (small dimension tables). Avoid Druid or Pinot here β the 3-table join requirement alone disqualifies them.
Scenario C β Apache Druid. Reasons: (1) Druid was designed for exactly this pattern β time-series metric data with tiered retention (hot segments in SSD for recent data, warm/cold segments in S3 for historical data) is a first-class Druid feature. The segment compaction pipeline handles rolling hourly rollups automatically. (2) Time-range queries by host/service/region with aggressive segment pruning is Druid's original design target. ClickHouse is a reasonable alternative (it also handles time-series and tiering), but Druid's native Kafka ingestion and rollup at ingestion time are the cleanest fit for this ingest volume.
A Druid cluster's p99 query latency has doubled from 120ms to 240ms over the past two hours. The cluster is serving a product analytics dashboard. No code was deployed. What are the three most likely causes, and what would you check first for each?
Cause 1: Segment count explosion (most common). Druid accumulates small realtime segments as data is ingested. If compaction is not running, segment count grows continuously. More segments = more fan-out from broker to historical nodes = more inter-process communication overhead per query. What to check: Druid Console β Segments tab β sort by time. If you see thousands of segments under 50MB each, compaction is lagging. Check the compaction task queue. Fix: trigger a compaction task for the affected datasource.
Cause 2: Concurrency saturation. If dashboard usage grew in the past two hours (peak business hours, a new feature rollout), the cluster may be hitting its concurrent query limit. Queries queue and wait for free threads, adding latency. What to check: Druid metrics β concurrent queries metric. Compare current concurrency vs. two hours ago. If concurrency grew significantly and is now above 70-80% of max_concurrent_queries, the cluster is queuing. Fix: scale out historical/broker nodes or reduce per-query resource limits to allow more concurrency.
Cause 3: Hot tier fill-up causing cold segment reads. If ingestion volume increased (e.g., a batch of historical data was loaded or a new high-volume event type was added), the hot tier (local SSD) may have filled up, forcing recent data to spill to warm/cold tiers. Queries that should hit SSD are now hitting HDD or object storage, causing 10-100Γ higher segment scan latency. What to check: Hot tier hit ratio metric. If it dropped from 97% to 85%, this is the cause. Fix: increase hot tier capacity, or adjust tiering policy to age out older segments faster.
You are operating a real-time metrics platform with the following retention requirements:
- Last 24 hours: Full raw events at 1-second granularity, all dimension columns, sub-100ms query latency required.
- Days 2β30: Hourly rollups (aggregate per host/service/region per hour), query latency under 500ms.
- Months 2β12: Daily rollups (aggregate per service per day), query latency under 2 seconds.
Design the tiering strategy: (a) which storage tier for each retention window, (b) how to implement the rollup transitions, and (c) what the estimated storage reduction is at each tier compared to raw events.
(a) Tier mapping:
- Last 24 hours β Hot tier (local NVMe SSD or memory-mapped). Sub-100ms SLO requires segments to be resident in fast local storage. This is the most expensive tier β size it to hold 24 hours of raw events. For a 50M events/minute stream, 24 hours = ~72 billion events Γ ~100 bytes/event β 7.2TB of raw data; after columnar compression and rollup at ingestion, typically 300GBβ1.5TB on disk.
- Days 2β30 β Warm tier (local HDD or EBS/persistent disk near the cluster). Hourly rollups are much smaller than raw events; 500ms SLO is achievable from local HDD or fast persistent block storage. Do not use object storage (S3/GCS) for this tier β the object-storage access latency (10β100ms per segment fetch) would blow the 500ms SLO for complex queries.
- Months 2β12 β Cold tier (object storage: S3, GCS, or Azure Blob). Daily rollups accessed infrequently; 2-second SLO is achievable from object storage for simple queries. Cold tier storage is 10β100Γ cheaper than SSD. This is where the economics of real-time OLAP systems become attractive at scale.
(b) Rollup transitions: In Druid, configure segment tiering rules in the Coordinator: "segments older than 24 hours move to tier=warm, segments older than 30 days move to tier=cold". Run a separate compaction task that processes day-old segments and produces hourly-aggregated segments (one row per host/service/region/hour replacing potentially millions of raw rows). A second compaction task processes 30-day-old segments and produces daily-aggregated segments. In Pinot, use the retention manager plus a separate offline table for pre-aggregated historical data ingested via batch job.
(c) Storage estimation per tier: Assuming 100 hosts Γ 50 services Γ 10 regions = 50,000 unique host/service/region combinations. Raw events: ~50M events/minute β 72 billion events/day. Hourly rollup: 50,000 combinations Γ 24 hours = 1.2 million rows/day β a 60,000Γ compression ratio vs. raw. Daily rollup: 50,000 combinations Γ 1 = 50,000 rows/day β a 1.44 millionΓ compression ratio vs. raw. The cold tier is essentially free to operate compared to the hot tier despite covering 11 months of history.
Bug Studies β When Real-Time Analytics Goes Wrong
Real bugs teach more than theory ever will. These four production incidents are reconstructed from real patterns that engineering teams hit repeatedly. None of them required exotic hardware failures or freak events β each one was a straightforward engineering mistake that looked reasonable at design time and only revealed its cost at scale.
session_id as a Dimension Destroyed the Bitmap Index
session_id as a dimension column to enable "per-session funnel analysis". Within 48 hours of deploying to production, segment sizes ballooned from 200 MB to 8 GB per segment. Dashboard query latency jumped from 100 ms to 30 seconds. The ingestion pipeline fell behind by 4 hours before the team realised what had happened.
What Went Wrong
Real-time OLAP engines like Druid and Pinot use bitmap indexes on dimension columns to accelerate filtering and grouping. A bitmap index has one bit-vector per distinct value β for a country column with 200 countries that is perfectly fine (200 bit-vectors, trivially small). But session_id is a UUID: every session is unique. In a data set with 1 billion events per day, that means 1 billion distinct values, and therefore 1 billion bit-vectors. The engine has to materialise an index entry for every single session, and almost every bit-vector is zero everywhere except one row. The index becomes larger than the actual data and provides zero compression benefit.
The math: a bitmap index over a column with cardinality C in a segment of N rows costs roughly C Γ N / 8 bytes. At C = 1 billion and N = 100 million rows per segment, that is ~12 TB per segment for the index alone β obviously impossible. Even at realistic cardinalities (C = 1 million), the index balloons the segment from 200 MB to 8 GB.
The SVG shows the core problem: a bitmap index on country (200 values) creates 200 compact, compressible bit-vectors β excellent. A bitmap index on session_id (1 billion unique values) creates 1 billion bit-vectors, each with only one bit set β no compression is possible and the index dwarfs the data itself.
What Went Wrong
The ingestion pipeline consumed from Kafka and loaded data into the engine. Kafka topics have a consumer lag metric that accurately reports how far behind a consumer is. The team had not set up any alert on this metric. The OLAP engine continued to answer queries β it just answered them with whatever data it had already ingested. It did not signal "my data is 2 hours old". From the dashboard user's perspective, charts still refreshed every 30 seconds; the numbers just quietly stopped moving.
This is a silent failure mode. Unlike a database crash or a 500 error, data staleness is invisible unless you explicitly monitor for it. A dashboard that shows stale data looks exactly like a healthy dashboard β until someone checks the timestamps.
The chart shows both offsets tracking together during normal load. When the campaign launches (red dashed line), the Kafka offset spikes because event production triples. The ingestion pipeline can't keep up and the two lines diverge β the gap is the staleness that dashboards silently serve to users.
What Went Wrong
Rollup is one of the most powerful features of real-time OLAP engines. Instead of storing one row per event (1 billion events/day = 1 billion rows), you pre-aggregate at ingestion time: group events by their dimension values within a time bucket (e.g., 1 minute), sum the metrics, store one row per group. For a dataset with 1 billion events/day and 10,000 unique dimension combinations, rollup might reduce 1 billion rows to 1 million rows β a 1000Γ reduction in storage and a 1000Γ reduction in query scan work.
The trade-off is that once you roll up, individual event identity is gone. You cannot ask "show me the exact sequence of clicks for user X" β only aggregated totals. For 99% of dashboard queries, that is exactly what you want. The rare need for event-level drill-down should be served by a separate raw event log (cheap S3-backed storage), not by removing rollup from the real-time OLAP layer.
What Went Wrong
Real-time OLAP engines like Druid and Pinot use tiered storage where the hot tier keeps frequently-queried segments in local fast storage. Hot-tier nodes have a finite amount of memory and disk. When traffic spikes and more segments are needed simultaneously than the hot tier can hold, the engine evicts the least-recently-used segments to S3. Queries that hit evicted segments now have to read from S3 β which can be 100Γ slower than local NVMe for small random reads.
The bug has two components: (a) the hot tier was not sized for peak traffic, only average traffic; (b) there was no alert when hot-tier eviction was happening, so the team did not know to add capacity until p99 had already spiked. Hot-tier eviction is one of the most common causes of surprise latency spikes in production OLAP deployments.
The diagram shows the normal (green) path β broker routes queries to the hot-tier NVMe nodes and returns in 80 ms. During the flash sale (orange box), hot-tier nodes run out of memory, evict segments to S3, and the next query for those segments falls through to the warm tier β a 100Γ latency increase from a storage tier change, not from any query change.
Real-World Architectures β How Big Companies Do Real-Time Analytics
The best way to understand real-time OLAP engine choices is to see the specific problems that drove each company to build or adopt a particular engine. These case studies show the architecture β not promotional numbers, but the engineering rationale and the data flow.
This is the canonical architecture shared by almost every large real-time analytics deployment. Events flow from source systems into Kafka, optionally through a stream processor for enrichment or windowing, then directly into the OLAP engine. Druid and Pinot have native Kafka consumers built in, so many deployments skip the separate stream processor entirely. Dashboards and internal APIs query the OLAP engine directly at sub-second speed.
LinkedIn created Apache Pinot to solve a very specific problem: user-facing analytics. "Who Viewed Your Profile" and similar features need to show each user their own real-time stats β profile views, post impressions, follower growth β updated within seconds, while hundreds of millions of users are checking simultaneously. This is fundamentally different from internal analytics dashboards: every query is for a different user's data, concurrency is extreme, and latency must be imperceptibly fast (sub-100 ms) or the feature feels broken.
LinkedIn built Pinot in-house because no existing system handled the combination of user-facing query latency (under 100 ms), high concurrency (tens of thousands of queries per second from end-user traffic), and fresh data (seconds old). They open-sourced Pinot in 2015. The system feeds Kafka events through Pinot's real-time ingestion, which partitions data by user ID so each query only hits the shards relevant to that user β a design that dramatically reduces fan-out compared to a global scatter-gather approach.
Netflix adopted Apache Druid for two distinct use cases: content performance analytics (how are shows performing by region, device type, time of day?) and operational analytics (are CDN nodes healthy? What are error rates by region and content type?). The operational analytics case is particularly interesting β it requires querying over real-time system telemetry at sub-second speed, which is more "observability" than traditional analytics.
Netflix runs Druid at multi-petabyte scale with what's called a Lambda architecture β the idea of running two parallel pipelines for the same data, one fast-and-approximate (the streaming path) and one slow-but-accurate (the batch path), then combining their answers so users see fresh numbers and trustworthy historical totals. Concretely: a real-time path (Kafka β Druid real-time ingestion) for fresh-but-approximate data, and a batch path (batch jobs β Druid historical segments) for accurate-but-lagged data. Druid merges the two paths transparently at query time. Netflix also built Mantis, their internal stream processing platform, which pre-processes events before they enter Druid β filtering, aggregating, and enriching at the stream layer to reduce the raw data volume that Druid has to index.
Uber uses Apache Pinot for internal real-time dashboards that track rider demand, driver supply, surge pricing effectiveness, and trip completion rates β all by city, time-of-day, and vehicle type, updated in near-real-time. The key challenge at Uber is geographic cardinality: the system needs to slice metrics by city (500+ cities), neighbourhood, and time simultaneously, which creates a large dimension space.
Uber built uReplicator, a custom Kafka-to-Kafka replication system that stabilises Pinot's Kafka consumption at scale. Standard Kafka consumer group rebalancing caused ingestion gaps at Uber's partition counts and throughput levels. uReplicator acts as a middleware that provides a stable, predictable feed to Pinot's real-time nodes.
Airbnb uses Apache Druid to power several analytics products: host dashboard metrics (how many views did your listing get this week? what's your acceptance rate?), search ranking and relevance analytics, and A/B experiment result dashboards. The A/B testing use case is particularly demanding: Airbnb runs hundreds of experiments simultaneously, and experiment analysts need to see metric results broken down by experiment variant, user segment, and time period β updated fresh enough to make ship/no-ship decisions quickly.
Airbnb chose Druid over Pinot for this use case because their query patterns lean more toward multi-dimensional exploration (analysts exploring data, not users checking their personal stats) and Druid's mature operator tooling suited their data engineering team. They use Druid's virtual columns feature extensively to avoid re-ingesting data every time a new computed dimension is needed for an experiment.
ClickHouse has become the de-facto standard for high-throughput log analytics and observability stacks at scale β used at companies including Cloudflare (HTTP log analytics), eBay, Uber, and many others for internal product analytics (feature usage tracking, message send rates, API call volumes) and observability (log aggregation, error rate dashboards). Teams typically choose ClickHouse over Druid/Pinot for a few consistent reasons: simpler operational model (no separate real-time/historical node split, no external deep storage required for basic deployments), richer SQL support, and significantly easier schema management (Druid and Pinot require schema changes with ingestion restart; ClickHouse's ALTER TABLE ADD COLUMN is immediate).
A log analytics pipeline is a good example of ClickHouse's strength: structured log events (JSON) are parsed at write time into typed columns, then ClickHouse's MergeTree storage engine compresses them aggressively. Engineers query logs using SQL with full-text LIKE searches on the message column and aggregate over structured fields β a pattern that is awkward in Druid/Pinot but natural in ClickHouse.
The table summarises the engine-to-use-case mapping seen across these companies. Pinot wins when user-facing sub-100 ms latency is the primary requirement. Druid wins when multi-tenant internal dashboards with Lambda architecture maturity are needed. ClickHouse wins when simplicity, flexibility, and rich SQL matter more than extreme concurrency. StarRocks/Doris wins when join performance against lakehouse formats (Iceberg, Delta) is required. All four have managed cloud offerings that eliminate most operational complexity.
Common Misconceptions β What People Get Wrong About Real-Time Analytics
Real-time OLAP is one of those topics where confident-sounding incorrect beliefs are common β partly because the category is relatively new, partly because the engine names (Druid, Pinot, ClickHouse) are less familiar than Postgres or Redis. These are the most common wrong turns engineers take when approaching this space for the first time.
Real-time OLAP engines and data warehouses (Snowflake, BigQuery, Amazon Redshift) solve different parts of the analytics problem. They are not competitors β they are complements.
Data warehouses excel at long-running complex analytical queries: historical trend analysis, year-over-year comparisons, complex multi-table joins, ad-hoc exploration by data analysts. These queries can take seconds or minutes and that is acceptable β they run infrequently, are not user-facing, and scan enormous amounts of data.
Real-time OLAP engines excel at a narrow but critical requirement: sub-second queries over fresh data under high concurrency. They are optimised for the live dashboard case, not the "run a six-table join over 3 years of transaction history" case.
The modern data stack typically uses both: Kafka β real-time OLAP engine for live dashboards β also β batch ETL β data warehouse for deep historical analysis. Removing the warehouse to save cost is a common and painful mistake β the real-time OLAP engine will gradually accumulate historical data, its operational cost will rise, and it will never perform as well on the batch analytical queries the warehouse was handling.
Both are distributed real-time OLAP engines with time-based segment partitioning, bitmap indexes, and Kafka ingestion. But their design centres are different enough to matter at production scale.
Apache Pinot was designed from the start for user-facing analytics at LinkedIn β where p99 latency must be under 100 ms even at tens of thousands of queries per second. Pinot's broker routing is partition-aware (routes to only relevant segment servers), its indexing includes sorted indexes and range indexes beyond simple bitmaps, and its latency SLOs at the design level are stricter than Druid's.
Apache Druid was designed for multi-tenant internal analytics with a Lambda architecture β supporting both a real-time path and a batch historical path that merge seamlessly. Druid's historical node system is more mature for managing petabytes of historical segments, its coordinator is more battle-tested for large-scale multi-tenant cluster management, and its operator community is larger and more experienced.
Choosing between them: if you need user-facing sub-100 ms latency, Pinot is the stronger choice. If you need mature multi-tenant cluster management and Lambda architecture support, Druid is the stronger choice. For most new deployments, ClickHouse is worth considering first because it avoids the operational complexity of both.
ClickHouse is simpler to operate than Druid or Pinot and is an excellent choice for a large fraction of real-time analytics use cases. But "good enough" has limits.
ClickHouse uses a scatter-gather model at the server level. At very high query concurrency (500+ simultaneous queries) over a very large cluster, ClickHouse's per-query CPU cost per server starts adding up. Druid and Pinot's segment-level fan-out parallelism (a single query hits many small segment servers, each returning a tiny partial aggregate) scales more gracefully for this specific extreme-concurrency pattern.
ClickHouse also does not have Druid/Pinot's tiered storage model built in by default (though ClickHouse Cloud and recent versions add cold storage support). For deployments where hot/warm/cold tier management over multiple petabytes is a first-class requirement, Druid/Pinot have more mature solutions.
That said: if your use case is internal analytics dashboards, product metrics, log analysis, or operational dashboards β and your query concurrency is in the hundreds rather than thousands β ClickHouse is almost certainly the right choice. It has richer SQL, simpler operations, and faster development iteration than either Druid or Pinot.
Both engines have improved join support in recent versions β Druid added hash joins in 0.20, Pinot added broadcast join support. But "improved" does not mean "fast at scale".
Joins in Druid and Pinot work well when one side of the join is small enough to broadcast to all segment servers (typically under a few hundred MB). A lookup join β "join this event table to a dimension table of 10,000 campaigns" β works acceptably because the dimension table fits in memory everywhere.
What remains slow is joining two large fact tables. There is no efficient distributed shuffle join in either Druid or Pinot that approaches the performance of a native columnar scan on a pre-denormalised wide table. The correct long-term strategy is: denormalise at ingestion time. If you need to join campaign metadata to impression events, do it in the stream processor (Flink, Kafka Streams) before the events land in the OLAP engine. Do not rely on join performance in the OLAP query layer for production dashboards.
In OLTP databases (Postgres, MySQL), adding an index on a frequently-filtered column is almost always a win. Real-time OLAP engines are different: every index you add costs storage at ingestion time and slows down write throughput.
Druid and Pinot build bitmap indexes automatically for dimension columns and range indexes for metric columns with range predicates. Adding additional index types (inverted, sorted, text) accelerates specific query patterns but increases segment size. At scale, a 30% increase in segment size means 30% more data to read from S3, 30% more hot-tier memory needed, and 30% more ingestion CPU.
The discipline: start with the default indexes. Measure which queries are slow. Only add an index when you can measure the query improvement vs. the ingestion + storage cost. Many teams add every index type "just in case" and then wonder why segments are 3Γ the size they expected.
This is partially true and highly engine-dependent. ClickHouse handles both well β it has no architectural split between "real-time" and "historical" nodes. You can store years of data and query it at sub-second speed (for manageable table sizes) without any tier-management overhead.
Druid and Pinot are more complex here. Both support storing historical segments in deep storage (S3/HDFS) and loading them on demand, but queries that span large historical ranges must load many segments from object storage β which is much slower than querying hot-tier data. Complex batch analytical queries ("year-over-year trend for all campaigns from 2019β2024") may time out or be prohibitively slow on Druid/Pinot.
The practical answer: use the right tool for each job. Real-time OLAP engine for sub-second fresh dashboards (last 90 days hot/warm). Data warehouse for deep historical analysis. The operational cost of keeping 5 years of data in Druid at hot-tier performance levels vastly exceeds the cost of a warehouse query that takes 20 seconds.
HyperLogLog (HLL) is one of the most useful tools in real-time analytics β it estimates distinct counts (unique users, unique sessions, unique IPs) in constant memory regardless of the actual cardinality. Instead of storing every distinct value, an HLL sketch stores a compact probabilistic summary. The trade-off is accuracy: HLL has a standard error rate of roughly 1β2%.
For most analytics use cases, 1β2% error is invisible: "you had 4,917,200 unique visitors" versus "4,900,000 unique visitors" β neither is actionable at a different level than the other. But some use cases genuinely require exact counts:
- Compliance and billing: "How many users accessed feature X?" for licensing audits or usage-based billing must be exact β an off-by-1% error at 10 million users is 100,000 users you either over- or under-charged.
- Fraud detection: Counting distinct credit cards used in a session where the threshold is "flag if more than 3" β a 1% error on a small cardinality is a 100% wrong answer.
- Legal reporting: GDPR data access counts, SOX audit trails β exact records required.
If your business has any of these requirements, use exact COUNT(DISTINCT) with a raw event store, not HLL on a pre-aggregated rollup. ClickHouse supports exact uniq() alongside HLL uniqHLL12() β pick deliberately based on the accuracy requirement of each specific metric.
Operational Playbook β Pick, Onboard, and Run a Real-Time OLAP Engine
Reading about real-time OLAP engines is the easy part. Choosing one, deploying it safely, and keeping it healthy in production is where most teams struggle. This five-stage playbook covers the decisions and tasks in the order you actually encounter them.
The engine choice is the single most important and least reversible decision in this stack. Migrating from one engine to another at production scale is a multi-month engineering project. Get this right before you write the first ingestion job.
Use this decision framework:
- Sub-100 ms user-facing queries at high concurrency (user dashboards, SaaS product analytics)? β Apache Pinot or managed StarTree. Pinot's partition-aware routing is purpose-built for this.
- Multi-tenant internal analytics with Lambda architecture and mature cluster management? β Apache Druid or managed Imply. Druid's historical node management is the most battle-tested.
- Simplicity + flexibility + rich SQL + moderate concurrency (under ~300 QPS)? β ClickHouse or ClickHouse Cloud. Fastest path from schema design to production query.
- Join-heavy workloads over Iceberg/Delta/Hudi lakehouse tables? β StarRocks or Apache Doris. Both have native lakehouse catalog connectors and efficient distributed join execution.
- API-first product where you want ClickHouse power without operating ClickHouse? β Tinybird (ClickHouse-as-an-API managed service).
After choosing an engine, schema design is where most onboarding mistakes happen. Get this wrong and you pay for it forever (or until you rebuild the entire data set).
Step A β Design your schema as a wide table. Resist the normalisation instinct. Join campaign metadata, user properties, and event attributes into one wide row at ingestion time (in the stream processor). Real-time OLAP engines are designed for wide tables: 20β50 columns, with most of them being dimension columns that can be filtered and grouped.
Step B β Classify every column before writing the schema. For each column ask: "Will this be filtered/grouped in WHERE or GROUP BY clauses?" β dimension column. "Will this be summed/averaged/counted?" β metric column. "Is this a unique identifier per event (UUID, session_id)?" β NOT a dimension β exclude from indexing or treat as a metric.
Step C β Configure rollup early. Rollup is much harder to add retroactively than to configure from the start. Decide your rollup granularity (1 minute is usually a good default), confirm which metrics are additive (SUM), and which require approximate data structures (distinct counts β HLL). If in doubt, enable rollup at 1-minute granularity and keep a raw event log in a separate cheap store for drill-down access.
Real-time OLAP systems have failure modes that do not appear under synthetic test loads. Test with data that matches production in cardinality, not just volume.
Load test with production-like cardinality. Generate test data that has the same distribution of dimension values as production β same number of unique campaign IDs, same country distribution, same device type ratios. A load test with 10 synthetic campaign IDs will pass even with a fatally high-cardinality schema, because 10 IDs is a perfectly acceptable bitmap index. Only production-like cardinality will reveal the problems from Section 19.
Chaos test query broker failures. Kill a query broker node while load is running. The remaining brokers should absorb traffic; queries should return errors or reroute, not cause a cluster-wide stall. If your broker layer is under-provisioned, a single broker death under load can cause query queue buildup that takes minutes to drain.
Measure p99 latency under concurrency, not just average. Most dashboard SLOs care about p99 β the experience of the 1% of users whose queries take longest. A 200 ms average with a 4,000 ms p99 is a worse user experience than a 400 ms average with a 600 ms p99. Always report p99 (and p999 for user-facing products) in load test results.
A real-time OLAP system in production has more moving parts than a typical OLTP database. The five signals below will catch 90% of production incidents before users notice:
- Query p99 latency β the primary SLO metric. Alert when p99 exceeds your threshold for 2+ minutes. Causes: hot-tier eviction, cardinality explosion, broker under-provisioning.
- Kafka consumer group lag β the staleness signal. Alert when lag exceeds 100,000 events (tune to your throughput). Causes: ingestion pipeline crash, schema validation failures silently dropping events, temporary throughput spike.
- Segment-size distribution β a leading indicator of schema problems. If any segment grows 3Γ the expected size, a high-cardinality dimension was likely added. Catch this before it cascades to storage exhaustion.
- Storage tier hit ratio β what fraction of query bytes are served from hot (fast) vs warm/cold (slow) storage. A sudden drop in hot-tier hit ratio predicts a p99 spike. Alert before the spike, not after.
- Error rate β query errors (broker returns 500) and ingestion errors (schema validation failures, Kafka deserialization errors). A low but non-zero ingestion error rate is often data loss in disguise.
Optimization in real-time OLAP is a continuous activity, not a one-time task. The four levers you will use repeatedly:
Cardinality control. Periodically audit dimension cardinality in production. Run SELECT COUNT(DISTINCT dim) FROM table for each dimension column. Any column with cardinality over 100,000 that was not intentionally designed that way is a candidate for removal or pre-aggregation. Cardinality grows silently as data accumulates β a column that had 5,000 distinct values at launch can have 500,000 after a year.
Materialized views for hot queries. If 80% of your dashboard traffic queries the same set of dimensions and time ranges, create a materialized view (or a separate rollup datasource in Druid/Pinot) that pre-aggregates exactly those dimensions. The view is maintained at ingestion time at near-zero marginal cost, and queries against it are 10β100Γ faster than scanning the base table.
Rollup granularity tuning. As data ages, you can increase rollup granularity β roll up 1-minute data into 1-hour data for segments older than 30 days. This is called "compaction" in Druid. It dramatically reduces segment count and storage cost for historical data while keeping the data queryable.
Tier-down stale data. Configure storage tier policies so that segments older than your "hot" window automatically move to warm storage (larger, slower disk or object storage). Do not keep data in the hot tier indefinitely β it crowds out the recent data that actually needs fast access. Set automatic tier-down policies and validate that old queries (last 30β90 days) still complete within an acceptable latency budget from the warm tier.
Cheat Sheet & Glossary
Quick-reference cards for review and interview prep. Pin this section β it condenses the whole page into one-liners and clear definitions.
Core Rules β One Line Each
Glossary
- Columnar storage
- Stores each column's values together on disk rather than each row. Enables vectorised SIMD operations and compression per column type β critical for fast analytical scans.
- Segment
- An immutable, time-bucketed file group containing columnar data + indexes for a specific time range. Druid and Pinot distribute query work across segments in parallel.
- Rollup
- Pre-aggregating many raw events into fewer rows at ingestion time β grouping by dimension values within time buckets and summing metrics. Reduces storage and query scan work by 100β1,000Γ.
- Materialized view
- A pre-computed query result stored as a physical table, maintained automatically as new data arrives. Eliminates repeated base-table scans for common query patterns.
- HyperLogLog (HLL)
- A probabilistic data structure for estimating COUNT(DISTINCT) in O(1) memory with ~1β2% standard error. Used in pre-aggregated schemas where exact distinct counts would be prohibitively expensive to store.
- T-Digest
- A data sketch for approximate percentile estimation (p50, p99, p999) that merges accurately at the tails where precision matters most. Used for latency percentile tracking in pre-aggregated schemas.
- Broker
- The query-routing node in Druid and Pinot. Receives SQL queries, routes sub-queries to the relevant historical/real-time nodes, merges partial results, and returns the final answer to the client.
- Historical node
- A Druid node that serves immutable historical segments loaded from deep storage. Responsible for query serving on older data. Contrasted with real-time nodes that serve fresh, actively-ingesting segments.
- Deep storage
- The durable, cheap object store (S3, GCS, HDFS) where Druid and Pinot persist all segment files. Deep storage is the source of truth; hot/warm nodes are just a cache on top of it.
- Hot / warm / cold tier
- Storage hierarchy: hot = recent segments on local NVMe SSD (fast, expensive); warm = less-recent segments on larger HDD or object storage (medium); cold = old segments on cheap S3 Glacier (slow, cheap). Tier-down policies move segments automatically as they age.
- Denormalize
- Joining dimension data (campaign metadata, user attributes) into the fact table at ingestion time to avoid joins at query time. The primary schema strategy for real-time OLAP β creates a "wide table".
- Star schema
- A normalised data warehouse schema with a central fact table joined to dimension tables (campaign, user, geography). Efficient for storage; inefficient for real-time OLAP queries that must join at query time.
- Wide table
- A denormalised fact table where dimension data has been merged in at ingestion time. Enables single-table scans without joins β the preferred schema pattern for real-time OLAP engines.
- Broadcast join
- A join strategy where the smaller table is copied to every node in the cluster so each node can join locally without network shuffle. Works for dimension tables under ~500 MB; breaks for large fact-to-fact joins.
- Shuffle join
- A distributed join where both tables are re-partitioned by join key across the cluster. Handles large tables but requires network transfer of data β expensive and slow in real-time OLAP engines; more suitable for batch processing frameworks like Spark.
- Cardinality
- The number of distinct values in a column. Low-cardinality columns (country: ~200) are ideal for bitmap indexes. High-cardinality columns (session_id: billions) destroy bitmap index efficiency and must be handled as metrics or excluded from indexing.