Skip to content
intermediate Phase 9 · Data Architecture: Persistence, CQRS, and Event Sourcing

Transactional vs Analytical Workloads

Separate OLTP serving paths from OLAP reporting and their data pipelines.

55m
0 problems
Topic Progress 0%

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:

  1. 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.
  2. 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.
  3. 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.
  4. Route by intent, not by 'it is a SELECT'. Read routers that classify by query shape (or, better, by endpoint: /api/orders/:id vs /analytics/export) prevent the next 'harmless read'. Connection-level separation — separate pools for app and BI tools — is the minimum viable version of this.
  5. 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 b in 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?

Question 1 options

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?

Question 2 options

3. A nightly job does SELECT * FROM orders WHERE updated_at >= yesterday. What does CDC do better?

Question 3 options

4. A Debezium connector's consumer has been down for three days. What is the immediate operational risk?

Question 4 options

Flashcards

Question

OLTP vs OLAP: access pattern, storage layout, and the failure mode when they are swapped

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?

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?

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.

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?

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.

Continue Learning

Before You Start

Next Topics

Related Reading