Module P-22·34 min read

Row vs columnar storage, warehouse vs lake vs lakehouse, why one analyst's GROUP BY takes production down, and why a read replica is the wrong fix.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-22 — OLTP vs OLAP vs HTAP

What this module covers: Why one analyst's GROUP BY can take production down, told as a physical-layout story rather than a query-tuning one. Row-oriented versus columnar storage, and why columnar wins on analytics by a factor of ten to thirty through I/O elimination and compression rather than cleverness. The two workload profiles set side by side, so the mismatch is legible. Why the read replica everyone reaches for is the wrong fix — it addresses read volume and the problem is read shape, and it drags Module P-5's recovery conflicts along with it. Warehouses, lakes and lakehouses, and what table formats added to files to make the middle option viable. Dimensional modelling, where denormalisation is correct and slowly-changing dimensions are how you avoid rewriting history. HTAP's real options and honest costs. The cost model inversion, where a SELECT * is a line item. And the distinction that decides your architecture: internal BI and customer-facing analytics are different systems with different latency budgets.


One GROUP BY Takes Production Down, and It Is Not the Analyst's Fault

The incident is always the same. An analyst, given read access to the production database because that is where the data is, runs something reasonable:

sql

Nine minutes later checkout latency is at four seconds, the connection pool is exhausted (Module P-4), and the pager fires. Nobody wrote a bad query — that SQL is fine, and against the right engine it returns in under a second. The mismatch is physical, and it has three separate mechanisms.

It reads the whole table, columns included that it does not want. orders has 60 columns; the query needs three. A row store reads 8 KB pages containing entire rows, so fetching created_at, customer_id and total_cents for 40 million rows means reading all 60 columns of all 40 million rows — perhaps 20 GB of I/O to answer a question about 600 MB of data.

It evicts the working set from the buffer cache. This is the part that hurts other people. Your OLTP workload runs fast because the hot 8 GB of index and data lives in memory (Module F-2's hierarchy, Module F-12's working set). Scanning 20 GB through a 16 GB buffer cache flushes it. When the scan ends, every subsequent point lookup is a disk read until the cache re-warms — so the damage outlasts the query, and latency stays bad after the "cause" has gone. Teams frequently misdiagnose this as a second, unrelated incident.

It holds one snapshot for nine minutes. An MVCC snapshot open for nine minutes prevents vacuum from reclaiming any row version created after it started (Module F-13), so the write-heavy tables bloat during exactly the window when the database is already struggling.

The correct conclusion is not "train the analysts" or "add an index." No index helps a query that aggregates 40 million rows — you cannot index your way out of reading everything, and an index scan over that fraction of a table is slower than a sequential scan, which is why the planner chose the scan. The conclusion is that you are running an analytical query on an engine built for a different physical access pattern.

Analogy: a warehouse organised for order picking, with each customer's order boxed together on a shelf, addressed by order number. Perfect for "fetch order 4417." Now someone asks how many blue items you shipped last year: a picker must open every box in the building, look at every item, and put it all back. The shelving that made single-order retrieval instant is precisely what makes the inventory question a full building sweep. A stocktaking warehouse is laid out differently — all the blue items together, counted in bulk. Neither layout is wrong; they answer different questions, and no amount of hiring more pickers converts one into the other.


Columnar Storage Wins by Reading Less and Compressing Better

Same logical table, two physical layouts:

text

Two effects compound, and both come from Module F-2 rather than from any algorithmic trick.

I/O elimination. Reading 3 of 60 columns reads roughly 3/60 of the bytes. That alone is a 20× reduction before anything else happens.

Compression, because adjacent values share a type and often a value. A column of created_at timestamps in insert order is nearly sorted, so delta encoding stores tiny differences. A region column with eight distinct values becomes a dictionary plus 3-bit codes. A status column that is 94% 'completed' run-length encodes to almost nothing. Typical columnar compression is 5–20× on real data; the same data in a row store, where a page interleaves timestamps, integers, UUIDs and free text, compresses at perhaps 2–3× because there is no local similarity to exploit.

Multiply them and a scan reads 1–3% of the bytes the row store would. That is where the "ten to thirty times faster" comes from — not clever algorithms, just far fewer bytes crossing the bus.

Three further mechanisms ride on the layout:

  • Vectorised execution. Values of one type, contiguous in memory, are processed in batches of thousands with SIMD instructions, instead of a function call per row. Module F-2's cache-line argument at its most favourable.
  • Late materialisation. Filter using only the columns in the WHERE clause; assemble full rows only for the survivors. A query filtering to 0.1% of rows never touches the other columns for the 99.9%.
  • Zone maps and pruning. Each block stores min/max per column, so a block whose maximum created_at predates your range is skipped without being read. Combined with partitioning, a three-year query against a ten-year table reads three years of blocks.

And the flip side, which is why nobody runs OLTP on columnar storage: a single-row read touches every column's storage, and a single-row update is expensive — you cannot update a run-length-encoded, dictionary-compressed block in place, so columnar engines write immutable files and merge them in the background. That is the Module P-2 LSM shape yet again, and it is why columnar systems are append-oriented and generally do not offer row-level locking, per-row constraints, or high-concurrency point writes.

OLTP (row)OLAP (columnar)
Access patternfew rows by key, all columnsall rows, few columns
Concurrencythousands of sessionstens of queries
Latency target1–10 ms1 s – 10 min
Write patternmany small INSERT/UPDATEbulk append, rarely updated
Schemanormalised, constraineddenormalised star schema
Indexesmany B-treesfew or none; zone maps and partitions
Wins byindex seeksnot reading the data
Optimised forcorrectness under concurrencybytes scanned per second

The Read Replica Is the Wrong Fix, and It Is Everyone's First Fix

"Point the analysts at a read replica" is the reflex, it is cheap, and it is wrong in a way worth being precise about — because it does relieve the symptom just enough to look like a solution for a few months.

A read replica solves read volume: more of the same shape of query than one machine can serve. This problem is read shape: a query whose physical access pattern the engine cannot serve efficiently anywhere. Moving it changes who suffers, not what it costs.

Five specific consequences:

  1. The scan costs the same. The replica is byte-identical to the primary, row-oriented, with the same layout. A nine-minute query is a nine-minute query. You have bought isolation, not performance, and analysts still wait nine minutes.
  2. It destroys the replica's other job. Most replicas exist to serve application read traffic. Analytical scans evict the replica's buffer cache exactly as they would the primary's, so your application's replica reads get slow — you have moved the incident to a different endpoint.
  3. The replica is not isolated from the primary. This is the one that surprises people, and Module P-5 covered the mechanism: a long-running query on a hot standby conflicts with WAL replay. Either replay stalls and replica lag climbs (breaking read-your-own-writes for the application, Module P-11), or the query is cancelled with ERROR: canceling statement due to conflict with recovery. Enable hot_standby_feedback to stop the cancellations and the primary retains dead tuples on the analyst's behalf — so a nine-minute analytical query now bloats the primary. The replica gave you less isolation than the architecture diagram suggests.
  4. It does not answer the actual questions. Analysts want to join orders against marketing spend, support tickets, and the billing system's data. None of that is in your OLTP database, and adding it there is how a transactional schema becomes a swamp.
  5. The economics are wrong. You are paying for a full OLTP-shaped replica — provisioned IOPS, memory sized for a working set, high availability — to run a workload that wants cheap bulk sequential throughput (Module O-8).

Read replicas are the right tool for read volume, geographic read latency, and isolating a bounded reporting query that runs occasionally. They are the wrong tool for an analytics practice. The tell that you have crossed the line is when someone asks for a second replica "just for analytics" — at that point you are building an analytics platform out of the wrong primitive.

Why this matters in production: the replica route is attractive because it defers the decision at almost no cost, and it fails gradually. Queries get slower as data grows, someone raises statement_timeout for the analytics role, then lag alarms start firing, then hot_standby_feedback gets turned on and the primary starts bloating, and the connection between that bloat and the analytics workload is genuinely hard to see from the primary's metrics. Six months of small accommodations, and the eventual migration happens under pressure.

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.