Design top-k heavy hitters
Design a top-k most viewed videos system for 10 billion views a day. Start with one counting server, grow it with a split by video, Count-Min Sketches for sliding windows and an exact daily batch, then stress it with the sketch error, late views, one viral video, regions and the batch beside the stream, with code.
- Read first
- EstimationBloom filters and sketches
- Scaling patterns
- Hot keys and hot partitionsMulti-region and active-active
“Show the 100 most viewed videos of the last hour, and of the last day, and keep the list fresh.”
It sounds like ORDER BY views DESC LIMIT 100. At ten billion views a day, it is a streaming
problem with a counting problem inside it. You cannot keep an exact count for every video in every
window without paying for a long tail of videos nobody will ever rank. So you take shortcuts, and
each shortcut gives up some exactness. The senior part of this problem is knowing exactly how much.
So this page starts with one counting server, and lets the numbers grow it. Then it tests the result against four hard questions. How wrong may a count be? What does “the last hour” mean when views arrive late? What about one viral video? And how do you merge lists that were counted in different places?
The answer in one minute
Section titled “The answer in one minute”If you only have a minute, this is the design. The rest of the page explains each part.
- Split the counting by video. Every view of a video goes to the same counting machine. Then each machine’s top list can be merged into the global one exactly.
- Count the long windows with a sketch. A Count-Min Sketch is a small grid of counters that estimates any video’s count. It can only count too high, never too low, and we know by how much.
- Slide windows by subtraction. Keep one sketch per minute and a running total. When a minute leaves the window, subtract its sketch.
- Combine views before Kafka. For a viral video, each ingest server sends one message per second with a count, not one message per view.
- An exact batch for the official chart. The live lists are estimates. A daily job counts the archive exactly, removes duplicates, and publishes the chart that pays artists.
Scope it
Section titled “Scope it”Four questions decide the design: over which windows, how big k is, how fresh, and whether counts must be exact.
Design a system that shows the top k most viewed videos. Think YouTube trending, but by raw views.
Whether you pin down the windows, k, freshness and exactness, because those four decide the whole design. Three minutes is right. Drawing Kafka before asking “top over what time window?” is the classic red flag.
These four questions matter most:
- Which windows? The last 5 minutes, the last hour and the last day, each moving forward every minute.
- How big is k? 100 on the page, up to 1,000 through the API.
- How fresh? A list is at most 2 minutes behind real time.
- Exact or approximate? The live lists may be approximate. The official daily chart must be exact.
From these answers we make five promises:
- Fresh. A view shows up in the live lists within 2 minutes.
- Honest counts. A live count is never below the true count. And it’s at most one view in a million of the window’s total above it. Videos nearly tied at rank 100 may swap.
- Fast. A top-k read takes under 50 ms at p99, at 10,000 reads a second.
- Always up. Reads work 99.9% of the time. A list that is a minute old beats an error.
- Never lost. A view we accepted is never lost before the daily batch has counted it.
We leave out three things. Personalized trending is a ranking problem that uses these counts. Fraud scoring of single views is out too: we only rate-limit and cap. And billing per view is the ad click aggregator.
Go deeperMore questions, and what each changes1 min
| Question | We assume | If the answer were different |
|---|---|---|
| “All time” too? | No | A running counter per video and a sorted index; no windows, no sketches |
| How many events? | 10 billion views a day | 100 million a day fits one server with exact counts |
| k of 100,000? | No | A full ranking: exact counts per video and a sorted store, not a heap |
| Does the count drive money? | Payouts use the batch counts | Billing-grade live counts need exactly-once processing per event |
| Count views or distinct viewers? | Views, with a cap per user against abuse | Distinct viewers need a HyperLogLog per candidate, not a counter |
| History needed? | Yes: “what was trending at 3 pm yesterday?” | Without history, published lists can live only in a cache |
| Per-country lists? | Later | Each extra dimension multiplies the counting state |
The numbers that decide it
Section titled “The numbers that decide it”Now we work out the numbers. In this problem, memory shapes the design more than disk does.
- View events a day
- 10 billion
- Peak over average
- 3×
- Bytes per event
- 100 bytes
- Distinct items viewed in an hour
- 50 million
- Distinct items viewed in a minute
- 3 million
- Aggregator partitions
- 32
- Allowed overcount per million views
- 1
- The 100th item's views in a day
- 1 million
10 billion views a day ÷ 86,400 seconds, and about three times that in the evening. One process that reads and counts events manages about 100,000 a second.
So we split the counting by video from the start.
About 3 million videos get a view in any minute, 50 million in an hour and 200 million in a day. The top 100 of a day have about a million views each. Most of the 200 million have fewer than ten.
A day that moves every minute needs a count per video for each of 24 hours, and for each of the 60 minutes of the current hour. At about 64 bytes per entry: (24 × 50 million + 60 × 3 million) × 64 bytes ≈ 88 GB.
So we exact counting fits on 32 machines, but nearly all of it would be spent on videos that will never be near rank 100.
To keep the error under one in a million, each of 32 partitions needs about 85,000 counters per row, 5 rows, and 87 sketches per partition.
So we use sketches for the long windows: one twentieth of the memory, sized by the accuracy we ask for, not by the number of videos.
One in a million of a day’s 10 billion views is 10,000 views: about 1% of the 100th video’s million.
So we accept the sketch for live lists: only near-ties at the cutoff can swap.
Raise the views tenfold and two decisions flip. One Kafka message per view stops fitting, so we must combine at ingest. And the error grows to 10% of the 100th video, so the sketch must widen. Try it in the calculator.
- Events, average
- 120,000/s
- Events, peak
- 350,000/s
- Past the 100,000 events/s one counting process takes: partition by item id.
- Ingest bandwidth, peak
- 35 MB/s
- Not a design driver: a handful of Kafka brokers take it.
- Raw events a day
- 1 TB
- Kept 7 days in Kafka and archived to object storage for the daily batch.
- Exact state, sliding hour and day
- 88 GB
- Far more RAM than a top 100 needs, almost all of it for long-tail items nobody ranks: use sketches for the hour and day.
- Sketch width per partition
- 85,000
- Counters per row. Each partition sees 1/parts of the views, so it needs 1/parts of the width.
- Sketch state, all windows
- 4.7 GB
- 5 rows of 4 byte counters, 87 sketches per partition (60 minutes, 24 hours, 3 sums). Set by accuracy, not by item count.
- Worst overcount, day window
- 10,000
- Holds for each item with probability 99.3% (5 rows).
- Day overcount, % of the 100th item's views
- 1
- Small enough that only near-ties at rank 100 can swap.
Build the design
Section titled “Build the design”We start with the simplest design that is correct. Then we let the numbers break it, one at a time. Each step fixes only what broke, adds only the boxes that fix needs, and says what the fix costs. Throughout, follow one scenario: views come in, and someone asks for the top 100 of the last hour.
Step 1. One counting server
Section titled “Step 1. One counting server”The simplest version is one server that keeps a map from video to count for the current minute, and sorts it when someone asks.
Stage 1: One counting server
Stage 1 of 6
One counting server
Why this stage
Nothing yet.
This is the smallest design that returns a top 100.
What we add
One server that keeps a hash map from item to count for the current minute, and sorts it when someone asks.
What it costs
One server takes about 100,000 events/s, a crash loses every count, and there is no window longer than a minute.
At 350k events per second, Counter server is 350% busy. Stage 2 is the fix.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
It has three flaws. One process counts about 100,000 events a second, a third of the peak. A crash loses every count. And there is no window longer than a minute.
Step 2. Split the counting by video
Section titled “Step 2. Split the counting by video”Ingest servers check each batch of views from the apps, then write each view to
Kafka, keyed by video. Kafka hashes the key to one of 64 partitions, so every view of a video lands on the same partition, and so on the same aggregator. Each of 32 aggregators counts its own videos. When a minute ends, each sends its own top 1,000 to a merger, which takes the best 1,000 overall and writes them to Redis. The read API serves from Redis.
Why is that merge exact? Because a video’s whole count lives on one aggregator. A video in the global top 100 must also be in its own aggregator’s top 100. Nothing on that machine can outrank it without outranking it everywhere. So the union of the local top lists contains the true answer.
Split by user instead, and a video’s views are spread over every machine. Then a video that is 150th everywhere can be 1st overall, and no local list contains it. A stress test comes back to this.
Stage 2: Partition by item
Stage 2 of 6
Partition by item
Why this stage
The peak is 350,000 events/s, three and a half times one server.
Counts must also survive a crash.
What we add
Ingest servers, Kafka keyed by item id, exact per-minute counters per partition, a merger that unions their top lists, a Redis copy of each list and a read API.
What it costs
Only tumbling one-minute windows, and one viral item lands on one partition.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
Step 3. Count the windows with a sketch
Section titled “Step 3. Count the windows with a sketch”So far every window is one fixed minute. The last hour and the last day need to forget old views, and forgetting needs to know what to subtract. Exact counts would mean a count per video per minute: the 88 GB from the estimates.
Instead each aggregator keeps one Count-Min Sketch per minute and one per hour, plus a running sum for each window. A view is added to its minute’s sketch and to the sums. When a minute leaves the window, its sketch is subtracted from the sum. Sketches add and subtract cell by cell, so the result is exactly the sketch of the minutes that remain.
A sketch can estimate any video, but it cannot list its biggest ones. So each window also keeps a heap of about 1,000 candidates. The first stress test explains both, with the code.
Stage 3: Sliding windows on sketches
Stage 3 of 6
Sliding windows on sketches
Why this stage
Product wants the last hour and the last day, sliding every minute.
Exact per-key buckets for that need about 88 GB of RAM, almost all of it for items nobody ranks.
A requirement and a memory bill force this stage, not traffic.
What we add
A Count-Min Sketch per minute and per hour, running sums that subtract the bucket that leaves, a heap of candidates per window, and event-time windows with a watermark.
What it costs
Counts are estimates: up to about 1 view in a million of the window total too high, so near-ties at the cutoff can swap.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
Step 4. An exact batch for the official chart
Section titled “Step 4. An exact batch for the official chart”The live day list is an estimate, and it counts retried batches twice. So it cannot pay artists. A
connector copies every Kafka partition to object storage, as hourly files. A daily Spark job
runs at 06:00 for the day before. It removes duplicates by event ID, drops views flagged by abuse
rules, applies the cap per user, counts exactly, and publishes the official top 1,000. Until it
lands, the daily list is marked provisional.
Stage 4: Exact daily lists
Stage 4 of 6
Exact daily lists
Why this stage
The official daily chart must be exact, deduplicated and include phones that upload views hours late.
A sketch on a stream cannot promise that.
What we add
Raw events archived to object storage, and a daily batch job that counts exactly and replaces the provisional daily list.
What it costs
Two code paths that must agree on what counts as a view.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
Follow one read and one view through the design:
Trace a request: Read the hourly top 100
Read the hourly top 100: step 1 of 3
Users to Top-k API
GET /v1/topk?window=1h&k=100. The load balancer terminates TLS and picks any API server. Most requests stop here: each server keeps every list in memory for 10 seconds.
- Users → Top-k API2 ms
- Top-k API → Top-k cache1 ms
- Response
Both paths take a few milliseconds. What takes time is the pipeline between them: 35 to 107 seconds from a view to a published list, inside the 2 minute promise.
This is enough for a mid-level answer. Split by video, sketches with a candidate heap for the windows, a merger, and an exact daily batch. The stress tests are what make it a senior answer.
Go deeperWhere the two minutes go1 min
| Step | What happens | Delay |
|---|---|---|
| App to Kafka | The app batches views; ingest writes them with full replication | 0 to 5 s |
| Kafka to aggregator | The aggregator adds each view to its minute and the sums | Under 1 s |
| Minute closes | The clock of events passes the minute’s end plus 30 s | 30 to 90 s |
| Aggregators to merger | Each sends its top 1,000 per window | Under 1 s |
| Merger to Redis to readers | Merge, rank, write; API servers cache lists for 10 s | Up to 11 s |
| Total | Inside the 2 minute promise | 35 to 107 s |
Go deeperThe API2 min
POST /v1/eventsAuthorization: Bearer <device-token>
{ "events": [ { "eventId": "e7c1...", "itemId": "v_9HJw3SB", "eventTime": "2026-10-28T19:04:11.200Z", "country": "BR", "watchMs": 31000 }] }
202 AcceptedApps batch up to 50 views, and send them every few seconds. 202 means “safely in Kafka”, not
“counted”. The eventId is made on the phone, so a retried batch can be removed later. Views can
arrive hours late. (A phone offline on a plane uploads on landing.) That’s why windows use the time
on the event. 429 means a device went over its rate limit. The app keeps its batch, and backs off.
GET /v1/topk?window=1h&k=100
200 OKCache-Control: public, max-age=10{ "window": "1h", "asOf": "2026-10-28T19:05:00Z", "provisional": true, "items": [ { "itemId": "v_9HJw3SB", "count": 4812004, "maxOvercount": 430 }, ... ] }window is 5m, 1h, 1d, or day=2026-10-27 for the official chart. asOf says how current
the list is. maxOvercount states the sketch’s error honestly: the count can be that much too
high, never too low. GET /v1/topk/history?window=1h&at=... returns the list published nearest a
time.
Go deeperThe data, and why counting stays in memory1 min
There are three very different stores. A log for events (Kafka, 7 days). Memory for counting (the stream job’s state). And a small key-value store for results (Redis, plus a column store such as ClickHouse for history and daily counts).
event in Kafka, keyed by item_id event_id 16 bytes from the device, for removing duplicates item_id ~12 bytes the Kafka key user_hash 8 bytes salted hash, for caps per user event_time 8 bytes when it happened on the device; windows use this ingest_time 8 bytes when we received it
Redis topk:{window}:{scope} -> JSON list, asOf, provisionalTable topk_history (window, scope, as_of) -> listTable daily_counts (day, item_id) -> viewsThe aggregator’s state (sketches, sums, heaps) is saved every minute to object storage, together with its Kafka positions. So state and input always match after a restart.
| Option | Problem at this scale |
|---|---|
| A database row per video | 350,000 writes a second, the hottest rows on one machine |
Redis ZINCRBY on each view |
Same write rate; a sorted set per minute; a union of 1,440 for the day |
| Counting in job memory | No network call per view; a restart replays up to a minute of Kafka |
Redis sorted sets are the right answer at a few thousand events a second, and in the leaderboard problem: exact scores, few updates per key.
Stress it
Section titled “Stress it”The design is complete. Now we test it where top-k is hard. How wrong is the sketch? Windows and late views. One viral video. Regions that each count part of a video. And the batch beside the stream. The viral video and the regions each add a few boxes.
Exact counts or a sketch
Section titled “Exact counts or a sketch”You said Count-Min Sketch. How does it work, how wrong can it be, and why not something else?
Whether you state the error correctly: a share of the window’s total, with a probability. Whether you know the sketch alone cannot produce a top list. And whether you can name an alternative. Saying the sketch is “1% accurate” for every video is the most common error in this problem.
How the sketch works. It is d rows of w counters, and each row has its own hash function. Adding a video adds its count to one cell in each row. Other videos land in the same cells, so each cell can only count too high. The estimate is the smallest of the video’s d cells.
How wrong it can be. At most ε times the window’s total N too high, with a chance set by the number of rows. Take ε at one in a million, and 5 rows. An estimate is at most 10,000 views high on a day of 10 billion, 99.3% of the time.
Why it needs a heap. The sketch answers “how many for this video?” and nothing else. It does not remember which videos it has seen. So each window keeps a min-heap of candidates. After each update, the video’s estimate is compared with the weakest candidate, and replaces it if larger. The heap holds 1,000 for a list of 100, so a video near the cutoff is still tracked when the window moves. Lists are re-scored against the sketch when published.
// A Count-Min Sketch: depth rows of width counters. An item adds to one cell per row, and other// items share those cells, so every row can only overcount it. The smallest of its cells is the// estimate: never below the true count, and at most (e / width) x the window's total above it,// with probability 1 - e^-depth for each query.final class CountMinSketch { private final int width; // a power of two, so a mask replaces a modulo on the hot path private final int depth; private final long[][] cells;
CountMinSketch(int width, int depth) { if (Integer.bitCount(width) != 1) throw new IllegalArgumentException("width: power of two"); this.width = width; this.depth = depth; this.cells = new long[depth][width]; }
void add(String item, long count) { long h = fnv1a(item); for (int row = 0; row < depth; row++) cells[row][index(h, row)] += count; }
long estimate(String item) { long h = fnv1a(item); long best = Long.MAX_VALUE; for (int row = 0; row < depth; row++) best = Math.min(best, cells[row][index(h, row)]); return best; }
// The sketch is linear: adding two sketches cell by cell gives exactly the sketch of both // streams together, so minutes merge into hours and partitions into regions with no extra error. void merge(CountMinSketch other) { combine(other, 1); }
// Linearity also runs backwards: subtracting a minute that left the window leaves exactly // the sketch of the minutes that remain. This is why we use plain updates, not // "conservative update", which is more accurate but cannot be subtracted. void subtract(CountMinSketch other) { combine(other, -1); }
private void combine(CountMinSketch other, int sign) { if (other.width != width || other.depth != depth) throw new IllegalArgumentException("only sketches of the same shape can be combined"); for (int row = 0; row < depth; row++) for (int col = 0; col < width; col++) cells[row][col] += sign * other.cells[row][col]; }
// Double hashing: row i uses h1 + i * h2. For a sketch this is as good as depth // independent hash functions, and it costs one hash of the item instead of depth. private int index(long h, int row) { int h1 = (int) h; int h2 = (int) (h >>> 32) | 1; // odd, so rows never collapse onto the same column return (h1 + row * h2) & (width - 1); }
// FNV-1a over the UTF-8 bytes, not String.hashCode: every aggregator in every region, in // any language, must put an item in the same cells, or their sketches cannot be merged. static long fnv1a(String s) { long h = 0xcbf29ce484222325L; for (byte b : s.getBytes(StandardCharsets.UTF_8)) { h ^= (b & 0xff); h *= 0x100000001b3L; } return h; }}
// The candidates for one window. The sketch counts every item but cannot list its biggest// ones; it only answers "how many for this item?". The heap remembers which items to ask about.final class TopK { private record Entry(long estimate, String item) {}
private final CountMinSketch sketch; // About 10 x the k we publish: an item just below the cutoff is still tracked when the // window slides and the items above it lose their old minutes. private final int capacity; private final Map<String, Long> candidates = new HashMap<>(); // item -> latest estimate // A min-heap with lazy deletion: when an item's estimate grows we push a new entry and // leave the old one, skipping it when it reaches the top. No O(k) search on every event. private final PriorityQueue<Entry> heap = new PriorityQueue<>(Comparator.comparingLong(Entry::estimate));
TopK(CountMinSketch sketch, int capacity) { this.sketch = sketch; this.capacity = capacity; }
void add(String item, long count) { sketch.add(item, count); long est = sketch.estimate(item); if (candidates.containsKey(item) || candidates.size() < capacity) { track(item, est); } else { Entry weakest = peekLive(); // Most of the long tail stops here: one comparison, no allocation. if (est > weakest.estimate()) { heap.poll(); candidates.remove(weakest.item()); track(item, est); } } if (heap.size() > 4 * capacity) compact(); // bounds the stale entries a hot item leaves }
// Estimates are fetched again when we publish: the cached ones only go stale upward, // because other items keep landing in the same cells after we last looked. List<Map.Entry<String, Long>> top(int n) { return candidates.keySet().stream() .map(item -> Map.entry(item, sketch.estimate(item))) .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(n) .toList(); }
// Called after the window slides. Every estimate fell by the minute that left, each by a // different amount, so the heap order is rebuilt from fresh estimates. void rescore() { candidates.replaceAll((item, est) -> sketch.estimate(item)); compact(); }
private void track(String item, long est) { candidates.put(item, est); heap.add(new Entry(est, item)); }
// Pops stale entries (an older estimate, or an evicted item) until the top is current. // A current entry always exists, because every candidate has one. private Entry peekLive() { while (true) { Entry top = heap.peek(); Long current = candidates.get(top.item()); if (current != null && current == top.estimate()) return top; heap.poll(); } }
private void compact() { heap.clear(); candidates.forEach((item, est) -> heap.add(new Entry(est, item))); }}import heapq
# A Count-Min Sketch: depth rows of width counters. An item adds to one cell per row, and# other items share those cells, so every row can only overcount it. The smallest of its cells# is the estimate: never below the true count, and at most (e / width) x the window's total# above it, with probability 1 - e^-depth for each query.class CountMinSketch: def __init__(self, width: int, depth: int) -> None: # A power of two, so a mask replaces a modulo on the hot path. if width & (width - 1): raise ValueError("width: power of two") self.width, self.depth = width, depth self.cells = [[0] * width for _ in range(depth)]
def add(self, item: str, count: int = 1) -> None: h = fnv1a(item) for row in range(self.depth): self.cells[row][self._index(h, row)] += count
def estimate(self, item: str) -> int: h = fnv1a(item) return min(self.cells[row][self._index(h, row)] for row in range(self.depth))
def merge(self, other: "CountMinSketch") -> None: # The sketch is linear: adding two sketches cell by cell gives exactly the sketch of both # streams together, so minutes merge into hours and partitions into regions with no # extra error. self._combine(other, 1)
def subtract(self, other: "CountMinSketch") -> None: # Linearity also runs backwards: subtracting a minute that left the window leaves exactly # the sketch of the minutes that remain. This is why we use plain updates, not # "conservative update", which is more accurate but cannot be subtracted. self._combine(other, -1)
def _combine(self, other: "CountMinSketch", sign: int) -> None: if (other.width, other.depth) != (self.width, self.depth): raise ValueError("only sketches of the same shape can be combined") for mine, theirs in zip(self.cells, other.cells): for col, v in enumerate(theirs): mine[col] += sign * v
def _index(self, h: int, row: int) -> int: # Double hashing: row i uses h1 + i * h2. For a sketch this is as good as depth # independent hash functions, and it costs one hash of the item instead of depth. h1 = h & 0xFFFFFFFF h2 = (h >> 32) | 1 # odd, so rows never collapse onto the same column return (h1 + row * h2) & (self.width - 1)
def fnv1a(s: str) -> int: # FNV-1a over the UTF-8 bytes, never hash(): Python salts it per process, and every # aggregator in every region must put an item in the same cells, or sketches cannot merge. h = 0xCBF29CE484222325 for b in s.encode("utf-8"): h = ((h ^ b) * 0x100000001B3) & 0xFFFFFFFFFFFFFFFF return h
# The candidates for one window. The sketch counts every item but cannot list its biggest# ones; it only answers "how many for this item?". The heap remembers which items to ask about.class TopK: def __init__(self, sketch: CountMinSketch, capacity: int) -> None: self._sketch = sketch # About 10 x the k we publish: an item just below the cutoff is still tracked when the # window slides and the items above it lose their old minutes. self._capacity = capacity self._candidates: dict[str, int] = {} # item -> latest estimate # A min-heap with lazy deletion: when an item's estimate grows we push a new entry and # leave the old one, skipping it when it reaches the top. No O(k) search per event. self._heap: list[tuple[int, str]] = []
def add(self, item: str, count: int = 1) -> None: self._sketch.add(item, count) est = self._sketch.estimate(item) if item in self._candidates or len(self._candidates) < self._capacity: self._track(item, est) else: weakest_est, weakest = self._peek_live() # Most of the long tail stops here: one comparison, no allocation. if est > weakest_est: heapq.heappop(self._heap) del self._candidates[weakest] self._track(item, est) if len(self._heap) > 4 * self._capacity: self._compact() # bounds the stale entries a hot item leaves
def top(self, n: int) -> list[tuple[str, int]]: # Estimates are fetched again when we publish: the cached ones only go stale upward, # because other items keep landing in the same cells after we last looked. fresh = [(item, self._sketch.estimate(item)) for item in self._candidates] return sorted(fresh, key=lambda p: p[1], reverse=True)[:n]
def rescore(self) -> None: # Called after the window slides. Every estimate fell by the minute that left, each by # a different amount, so the heap order is rebuilt from fresh estimates. for item in self._candidates: self._candidates[item] = self._sketch.estimate(item) self._compact()
def _track(self, item: str, est: int) -> None: self._candidates[item] = est heapq.heappush(self._heap, (est, item))
def _peek_live(self) -> tuple[int, str]: # Pops stale entries (an older estimate, or an evicted item) until the top is current. # A current entry always exists, because every candidate has one. while True: est, item = self._heap[0] if self._candidates.get(item) == est: return est, item heapq.heappop(self._heap)
def _compact(self) -> None: self._heap = [(est, item) for item, est in self._candidates.items()] heapq.heapify(self._heap)// A Count-Min Sketch: depth rows of width counters. An item adds to one cell per row, and other// items share those cells, so every row can only overcount it. The smallest of its cells is the// estimate: never below the true count, and at most (e / width) x the window's total above it,// with probability 1 - e^-depth for each query.sealed class CountMinSketch{ private readonly int _width; // a power of two, so a mask replaces a modulo on the hot path private readonly int _depth; private readonly long[,] _cells;
public CountMinSketch(int width, int depth) { if (!int.IsPow2(width)) throw new ArgumentException("width: power of two"); (_width, _depth) = (width, depth); _cells = new long[depth, width]; }
public void Add(string item, long count = 1) { ulong h = Fnv1a(item); for (int row = 0; row < _depth; row++) _cells[row, Index(h, row)] += count; }
public long Estimate(string item) { ulong h = Fnv1a(item); long best = long.MaxValue; for (int row = 0; row < _depth; row++) best = Math.Min(best, _cells[row, Index(h, row)]); return best; }
// The sketch is linear: adding two sketches cell by cell gives exactly the sketch of both // streams together, so minutes merge into hours and partitions into regions with no extra error. public void Merge(CountMinSketch other) => Combine(other, 1);
// Linearity also runs backwards: subtracting a minute that left the window leaves exactly // the sketch of the minutes that remain. This is why we use plain updates, not // "conservative update", which is more accurate but cannot be subtracted. public void Subtract(CountMinSketch other) => Combine(other, -1);
private void Combine(CountMinSketch other, int sign) { if (other._width != _width || other._depth != _depth) throw new ArgumentException("only sketches of the same shape can be combined"); for (int row = 0; row < _depth; row++) for (int col = 0; col < _width; col++) _cells[row, col] += sign * other._cells[row, col]; }
// Double hashing: row i uses h1 + i * h2. For a sketch this is as good as depth // independent hash functions, and it costs one hash of the item instead of depth. private int Index(ulong h, int row) { uint h1 = (uint)h; uint h2 = (uint)(h >> 32) | 1; // odd, so rows never collapse onto the same column return (int)((h1 + (uint)row * h2) & (uint)(_width - 1)); }
// FNV-1a over the UTF-8 bytes, not string.GetHashCode, which .NET randomizes per process: // every aggregator in every region must put an item in the same cells, or sketches can't merge. public static ulong Fnv1a(string s) { ulong h = 0xcbf29ce484222325; foreach (byte b in Encoding.UTF8.GetBytes(s)) { h ^= b; h *= 0x100000001b3; } return h; }}
// The candidates for one window. The sketch counts every item but cannot list its biggest// ones; it only answers "how many for this item?". The heap remembers which items to ask about.sealed class TopK(CountMinSketch sketch, int capacity){ // capacity is about 10 x the k we publish: an item just below the cutoff is still tracked // when the window slides and the items above it lose their old minutes. private readonly Dictionary<string, long> _candidates = new(); // item -> latest estimate // A min-heap with lazy deletion: when an item's estimate grows we enqueue a new entry and // leave the old one, skipping it when it reaches the top. No O(k) search on every event. private PriorityQueue<string, long> _heap = new();
public void Add(string item, long count = 1) { sketch.Add(item, count); long est = sketch.Estimate(item); if (_candidates.ContainsKey(item) || _candidates.Count < capacity) { Track(item, est); } else { var (weakest, weakestEst) = PeekLive(); // Most of the long tail stops here: one comparison, no allocation. if (est > weakestEst) { _heap.Dequeue(); _candidates.Remove(weakest); Track(item, est); } } if (_heap.Count > 4 * capacity) Compact(); // bounds the stale entries a hot item leaves }
// Estimates are fetched again when we publish: the cached ones only go stale upward, // because other items keep landing in the same cells after we last looked. public List<(string Item, long Count)> Top(int n) => _candidates.Keys .Select(item => (Item: item, Count: sketch.Estimate(item))) .OrderByDescending(p => p.Count) .Take(n) .ToList();
// Called after the window slides. Every estimate fell by the minute that left, each by a // different amount, so the heap order is rebuilt from fresh estimates. public void Rescore() { foreach (var item in _candidates.Keys.ToList()) _candidates[item] = sketch.Estimate(item); Compact(); }
private void Track(string item, long est) { _candidates[item] = est; _heap.Enqueue(item, est); }
// Dequeues stale entries (an older estimate, or an evicted item) until the top is current. // A current entry always exists, because every candidate has one. private (string Item, long Estimate) PeekLive() { while (true) { _heap.TryPeek(out var item, out var est); if (_candidates.TryGetValue(item!, out var current) && current == est) return (item!, est); _heap.Dequeue(); } }
private void Compact() => _heap = new PriorityQueue<string, long>(_candidates.Select(c => (c.Key, c.Value)));}// CountMinSketch has depth rows of width counters. An item adds to one cell per row, and other// items share those cells, so every row can only overcount it. The smallest of its cells is the// estimate: never below the true count, and at most (e / width) x the window's total above it,// with probability 1 - e^-depth for each query.type CountMinSketch struct { width uint32 // a power of two, so a mask replaces a modulo on the hot path depth int cells [][]int64}
func NewCountMinSketch(width uint32, depth int) *CountMinSketch { if width == 0 || width&(width-1) != 0 { panic("width: power of two") } cells := make([][]int64, depth) for i := range cells { cells[i] = make([]int64, width) } return &CountMinSketch{width, depth, cells}}
func (s *CountMinSketch) Add(item string, count int64) { h := fnv1a(item) for row := 0; row < s.depth; row++ { s.cells[row][s.index(h, row)] += count }}
func (s *CountMinSketch) Estimate(item string) int64 { h := fnv1a(item) best := int64(math.MaxInt64) for row := 0; row < s.depth; row++ { best = min(best, s.cells[row][s.index(h, row)]) } return best}
// Merge relies on the sketch being linear: adding two sketches cell by cell gives exactly the// sketch of both streams together, so minutes merge into hours and partitions into regions// with no extra error.func (s *CountMinSketch) Merge(o *CountMinSketch) { s.combine(o, 1) }
// Subtract runs linearity backwards: removing a minute that left the window leaves exactly the// sketch of the minutes that remain. This is why we use plain updates, not "conservative// update", which is more accurate but cannot be subtracted.func (s *CountMinSketch) Subtract(o *CountMinSketch) { s.combine(o, -1) }
func (s *CountMinSketch) combine(o *CountMinSketch, sign int64) { if o.width != s.width || o.depth != s.depth { panic("only sketches of the same shape can be combined") } for row := range s.cells { for col, v := range o.cells[row] { s.cells[row][col] += sign * v } }}
// index uses double hashing: row i uses h1 + i*h2. For a sketch this is as good as depth// independent hash functions, and it costs one hash of the item instead of depth.func (s *CountMinSketch) index(h uint64, row int) uint32 { h1 := uint32(h) h2 := uint32(h>>32) | 1 // odd, so rows never collapse onto the same column return (h1 + uint32(row)*h2) & (s.width - 1)}
// fnv1a hashes the UTF-8 bytes with a fixed algorithm, never maphash (seeded per process):// every aggregator in every region must put an item in the same cells, or sketches can't merge.func fnv1a(s string) uint64 { h := uint64(0xcbf29ce484222325) for i := 0; i < len(s); i++ { h ^= uint64(s[i]) h *= 0x100000001b3 } return h}
type entry struct { estimate int64 item string}
// minHeap implements container/heap. It may hold stale entries; see peekLive.type minHeap []entry
func (h minHeap) Len() int { return len(h) }func (h minHeap) Less(i, j int) bool { return h[i].estimate < h[j].estimate }func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }func (h *minHeap) Push(x any) { *h = append(*h, x.(entry)) }func (h *minHeap) Pop() any { old := *h e := old[len(old)-1] *h = old[:len(old)-1] return e}
// TopK holds the candidates for one window. The sketch counts every item but cannot list its// biggest ones; it only answers "how many for this item?". The heap remembers which to ask about.type TopK struct { sketch *CountMinSketch // About 10 x the k we publish: an item just below the cutoff is still tracked when the // window slides and the items above it lose their old minutes. capacity int candidates map[string]int64 // item -> latest estimate // A min-heap with lazy deletion: when an item's estimate grows we push a new entry and // leave the old one, skipping it when it reaches the top. No O(k) search on every event. heap minHeap}
func NewTopK(sketch *CountMinSketch, capacity int) *TopK { return &TopK{sketch: sketch, capacity: capacity, candidates: map[string]int64{}}}
func (t *TopK) Add(item string, count int64) { t.sketch.Add(item, count) est := t.sketch.Estimate(item) if _, ok := t.candidates[item]; ok || len(t.candidates) < t.capacity { t.track(item, est) } else if weakest := t.peekLive(); est > weakest.estimate { // Most of the long tail fails the comparison above: no allocation, nothing to do. heap.Pop(&t.heap) delete(t.candidates, weakest.item) t.track(item, est) } if len(t.heap) > 4*t.capacity { t.compact() // bounds the stale entries a hot item leaves }}
type Counted struct { Item string Count int64}
// Top fetches estimates again: the cached ones only go stale upward, because other items keep// landing in the same cells after we last looked.func (t *TopK) Top(n int) []Counted { out := make([]Counted, 0, len(t.candidates)) for item := range t.candidates { out = append(out, Counted{item, t.sketch.Estimate(item)}) } sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) return out[:min(n, len(out))]}
// Rescore is called after the window slides. Every estimate fell by the minute that left, each// by a different amount, so the heap order is rebuilt from fresh estimates.func (t *TopK) Rescore() { for item := range t.candidates { t.candidates[item] = t.sketch.Estimate(item) } t.compact()}
func (t *TopK) track(item string, est int64) { t.candidates[item] = est heap.Push(&t.heap, entry{est, item})}
// peekLive pops stale entries (an older estimate, or an evicted item) until the top is// current. A current entry always exists, because every candidate has one.func (t *TopK) peekLive() entry { for { top := t.heap[0] if cur, ok := t.candidates[top.item]; ok && cur == top.estimate { return top } heap.Pop(&t.heap) }}
func (t *TopK) compact() { t.heap = t.heap[:0] for item, est := range t.candidates { t.heap = append(t.heap, entry{est, item}) } heap.Init(&t.heap)}// A Count-Min Sketch: depth rows of width counters. An item adds to one cell per row, and other// items share those cells, so every row can only overcount it. The smallest of its cells is the// estimate: never below the true count, and at most (e / width) x the window's total above it,// with probability 1 - e^-depth for each query.class CountMinSketch { private readonly cells: Float64Array[]; // exact integers up to 2^53, plenty for a day of views
constructor( private readonly width: number, // a power of two, so a mask replaces a modulo private readonly depth: number, ) { if (width & (width - 1)) throw new Error('width: power of two'); this.cells = Array.from({ length: depth }, () => new Float64Array(width)); }
add(item: string, count = 1): void { const h = fnv1a(item); for (let row = 0; row < this.depth; row++) this.cells[row][this.index(h, row)] += count; }
estimate(item: string): number { const h = fnv1a(item); let best = Infinity; for (let row = 0; row < this.depth; row++) best = Math.min(best, this.cells[row][this.index(h, row)]); return best; }
// The sketch is linear: adding two sketches cell by cell gives exactly the sketch of both // streams together, so minutes merge into hours and partitions into regions with no extra error. merge(other: CountMinSketch): void { this.combine(other, 1); }
// Linearity also runs backwards: subtracting a minute that left the window leaves exactly // the sketch of the minutes that remain. This is why we use plain updates, not // "conservative update", which is more accurate but cannot be subtracted. subtract(other: CountMinSketch): void { this.combine(other, -1); }
private combine(other: CountMinSketch, sign: number): void { if (other.width !== this.width || other.depth !== this.depth) throw new Error('only sketches of the same shape can be combined'); this.cells.forEach((mine, row) => other.cells[row].forEach((v, col) => (mine[col] += sign * v)), ); }
// Double hashing: row i uses h1 + i * h2. For a sketch this is as good as depth // independent hash functions, and it costs one hash of the item instead of depth. private index(h: bigint, row: number): number { const h1 = Number(h & 0xffffffffn); const h2 = (Number(h >> 32n) | 1) >>> 0; // odd, so rows never collapse onto one column return ((h1 + row * h2) >>> 0) & (this.width - 1); }}
// FNV-1a over the UTF-8 bytes with a fixed algorithm: every aggregator in every region, in any// language, must put an item in the same cells, or their sketches cannot be merged.function fnv1a(s: string): bigint { let h = 0xcbf29ce484222325n; for (const b of new TextEncoder().encode(s)) h = BigInt.asUintN(64, (h ^ BigInt(b)) * 0x100000001b3n); return h;}
// A binary min-heap of [estimate, item]; the standard library has none.class MinHeap { readonly items: [number, string][] = []; get size(): number { return this.items.length; } peek(): [number, string] { return this.items[0]; } push(e: [number, string]): void { const a = this.items; a.push(e); for (let i = a.length - 1; i > 0;) { const p = (i - 1) >> 1; if (a[p][0] <= a[i][0]) return; [a[i], a[p]] = [a[p], a[i]]; i = p; } } pop(): void { const a = this.items; const last = a.pop()!; if (!a.length) return; a[0] = last; for (let i = 0; ;) { const l = 2 * i + 1; const r = l + 1; let m = i; if (l < a.length && a[l][0] < a[m][0]) m = l; if (r < a.length && a[r][0] < a[m][0]) m = r; if (m === i) return; [a[i], a[m]] = [a[m], a[i]]; i = m; } }}
// The candidates for one window. The sketch counts every item but cannot list its biggest// ones; it only answers "how many for this item?". The heap remembers which items to ask about.class TopK { private readonly candidates = new Map<string, number>(); // item -> latest estimate // A min-heap with lazy deletion: when an item's estimate grows we push a new entry and // leave the old one, skipping it when it reaches the top. No O(k) search on every event. private heap = new MinHeap();
constructor( private readonly sketch: CountMinSketch, // About 10 x the k we publish: an item just below the cutoff is still tracked when the // window slides and the items above it lose their old minutes. private readonly capacity: number, ) {}
add(item: string, count = 1): void { this.sketch.add(item, count); const est = this.sketch.estimate(item); if (this.candidates.has(item) || this.candidates.size < this.capacity) { this.track(item, est); } else { const [weakestEst, weakest] = this.peekLive(); // Most of the long tail stops here: one comparison, no allocation. if (est > weakestEst) { this.heap.pop(); this.candidates.delete(weakest); this.track(item, est); } } if (this.heap.size > 4 * this.capacity) this.compact(); // bounds a hot item's stale entries }
// Estimates are fetched again when we publish: the cached ones only go stale upward, // because other items keep landing in the same cells after we last looked. top(n: number): [string, number][] { return [...this.candidates.keys()] .map((item): [string, number] => [item, this.sketch.estimate(item)]) .sort((a, b) => b[1] - a[1]) .slice(0, n); }
// Called after the window slides. Every estimate fell by the minute that left, each by a // different amount, so the heap order is rebuilt from fresh estimates. rescore(): void { for (const item of this.candidates.keys()) this.candidates.set(item, this.sketch.estimate(item)); this.compact(); }
private track(item: string, est: number): void { this.candidates.set(item, est); this.heap.push([est, item]); }
// Pops stale entries (an older estimate, or an evicted item) until the top is current. // A current entry always exists, because every candidate has one. private peekLive(): [number, string] { for (;;) { const [est, item] = this.heap.peek(); if (this.candidates.get(item) === est) return [est, item]; this.heap.pop(); } }
private compact(): void { this.heap = new MinHeap(); for (const [item, est] of this.candidates) this.heap.push([est, item]); }}Why not something else? Space-Saving is the better pure top-k algorithm, with the list built in. But its summaries can’t be subtracted, and windows subtract the minute that left.
Go deeperThe four options, and sizing the sketch2 min
N is the total count in the window. m is the number of counters kept.
| Approach | Memory | Guarantee | Lists the top? | Add and subtract |
|---|---|---|---|---|
| Exact hash map | One entry per video | Exact | Yes | Add yes; sliding needs a map per bucket |
| Count-Min Sketch + heap | w × d counters + a heap | Never under; over by at most εN with probability 1 − δ | Only with the heap | Both exact |
| Space-Saving | m counters | Every video above N/m is kept; each over by at most N/m | Yes | Add with bounded error; no subtract |
| Misra-Gries | m counters | Each under by at most N/(m + 1); every video above that is kept | Yes | Add with bounded error; no subtract |
The bound. On average, one cell holds an extra N ÷ w. Set w to e ÷ ε. Then the chance one row is more than εN too high is at most 1 in e. The chance all d rows are is e to the power −d.
Sizing. Each partition sees one 32nd of the views, and its error is relative to its own total. So w = e ÷ (ε × 32) ≈ 85,000. The code rounds widths up to a power of two (131,072), so a mask replaces a division. That lowers the error, and raises fleet memory to about 7.3 GB. That’s still a twelfth of exact state.
Conservative update. On an add, raise each cell only as far as the video’s new estimate. It cuts overcounting a lot. But the sketch can then no longer be subtracted safely, so it suits windows that never slide, not our running sums.
Why not a HyperLogLog per video? A HyperLogLog counts distinct users, not views, and one per video brings back the per-video cost we are avoiding. It belongs on the candidates only, if the product ranks by distinct viewers.
Windows, and views that arrive late
Section titled “Windows, and views that arrive late”What exactly is “the last hour”, and what happens to a view that arrives ten minutes late?
“The last hour” here means a hopping window: one hour long, moving forward one minute at a time. The hour and 5-minute windows are built from minute sketches. The day is built from hour sketches, so its old edge moves an hour at a time and it covers 24 to 25 hours.
A view belongs to the minute it happened, on the phone, not the minute we received it. The aggregator tracks a watermark: the newest event time it has seen, minus 30 seconds of allowed disorder. When the watermark passes the end of a minute, that minute is closed, the window moves, and lists are published. Ingest replaces device times in the future with its own clock, so one phone with a wrong clock cannot close every minute early.
Late views. Sketches are sums. So a late view whose minute is still inside a window is just added to that minute, and to the running sum. The next publish includes it. Only views older than the whole window cannot be added, because their minute was already subtracted. Those are counted as late, and the daily batch counts them from the archive.
- Color key
- Service
- Background worker
- Queue or stream
// One sliding window, for example the last hour, as one sketch per minute plus their running// sum. An event updates its minute and the sum. When the watermark passes a minute boundary,// the minute that fell out is subtracted, so a read is one lookup on the sum, never sixty.// The day window works the same way with 24 hour sketches, each the merge of 60 minutes.final class SlidingWindow { private final int buckets; // 60 one-minute buckets for the hour private final int width, depth; private final TreeMap<Long, CountMinSketch> byMinute = new TreeMap<>(); private final CountMinSketch sum; // all buckets added together: what reads ask private final TopK top; // built on sum, so TopK.add updates the sum too private long closed; // newest minute the watermark has passed, in minutes since the epoch long late; // too old for this window: only the daily batch will count these
SlidingWindow(int buckets, int width, int depth, int capacity) { this.buckets = buckets; this.width = width; this.depth = depth; this.sum = new CountMinSketch(width, depth); this.top = new TopK(sum, capacity); }
// Keyed by event time, not arrival time: a backlog replayed after an outage lands in the // minutes it happened in, instead of looking like a sudden spike now. void add(String item, long count, long eventMinute) { if (eventMinute <= closed - buckets) { late++; // its minute has already been subtracted; adding it now would never expire return; } // A late event whose minute is still inside the window is simply added: the sketch is // a sum, so order does not matter. Minutes after the watermark are kept the same way. byMinute.computeIfAbsent(eventMinute, m -> new CountMinSketch(width, depth)).add(item, count); top.add(item, count); }
// The watermark says no more events older than `minute` are expected (bar a few late ones). // Everything at or before minute - buckets leaves the window. void advanceTo(long minute) { if (minute <= closed) return; closed = minute; var expired = byMinute.headMap(closed - buckets, true); if (expired.isEmpty()) return; expired.values().forEach(sum::subtract); expired.clear(); top.rescore(); // every candidate lost a different amount }
List<Map.Entry<String, Long>> top(int k) { return top.top(k); }}# One sliding window, for example the last hour, as one sketch per minute plus their running# sum. An event updates its minute and the sum. When the watermark passes a minute boundary,# the minute that fell out is subtracted, so a read is one lookup on the sum, never sixty.# The day window works the same way with 24 hour sketches, each the merge of 60 minutes.class SlidingWindow: def __init__(self, buckets: int, width: int, depth: int, capacity: int) -> None: self._buckets = buckets # 60 one-minute buckets for the hour self._width, self._depth = width, depth self._by_minute: dict[int, CountMinSketch] = {} self._sum = CountMinSketch(width, depth) # all buckets added together: what reads ask self._top = TopK(self._sum, capacity) # built on the sum, so TopK.add updates it too self._closed = 0 # newest minute the watermark has passed, in minutes since the epoch self.late = 0 # too old for this window: only the daily batch will count these
def add(self, item: str, count: int, event_minute: int) -> None: # Keyed by event time, not arrival time: a backlog replayed after an outage lands in the # minutes it happened in, instead of looking like a sudden spike now. if event_minute <= self._closed - self._buckets: self.late += 1 # its minute was already subtracted; adding it now would never expire return # A late event whose minute is still inside the window is simply added: the sketch is # a sum, so order does not matter. Minutes after the watermark are kept the same way. bucket = self._by_minute.get(event_minute) if bucket is None: bucket = self._by_minute[event_minute] = CountMinSketch(self._width, self._depth) bucket.add(item, count) self._top.add(item, count)
def advance_to(self, minute: int) -> None: # The watermark says no more events older than `minute` are expected (bar a few late # ones). Everything at or before minute - buckets leaves the window. if minute <= self._closed: return self._closed = minute expired = [m for m in self._by_minute if m <= minute - self._buckets] if not expired: return for m in expired: self._sum.subtract(self._by_minute.pop(m)) self._top.rescore() # every candidate lost a different amount
def top(self, k: int) -> list[tuple[str, int]]: return self._top.top(k)// One sliding window, for example the last hour, as one sketch per minute plus their running// sum. An event updates its minute and the sum. When the watermark passes a minute boundary,// the minute that fell out is subtracted, so a read is one lookup on the sum, never sixty.// The day window works the same way with 24 hour sketches, each the merge of 60 minutes.sealed class SlidingWindow{ private readonly int _buckets; // 60 one-minute buckets for the hour private readonly int _width, _depth; private readonly SortedDictionary<long, CountMinSketch> _byMinute = new(); private readonly CountMinSketch _sum; // all buckets added together: what reads ask private readonly TopK _top; // built on the sum, so TopK.Add updates it too private long _closed; // newest minute the watermark has passed, epoch minutes public long Late { get; private set; } // too old for this window: only the daily batch counts these
public SlidingWindow(int buckets, int width, int depth, int capacity) { (_buckets, _width, _depth) = (buckets, width, depth); _sum = new CountMinSketch(width, depth); _top = new TopK(_sum, capacity); }
// Keyed by event time, not arrival time: a backlog replayed after an outage lands in the // minutes it happened in, instead of looking like a sudden spike now. public void Add(string item, long count, long eventMinute) { if (eventMinute <= _closed - _buckets) { Late++; // its minute was already subtracted; adding it now would never expire return; } // A late event whose minute is still inside the window is simply added: the sketch is // a sum, so order does not matter. Minutes after the watermark are kept the same way. if (!_byMinute.TryGetValue(eventMinute, out var bucket)) _byMinute[eventMinute] = bucket = new CountMinSketch(_width, _depth); bucket.Add(item, count); _top.Add(item, count); }
// The watermark says no more events older than `minute` are expected (bar a few late ones). // Everything at or before minute - buckets leaves the window. public void AdvanceTo(long minute) { if (minute <= _closed) return; _closed = minute; var expired = _byMinute.Keys.TakeWhile(m => m <= minute - _buckets).ToList(); if (expired.Count == 0) return; foreach (var m in expired) { _sum.Subtract(_byMinute[m]); _byMinute.Remove(m); } _top.Rescore(); // every candidate lost a different amount }
public List<(string Item, long Count)> Top(int k) => _top.Top(k);}// SlidingWindow is one sliding window, for example the last hour, as one sketch per minute plus// their running sum. An event updates its minute and the sum. When the watermark passes a// minute boundary, the minute that fell out is subtracted, so a read is one lookup on the sum,// never sixty. The day window works the same way with 24 hour sketches.type SlidingWindow struct { buckets int64 // 60 one-minute buckets for the hour width uint32 depth int byMinute map[int64]*CountMinSketch sum *CountMinSketch // all buckets added together: what reads ask top *TopK // built on sum, so TopK.Add updates it too closed int64 // newest minute the watermark has passed, in minutes since the epoch Late int64 // too old for this window: only the daily batch will count these}
func NewSlidingWindow(buckets int64, width uint32, depth, capacity int) *SlidingWindow { sum := NewCountMinSketch(width, depth) return &SlidingWindow{ buckets: buckets, width: width, depth: depth, byMinute: map[int64]*CountMinSketch{}, sum: sum, top: NewTopK(sum, capacity), }}
// Add is keyed by event time, not arrival time: a backlog replayed after an outage lands in// the minutes it happened in, instead of looking like a sudden spike now.func (w *SlidingWindow) Add(item string, count, eventMinute int64) { if eventMinute <= w.closed-w.buckets { w.Late++ // its minute was already subtracted; adding it now would never expire return } // A late event whose minute is still inside the window is simply added: the sketch is a // sum, so order does not matter. Minutes after the watermark are kept the same way. bucket, ok := w.byMinute[eventMinute] if !ok { bucket = NewCountMinSketch(w.width, w.depth) w.byMinute[eventMinute] = bucket } bucket.Add(item, count) w.top.Add(item, count)}
// AdvanceTo is called when the watermark says no more events older than minute are expected// (bar a few late ones). Everything at or before minute - buckets leaves the window.func (w *SlidingWindow) AdvanceTo(minute int64) { if minute <= w.closed { return } w.closed = minute expired := false for m, bucket := range w.byMinute { if m <= minute-w.buckets { w.sum.Subtract(bucket) delete(w.byMinute, m) expired = true } } if expired { w.top.Rescore() // every candidate lost a different amount }}
func (w *SlidingWindow) Top(k int) []Counted { return w.top.Top(k) }// One sliding window, for example the last hour, as one sketch per minute plus their running// sum. An event updates its minute and the sum. When the watermark passes a minute boundary,// the minute that fell out is subtracted, so a read is one lookup on the sum, never sixty.// The day window works the same way with 24 hour sketches, each the merge of 60 minutes.class SlidingWindow { private readonly byMinute = new Map<number, CountMinSketch>(); private readonly sum: CountMinSketch; // all buckets added together: what reads ask private readonly topK: TopK; // built on the sum, so TopK.add updates it too private closed = 0; // newest minute the watermark has passed, in minutes since the epoch late = 0; // too old for this window: only the daily batch will count these
constructor( private readonly buckets: number, // 60 one-minute buckets for the hour private readonly width: number, private readonly depth: number, capacity: number, ) { this.sum = new CountMinSketch(width, depth); this.topK = new TopK(this.sum, capacity); }
// Keyed by event time, not arrival time: a backlog replayed after an outage lands in the // minutes it happened in, instead of looking like a sudden spike now. add(item: string, count: number, eventMinute: number): void { if (eventMinute <= this.closed - this.buckets) { this.late++; // its minute was already subtracted; adding it now would never expire return; } // A late event whose minute is still inside the window is simply added: the sketch is a // sum, so order does not matter. Minutes after the watermark are kept the same way. let bucket = this.byMinute.get(eventMinute); if (!bucket) this.byMinute.set(eventMinute, (bucket = new CountMinSketch(this.width, this.depth))); bucket.add(item, count); this.topK.add(item, count); }
// The watermark says no more events older than `minute` are expected (bar a few late ones). // Everything at or before minute - buckets leaves the window. advanceTo(minute: number): void { if (minute <= this.closed) return; this.closed = minute; const expired = [...this.byMinute.keys()].filter((m) => m <= minute - this.buckets); if (!expired.length) return; for (const m of expired) { this.sum.subtract(this.byMinute.get(m)!); this.byMinute.delete(m); } this.topK.rescore(); // every candidate lost a different amount }
top(k: number): [string, number][] { return this.topK.top(k); }}Go deeperOther window types1 min
| Window type | What it is | Fit here |
|---|---|---|
| Tumbling | Fixed, not overlapping: 19:00 to 20:00 | The official daily chart (a calendar day) |
| Hopping | One hour long, moving every minute | Live lists |
| Truly sliding | Exactly the last 3,600 s at every instant | Never worth it for a list read every 10 s |
| Exponential decay | Old views count less, no edge | Trending by rate against a baseline, as in typeahead |
A longer watermark delay than 30 seconds buys little: views from healthy phones arrive within seconds, and late adds cover the rest. Flink’s documentation explains the model.
One viral video
Section titled “One viral video”Traffic grows ten times, and one music video takes 15% of all views on release night. What breaks?
At 3.5 million views a second, two things break. Kafka carries one message per view, more than twice what it was built for. And the aggregator that owns the viral video gets its normal share, plus 15% of all views. That’s 630,000 events a second, for a process that takes 100,000. Adding partitions does nothing for that one video, because its key still hashes to one place.
The fix is to combine before Kafka. Each ingest server keeps a small map of (video, minute) to count, and sends it every second. A viral video with 525,000 views a second becomes about 120 messages a second, one per ingest server. The long tail barely shrinks, but overall, messages drop to about a quarter of views.
Stage 5: Pre-aggregate at ingest
Stage 5 of 6
Pre-aggregate at ingest
Why this stage
At 10 times the traffic, 3.5 million events/s overload the Kafka cluster, and the aggregator holding a viral item (15% of all views) needs six times its capacity.
What we add
Ingest servers sum counts per item for one second and send (item, minute, count) instead of single events.
Raw batches go to a separate topic, one record per batch, for the archive.
What it costs
One more second of delay, and per-event deduplication moves entirely to the daily batch.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
- Color key
- Background worker
- Queue or stream
- Service
The sketch already takes a count with each update, so nothing after Kafka changes. But a combined message has no event IDs, and the batch needs them. So ingest also writes each app batch, whole, to a raw topic that only the archive reads: one message per 50 views.
Go deeperOther fixes for a hot key1 min
| Fix | How it works | Cost |
|---|---|---|
| More partitions | Spreads videos over more aggregators | Nothing for one video: it still hashes to one partition |
| Salting the key | video#0 to video#7 on 8 partitions |
One video’s count is split again, so the merge needs two phases |
| Combine at ingest | Sum per video for 1 s before sending | 1 s more delay; duplicate removal moves to the batch |
| A separate path for hot videos | Detect hot videos and count them apart | Detection lag, and two code paths for one count |
Regions, and merging split lists
Section titled “Regions, and merging split lists”Now there are three regions, and each sends its top 100. Can we add them up and take the top 100?
One region is one failure domain. And copying every view across an ocean costs money for nothing. So each region runs its own ingest, Kafka and aggregators. It sends only summaries to a global merger: its closed minute sketches, and its top candidates. Every region uses the same partitions and hash functions. So the merger adds the sketches cell by cell, into one global sketch. That is about 1.4 MB a second per region.
Stage 6: One pipeline per region
Stage 6 of 6
One pipeline per region
Why this stage
Viewers are on three continents, one region is one failure domain, and at 10 million events/s one region is past what it can take.
Raw events should not cross oceans.
What we add
The ingest, Kafka and aggregator path in every region.
A global merger combines regional sketches and candidates in two phases.
What it costs
An item is now split across regions, so merging top lists is no longer exact without the second phase; the global list lags a few seconds more.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
The catch: a video’s count is now split across regions, so the union of regional top lists is no longer exact. Take the top 1 from two regions:
| Video | Region A | Region B | Total |
|---|---|---|---|
| a | 10 | 0 | 10 |
| d | 0 | 10 | 10 |
| c | 8 | 8 | 16 |
Each region’s top 1 is a or d. The merge says they tie at 10. The true winner, c with 16, never left either region.
The two-phase merge fixes this, and can prove it missed nothing. It comes from the TPUT algorithm (Cao and Wang, 2004), here with m regions:
- Each region sends its top k. Summing gives a lower bound on each video’s total, so the k-th largest sum, τ, is at or below the true k-th total.
- A video whose total reaches τ must have at least τ ÷ m in some region. That’s because m numbers that are each below τ ÷ m add up to less than τ. So each region sends every video at or above τ ÷ m. No true top-k video can be missing now.
- The merger scores these candidates against the global sketch, and ranks them.
- Color key
- Service
// What the global merger can ask each region. With sketches, the counts are the region's// estimates; the argument below holds for any non-negative counts, estimated or exact.interface Region { Map<String, Long> top(int k); // its own top k Map<String, Long> atLeast(long threshold); // every candidate with a local count >= threshold Map<String, Long> counts(Set<String> items); // local counts for these items, 0 if unseen}
// The exact global top k when one item's views are split across regions. Taking each region's// top k and summing is not enough: an item ranked 150th everywhere can be first in total.// This is the three-phase TPUT algorithm (Cao and Wang, 2004).final class GlobalMerger { static List<Map.Entry<String, Long>> exactTopK(List<Region> regions, int k) { // Phase 1: each region's own top k. A partial sum never exceeds an item's real total, so // the k-th largest partial sum, tau, is a floor under the real k-th total. Map<String, Long> partial = new HashMap<>(); for (Region r : regions) r.top(k).forEach((item, c) -> partial.merge(item, c, Long::sum)); long tau = partial.values().stream() .sorted(Comparator.reverseOrder()) .skip(k - 1) .findFirst() .orElse(0L); // fewer than k items seen: tau 0 asks for everything, still correct
// Phase 2: an item whose total reaches tau must have at least tau / m in some region, // because m numbers that each fall short of tau / m add up to less than tau. So asking // every region for all items at or above tau / m cannot miss a true top-k item. long threshold = tau / regions.size(); // rounding down only adds candidates Set<String> candidates = new HashSet<>(partial.keySet()); for (Region r : regions) candidates.addAll(r.atLeast(threshold).keySet());
// Phase 3: full totals for the candidates only. The paper also drops candidates whose // upper bound cannot reach tau before this step; left out to keep the idea visible. Map<String, Long> total = new HashMap<>(); for (Region r : regions) r.counts(candidates).forEach((item, c) -> total.merge(item, c, Long::sum)); return total.entrySet().stream() .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(k) .toList(); }}from typing import Protocol
# What the global merger can ask each region. With sketches, the counts are the region's# estimates; the argument below holds for any non-negative counts, estimated or exact.class Region(Protocol): def top(self, k: int) -> dict[str, int]: ... # its own top k
def at_least(self, threshold: int) -> dict[str, int]: ... # local count >= threshold
def counts(self, items: set[str]) -> dict[str, int]: ... # local counts, 0 if unseen
# The exact global top k when one item's views are split across regions. Taking each# region's top k and summing is not enough: an item ranked 150th everywhere can be first in# total. This is the three-phase TPUT algorithm (Cao and Wang, 2004).def global_top_k(regions: list[Region], k: int) -> list[tuple[str, int]]: # Phase 1: each region's own top k. A partial sum never exceeds an item's real total, so # the k-th largest partial sum, tau, is a floor under the real k-th total. partial: dict[str, int] = {} for r in regions: for item, c in r.top(k).items(): partial[item] = partial.get(item, 0) + c ranked = sorted(partial.values(), reverse=True) tau = ranked[k - 1] if len(ranked) >= k else 0 # tau 0 asks for everything, still correct
# Phase 2: an item whose total reaches tau must have at least tau / m in some region, # because m numbers that each fall short of tau / m add up to less than tau. So asking # every region for all items at or above tau / m cannot miss a true top-k item. threshold = tau // len(regions) # rounding down only adds candidates candidates = set(partial) for r in regions: candidates.update(r.at_least(threshold))
# Phase 3: full totals for the candidates only. The paper also drops candidates whose # upper bound cannot reach tau before this step; left out to keep the idea visible. total: dict[str, int] = {} for r in regions: for item, c in r.counts(candidates).items(): total[item] = total.get(item, 0) + c return sorted(total.items(), key=lambda p: p[1], reverse=True)[:k]// What the global merger can ask each region. With sketches, the counts are the region's// estimates; the argument below holds for any non-negative counts, estimated or exact.interface IRegion{ Dictionary<string, long> Top(int k); // its own top k Dictionary<string, long> AtLeast(long threshold); // local count >= threshold Dictionary<string, long> Counts(IReadOnlySet<string> items); // local counts, 0 if unseen}
// The exact global top k when one item's views are split across regions. Taking each region's// top k and summing is not enough: an item ranked 150th everywhere can be first in total.// This is the three-phase TPUT algorithm (Cao and Wang, 2004).static class GlobalMerger{ public static List<(string Item, long Count)> ExactTopK(IReadOnlyList<IRegion> regions, int k) { // Phase 1: each region's own top k. A partial sum never exceeds an item's real total, so // the k-th largest partial sum, tau, is a floor under the real k-th total. var partial = new Dictionary<string, long>(); foreach (var r in regions) foreach (var (item, c) in r.Top(k)) partial[item] = partial.GetValueOrDefault(item) + c; // Fewer than k items seen: tau 0 asks for everything, which is still correct. long tau = partial.Values.OrderByDescending(c => c).Skip(k - 1).FirstOrDefault();
// Phase 2: an item whose total reaches tau must have at least tau / m in some region, // because m numbers that each fall short of tau / m add up to less than tau. So asking // every region for all items at or above tau / m cannot miss a true top-k item. long threshold = tau / regions.Count; // rounding down only adds candidates var candidates = new HashSet<string>(partial.Keys); foreach (var r in regions) candidates.UnionWith(r.AtLeast(threshold).Keys);
// Phase 3: full totals for the candidates only. The paper also drops candidates whose // upper bound cannot reach tau before this step; left out to keep the idea visible. var total = new Dictionary<string, long>(); foreach (var r in regions) foreach (var (item, c) in r.Counts(candidates)) total[item] = total.GetValueOrDefault(item) + c; return total .OrderByDescending(p => p.Value) .Take(k) .Select(p => (Item: p.Key, Count: p.Value)) .ToList(); }}// Region is what the global merger can ask each region. With sketches, the counts are the// region's estimates; the argument below holds for any non-negative counts, estimated or exact.type Region interface { Top(k int) map[string]int64 // its own top k AtLeast(threshold int64) map[string]int64 // local count >= threshold Counts(items map[string]struct{}) map[string]int64 // local counts, 0 if unseen}
// GlobalTopK is the exact global top k when one item's views are split across regions. Taking// each region's top k and summing is not enough: an item ranked 150th everywhere can be first// in total. This is the three-phase TPUT algorithm (Cao and Wang, 2004).func GlobalTopK(regions []Region, k int) []Counted { // Phase 1: each region's own top k. A partial sum never exceeds an item's real total, so // the k-th largest partial sum, tau, is a floor under the real k-th total. partial := map[string]int64{} for _, r := range regions { for item, c := range r.Top(k) { partial[item] += c } } sums := make([]int64, 0, len(partial)) for _, c := range partial { sums = append(sums, c) } sort.Slice(sums, func(i, j int) bool { return sums[i] > sums[j] }) var tau int64 // fewer than k items seen: tau 0 asks for everything, still correct if len(sums) >= k { tau = sums[k-1] }
// Phase 2: an item whose total reaches tau must have at least tau / m in some region, // because m numbers that each fall short of tau / m add up to less than tau. So asking // every region for all items at or above tau / m cannot miss a true top-k item. threshold := tau / int64(len(regions)) // rounding down only adds candidates candidates := map[string]struct{}{} for item := range partial { candidates[item] = struct{}{} } for _, r := range regions { for item := range r.AtLeast(threshold) { candidates[item] = struct{}{} } }
// Phase 3: full totals for the candidates only. The paper also drops candidates whose // upper bound cannot reach tau before this step; left out to keep the idea visible. total := map[string]int64{} for _, r := range regions { for item, c := range r.Counts(candidates) { total[item] += c } } out := make([]Counted, 0, len(total)) for item, c := range total { out = append(out, Counted{item, c}) } sort.Slice(out, func(i, j int) bool { return out[i].Count > out[j].Count }) return out[:min(k, len(out))]}// What the global merger can ask each region. With sketches, the counts are the region's// estimates; the argument below holds for any non-negative counts, estimated or exact.interface Region { top(k: number): Promise<Map<string, number>>; // its own top k atLeast(threshold: number): Promise<Map<string, number>>; // local count >= threshold counts(items: Set<string>): Promise<Map<string, number>>; // local counts, 0 if unseen}
const addInto = (into: Map<string, number>, from: Map<string, number>) => from.forEach((c, item) => into.set(item, (into.get(item) ?? 0) + c));
// The exact global top k when one item's views are split across regions. Taking each region's// top k and summing is not enough: an item ranked 150th everywhere can be first in total.// This is the three-phase TPUT algorithm (Cao and Wang, 2004).async function globalTopK(regions: Region[], k: number): Promise<[string, number][]> { // Phase 1: each region's own top k. A partial sum never exceeds an item's real total, so // the k-th largest partial sum, tau, is a floor under the real k-th total. // Regions are asked in parallel: each phase costs one cross-region round trip, not m. const partial = new Map<string, number>(); for (const top of await Promise.all(regions.map((r) => r.top(k)))) addInto(partial, top); const ranked = [...partial.values()].sort((a, b) => b - a); const tau = ranked.length >= k ? ranked[k - 1] : 0; // tau 0 asks for everything, still correct
// Phase 2: an item whose total reaches tau must have at least tau / m in some region, // because m numbers that each fall short of tau / m add up to less than tau. So asking // every region for all items at or above tau / m cannot miss a true top-k item. const threshold = Math.floor(tau / regions.length); // rounding down only adds candidates const candidates = new Set(partial.keys()); for (const above of await Promise.all(regions.map((r) => r.atLeast(threshold)))) for (const item of above.keys()) candidates.add(item);
// Phase 3: full totals for the candidates only. The paper also drops candidates whose // upper bound cannot reach tau before this step; left out to keep the idea visible. const total = new Map<string, number>(); for (const counts of await Promise.all(regions.map((r) => r.counts(candidates)))) addInto(total, counts); return [...total].sort((a, b) => b[1] - a[1]).slice(0, k);}Go deeperWays to merge, compared1 min
| Fix | How it works | Exact? |
|---|---|---|
| Send more candidates | Each region sends its top 10,000 instead of k | No: misses become rare, never impossible |
| Send candidates plus a bound | Also send each region’s k-th count, an upper bound on the rest | Tells you when the answer may be wrong |
| Two phases (TPUT) | A threshold that can’t miss, then sum for candidates only | Yes, for counts that are never negative |
| Add sketches, re-score | Score the union of candidates on the global sketch | Counts yes; the candidate set still not |
A region answers step 2 from its candidate heaps, since a sketch cannot list videos. So the proof holds only while each region’s weakest candidate is below τ ÷ m. The region reports that floor, and the merger flags the list when it isn’t.
When a region goes dark for ten minutes, the merger publishes with the regions it has and marks the list partial. When the region recovers, it catches up and resends the affected minute sketches, and the hour and day windows heal on their own. Regional lists, “top in Brazil”, become nearly free.
The batch beside the stream
Section titled “The batch beside the stream”A stream and a batch now compute the same daily counts. Isn’t that two systems that will disagree? Why not one?
Running a batch beside a stream is called the lambda shape. Running one stream for everything, and replaying Kafka to fix it, is called kappa. Here, one exact stream would need the 88 GB of exact state, plus weeks of Kafka for very late views. A batch pays for exactness once a day, over files that never change, and can simply run again.
So we use lambda for one output only: the official daily chart. Two code paths do drift apart, so one shared library decides what counts as a view, for both. And each morning, the batch’s exact top 100 is compared with the stream’s. When they share fewer than 95 videos, someone is paged. Kappa wins when exact counts are needed live and the state is affordable, as in the ad click aggregator.
When things break
Section titled “When things break”An aggregator crashes mid-minute. What do users see, and are any views lost or counted twice?
Whether you know state and Kafka positions are saved together, so a replay counts each view once.
The rule: reads never fail because counting stopped. Readers get the last published list, with its
asOf time.
An aggregator saves its sketches, heaps and Kafka positions in one checkpoint every minute. After a crash, it loads the checkpoint and reads Kafka again from the saved position. Every event after the checkpoint is counted once, into state that didn’t include it. Its videos freeze for about a minute, and nothing is lost.
- Color key
- Database
- Background worker
- Queue or stream
Click any box to break it and see what users would notice:
Break it: what fails, who notices, what users see
Failure: click any box to break it
An aggregator goes down
What breaks
Its partitions stop being counted, so its items freeze in the lists.
How we notice
Consumer lag on its partitions and missing checkpoints.
What users see
Lists stay up but go stale for those items for about a minute.
What we do
The job restarts from its last checkpoint (sketches, heaps and Kafka offsets together) and replays from those offsets, so nothing is lost or counted twice.
Go deeperEvery failure, and what users see1 min
| Part | Failure | What users see | What we do |
|---|---|---|---|
| Ingest server | Dies | Its last second of counts missing live | Apps retry unacknowledged batches; the batch removes duplicates |
| Kafka broker | Dies | Nothing | A new leader; full replication loses nothing |
| Aggregator | Crashes | Its videos freeze for about a minute | Restore the checkpoint and replay |
| Merger | Dies | Lists stop updating; old ones served | A standby takes over; it holds no state |
| Redis | Primary dies | Seconds of errors after the 10 s cache | Promote a replica; lists are republished every minute |
| Archive | Writes fail | Nothing, for up to 7 days | Resume from positions; page long before Kafka forgets |
| Daily batch | Late or fails | The daily chart stays provisional | Run it again; it reads files that never change |
| Region | Outage | Its views are missing from global lists | Lists flagged partial; apps buffer and upload later |
| Output | Guarantee | What users can see |
|---|---|---|
| Live counts | Never under; over by at most one in a million of the window | Hundreds too high on the hour, up to 10,000 on the day |
| Live membership | Complete over the candidate heaps | The 101st shown as 100th when nearly tied |
| Late views, inside window | Counted on the next publish | A past minute’s count rises slightly later |
| Official daily chart | Exact, no duplicates, views up to 48 h late | Can differ from yesterday’s live list |
The checkpoint is what Flink calls exactly-once state. It says nothing about duplicates the apps send: a retried batch is in Kafka twice. Removing those needs an ID per event, which is the batch’s job.
Go deeperRunning it: alerts, abuse, deploys and cost1 min
Wake someone up when:
- The newest list is older than 3 minutes.
- An aggregator is more than 60 seconds behind.
- Ingest success drops below 99.9%.
- The morning comparison fails.
- Views too late for any window pass 0.5% of traffic. That means a watermark or app release is wrong.
Abuse is the real work. A trending list is a target. A few thousand scripted devices can push a video into the top 100. The defenses: rate limits per device at ingest, a cap on views per user per video per hour, and holding new videos back from the global list until their viewers look varied. A block list, checked at publish time, lets the trust team remove a video within a minute.
Deploys. Changing the sketch’s shape or hash seed makes old state impossible to merge. So the new version replays a day of Kafka in a parallel job before the merger switches to it. Adding Kafka partitions moves videos between aggregators mid-window, so it is a new topic, run side by side for a day.
Cost is Kafka brokers and the batch’s daily scan of 1 TB, not the sketches.
Wrap up
Section titled “Wrap up”The whole design
Section titled “The whole design”Here is everything we built, in the order we built it. Use the buttons to step through it.
The full design, stage by stage
Stage 6 of 6
One pipeline per region
Why this stage
Viewers are on three continents, one region is one failure domain, and at 10 million events/s one region is past what it can take.
Raw events should not cross oceans.
What we add
The ingest, Kafka and aggregator path in every region.
A global merger combines regional sketches and candidates in two phases.
What it costs
An item is now split across regions, so merging top lists is no longer exact without the second phase; the global list lags a few seconds more.
At 350k events per second, every part has headroom. Raise the traffic until something turns red.
The numbers behind the busy bars
What one machine can do
- One counting process handles about 100,000 events a second.
- One ingest server takes about 50,000 events a second, because clients send batches.
- The Kafka cluster, as provisioned, takes about 1.5 million messages a second.
- One API server answers about 5,000 reads a second.
The hot item
- The aggregator bar shows the busiest instance: the one holding a viral item at 15% of all views.
What saves work
- Pre-aggregation cuts messages to about a quarter.
- The largest region takes about 40% of traffic.
The decisions, and what each one costs
Section titled “The decisions, and what each one costs”| Decision | We chose | We gave up | We’d choose differently if |
|---|---|---|---|
| Splitting | By video | Even load under a viral video | Counts were per user (split by user, two-phase merge) |
| Live counting | Count-Min Sketch + candidate heap | Exact live counts | Distinct videos were few (exact maps) |
| Windows | Hopping by the minute, event time, 30 s watermark | 30 s of freshness | Trending needed no edge at all (exponential decay) |
| Hot videos | Combine at ingest for 1 s | One second; duplicate removal live | Lists had to be under a second old (salting, two phases) |
| Regions | Regional pipelines, two-phase global merge | A few seconds on global lists | One region served everyone |
| Exact daily | A batch for the official chart only | A single code path | Exact counts were needed live, with affordable state |
How to spend 45 minutes
Section titled “How to spend 45 minutes”- 0 to 4. Windows, k, freshness, exact or approximate. Accuracy as a number.
- 4 to 8. 350,000 views a second at peak, 88 GB exact against 4.7 GB of sketches.
- 8 to 18. Build it: split by video and why the merge is exact, sketches for the windows, the daily batch. A mid-level answer can stop here.
- 18 to 30. The stress tests: the sketch’s error, then late views, then a viral video.
- 30 to 40. Regions and the merge, then stream against batch.
- 40 to 45. An aggregator crash, then your own questions.
Never skip splitting by video, the error bound, or why merging split top lists can be wrong.
Follow-up questions
Section titled “Follow-up questions”Interviewer asksNow product wants the top 100 per country, for 200 countries. What changes?
A full-size sketch per country multiplies state by 200, and most countries are small. One shared sketch keyed by country and video makes every error relative to the global total, too coarse for a small country. Better: size each country’s sketch by its traffic, or keep a Space-Saving summary per country.
Interviewer asksHow do you know the sketch is still sized right six months from now?
The morning comparison measures it: the batch’s exact counts give the true error of every published estimate. Alert when the error for top videos nears the bound, which happens as traffic grows. Widening is a deploy with fresh state, at a day boundary.
Go deeperMore follow-up questions1 min
A bot farm pushes one video into the top 10. How do you notice and stop it? Signals: views from few accounts, new devices, one network, or watch times near zero. The cap per user removes repeats, and the batch cleans the next morning’s chart. For the live list, the block list at publish time removes it within a minute.
How would typeahead’s trending queries use this? The same pipeline, keyed by the normalized query, with a short window. Typeahead ranks by the rise against a baseline, so it adds a ratio on top of these counts. See the typeahead page.
Why not a Redis sorted set with ZINCRBY on every view? That is 350,000 writes a second, with the hottest videos on one machine. And forgetting old minutes means a sorted set per minute, and a union of 1,440 for the day. Counting in memory turns 350,000 writes a second into a few a minute.
Further reading
Section titled “Further reading”- Probabilistic structures, message queues and streams and partitioning on this site, and the typeahead problem, whose trending queries run on this pipeline.
- An improved data stream summary: the count-min sketch and its applications, Cormode and Muthukrishnan: the sketch, its error bound and its proofs.
- Efficient Computation of Frequent and Top-k Elements in Data Streams, Metwally, Agrawal and El Abbadi: the Space-Saving algorithm and its guarantees.
- Questioning the Lambda Architecture, Jay Kreps: the case for kappa, and the cost of two code paths.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.