10

Batch processing

Online services answer one request at a time and are judged by response time. Batch jobs take a big, fixed dataset, run for minutes or hours, and are judged by throughput. Their key property is that input is never modified: a job reads immutable files and writes new ones. That idea goes back to Unix pipes, MapReduce spread it across thousands of machines, and dataflow engines such as Spark and Flink made it fast. Here you run each layer and see how the data moves.

Unix tools: one pipeline, six small programs

unix-philosophyuniform-interfacesort-vs-in-memory-aggregation
Find the five most requested pages in a web server log, using nothing but a chain of standard tools. Step through the stages and look at each one's input and output.

The pipeline

click a stage to inspect · tick to enable
|
|
|
|
|

awk '{print $7}' — Split each line on whitespace and print only the 7th field: the requested path.

stdin · 30 lines

66.249.20.194 - - [27/Feb/2015:17:55:02 +0000] "GET /favicon.ico HTTP/1.1" 200 4535
93.184.50.22 - - [27/Feb/2015:17:55:13 +0000] "GET / HTTP/1.1" 200 4448
66.249.176.73 - - [27/Feb/2015:17:55:49 +0000] "GET /feed.xml HTTP/1.1" 200 4880
216.58.223.79 - - [27/Feb/2015:17:56:11 +0000] "GET /about HTTP/1.1" 200 6994
216.58.94.184 - - [27/Feb/2015:17:56:40 +0000] "GET /js/app.js HTTP/1.1" 304 0
151.101.154.174 - - [27/Feb/2015:17:57:00 +0000] "GET / HTTP/1.1" 200 5384
66.249.135.213 - - [27/Feb/2015:17:57:03 +0000] "GET /2015/02/talk.html HTTP/1.1" 200 4336
151.101.218.169 - - [27/Feb/2015:17:57:26 +0000] "GET /js/app.js HTTP/1.1" 200 6294
151.101.224.182 - - [27/Feb/2015:17:57:45 +0000] "GET /favicon.ico HTTP/1.1" 200 8588
216.58.137.220 - - [27/Feb/2015:17:57:48 +0000] "GET /feed.xml HTTP/1.1" 200 5000
93.184.147.100 - - [27/Feb/2015:17:58:07 +0000] "GET /css/typography.css HTTP/1.1" 200 1805
151.101.196.16 - - [27/Feb/2015:17:58:33 +0000] "GET /2014/12/kafka.html HTTP/1.1" 200 1645
93.184.251.252 - - [27/Feb/2015:17:59:04 +0000] "GET /favicon.ico HTTP/1.1" 200 5371
151.101.81.159 - - [27/Feb/2015:17:59:43 +0000] "GET /img/logo.png HTTP/1.1" 200 3701
93.184.140.66 - - [27/Feb/2015:17:59:49 +0000] "GET /2015/02/talk.html HTTP/1.1" 200 727
66.249.226.8 - - [27/Feb/2015:17:59:53 +0000] "GET /about HTTP/1.1" 200 1197
93.184.46.2 - - [27/Feb/2015:18:00:33 +0000] "GET /css/typography.css HTTP/1.1" 200 5944
93.184.206.32 - - [27/Feb/2015:18:01:07 +0000] "GET /favicon.ico HTTP/1.1" 200 1412
93.184.185.81 - - [27/Feb/2015:18:01:35 +0000] "GET /2015/02/talk.html HTTP/1.1" 200 1120
66.249.127.112 - - [27/Feb/2015:18:01:43 +0000] "GET /2014/12/kafka.html HTTP/1.1" 200 2719
66.249.198.86 - - [27/Feb/2015:18:01:44 +0000] "GET /favicon.ico HTTP/1.1" 200 3511
216.58.47.33 - - [27/Feb/2015:18:01:48 +0000] "GET /robots.txt HTTP/1.1" 200 7008
216.58.86.67 - - [27/Feb/2015:18:02:04 +0000] "GET /about HTTP/1.1" 304 0
216.58.5.139 - - [27/Feb/2015:18:02:21 +0000] "GET / HTTP/1.1" 200 3644
151.101.74.83 - - [27/Feb/2015:18:02:57 +0000] "GET /2014/12/kafka.html HTTP/1.1" 200 2073
216.58.96.251 - - [27/Feb/2015:18:02:58 +0000] "GET /feed.xml HTTP/1.1" 200 5221
151.101.7.250 - - [27/Feb/2015:18:03:22 +0000] "GET / HTTP/1.1" 200 1038
93.184.221.138 - - [27/Feb/2015:18:03:42 +0000] "GET / HTTP/1.1" 200 7879
151.101.153.67 - - [27/Feb/2015:18:04:17 +0000] "GET /favicon.ico HTTP/1.1" 200 8652
216.58.141.54 - - [27/Feb/2015:18:04:25 +0000] "GET /2015/02/talk.html HTTP/1.1" 200 7787

stdout · 30 lines

/favicon.ico
/
/feed.xml
/about
/js/app.js
/
/2015/02/talk.html
/js/app.js
/favicon.ico
/feed.xml
/css/typography.css
/2014/12/kafka.html
/favicon.ico
/img/logo.png
/2015/02/talk.html
/about
/css/typography.css
/favicon.ico
/2015/02/talk.html
/2014/12/kafka.html
/favicon.ico
/robots.txt
/about
/
/2014/12/kafka.html
/feed.xml
/
/
/favicon.ico
/2015/02/talk.html
requests in log
30
lines after stage
30
awk '{print $7}'
final answer
correct
vs. counting in a hash table
$ cat access.log | awk '{print $7}' | sort | uniq -c | sort -r -n | head -n 5

Try: untick sort. uniq -c only merges adjacent duplicates, so one URL comes out as several partial counts and the top 5 is wrong. Each program does one thing and knows nothing about the others; stdin/stdout lines are the only interface they share.

Word count on a tiny corpus. You write only the mapper (emit each word with a 1) and the reducer (sum the values). The framework partitions, sorts, shuffles and merges, the same work sort and uniq did above, spread over machines.
combiner

The input file in HDFS is cut into M blocks. The scheduler starts one map task per block, ideally on a machine that already stores that block.

Map tasks

mapper 1 · block 1
the log is the database
a database is a cache of the log
batch jobs read the log
mapper 2 · block 2
a job writes new files
jobs read files and write files
the files are immutable
mapper 3 · block 3
a job is a function of the input
rerun the job on the input

Shuffle (network)

Nothing on the wire yet: reducers start fetching once map output is sorted and written.

Every mapper sends to every reducer: M × R = 6 transfers. Numbers = records per transfer.

Reduce tasks

reducer 1 · part-r-00000
waiting for map output…
reducer 2 · part-r-00001
waiting for map output…
pairs emitted by map
47
records shuffled
47
one per word occurrence
reducer load
20–27
records per reducer
distinct keys
22
e.g. "the" → R1

Try: step to “Partition & sort” and change R; every key is always routed to the same reducer, because its partition is a pure function of the key. Then turn the combiner on and watch the shuffle shrink: mappers pre-sum their own counts before sending.

Join a stream of user activity events with the users' profiles to get the viewer's age for every page view. The data is the same in every mode; what changes is where each record has to travel and what each worker must hold in memory.
activity
hot keys

Reduce-side sort-merge join. Two sets of mappers read profiles and events and both emit user_id as the key. The shuffle brings every record for a user to the same reducer; a secondary sort puts the profile first, then the events in time order. The reducer keeps just one profile in memory and streams the events past it.

reducer 1

4 joined rows
work (records)6
sorted reducer input (key, then profile first)
profile u101born 1985
u101/helpt13
profile u102born 1999
u102/item/9t7
u102/item/7t12
u102/homet16

reducer 2

7 joined rows
work (records)9
sorted reducer input (key, then profile first)
profile u103born 1972
u103/item/9t3
u103/homet4
u103/item/7t8
u103/searcht15
profile u104born 1991
u104/searcht1
u104/item/9t10
u104/item/9t18

reducer 3

7 joined rows
work (records)9
sorted reducer input (key, then profile first)
profile u105born 2002
u105/homet2
u105/searcht5
u105/helpt6
u105/searcht11
u105/helpt17
profile u106born 1966
u106/searcht9
u106/cartt14
records over the network
24
every input record is shuffled
slowest / average worker
1.13×
job ends when the slowest task ends
profiles held in memory
1
one user at a time
joined output rows
18
= 18 events

Try: make u104 a celebrity under sort-merge; one reducer gets all its events and the whole job waits for it. Switch on the skewed join (as in Pig or Hive): the hot key's events are scattered across reducers and its profile is copied to each. Then try broadcast: no shuffle and no skew, but only because every mapper can hold the whole small table.

With high-level APIs such as Hive, Pig or Spark SQL you write just join. The optimizer picks a broadcast join when one side is small and a sort-merge join otherwise, which is why declarative batch queries often beat hand-written MapReduce.

The output of a batch workflow

batch-output-immutability
A nightly job computes recommendations that a web app reads. You can push the results into the production database record by record, or write a complete new database file and switch readers over. Then deploy a bug.
next run's code

Output directories (immutable)

/recs/run-1/
◂ serving

The job writes a complete new file set (like building an SSTable or Lucene index) next to the old one. Readers switch only when it is done.

run 1 built and serving

What the application reads right now

get(ada)recs(ada)@run1
get(bo)recs(bo)@run1
get(cy)recs(cy)@run1
get(dee)recs(dee)@run1
get(eli)recs(eli)@run1
get(fay)recs(fay)@run1
get(gus)recs(gus)@run1
get(hal)recs(hal)@run1
corrupt rows visible
0
runs mixed in reads
1
one consistent version
recovery from a bad run
roll back
old files still exist

Try: with the buggy code, run the job in both modes. Building new files leaves production untouched until the switch, and Roll back repoints readers to yesterday's output: the inputs are immutable, so a bad deploy costs a re-run, not lost data. Writing straight into the database exposes half-done runs and leaves nothing to roll back to.

The same multi-stage workflow on two engines. MapReduce writes every intermediate result to HDFS and holds each stage until the previous one is completely done; a dataflow engine streams partitions through all the operators. Kill a task and compare how each recovers.
kill a task in
dataflow checkpoint

MapReduce workflow: materialize every stage to HDFS

stage 1stage 2stage 3stage 40510152025303540MapReduce done: 39.5

Dataflow engine (Spark, Flink, Tez): pipeline operators

stage 1stage 2stage 3stage 40510152025303540dataflow done: 12.5
■ task start-up■ map/reduce work■ pipelined operator■ write replicated output■ work lost to the crash■ recomputed from lineage
MapReduce job time
39.5
dataflow job time
12.5
3.2× faster
MR files written to HDFS
4 × 3 replicas
every intermediate stage
dataflow writes
1 × 3 replicas
final output only
cost of the crash
—
kill a task to compare

Try: kill a task in the last stage. MapReduce reruns just that task, because its input sits durably in HDFS; the dataflow engine has kept nothing, so it recomputes the lost partition's whole lineage from the original input. Add a checkpoint after stage 2 and the recompute chain gets shorter, but the checkpoint write itself sits on the critical path: a trade-off that pays off for long or expensive lineages. Either way, skipping materialization and the barrier between stages wins by a wide margin. (A toy cost model: 4 tasks per stage, one straggler each.)

Graphs and iteration: Pregel supersteps

pregel-bsp
Iterative graph algorithms keep running until nothing changes, which a single MapReduce pass can't express. In the bulk synchronous parallel model, vertices send each other messages in rounds; a barrier between rounds keeps it deterministic.
click a vertex to make it the source

Superstep 0

421581026231173A∞B∞C∞D∞E∞F∞G∞H∞
◌ worker 1◌ worker 2◌ worker 3● message for next superstepbright = active, dim = voted to halt
superstep
0
messages in flight
0
delivered at the next superstep
cross-machine messages
0
go over the network
active vertices
8 / 8
total messages
0

Try: step through shortest paths from A. In each superstep a vertex reads the messages sent to it in the previous one, keeps the best distance, and sends candidate distances along its edges; vertices with nothing new vote to halt and sleep until a message arrives. The job ends when every vertex is halted and no message is in flight. Vertices live on different workers, so most messages cross the network, which is why Pregel jobs are often slower than a single machine for small graphs.

  • takeawayBatch processing is Unix pipes at scale. Small programs share one interface (files of records), sorting does the grouping, and MapReduce adds partitioning by key and the shuffle that brings every key to one reducer.
  • takeawayMost of the cost is moving data. Sort-merge joins work on anything but shuffle everything. Broadcast and partitioned hash joins skip the shuffle when the inputs allow it. Hot keys make the slowest worker set the job time.
  • takeawayImmutable input plus fresh output makes jobs safe to retry, rerun and roll back. Dataflow engines drop the materialization between stages for speed and recover by recomputing from lineage instead.

Key concepts · 18

book chapter 10
  1. Unix philosophy

    Small programs that each do one thing well, composed with pipes into larger jobs.

    Because every tool reads and writes the same thing, tools written decades apart compose freely, and you can inspect any intermediate stage by cutting the pipe. MapReduce is the same idea spread over many machines.

    awksortuniqxargs
    ↑ see it in the visualization
  2. Uniform interface

    Every program takes stdin and writes stdout as a stream of newline-separated records; MapReduce uses files in a distributed filesystem the same way.

    A shared interface is what makes composition possible. It also separates logic from wiring: a program does not care where its input came from or where its output goes.

    ↑ see it in the visualization
  3. Sorting vs in-memory aggregation

    Counting by sorting so equal keys are adjacent, as opposed to holding a hash table of counts in memory.

    A hash table is fast while the distinct keys fit in RAM. Sorting can spill to disk and merge sorted runs, so it scales past memory, which is why MapReduce is built around sorting.

    ↑ see it in the visualization
  4. Distributed filesystem (HDFS)

    A shared-nothing filesystem: a daemon on each machine stores blocks, a central NameNode tracks where they are, and blocks are replicated.

    It is the stdin/stdout of MapReduce. Jobs read input and write output as files in it, and the scheduler runs each map task on a machine that already holds that block (data locality).

    HDFSGFSAmazon S3
    ↑ see it in the visualization
  5. MapReduce

    A programming model and framework: a user-defined mapper extracts key-value pairs from each record, the framework sorts and groups them by key, and a reducer processes all the values for each key.

    You write two callbacks that each see a small piece of the data, and the framework handles parallelism, moving data between machines and retrying failures. Each job is a single map-sort-reduce, so real work is chained into workflows.

    Hadoop MapReduceGoogle MapReduceCouchDB views
    ↑ see it in the visualization
  6. Shuffle

    Partitioning mapper output by a hash of the key, sorting it, and copying each partition from every mapper to its reducer.

    It guarantees every value for a key reaches the same reducer, and it is the most expensive part of most jobs: an all-to-all copy over the network. Combiners shrink it by pre-aggregating on the mapper.

    ↑ see it in the visualization
  7. Reduce-side sort-merge join

    Mappers emit the join key from both inputs, the shuffle brings all records with that key to one reducer, and the reducer joins them.

    It needs no assumptions about the inputs, and each reducer only holds one key's records at a time. The price is sorting, copying and merging every input record.

    HivePig
    ↑ see it in the visualization
  8. Secondary sort

    Arranging reducer input so that, within one key, records arrive in a chosen order, e.g. the profile record first and then events by timestamp.

    With the record you join against arriving first, the reducer can stream the rest past it with constant memory instead of buffering them.

    ↑ see it in the visualization
  9. Skew and hot keys in joins

    A few keys (a celebrity's user id) have far more records than the rest, so one reducer gets much more work.

    The job finishes only when the slowest reducer does. Skewed joins sample for hot keys, spread their records across several reducers, and copy the other side of the join to each of them.

    Pig skewed joinHive skew joinCrunch sharded join
    ↑ see it in the visualization
  10. Broadcast hash join

    A map-side join: the small input is loaded entirely into an in-memory hash table in every mapper, which then streams its block of the large input and looks keys up.

    No reducers, no sort and no shuffle, so it is much faster. It only works when one side is small enough to fit in each mapper's memory.

    Pig replicated joinHive MapJoinSpark broadcast join
    ↑ see it in the visualization
  11. Partitioned hash join

    A map-side join for two inputs already partitioned the same way on the join key: mapper i joins partition i of each input.

    Each mapper only needs one partition of the smaller side in memory. It relies on how the inputs were written earlier, so it needs the partitioning recorded as metadata (Hive bucketed map joins).

    ↑ see it in the visualization
  12. Immutable batch output

    Batch jobs write fresh files (search indexes, key-value database files) rather than mutating a live database; readers switch to the new output atomically.

    Bad code costs a re-run or a rollback, not corrupted data (human fault tolerance). Building files in bulk is also far faster than one network write per record, and it doesn't overload production databases.

    Lucene index segmentsVoldemortHBase bulk loadElephantDB
    ↑ see it in the visualization
  13. Workflow

    A chain of jobs where each job's output directory is the next one's input, managed by a scheduler that runs them in dependency order.

    Most real analyses take dozens of MapReduce jobs. Since each output is a file, any stage can be rerun or inspected on its own.

    OozieAzkabanLuigiAirflow
    ↑ see it in the visualization
  14. Materialization of intermediate state

    Writing each job's full output to the distributed filesystem, replicated, before the next job may start.

    It makes fault tolerance simple (just rerun the failed task), but every intermediate dataset is written and replicated, and a stage cannot start until the slowest task of the previous one is done.

    ↑ see it in the visualization
  15. Dataflow engine

    An engine that runs a whole workflow as one job: a graph of operators that hand data to each other directly, sorting only where needed.

    Operators can start as soon as input arrives, intermediate data stays in memory or on local disk, and there is no repeated task start-up. That makes it much faster than a chain of MapReduce jobs.

    SparkFlinkTez
    ↑ see it in the visualization
  16. Recomputation from lineage

    Fault tolerance without materialization: lost data is rebuilt by rerunning the operators that produced it, starting from input that is still available.

    Avoids writing intermediate state at all, but a failure late in a long pipeline can mean a lot of recomputation, and operators must be deterministic so the rebuilt data matches what was lost. Checkpoints bound the cost.

    Spark RDD lineageFlink checkpoints
    ↑ see it in the visualization
  17. Pregel / bulk synchronous parallel

    A graph processing model: in each superstep every vertex processes messages sent to it, updates its state, and sends messages along its edges.

    Iterative graph algorithms like PageRank don't fit one MapReduce pass. Pregel keeps vertex state in memory between iterations, and a checkpoint after each superstep gives fault tolerance.

    PregelApache GiraphSpark GraphXFlink Gelly
    ↑ see it in the visualization
  18. High-level APIs and declarative queries

    Relational-style operators (join, filter, group by) and SQL on top of the engines, instead of hand-written map and reduce functions.

    Less code, and a query optimizer can choose the join algorithm (broadcast vs sort-merge) and push filters down. Unlike an MPP database, it still lets you run arbitrary code over arbitrary file formats.

    HivePigSpark SQLFlink Table APICascading
    ↑ see it in the visualization