Row Storage Serves One Checkout; Column Storage Serves a Year of Revenue
A checkout service issues 2,000 transactions per second, each touching a handful of rows by primary key in under 5 ms. An analyst runs one query: revenue by country by week over the last 12 months. Both hit 'the orders table' — and they want opposite physical layouts. OLTP is many small reads and writes of individual records; OLAP is few, enormous scans that aggregate millions of records. A storage engine optimized for the first is pathologically bad at the second, and no amount of indexing fully hides it.
Why the layouts diverge, mechanically:
| Aspect | OLTP (row store) | OLAP (column store) |
|---|---|---|
| Unit of I/O | whole row (all columns of one order) | column segment (one column of millions of rows) |
| Query shape | point lookups, short ranges, index seeks | full scans + aggregates + joins over wide time ranges |
| Writes | row-at-a-time, ACID, indexes maintained | batch loads, bulk compaction, mostly append |
| Compression | modest (heterogeneous rows) | extreme (same-typed values adjacent) |
| Concurrency | high transaction count, low per-query cost | low query count, very high per-query CPU/IO |
| Failure if misplaced | scan = full table heap walk | point lookup = gather one value across segments |
The column-store numbers are worth internalizing: reading three columns out of fifty from a billion-row table touches ~6% of the data, and compression ratios of 5–10x are routine because equal values sit together (dictionary + RLE + delta encoding). That is why one analyst query on a columnar engine can run in seconds where the same predicate on a row-store replica takes minutes and a CPU core. Conversely, that columnar engine is miserable at UPDATE orders SET status='shipped' WHERE id=? — it must rewrite segment files — which is why warehouses favor append-plus-merge (or pure append) load patterns. Row stores, meanwhile, answer a primary-key read with one index seek plus one heap fetch: single-digit milliseconds regardless of table size, which is exactly what a checkout path needs and exactly what a scan destroys.
The middle ground exists and is increasingly the default: HTAP engines (SQL Server with columnstore, Postgres with columnar extensions, SingleStore, TiFlash replicas) keep a row store for serving and a columnar copy for analytics in one system, refreshed continuously. That collapses the pipeline but does not erase the trade: heavy analytical queries still contend for the same node's resources unless the engine isolates them (separate replicas, workload groups, or resource governors — which you should confirm exists before betting latency-sensitive serving on it).
Interview framing: name the workload by its shape — request-per-key versus scan-over-range — then derive layout from that, rather than reciting 'SQL for transactions, NoSQL for analytics' (which is wrong anyway; the axis is row-vs-columnar, not relational-vs-not). Also be ready for the cost side: OLTP rows are expensive to write (every index must be maintained, every constraint checked, every WAL record flushed) while OLAP queries are expensive to serve — which is why mixing them on one node fails in both directions. The most common way teams discover this: reporting queries aimed at a serving replica, the next chapter.
The Reporting Query That Took Down Checkout: When Analytics Hits the Serving Path
The dashboard's 'export all orders from last quarter with customer details' is 'just a read — the DBA said read replicas are free'. At 14:00 it runs against a replica; the query scans 40M rows, spills to disk, and saturates the replica's I/O. Checkout's read routing sends 30% of traffic to that replica; p99 climbs from 40 ms to 900 ms; the alert fires — and the warehouse team's response is that they were told to read from replicas. The mistake is treating replicas as free read capacity rather than as serving capacity with a defined job.
The degradation paths, each measurable:
1. I/O saturation: big scans evict the buffer pool → subsequent point reads go to disk
(µs → ms), p99 explodes even though CPU looks idle
2. CPU saturation: complex joins/aggregations pin cores; the replica's capacity
intended for user reads is consumed by one query
3. WAL/sync pressure: long-running transactions hold snapshots; replication lag grows;
failover promotes a node that is minutes behind
4. Lock/queue effects: on MySQL replicas, long SELECTs can block; on both engines,
statement queues add latency variance to every other query
The fixes, in the order you should propose them:
- Isolate analytically-shaped reads onto infrastructure whose job they are. A dedicated analytics replica (or the warehouse) absorbs the scans; user-facing reads keep a replica pool sized for point lookups. This is capacity planning by workload shape — the same split the last chapter derived.
- Impose limits.
statement_timeout(Postgres, e.g. 5 s for the serving pool), resource governors/workload groups (SQL Server, MySQL 8 resource groups), query queues, and per-tenant concurrency caps. A killed query is a support ticket; an unbounded one is an outage. - Materialize what dashboards actually need. Nightly or hourly rollups (
revenue_by_day_country) turn a 40M-row scan into a 90-row read — precomputation is the oldest performance technique and still the best first move for aggregate dashboards. - Route by intent, not by 'it is a SELECT'. Read routers that classify by query shape (or, better, by endpoint:
/api/orders/:idvs/analytics/export) prevent the next 'harmless read'. Connection-level separation — separate pools for app and BI tools — is the minimum viable version of this. - Scale out the serving tier read-side: replicas for point reads, pgbouncer for connection storms, and cache layers for hot keys — none of which helps if scans still share the pool.
Detection deserves a note for interviews: the signature is replica p99 with normal primary load, plus pg_stat_activity/processlist showing one or two long scans, high shared_blks_read (disk reads) per statement, and growing replication lag. Have a first-response script: identify the statement, cancel it (pg_cancel_backend), cap the user, and only then discuss prevention.
An interview answer that lands states the principle — replicas are for reads with the same SLA as the serving path; analytics is a different SLA and gets different infrastructure — then lists limits and materialization as the guardrails. That raises the natural next question: how does the warehouse get fresh data in the first place? The answer — change data capture from the database's own log — is the final chapter of this topic.
Change Data Capture: Streaming the WAL Into the Warehouse Without a Nightly ETL Crawl
The nightly job runs SELECT * FROM orders WHERE updated_at >= yesterday at 02:00. Two problems surface immediately: rows updated without touching updated_at are missed forever; and when a backfill updates 3M rows at 15:00, the warehouse is wrong until tomorrow. Change data capture fixes both by reading the database's own log of changes — the write-ahead log already records every physical mutation, so CDC taps it instead of re-querying tables.
app writes row ─► WAL (every insert/update/delete, in commit order)
│
├─ Debezium / pglogical / MySQL binlog reader / native connector
▼
change stream (ordered per partition/tx, with LSN/gtid + before/after images)
│ exactly the committed changes — including ones no updated_at touched
▼
connector → Kafka / Kinesis / Pub/Sub → sinks:
warehouse (upsert by PK) · search index · cache · another service
What CDC gives you, stated as engineering properties:
- Completeness: every committed change appears, including deletes (the pattern-query approach structurally misses deletes and out-of-band updates — application-layer writes, migrations, DBA fixes).
- Ordering: changes stream in commit order per transaction, so a downstream projection can apply them without reordering hazards — this is what makes
INSERT a, INSERT bin one transaction safe for consumers. - Low freshness: seconds rather than a day; streaming sinks keep analytics near-real-time without hammering the source with queries.
- Decoupled source load: one sequential log read instead of repeated indexed scans over growing tables — the OLTP cluster keeps serving while the ecosystem consumes its tail.
The sharp edges, because interviews ask for them: snapshot bootstrap (a new connector must first take a consistent snapshot of existing rows, then switch to streaming — done correctly, without a gap or duplicate window); schema evolution (column adds/renames flow through the stream and must be handled downstream — see Schema Evolution and Compatibility); idempotent sinks (redelivery happens; warehouse sinks upsert on primary key or use merge semantics keyed on the log position); replica-slot growth (Postgres wal_level=logical replication slots grow unboundedly if the consumer stalls — monitor slot lag or you fill the disk and block checkpoints, taking the primary down); and PII propagation (CDC copies everything, including columns you would never export — masking policies must live at the connector).
Choosing the transport for the changes: direct connector-to-sink (simplest, Debezium to a warehouse connector) when there is exactly one consumer; connector to a log (Kafka) when there are several consumers with independent progress, replay needs, or different sinks — the log becomes the shared distribution point and gives backfills for free (Message Queues and Brokers covers the replay mechanics); and query-based replication only when the source cannot expose a log (some SaaS systems), accepting its polling and completeness costs.
Delivery-guarantee summary to say out loud: at-least-once from the log to the sink, so make sink application idempotent (upsert by PK + position), and treat full refreshes as the reconciliation net. This outbox-and-projection machinery — where the write model feeds independently built read models — is exactly the boundary the next topic formalizes: CQRS Pattern.
Quiz
1. Why does a three-column aggregate over a billion-row orders table run orders of magnitude faster on a column store than on a row store?
2. A reporting query pushed to a serving replica drives checkout p99 from 40 ms to 900 ms while the primary's write load is unchanged. What is the most likely mechanism?
3. A nightly job does SELECT * FROM orders WHERE updated_at >= yesterday. What does CDC do better?
4. A Debezium connector's consumer has been down for three days. What is the immediate operational risk?
Flashcards
Question
OLTP vs OLAP: access pattern, storage layout, and the failure mode when they are swapped
Click to reveal answer
Answer
OLTP: many small point reads/writes by key → row store, indexes, ACID. OLAP: few huge scans aggregating wide time ranges → column store with segment I/O and heavy compression. Running analytics on OLTP infra evicts buffer pools and saturates I/O/CPU so serving p99 collapses; running point lookups on a column store gathers one value across segments.
Question
Why are read replicas not free capacity for reporting?
Click to reveal answer
Answer
Replicas exist to absorb serving-path reads with the same SLA. Large scans saturate their I/O and buffer pool (point reads fall to disk), pin CPU on joins/aggregations, extend replication lag through long snapshots, and can failover-promote a node that is minutes behind. Fix: separate analytics replicas/warehouses, statement timeouts and resource groups, materialized rollups, and route by endpoint rather than by 'it is a SELECT'.
Question
How does change data capture work, and what does it capture that query-based ETL cannot?
Click to reveal answer
Answer
CDC reads the database's own change log (WAL/binlog/transaction log) through a connector and streams committed changes with position and before/after images. It captures updates to columns never touched by the ETL predicate, deletes, and out-of-band fixes — in commit order — with seconds of freshness, at the cost of one sequential log read instead of repeated table scans.
Question
Name the four sharp edges of a CDC pipeline and the mitigation for each.
Click to reveal answer
Answer
Snapshot bootstrap (consistent initial load then switch to streaming — no gap/duplicate window) · schema evolution flowing through the stream (versioned contracts downstream) · at-least-once delivery (idempotent sinks: upsert by PK + log position) · unbounded replication-slot growth if the consumer stalls (monitor slot lag, alert before disk fills) — plus PII propagation, which needs masking at the connector.
Question
Direct connector-to-sink versus connector-to-Kafka: when does each fit?
Click to reveal answer
Answer
Direct to sink when exactly one consumer needs the changes — simplest, fewest moving parts. Through a log when several consumers need independent progress, replay/backfill capability, or different sinks: the log becomes the shared distribution point (offsets per consumer, retention-based replay) and decouples source schema changes from any single downstream.
Revision Notes
Key Takeaways
- 1. Workload shape decides storage: point-key access wants rows and indexes, range aggregation wants columns and compression — mixing them on one node fails in both directions
- 2. A replica absorbs reads with the serving SLA; unbounded reporting queries convert it into a liability, so classify by endpoint and cap statement cost
- 3. CDC turns the database log into the integration backbone: complete, ordered, fresh, and cheap on the source compared with polling scans
- 4. The CDC failure modes are operational — snapshot gaps, slot growth, schema drift, PII spread — and each has a named mitigation
- 5. Freshness requirements pick the transport: streaming through a log for multiple consumers, direct connector for one, polling only when no log exists
Interview Tips
- • "How would you serve this dashboard?" — materialized rollup first, then a dedicated analytics replica/warehouse, then limits (timeouts, resource groups); explicitly reject querying the serving pool by default
- • "Row store or column store?" — derive from the access pattern, quote the I/O argument (three of fifty columns read, compression on like values), and mention HTAP only with resource-isolation caveats
- • "How do you keep the warehouse fresh?" — CDC from the WAL via a connector, streaming through a log for multiple sinks, idempotent upserts, and the slot-lag alarm as the gotcha that shows real experience
- • "What breaks in a nightly ETL?" — predicate misses (updates without touching updated_at), deletes, day-long staleness, and backfill storms — then position CDC plus occasional full refresh as the pair
Cheat Sheet
Transactional vs Analytical Workloads
| OLTP | OLAP | |
|---|---|---|
| pattern | thousands of small key lookups/short writes, ms budget | few huge scans + aggregates, seconds–minutes |
| layout | row store + indexes (all columns per row) | column store (referenced columns only, 5–10x compression) |
| writes | row-at-a-time, ACID, index maintenance | bulk load / append + compaction |
| misplaced | aggregate = full heap walk | point lookup = gather across segments |
Reporting on the serving replica — signature & fix:
symptom: replica p99 ↑↑ with flat primary load
signal: pg_stat_activity long scans · high shared_blks_read · slot/replication lag
fix: dedicated analytics replica/warehouse → statement_timeout + resource groups
→ materialize rollups (revenue_by_day_country) → route by endpoint, separate pools
Principle: replicas serve reads with the serving SLA; analytics has a different SLA ⇒ different infra.
CDC pipeline:
WAL/binlog → connector (Debezium/…) → log (Kafka) or direct sink → warehouse/index/cache
order per tx = commit order · positions (lsn/gtid) · at-least-once ⇒ idempotent sink upsert
Captures: updates that never touched the ETL predicate · deletes · out-of-band fixes.
Edges: snapshot bootstrap · schema evolution through stream · slot growth if consumer stalls
(monitor wal size/lag — disk fill takes the primary down) · PII masked at connector.
Choose: one consumer → direct sink · many consumers/backfill → via log · no log available → polling (accept costs).
Freshness: seconds vs nightly. Full refresh remains the reconciliation net.