System designCore14 min

Stream processing

A batch job answers a question about data that has stopped arriving. A stream job answers it while the data is still arriving, and that means deciding what to count, when a minute is finished, and how to survive a crash without counting anything twice.

1 · The problem

A nightly job answers yesterday's question

A ride-hailing app raises prices in a zone when riders there outnumber drivers. To do that it needs one number, again and again: how many ride requests came from each zone in the last minute. A report that runs every night over the day's requests is correct and useless. By the time it finishes, the concert crowd that needed more drivers has gone home.

Stream processing computes over data as it arrives. The ride requests flow into a topic on a log-based broker (Message queues & pub/sub), and a long-running stream job reads them, keeps running counts, and publishes a fresh result every minute:

ride-requests topicfrom rider phonesStream jobcount per zonePricing serviceread in orderevery minuteride-requests topicfrom rider phonesStream jobcount per zonePricing serviceread in orderevery minute

The job never finishes. It reads requests as they come and keeps publishing results for the minutes that have just ended.

The difference is not only speed. A batch job reads bounded data, a set with an end, and can wait until it has seen all of it. A stream job reads unbounded data that never ends, so every question that batch answers by "wait for the end" needs a new answer:

Question

Batch job

Stream job

What is the input?

A finished set: yesterday's requests

An endless sequence of events

When is a result ready?

When the job ends, minutes or hours later

Soon after each time window closes

When is a minute complete?

Obvious: all data is already there

Never certain; a watermark decides (below)

After a crash?

Rerun the job

Restore a checkpoint and replay

The rest of this page answers the stream job's questions in order: where its counts live, how an endless stream is cut into minutes, which clock decides the minute, when a minute is finished, and how a crash is survived.

2 · The memory

Counting needs memory, and the memory is split by key

Some steps look at one event at a time and forget it. Others must remember earlier events. The difference decides how hard a step is to scale and to recover:

Kind

Ride-app example

Remembers

Stateless: filter

Drop requests from test accounts

Nothing

Stateless: map

Turn GPS coordinates into a zone name

Nothing

Stateful: aggregate

Count requests per zone per minute

A running count for every open zone and minute

Stateful: join

Pair each request with the zone's free drivers

The latest driver count per zone

The memory a stateful step keeps is its state. A busy city has hundreds of zones, more requests than one machine can count, so the job runs as several instances, each reading some partitions of the topic. That only works if every request for one zone reaches the same instance, which holds that zone's count. The job routes events by key, here the zone name, exactly as a database shards rows by key (Sharding & partitioning):

ride-requests topicrequests from all zonesInstance AMidtown, HarborInstance BAirport, Old TownInstance CStadium, Riversidekey: Midtownkey: Airportkey: Stadiumride-requests topicrequests from all zonesInstance AMidtown, HarborInstance BAirport, Old TownInstance CStadium, Riversidekey: Midtownkey: Airportkey: Stadium

Each instance owns the counts for its zones in local memory or on local disk. A count is updated without asking any other machine, which is what makes stream jobs fast.

Local state is fast because no network call is needed per event. It also brings the two problems the rest of the page deals with. One zone can be so busy that its instance falls behind (Hot partitions & skew). And local state dies with its machine, unless the job saves it somewhere (see the checkpoints section).

3 · The windows

An endless stream is counted in windows

"How many requests from Midtown?" has no answer on an endless stream: the count only grows. The useful question is always about a stretch of time, such as "in the minute from 18:00 to 18:01". That stretch is a window. The job keeps one count per zone per open window and publishes it when the window ends.

Three shapes of window cover almost every need:

Window

Shape

Ride-app use

Each event counts in

Tumbling

Fixed size, no overlap: 18:00–18:01, 18:01–18:02, …

Requests per zone per minute

Exactly one window

Hopping (Flink: sliding)

Fixed size, starting every hop: 5 minutes, a new one every minute

A smoother 5-minute demand, updated every minute

Size ÷ hop = 5 windows

Session

Grows while events keep coming; ends after a gap of inactivity

One rider's app session, ended by 10 idle minutes

One session, which may merge with the next

The shapes cost different amounts of memory. A tumbling window holds one count per zone. A 5-minute window that hops every minute keeps 5 overlapping windows open, so 5 counts per zone, and each request updates all 5. A session window has no fixed end at all: two bursts of taps 8 minutes apart merge into one session, because they are within the 10-minute gap.

A window has a start and an end on a clock. Which clock? The obvious answer, the clock on the job's machine, is wrong surprisingly often.

4 · The clock

The minute an event happened isn't the minute it arrives

A rider in a tunnel taps "request ride" at 18:00:50. The phone has no signal, so the app holds the request and sends it when the train leaves the tunnel. It reaches the job at 18:01:20:

The request happened in the 18:00 minute and arrived in the 18:01 minute. Counting it in 18:01 misplaces it.

There are two clocks, and every stream job must choose one:

  • Event time: when the event happened, stamped by the device that produced it and carried in the event.
  • Processing time: when the job's machine handles the event.

Between them sits ingestion time, the moment the broker stored the event, which is fixed once written but still says nothing about the tunnel.

The gap between event time and processing time is not a small constant. It changes with signal, load and failures, and it is largest exactly when something goes wrong. Suppose the job is down for 20 minutes and then reads the backlog in 2 minutes:

Counted by

What the pricing service sees

Processing time

20 empty minutes, then 2 minutes holding 22 minutes of requests: a fake spike and a price surge

Event time

22 normal minutes, each with its own requests; the results are late, not wrong

So jobs whose results describe the real world count by event time. Processing time is fine when the question really is about the job, such as "how many events did we handle this minute?". One caveat: event time is only as good as the clock that stamped it, and phone clocks drift (Time, clocks & ordering).

5 · The wait

A watermark decides when a minute is finished

Counting by event time creates a new problem. At 18:01:00 on the job's clock, is the 18:00 minute finished? A request stamped 18:00:50 might still be in a tunnel. The job can never be sure it has seen everything, yet it has to publish a count at some point.

A watermark is the job's declaration of progress. A watermark of 18:01:00 says: "no more events stamped before 18:01:00 are expected". When it passes the end of a window, the window's result is published and its count can be freed. A common way to compute it:

watermark = latest event time seen − allowed delay

With an allowed delay of 30 seconds, the 18:00 minute closes once the job has seen an event stamped 18:01:30. The delay is the one dial the designer turns, and it trades speed for completeness. Assuming most events arrive within a second of happening:

Allowed delay

18:00 count published at about

Request 30 s late

Request 3 min late

5 seconds

18:01:05

Late

Late

30 seconds

18:01:30

Counted

Late

5 minutes

18:06:00

Counted

Counted

The tunnel request, 30 seconds late, makes it into the 18:00 count with a 30-second delay. Pricing that waits 5 minutes for every count is too slow to be useful, so some events will always be late. A watermark is a guess, not a guarantee, and the job needs a policy for events that arrive after it:

Policy

What happens to a late request

Fits when

Drop it

Ignored. The default in Flink, and in Kafka Streams once a window's grace period has passed

A few missing requests don't change a price

Update the result

The window's count is kept a while longer and a corrected count is published

Consumers accept a second result for the same minute

Set it aside

Sent to a separate output, to fix the numbers later

A nightly report must be exact

Results can also come early. A job can publish an estimate for the current minute every 10 seconds and the final count when the watermark passes, so the pricing service never waits a full minute to react. One trap: the watermark is the slowest input's progress. If one topic partition receives no events, the job's watermark stops moving and every window stays open; stream frameworks let you mark such an input as idle so the others can move on.

6 · The crash

A crash rolls back to the last checkpoint and replays

Instance A holds the open counts for Midtown and Harbor. Its machine dies. Those counts are gone, and the requests that built them were already read from the topic. Re-reading the whole topic is too slow; skipping ahead loses requests. The fix combines two things the job already has: a log it can rewind, and state it can copy.

At a regular interval the designer chooses, the job takes a checkpoint: a consistent snapshot of every instance's state together with each input's position in the log, saved to durable storage. "Consistent" means all instances snapshot at the same point in the input. Flink does this by sending markers through the stream along with the events. On a crash, the job goes back to the last checkpoint:

The counts and the read position are restored together, so requests 4,181 to 4,260 are read twice but counted once: the first counts died with the crash.

This gives exactly-once state: every request affects the counts exactly once, even though some were read twice. It is the same idea as storing a consumer's position with its result (Delivery semantics), applied to a whole job at once. Kafka Streams keeps the same promise differently: each instance's state is always written to a changelog topic, from which a replacement instance rebuilds it, and with exactly-once on, the changelog writes, the output and the input offsets commit together.

The guarantee covers the job's state, not what it has already sent out. If the job published Midtown's 18:00 count after checkpoint 7 and then crashed, the replay publishes it again. Two ways to make that harmless:

  • Overwrite by key. Write the count as "Midtown, 18:00 → 42" into a table keyed by zone and minute. A repeat writes the same value again.
  • Commit output with the checkpoint. Hold output in a transaction that commits only when the checkpoint completes. Nothing is published twice, but consumers see results one checkpoint later.

7 · In practice

Streams join tables, and a log can be replayed to fix a bug

Demand alone doesn't set a price; it has to be compared with supply. Drivers' apps report their position every few seconds, so the job keeps a table of free drivers per zone, updated from that second stream. Each ride request is joined with the table as it arrives, a stream-table join:

Ride requestsDriver positionsFree drivers per zoneMidtown: 12Join by zonedemand ÷ supplyupdateeach requestlook up zoneRide requestsDriver positionsFree drivers per zoneMidtown: 12Join by zonedemand ÷ supplyupdateeach requestlook up zone

A table is a stream's latest value per key. The driver stream builds it; the request stream reads it, and the ratio goes to the pricing service. Each lookup is local, because both streams are keyed by zone.

Bugs need a different tool. Suppose the map step put part of Midtown into Harbor for a week. A batch system would rerun the report. A stream job can do the same thing, as long as the topic still holds the week's events: start a second copy of the fixed job from the start of the retained log, let it write to a new table, and point the pricing service at the new table once it has caught up. Then delete the old one.

Using one job for both live results and reprocessing is the idea behind the Kappa architecture, which avoids running a batch system and a stream system side by side with the same logic written twice (Lambda vs Kappa architecture).

Streaming is not always the right tool. If the answer is needed tomorrow, a batch job is simpler to write, test and rerun. Choose a stream job when a result loses its value within minutes, and then settle these decisions up front:

  • Key: what must be counted together, so it lands on one instance.
  • Window: tumbling, hopping or session, and its size.
  • Clock: event time for anything about the real world.
  • Lateness: how long to wait, and what happens to events that arrive after that.
  • Output: overwrite by key, or commit with checkpoints, so a replay doesn't double-count.

Check yourself

5 questions

1. A stream job counting requests per minute is down for 20 minutes, then reads the backlog in 2 minutes. It counts by processing time. What does the pricing service see?
2. A job uses 10-minute hopping windows that start every 2 minutes. In how many windows is each request counted?
3. The watermark is the latest event time seen minus 30 seconds. A request stamped 18:00:10 arrives after an event stamped 18:01:45 has been seen, and the job keeps the default policy of dropping late events. What happens to it?
4. A job restores checkpoint 7 after a crash and replays from its saved offset. Why can the pricing table receive Midtown's 18:00 count twice, and what makes that harmless?
5. Why does a job counting per zone route every request for one zone to the same instance?

Takeaways

Remember this

  • Stream processing computes over unbounded data as it arrives, publishing results for windows of time instead of waiting for the end.
  • Stateful steps such as counts and joins keep local state, partitioned by key so each key's state lives on one instance.
  • Count by event time, when things happened, not processing time, or every delay and backlog distorts the results.
  • A watermark decides when a window is finished; its delay trades fast results against counting late events, and some events will always be late.
  • Checkpoints save state and input positions together, giving exactly-once state; output needs overwrites by key or commits tied to checkpoints.