System Design Primer · llms.txt
Written for this site, not part of The System Design Primer. The sources it draws on are listed under Further reading.
Most system design questions are about online systems: a user sends a request, a service reads an index or a cache, and a response comes back in tens of milliseconds. Latency and availability under concurrent load are what matter there.
Organizations also pile up data that nobody queries one row at a time: terabytes to petabytes of logs, payments, sensor readings and crawled web pages. Turning that into something useful (daily reports, billing summaries, search indexes, training data for machine learning models) is the job of batch processing: jobs that run over a large, bounded input, such as "yesterday's logs", and produce an output, taking minutes or hours instead of milliseconds. What matters for a batch job is throughput, and finishing correctly even when machines fail partway through.
The primer lists "Distributed computing with MapReduce" as a topic still under development. This chapter covers it: Google's MapReduce and its open-source version in Hadoop, Apache Spark and its multi-stage execution, the storage systems batch jobs read from (data warehouses, data lakes and lakehouses), the Lambda and Kappa architectures for combining batch with streaming, and the problems that show up in production. It builds on the primer's database and asynchronism chapters and on the extra chapter on event streaming.
A common mistake is to run large analytical queries directly against the database that serves live traffic. The two workloads want different things.
| Property | Online transaction processing (OLTP) | Analytics and batch (OLAP) |
|---|---|---|
| Main users | End users, through apps and web clients | Analysts, data engineers, ML pipelines |
| Access pattern | Small reads and writes of a few rows, by key | Scans over millions or billions of rows, reading a few columns |
| Latency goal | Milliseconds | Seconds for interactive queries; minutes to hours for batch jobs |
| Data layout | Normalized, row-oriented tables (PostgreSQL, MySQL) | Denormalized, column-oriented storage (Parquet, ORC, ClickHouse, warehouses) |
| Freshness | Current to the latest commit | Snapshots, often loaded hourly or daily |
| Limited by | Random I/O, lock contention, round trips | Sequential scan throughput, memory, and network shuffle |
An operational database is tuned for fast lookups by key and for transactions. A full scan over 500 million rows in the same database competes with live traffic for disk and CPU, pushes the hot working set out of the buffer cache so ordinary queries slow down, and, as a long-running transaction, can hold back cleanup of old row versions (vacuum in PostgreSQL, purge in MySQL). Most databases use MVCC, so the scan doesn't block writers with locks, but it can still slow every checkout on the site. The usual fix is to copy the data out, through a read replica, a change-data-capture stream, or periodic exports, and run analytics on separate systems. The database 101 chapter covers row and column storage in more detail.
Jeffrey Dean and Sanjay Ghemawat described MapReduce in a 2004 paper from Google. It let engineers process terabytes across thousands of ordinary machines by writing two functions, while the framework handled distribution, network transfer and machine failures. Hadoop is the open-source implementation, with HDFS in place of Google's file system, GFS.
A job is a map function and a reduce function, connected by a shuffle and sort step the framework runs.
The input is divided into splits, usually one file-system block each: 64 MB in GFS, 128 MB by default in current HDFS.
(key, value) pairs.(word, 1) for each word.The shuffle gets all values for the same key to the same reduce task:
hash(key) mod R.The shuffle is usually the most expensive part of a job, since every intermediate byte crosses the network. A combiner reduces it: an optional function that runs on the map side and pre-aggregates output before it is sent. In word count, a combiner turns a thousand ("the", 1) pairs from one map task into a single ("the", 1000). A combiner is only correct for operations that are associative and commutative, such as sum, count and max. A plain average isn't one, but you can combine (sum, count) pairs and divide at the end.
The reduce function is called once per key, with an iterator over that key's values: reduce(key, list of values).
part-00000, part-00001, and so on.With thousands of machines, something fails during almost every large job. MapReduce handles this by re-running work:
MapReduce made large-scale processing practical, but its model has real costs:
Spark started at UC Berkeley's AMPLab in 2009, was open-sourced in 2010, and became a top-level Apache project in 2014. It is now the most widely used general-purpose batch engine.
Spark made two main changes: Resilient Distributed Datasets (RDDs), and execution of a whole multi-step program as one job, planned as a directed acyclic graph (DAG) of stages.
An RDD is an immutable collection of records, split into partitions that are processed in parallel across the cluster. DataFrames and Datasets, the APIs most Spark code uses today, are built on top of RDDs.
rdd.cache() or rdd.persist(), and later steps or later iterations read it from memory instead of recomputing it or reading it from disk. This is where the large speed-ups come from: the RDD paper reported iterative machine learning jobs running up to 20× faster than on Hadoop. Data that isn't explicitly cached is not kept in memory between actions.Spark builds a plan for the whole program instead of running it as separate two-step jobs. When an action such as count() or write runs, Spark's DAG scheduler splits the chain of transformations into stages at every point where data has to be redistributed. (For DataFrame and SQL code, the Catalyst optimizer first rewrites the query plan, for example pushing filters down toward the data source, before it is turned into stages.)
map, filter and flatMap compute each output partition from a single input partition. Spark chains them into one task that streams each record through all of them, with no network transfer and no intermediate output.groupByKey, reduceByKey and most joins need records with the same key from every input partition. Each one marks a stage boundary with a shuffle. Like MapReduce, Spark writes shuffle output to local disk on the sending side, so a failed downstream task can re-fetch it without re-running the previous stage. Spark keeps the shuffle; it removes the replicated distributed-file-system write between steps.reduceByKey combines values on the map side before the shuffle, as a MapReduce combiner does. groupByKey ships every value across the network, so it should be avoided when an aggregation will do.Engines such as Spark, Trino and the warehouses' own query engines need storage built for large scans. Analytical storage has gone through three broad designs.
AVG(price) reads only the price column. Instead of B-tree indexes, warehouses keep min/max statistics per block of rows and skip blocks that can't match a filter.SELECT * FROM orders TIMESTAMP AS OF '2026-10-01' in Spark SQL, or roll back a bad write.created_at > '2026-10-10' skips files whose range can't match, which on a table partitioned or sorted by date can mean skipping most of it.The warehouse and the lakehouse have been converging: warehouses can now read and write open table formats, and lakehouse engines have added warehouse features.
Many systems need both fresh numbers (a dashboard updated within seconds, fraud alerts) and complete, correct numbers over history (monthly billing, retraining a model). Two architectures for getting both are known by name.
Nathan Marz described the Lambda architecture around 2011. It runs the same logic twice, in three layers:
The main drawback: the same business logic is written and maintained twice, once for the stream processor and once for the batch engine, often in different frameworks. Every change has to land in both, and when the two give slightly different numbers someone has to find out why.
Jay Kreps, one of Kafka's creators, proposed what he called the Kappa architecture in a 2014 essay, "Questioning the Lambda Architecture". It drops the batch layer:
Kappa has its own costs. The log has to keep enough history to replay, which is where tiered storage (old log segments moved to object storage) helps. Replaying months of events through a streaming engine can be much slower than a batch engine reading columnar files. And some computations, such as a large join over all history, are more natural as batch jobs. Many teams end up in between: one codebase (for example Spark, whose batch and streaming APIs share most code, or Flink, which runs both), with batch runs for backfills.
A stage finishes when its slowest task finishes. If the data is partitioned by user_id and one key has far more records than the rest, the task that owns it runs long after the others are done.
An example with assumed numbers: 1 billion events from 10 million users, about 100 each, spread over 1,000 reduce tasks. That's about 1,000,000 events per task. Now add one celebrity account with 100 million events. Every one of them hashes to the same task, which gets about 101 million events, roughly 100× the average. If a typical task takes a minute, that one takes over an hour and a half, and the job takes as long as it does.
Mitigation: salting. Split the hot key into K sub-keys by appending a random number:
salted_key = user_id + "_" + (random integer from 0 to K − 1)
The first aggregation groups by salted_key, spreading the hot key's records over up to K tasks. A second, much smaller aggregation strips the salt and combines the K partial results. This works for aggregations that can be combined in pieces, such as sums, counts and max. For a join on a hot key, salt the large side and copy each matching row of the small side K times, once per salt value, so every salted row still finds its match.
Engines help too. Spark's adaptive query execution can detect skewed partitions in a join at runtime and split them, and broadcasting a small table to every task avoids shuffling the large side at all.
Batch jobs fail partway through: out-of-memory errors, preempted spot instances, a bad record. If a job that appends rows to a table dies after writing 60% of them and is simply restarted, those 60% are written twice.
Mitigations:
Write to a temporary location, then commit atomically. Each task writes to a private temporary directory (_temporary/<attempt>/ in Hadoop's output committer). When a task succeeds its output is promoted, and when the whole job succeeds it is moved to the final path and marked with a _SUCCESS file. Readers only consume committed output. On HDFS a rename is an atomic metadata operation. On S3 and other object stores a "rename" is a copy followed by a delete, neither atomic nor fast, which is why object-store committers and lakehouse table formats (whose commit is one atomic metadata swap) exist.
Overwrite a whole partition instead of appending. Make each run produce one complete partition and replace it:
INSERT OVERWRITE TABLE daily_metrics PARTITION (date = '2026-10-13')
SELECT ...
Running the job for 13 October once or five times leaves the same data. Scheduling jobs by the date of the data they process, not by the time they happen to run, also makes it safe to re-run any past day.
These two together make a job idempotent: safe to retry. That's what "exactly-once" means for batch output in practice. Tasks may run more than once, but their results are committed once. The distributed transactions chapter covers the same idea for services.
In MapReduce, each iteration is a separate job. It reads its input from the distributed file system, shuffles, and writes its full output back to HDFS with three replicas, which the next iteration then reads. Fifty iterations mean fifty replicated writes and fifty reads of the whole dataset.
Spark runs the whole loop as one program. The dataset reused on every iteration is cached in the executors' memory once, and later iterations read it from there. Steps that don't need a shuffle are chained inside one task with no intermediate output. Shuffles still happen, and still write to local disk, but nothing has to go through the replicated file system between steps. Lineage replaces replication for fault tolerance: a lost cached partition is recomputed from its parents.
Whether it uses a combiner (in Spark, whether it uses reduceByKey instead of groupByKey). Without one, each map task sends one (word, 1) pair for every occurrence of every word, so the shuffle is roughly as large as the input text. With one, each map task sends one (word, count) pair per distinct word it saw, which for natural language is a tiny fraction of that. A combiner is valid here because addition is associative and commutative.
Skew is when a few keys have far more records than the rest. Every record for one key goes to the same task, so the task that owns a hot key runs long after all the others have finished, and the whole stage waits for it. It may also run out of memory.
Salting appends a random suffix from 0 to K − 1 to the key before the shuffle, which splits the hot key into K sub-keys handled by different tasks. Each produces a partial result, and a second, small aggregation strips the salt and combines the K partials. For a join, the large side is salted and the small side's matching rows are copied once per salt value.
Don't let partial output become visible, and make each run replace its result instead of adding to it. Tasks write to temporary locations and the job commits atomically at the end, with a directory rename on HDFS, or a single metadata commit with a table format such as Iceberg or Delta (an S3 rename isn't atomic). The job writes a complete partition for the day it processes and overwrites that partition, so a retry replaces the earlier attempt's output instead of appending to it.
One codebase. In Lambda, the same logic exists in a batch job and a streaming job, every change has to be made twice, and the two can drift and disagree. In Kappa, a single streaming job produces every output, and recomputing history means replaying the log through a new version of that job into a new table, then switching readers over.
The costs: the log must keep enough history to replay, and replaying a long history through a streaming engine can be much slower than a batch engine scanning columnar files. Some heavy computations over all history are simply easier to write as batch jobs.
Previous: Monitoring, logging and tracing · Next: Design a URL shortener (like TinyURL or Bitly)