Analytics: OLTP vs OLAP and time-series stores
In one line
Transactional databases (OLTP) are built for many small reads and writes of single rows; analytical stores (OLAP) are built for scanning and aggregating billions of rows by a few columns; time-series stores specialise in timestamped metrics. Keep them separate and feed analytics from the transactional side through CDC or event streams.
What it is
OLTP (online transaction processing) is the workload of your product: insert an order, update a balance, fetch a user by ID. Queries touch a few rows, need millisecond latency and strict correctness. Row-oriented databases such as PostgreSQL and MySQL store each row's columns together, which is ideal for reading or writing a whole row.
OLAP (online analytical processing) is the workload of dashboards and reports: 'revenue per city per hour for the last 90 days'. Queries scan millions or billions of rows but only a few columns. Column-oriented stores (ClickHouse, Apache Druid, Apache Pinot, BigQuery, Snowflake, Redshift) store each column separately, compress it heavily (often 5 to 20 times), and process data in vectorised batches, so scanning one column of a billion rows takes seconds or less.
Time-series stores (Prometheus, InfluxDB, TimescaleDB, VictoriaMetrics) are a specialised case for data shaped as (metric, labels, timestamp, value): server metrics, sensor readings, stock ticks. They optimise for append-only writes in time order, compression of nearby values, downsampling (keeping 1-minute averages after 7 days) and automatic retention.
The architecture that connects them: the OLTP database stays the source of truth; changes flow out via CDC or events into Kafka; stream processors or batch jobs load them into a warehouse or real-time OLAP store; dashboards and data scientists query that store, never the production primary.
Why interviewers ask
Many designs have an analytics requirement hidden inside: 'show the seller their sales dashboard', 'count ad clicks', 'top trending songs', 'monitor p99 latency'. Interviewers check that you do not run heavy aggregations on the production database, that you understand why column stores are fast, and that you can choose between batch (hours late, cheap) and real-time (seconds late, more complex) analytics.
How it works
- 1Separate the workloads: analytical queries that scan large ranges compete with transactions for CPU, memory and I/O. Even a read replica only isolates load partly, and long queries on a replica can conflict with replication.
- 2Capture changes: Debezium reads the Postgres WAL or MySQL binlog and publishes row changes to Kafka; product events (clicks, views) go straight to Kafka from services.
- 3Batch path (ELT): land raw data in object storage as Parquet (a data lake), then transform with SQL (dbt) in a warehouse such as BigQuery, Snowflake or Redshift. Freshness: minutes to hours. Cheap and flexible for ad-hoc analysis.
- 4Real-time path: ingest from Kafka directly into ClickHouse, Druid or Pinot, which make rows queryable within seconds; or pre-aggregate with Flink (for example clicks per ad per minute) and write results.
- 5Model for analytics: a star schema with a large fact table (orders, events) and small dimension tables (city, restaurant, date), or one wide denormalised table, which column stores handle well.
- 6Partition and sort by time: partition by day or month so queries skip old data, and sort by the most common filter (for example (city, event_time)) so the engine reads only matching blocks.
- 7Pre-aggregate what is queried constantly: materialized views or rollup tables (per minute, per hour) turn a billion-row scan into a thousand-row lookup.
- 8Time-series specifics: keep raw points for a short window (say 15 days), downsample to coarser resolution for long-term storage, and limit label cardinality, since every unique label combination (for example user_id as a label) creates a new series and can exhaust memory.
Worked example · Real-time order dashboard for a food delivery app
- Source: 5M orders a day in Postgres, about 60 per second average, 600 per second at peak. Each order produces about 8 status events, so roughly 40M events a day.
- The ops team wants 'orders per city per minute, average delivery time, cancellations' with under 10 s delay, plus 13 months of history.
- Running GROUP BY city, minute over 90 days of orders on the Postgres primary would scan about 450M rows and hurt checkout latency. Rejected.
- Pipeline: Debezium CDC from Postgres -> Kafka topic orders.changes -> ClickHouse Kafka engine -> MergeTree table partitioned by month, ordered by (city_id, event_time).
- Storage: 40M events x 13 months is about 15.6B rows. At around 100 bytes per row raw and roughly 10 times compression, that is about 150 to 200 GB on disk, which fits a small ClickHouse cluster.
- A materialized view keeps per-city per-minute counts; the live dashboard reads about 1,440 rows per city per day instead of millions of raw rows, answering in under 100 ms.
- Nightly, the same Kafka topics are also written as Parquet to S3, and the finance team runs month-end reports in a warehouse without touching ClickHouse or Postgres.
Picture it
An OLTP database is a shop's billing counter: fast for one customer at a time, every bill exact. An OLAP store is the accountant's back office: nobody is waiting at the counter, but they go through months of bills by column ('total of all amounts by city'). You would not let the accountant block the counter to count last year's sales.
Reference code
-- ClickHouse: column store table plus a per-minute rollup
CREATE TABLE order_events (
event_time DateTime,
city_id UInt32,
order_id UInt64,
status LowCardinality(String),
amount UInt32
) ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (city_id, event_time);
CREATE MATERIALIZED VIEW orders_per_min
ENGINE = SummingMergeTree ORDER BY (city_id, minute) AS
SELECT city_id, toStartOfMinute(event_time) AS minute,
count() AS orders, sum(amount) AS revenue
FROM order_events WHERE status = 'PLACED'
GROUP BY city_id, minute;Key patterns
CDC into OLAP
When: Analytics on data that lives in the transactional database
How: Debezium (WAL/binlog) -> Kafka -> warehouse or real-time OLAP store; the primary never runs analytical queries.
Lambda vs Kappa
When: Combining real-time and historical analytics
How: Lambda runs a batch path and a streaming path and merges them; Kappa uses one streaming path and replays the log to recompute. Most teams now prefer a single streaming path plus a lake for history.
Rollups and materialized views
When: The same aggregate is queried repeatedly
How: Pre-aggregate per minute or hour on ingest; query the rollup, keep raw data for drill-down.
Downsampling and retention
When: Metrics and time series
How: Raw resolution for days, 1-minute for weeks, 1-hour for years; drop whole time partitions when they expire.
Real-world use
Uber, Pinot and real-time analytics
Apache Pinot, created at LinkedIn for features like 'who viewed my profile', is used at Uber and others for user-facing real-time analytics at high query rates, ingesting directly from Kafka.
ClickHouse at Cloudflare
Cloudflare has described using ClickHouse to store and query HTTP and DNS analytics at millions of rows per second of ingest, powering customer dashboards.
Zerodha and ClickHouse
Zerodha's tech team has publicly discussed using ClickHouse alongside PostgreSQL for large analytical and log workloads, keeping heavy scans away from the transactional databases.
Prometheus and Grafana
The standard open-source stack for service metrics: Prometheus scrapes time series and stores them with high compression; Thanos, Cortex or VictoriaMetrics add long-term storage. High-cardinality labels are the classic way to break it.
Cloud warehouses
BigQuery, Snowflake and Redshift separate storage from compute and bill by data scanned or compute time, so partitioning and selecting only needed columns directly cut cost.
In a project
A D2C brand's seller dashboard runs 'sales by product by day' queries directly on the MySQL primary. During festive sales, dashboard queries take 40 seconds and checkout p99 jumps from 200 ms to 3 s because the primary's CPU is saturated.
- Measure: 70% of primary CPU at peak comes from dashboard queries scanning the orders and order_items tables.
- Stream changes with Debezium from the MySQL binlog into Kafka, then into ClickHouse, with a denormalised order_items_wide table (seller, product, category, city, price, quantity, created_at).
- Partition by month and order by (seller_id, created_at) so each seller's query reads only its own blocks.
- Add a materialized view of daily sales per seller per product; dashboards read this rollup.
- Route all dashboard and export traffic to ClickHouse; the MySQL primary serves only the product and checkout paths.
- Show 'data as of N seconds ago' on the dashboard, since the pipeline lag is typically 5 to 15 seconds.
Impact: Dashboard queries fall from 40 s to under 300 ms, checkout p99 returns to about 200 ms during the next sale, and the primary's CPU at peak drops from 95% to about 45%.
Trade-offs
Use it when
- OLTP (Postgres, MySQL): user-facing reads and writes of individual records with transactions
- Real-time OLAP (ClickHouse, Druid, Pinot): dashboards and user-facing analytics with seconds of freshness and high query rates
- Warehouse (BigQuery, Snowflake, Redshift): ad-hoc analysis, BI and reports where minutes or hours of delay are fine
- Time-series (Prometheus, TimescaleDB, InfluxDB): metrics, monitoring, sensor and tick data with retention and downsampling
Avoid it when
- OLAP stores as the source of truth or for frequent single-row updates and deletes; they are built for bulk appends
- Heavy aggregations on the OLTP primary, especially at peak
- Real-time pipelines when a daily report is all the business needs; batch is simpler and cheaper
- Time-series stores with unbounded label cardinality such as user or order IDs as labels
Common mistakes
- Running dashboard and export queries on the production primary.
- Assuming a read replica solves analytics: long scans still compete for resources and lag replication.
- Using a column store for row-level updates, such as changing one order's status in place many times a second.
- Not partitioning by time, so every query scans all history.
- Putting high-cardinality values (user_id, request_id) into Prometheus labels.
- Promising real-time analytics without stating the freshness target and pipeline lag.
- Counting with exact distinct over billions of rows when an approximate sketch (HyperLogLog) within 1 to 2% error is enough.
Interview questions
Quick revision
- OLTP: few rows, many writes, ms latency, row store (Postgres, MySQL).
- OLAP: many rows, few columns, aggregations, column store (ClickHouse, Druid, Pinot, BigQuery, Snowflake).
- Column stores: read only needed columns, compress 5-20x, vectorised.
- Pipeline: OLTP -> CDC/events -> Kafka -> OLAP / lake (Parquet on S3).
- Partition by time; sort by the most common filter.
- Pre-aggregate with materialized views / rollups.
- Time-series: append-only, downsample, retention, watch label cardinality.
- Batch = cheap, hours late; streaming = seconds late, more moving parts.
- Never run analytics on the production primary.
Check yourself
1. Why do column stores compress data so well?
2. Which workload fits OLTP best?
3. What is the safest way to load data from a production Postgres into an analytics store continuously?
4. A team adds user_id as a Prometheus label for a request counter. What is the risk?
See it in a full design
These walkthroughs use this lesson.
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.