~learn.

Day 0631 minPreface+60 XP

Counting at Scale: Bloom Filters, HyperLogLog, Count-Min Sketch and Top-K.

8 stages5 code

watch it first · newThe whole lesson in a 24-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Preface · 1 min

Preface#

The week's store runs its weekend flash sale again. Yesterday's query layer is fast, late data is handled, and the CFO trusts the numbers. Then the marketing team adds three tiles to the live dashboard. (An illustrative scene: the times and sizes are made up; each failure is a classic one.)

THE DASHBOARD THAT COUNTED EVERYTHING (ILLUSTRATIVE)
  • Friday 18:00 —
    Three new tiles go live: unique visitors by category × country, trending products this hour, and "have we seen this event id?" checks for Day 1's deduplication. Each keeps every key it has seen, exactly
  • Saturday 10:00 —
    The Flink job's state passes 40 GB. Checkpoints take longer than the checkpoint interval, so they start to fail
  • Saturday 10:20 —
    A task manager runs out of memory. The job restarts from the last checkpoint, replays, and runs out of memory again. The dashboard freezes
  • Saturday 11:00 —
    Someone adds the seven daily "unique visitors" numbers to get "unique visitors this week". A shopper who came every day is counted seven times
  • Monday 09:00 —
    The post-mortem finds one root cause: every tile paid memory for every distinct key, and none of the answers needed to be exact

None of these tiles was wrong to ask for. What broke is the assumption that counting means remembering. Today's tools count with a fixed, small amount of memory and a known, bounded error.

Yesterday (Day 5) listed "approximate algorithms — HyperLogLog, sketches" and "Bloom filters — skip blocks without matches" among the query-layer tools. Today we open them up: how each works, what it costs, how wrong it can be, and where it fits in this week's pipeline.

Part I · Stage 1 of 8 · 2 min

Part I: Foundations#

Chapter 1: Why Exact Counting Breaks#

1.1 Four Questions, Four Structures#

Almost every "counting" question in analytics is one of four kinds:

Question Example on the store Exact answer needs Sketch
Membership: have I seen x? Is this event_id a duplicate? Every key seen Bloom filter (or cuckoo filter)
Cardinality: how many distinct x? Unique visitors per category per day Every key seen HyperLogLog
Frequency: how often did x occur? Views of product 42 this hour A counter per key Count-Min Sketch
Top-K: which x occur most? Top 10 trending products A counter per key, then a sort Space-Saving / Misra-Gries

The exact answers all have the same cost: memory that grows with the number of distinct keys, not with the number of events. Ten billion page views of one product cost one counter; ten million different visitors cost ten million entries.

1.2 Memory per Distinct Key#

A set of keys is never just the keys. Redis's own documentation sizes a plain set at roughly capacity × (192 bits + value), about 40 bytes per IP address (Redis docs, Bloom filter). Hash sets in Java or Python cost the same order: the key, pointers, and empty slots so the table stays fast.

Day 2's streaming DAU job kept a set() of user ids per day. That is fine for one number. It breaks when the same question is asked per slice: every user appears in several slices, and every slice keeps its own set.

Your turn · checkpoint 1not answered

Before the numbers: what do exact unique-visitor tiles cost?

The store has 10 million daily visitors (assumed). The tile shows unique visitors per category × country: 200 categories × 50 countries = 10,000 cells. A visitor browses about 3 categories in one country, so appears in 3 cells. At about 40 bytes per set entry, how much memory do one day's exact sets need? And how much to answer "unique visitors over any date range in the last 90 days"?

Think it through, or jot it down. Then compare.

One strong answer

  • Entries a day: 10M visitors × 3 cells = 30 million (user, cell) entries; × 40 B ≈ 1.2 GB of sets for one day.
  • 90 days: you can't add daily uniques (a returning visitor would count once per day), so to answer arbitrary ranges you must keep the sets: 90 × 1.2 GB ≈ 108 GB, and each 30-day query unions 30 sets per cell.
  • So the cost is set by distinct keys × slices × days, and it keeps growing with every new dimension the business asks for.

1.3 The Sketch Contract#

A sketch is a small summary of a stream that answers one kind of question approximately. The useful ones share four properties:

  1. Fixed (or slowly growing) memory, chosen up front from the error you can accept, not from the data's size.
  2. A known error with a known direction. A Bloom filter never says "no" to a key it has seen; a Count-Min Sketch never under-counts. Knowing which way the error goes tells you where it's safe to use.
  3. One pass. Each event is touched once, so it fits a Flink operator.
  4. Mergeable. Sketches built on different partitions, hours or machines combine into the sketch of the union: build in parallel, merge at query time.

Bloom filters, HyperLogLog and Count-Min hash each key to random-looking bits and keep a tiny statistic of those bits, so a biased hash biases every estimate. (Space-Saving and Misra-Gries keep the keys themselves.)

Part II · Stage 2 of 8 · 6 min

Part II: Membership — Bloom Filters#

Chapter 2: Bloom Filters#

2.1 How It Works#

Burton Bloom proposed saving memory by "exploiting the possibility that a small fraction of errors of commission may be tolerable", using "a smaller core resident hash area" plus "some secondary and perhaps time-consuming test to 'catch' the small fraction of errors" (Bloom, Communications of the ACM, 1970). His example: a hyphenation program whose in-memory filter avoids most lookups in a disk dictionary.

The structure, his "method 2", is an array of m bits and k hash functions:

  • Insert x: set the k bits at positions h₁(x)…h_k(x).
  • Query x: if any of the k bits is 0, x was definitely never inserted. If all are 1, x was probably inserted: other keys may have set those bits.

So a Bloom filter has false positives but no false negatives. That one-sided error is the whole point: "no" is free and certain, "maybe" sends you to the slow, exact check.

pythonBloomFilter · 21 lines
import hashlib, math

class BloomFilter:
    def __init__(self, n_expected: int, fp_rate: float):
        self.m = math.ceil(-n_expected * math.log(fp_rate) / math.log(2) ** 2)  # bits
        self.k = max(1, round(self.m / n_expected * math.log(2)))               # hash functions
        self.bits = bytearray((self.m + 7) // 8)

    def _positions(self, key: str):
        d = hashlib.blake2b(key.encode(), digest_size=16).digest()
        h1, h2 = int.from_bytes(d[:8], "little"), int.from_bytes(d[8:], "little")
        return [(h1 + i * h2) % self.m for i in range(self.k)]   # k indexes from two hashes

    def add(self, key: str) -> None:
        for p in self._positions(key):
            self.bits[p >> 3] |= 1 << (p & 7)

    def might_contain(self, key: str) -> bool:
        return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._positions(key))

seen = BloomFilter(n_expected=100_000_000, fp_rate=0.01)   # ~120 MB, k = 7

2.2 The False-Positive Maths#

After inserting n keys with k hashes into m bits, the chance that a given bit is still 0 is about e^(−kn/m). A key that was never inserted is a false positive when all k of its bits are 1:

p ≈ (1 − e^(−kn/m))^k

Two results follow, and they are the ones to remember:

  • Best k = (m/n) · ln 2, which leaves about half the bits set. Bloom found the same balance point: the smallest hash area "occurs when half the bits are 1 and half are 0" (Bloom, 1970).
  • Bits per key for a target p: m/n = −ln p / (ln 2)² ≈ 1.44 · log₂(1/p).
Target false-positive rate Bits per key Best k
10% 4.8 3
1% 9.6 7
0.1% 14.4 10
0.01% 19.2 13–14

Redis's docs agree: "1% error rate requires 7 hash functions and 9.585 bits per item". Each 10× drop costs about 4.8 more bits per key, whatever the key's size. So one billion keys at 1% take ≈ 1.2 GB; a hash set of them at ~40 bytes each, about 40 GB.

2.3 Sizing: n Is a Promise#

The formula assumes you know n. A Bloom filter can't grow in place: once more keys arrive than it was sized for, more bits are 1 and the false-positive rate climbs, silently.

Your turn · checkpoint 2not answered

What breaks if the filter fills up?

You sized a filter for 100 million event ids at 1% (9.6 bits per key, k = 7). The flash sale pushes 300 million ids into it before anyone resets it. Roughly what is the false-positive rate now, and what would you do differently?

Think it through, or jot it down. Then compare.

One strong answer

  • The rate: with n three times the plan, kn/m ≈ 7 × 3 / 9.6 ≈ 2.2, so p ≈ (1 − e^(−2.2))^7 ≈ 44%. At 2× it is already ~16%. Nothing errors: it just says "maybe" to almost half of the new keys.
  • Instead: size for the peak with headroom; rotate filters by time (one per hour, check the last few), which also bounds how long a key is remembered; or use a scalable filter that adds a new, larger layer when one fills (Redis Bloom's expansion option works this way). Alert on the fill ratio (fraction of bits set): above ~50% the filter is past its design point.

2.4 Deletes: Counting Bloom and Cuckoo Filters#

A Bloom filter can't delete: clearing a bit might remove other keys that share it, creating false negatives. Two variants fix that:

  • Counting Bloom filter: each position is a small counter instead of a bit; insert increments, delete decrements. Fan et al. note the counters need "four or more bits", so it "requires 4× more space than a standard Bloom filter" (Fan, Andersen, Kaminsky & Mitzenmacher, CoNEXT 2014).
  • Cuckoo filter (same paper): a cuckoo hash table of short fingerprints, filled to about 95%. It supports deletes and "uses less space than Bloom filters in many practical applications, if the target false positive rate ϵ is less than 3%". Caveats: a deleted item "must have been previously inserted" (or you may remove another item with the same fingerprint), and it is "not suitable for applications that insert the same item more than 2b times" (b is the bucket size).

2.5 Where Bloom Filters Earn Their Keep#

LSM storage engines. In Cassandra's read path, "to avoid checking every SSTable data file for the partition being requested, Cassandra employs a data structure known as a bloom filter" that says the data "definitely does not exist in the given file" or "probably exists" (Apache Cassandra docs). The per-table bloom_filter_fp_chance defaults to 0.1 with LeveledCompactionStrategy and 0.01 otherwise, and 0.01 "will require about three times as much memory" as 0.1. RocksDB's wiki: "9.9 bits per key (1% false positive rate) is 99% as effective as 100 bits per key", and SST filters commonly use "~10% of system RAM" (RocksDB wiki). Day 5's "skip blocks without matches" is the same idea.

DiagramA point read in an LSM engine, with one Bloom filter per SSTable
%% title: A point read in an LSM engine, with one Bloom filter per SSTable
flowchart TD
  R@{ icon: "sd:api", label: "get(user:42)" }
  MT@{ icon: "sd:cache", label: "Memtable (RAM)" }
  B1{"SSTable 1 Bloom:<br/>user:42?"}
  B2{"SSTable 2 Bloom:<br/>user:42?"}
  B3{"SSTable 3 Bloom:<br/>user:42?"}
  S1@{ icon: "sd:file", label: "SSTable 1 (disk)" }
  S2@{ icon: "sd:file", label: "SSTable 2 (disk)" }
  S3@{ icon: "sd:file", label: "SSTable 3 (disk)" }
  NF(["Not found<br/>no SSTable read from disk"]):::ok
  R --> MT
  MT -->|"miss"| B1
  B1 -->|"no: skip file"| B2
  B1 -.->|"maybe"| S1
  B2 -->|"maybe (false for ~1% of absent keys)"| S2
  B2 -->|"no: skip file"| B3
  B3 -.->|"maybe"| S3
  B3 -->|"no"| NF
  class R client
  class MT cache
  class S1,S2,S3 store

CDN caches and one-hit wonders. On one Akamai cluster over two days, "74% of the roughly 400 million objects in cache were accessed only once". Caching those "one-hit-wonders" wastes disk, so Akamai caches an object only on its second request, remembering first requests in a Bloom filter. With filtering on, "the byte hit rate increases from around 74% to 83%", and disk writes drop "from an average of 10209 writes per second to 5738 writes per second, a decrease of 44%" (Maggs & Sitaraman, ACM SIGCOMM CCR, 2015). The false positives here are harmless: an occasional one-hit object gets cached anyway.

Deduplication in the pipeline. Day 1 gave every event a client-generated event_id so retries can be dropped. A Bloom filter of recent ids looks like a cheap dedup check; check which way its error goes first.

Your turn · checkpoint 3not answered

The duplicate that wasn't

A teammate replaces Day 1's exact dedup store with a Bloom filter at 1%: if the filter says an event_id was seen, the event is dropped as a retry. At 100 million events a day, what goes wrong, and how would you keep the filter's speed without the damage?

Think it through, or jot it down. Then compare.

One strong answer

  • What goes wrong: a false positive means a new event is called a duplicate and dropped: about 1% of genuinely new events, ~1 million a day, silently lost. The revenue tile is now 1% low, every day.
  • Keep the speed: use the filter for the certain answer only. "Definitely not seen" (almost every event) is accepted at once; "maybe seen" goes to the exact check (Flink keyed state or a key-value store with a TTL).
  • The rule: only let a Bloom filter decide alone where a false "yes" is cheap (caching, skipping a disk read), never where it destroys data.

Part III · Stage 3 of 8 · 5 min

Part III: Distinct Counts — HyperLogLog#

Chapter 3: HyperLogLog#

3.1 The Intuition#

Hash every user id to a random-looking 64-bit number. Look at the position of the first 1-bit. Half of all hashes start with 1, a quarter with 01, an eighth with 001. If the longest run of leading zeros you have seen is 20, you have probably seen around 2²⁰ ≈ a million distinct values. Duplicates don't matter: the same id always hashes to the same bits, so seeing it again changes nothing.

One maximum is noisy (one lucky hash doubles it), so HyperLogLog averages many:

  1. Use the first p bits of the hash to pick one of m = 2^p registers.
  2. In that register keep the maximum "position of the first 1-bit" seen in the remaining bits.
  3. Estimate with a normalized harmonic mean of 2^register across all registers. The harmonic mean tames outliers that the earlier LogLog's geometric mean let through (Flajolet, Fusy, Gandouet & Meunier, AofA 2007). Each register holds a count of zeros, so 5–6 bits suffice.

3.2 Error and Memory#

The paper's headline: the standard error is "typically about 1.04/√m", and the algorithm can "estimate cardinalities well beyond 10⁹ with a typical accuracy of 2% while using a memory of only 1.5 kilobytes" (Flajolet et al., 2007).

Precision p Registers m Standard error 1.04/√m Dense size at 6 bits per register
10 1,024 3.25% 768 B
12 4,096 1.63% 3 KB
14 16,384 0.81% 12 KB
15 32,768 0.57% 24 KB
16 65,536 0.41% 48 KB

The error is a standard deviation: about two-thirds of estimates fall within ±1σ, 95% within ±2σ. Halving it costs 4× the memory, and memory doesn't depend on n.

3.3 Small and Huge Counts: HLL++#

The raw estimator is biased for small sets: with few values, most registers are still 0. The original paper switches to linear counting, m · ln(m / empty registers), when the estimate is below 5m/2, and applies a correction near 2³² because it used 32-bit hashes (Flajolet et al., 2007).

Google's HyperLogLog++ engineered this for PowerDrill, its interactive column store, where some count-distinct queries "exceeded the available memory". PowerDrill ran "about 5 million such count distinct computations" a day, "99% of these computations yield a result of 100 or less", while "about 100 computations a day yield a result greater than 1 billion" (Heule, Nunkesser & Hall, EDBT 2013). The three changes:

  • A 64-bit hash. Registers grow from 5 to 6 bits (memory 6 · 2^p bits), and the large-range correction "is no longer needed".
  • Empirical bias correction for the middle range, where the raw estimate overestimates.
  • A sparse representation: when most registers are empty, store only (index, value) pairs, and switch to the dense array once the list would exceed 6m bits. At precision 14 the sparse form cuts the average error "for cardinalities up to roughly 12000" by "a factor of 4".

3.4 Merging: the Property That Makes HLL Work in Pipelines#

Two HLLs with the same precision and hash merge by taking the register-wise maximum: exactly the sketch of the union of both streams. So you build sketches per partition, minute or slice in parallel, and answer "unique visitors last 30 days, Electronics, India" by merging 30 daily sketches. And re-adding an element is a no-op, so replays and duplicates don't inflate the count: HLL inserts are idempotent.

What merging can't do well is intersection. "Users who visited both Electronics and Books" by inclusion-exclusion, |A| + |B| − |A ∪ B|, subtracts large noisy numbers to get a small one, so its relative error explodes when the overlap is small. Apache DataSketches' Theta sketches were built for this: the framework's combining function "is extended to set Intersections and Differences as well" (Apache DataSketches docs).

pythonHyperLogLog · 27 lines
import hashlib, math

class HyperLogLog:
    def __init__(self, p: int = 14):
        self.p, self.m = p, 1 << p
        self.reg = bytearray(self.m)
        self.alpha = 0.7213 / (1 + 1.079 / self.m)        # bias constant for m >= 128

    def add(self, item: str) -> None:
        x = int.from_bytes(hashlib.blake2b(item.encode(), digest_size=8).digest(), "big")
        idx = x >> (64 - self.p)                          # first p bits pick the register
        rest = x & ((1 << (64 - self.p)) - 1)
        rank = (64 - self.p) - rest.bit_length() + 1      # position of the first 1-bit
        self.reg[idx] = max(self.reg[idx], rank)

    def count(self) -> int:
        est = self.alpha * self.m ** 2 / sum(2.0 ** -r for r in self.reg)
        zeros = self.reg.count(0)
        if est <= 2.5 * self.m and zeros:                 # small range: linear counting
            est = self.m * math.log(self.m / zeros)
        return round(est)

    def merge(self, other: "HyperLogLog") -> "HyperLogLog":
        assert self.p == other.p, "same precision and same hash, or the merge is meaningless"
        out = HyperLogLog(self.p)
        out.reg = bytearray(max(a, b) for a, b in zip(self.reg, other.reg))
        return out
Your turn · checkpoint 4not answered

Unique visitors this week

Marketing wants "unique visitors this week" per category. You already store each day's exact unique count per category. Option A: sum the seven daily numbers. Option B: keep the raw (user, category, day) rows and run COUNT(DISTINCT) over the week. Option C: store an HLL sketch per category per day. Which do you pick, and what does each cost?

Think it through, or jot it down. Then compare.

One strong answer

  • A is wrong, not approximate: a visitor who came on 5 days counts 5 times. It measures visitor-days, not visitors.
  • B is exact but expensive: every weekly, monthly or custom-range query rescans and deduplicates the raw rows, and the work grows with the range.
  • C: merge 7 sketches (12 KB each at p = 14) per category and read the count, ±0.81% standard error. Any range is a merge of daily sketches; nothing is rescanned.
  • Pick C for dashboards, and keep B for the rare audited number (finance, billing) that must be exact.

3.5 HyperLogLog in Production#

Redis. PFADD adds elements, PFCOUNT returns the estimate, PFMERGE combines keys. The implementation "uses up to 12 KB of memory and provides a standard error rate of 0.81%", and can count sets of up to 2⁶⁴ members (Redis docs). The dense form is "a Redis string of 12288 bytes in order to store 16384 6-bit counters"; small HLLs use a run-length-encoded sparse form, and Redis switches automatically. Two details matter at scale: PFCOUNT with several keys merges them on the fly, which "may take a time in the order of magnitude of the millisecond, and should be not abused", and because the last bytes cache the computed cardinality, "PFCOUNT is technically a write command" (Redis docs, PFCOUNT).

BigQuery. APPROX_COUNT_DISTINCT "is less accurate than COUNT(DISTINCT expression), but performs better on very large input". It returns a number, not a sketch, and "doesn't allow partial aggregations, re-aggregations, and custom precision". For stored, mergeable sketches use the HLL++ functions: HLL_COUNT.INIT(input, precision) builds a sketch as BYTES, with precision from 10 to 24 and a default of 15; HLL_COUNT.MERGE_PARTIAL merges sketches into a sketch; HLL_COUNT.MERGE and HLL_COUNT.EXTRACT return counts. If merged sketches have different precisions, "the precision will be downgraded to the lowest precision involved". BigQuery's sketches are larger than Redis's: the documented maximum at precision 14 is 16 KiB + 30 B, against Redis's 12 KB (Google Cloud docs).

sql12 lines
-- Nightly: one sketch per day, category and country (the fact table from Day 3)
CREATE TABLE agg_daily_visitors AS
SELECT DATE(event_time) AS day, category, country,
       HLL_COUNT.INIT(user_id, 14) AS visitors_hll          -- BYTES, ~16 KB at most
FROM fact_page_views
GROUP BY day, category, country;

-- Dashboard: unique visitors for any range, without touching raw events
SELECT category, HLL_COUNT.MERGE(visitors_hll) AS unique_visitors
FROM agg_daily_visitors
WHERE day BETWEEN '2026-10-01' AND '2026-10-28' AND country = 'IN'
GROUP BY category;

Apache DataSketches. A library of mergeable sketches with Druid and Hive integrations; its HLL sketch takes lgK "from 4 to 21" and 4-, 6- or 8-bit registers (Apache DataSketches docs).

DiagramAnswering "unique visitors, last 28 days" from stored sketches (illustrative numbers)
%% title: Answering "unique visitors, last 28 days" from stored sketches (illustrative numbers)
sequenceDiagram
  participant D as Dashboard
  participant Q as Query API
  participant C as Result cache
  participant W as Warehouse (sketch table)
  D->>Q: unique visitors, Electronics, IN, 1-28 Oct
  Q->>C: lookup (range, slice)
  C-->>Q: miss
  Q->>W: HLL_COUNT.MERGE over 28 daily sketches
  Note over W: reads 28 rows of at most ~16 KB each, not 28 days of raw events
  W-->>Q: 1,184,203 (plus or minus about 0.8%)
  Q->>C: store with a TTL
  Q-->>D: ~1.18M unique visitors

Part IV · Stage 4 of 8 · 3 min

Part IV: Frequencies — Count-Min Sketch#

Chapter 4: Count-Min Sketch#

4.1 How It Works#

A Count-Min Sketch (CMS) is a table of d rows × w counters, with one hash function per row (Cormode & Muthukrishnan, Journal of Algorithms, 2005):

  • Update (x, c): in each row j, add c to counter[j][h_j(x)].
  • Query x: return the minimum of x's d counters.

Each of x's counters holds x's true count plus whatever collided into it. Collisions only add, so the minimum is the least-polluted estimate.

4.2 The Bounds#

With w = ⌈e/ε⌉ and d = ⌈ln(1/δ)⌉, the paper's Theorem 1 gives, for every key i:

aᵢ ≤ âᵢ ≤ aᵢ + ε · N, with probability at least 1 − δ,

where aᵢ is the true count, âᵢ the estimate and N the total of all counts (Cormode & Muthukrishnan, 2005). Three consequences:

  • Never under-counts: the estimate is an upper bound.
  • The error is relative to the whole stream: ε = 0.001 means "within 0.1% of all events", enormous for a rare key, tiny for a popular one.
  • Memory ignores the number of keys: ε = 0.001, δ = 0.01 gives w = 2,719, d = 5: about 53 KB of 4-byte counters for a thousand products or a hundred million.
Your turn · checkpoint 5not answered

What does 2,000 views mean?

Product page views flow into a CMS with ε = 0.001 and δ = 0.01. The store has 50 million page views a day. At the end of the day, product 42's estimate is 2,000. What can you say about its true count? What about a product whose estimate is 400,000?

Think it through, or jot it down. Then compare.

One strong answer

  • The bound: ε · N = 0.001 × 50M = 50,000 views of possible over-count (with 99% probability).
  • Product 42: the true count is at most 2,000 (CMS never under-counts) and at least 2,000 − 50,000, i.e. anything from 0 to 2,000. The sketch can't tell it from a product nobody viewed.
  • The 400,000 product: true count between 350,000 and 400,000: within 12.5%, good enough to rank it as a top seller.
  • So a CMS is for heavy hitters. Redis's docs say the same: results below a threshold set by the error rate "should be ignored and often even approximated to zero". For exact per-product counts, keep counters per product.

4.3 Conservative Update#

Estan and Varghese introduced a simple change for counter arrays like this, conservative update: when x arrives, raise each of its counters only as far as needed, to max(counter, current estimate + c), instead of adding c to all of them. In their words, "no counter is increased to more than the size of the smallest counter plus the size of the packet", and they call it "an important optimization for multistage filters that improves performance by an order of magnitude" (Estan & Varghese, SIGCOMM 2002).

Why it is still safe: x's true count is at most its current minimum plus c, so raising counters to that level never makes any key's estimate lower than its true count. What it costs: counters no longer equal sums of updates, so the sketch can't take decrements (no deletes, no negative updates), and the clean linear analysis no longer applies. For page views and clicks, which only grow, it is a free accuracy gain.

4.4 From Counts to Heavy Hitters#

A CMS stores counters, not keys: it can answer "how many views did product 42 get?" but not "which products are hot?". Cormode and Muthukrishnan define the φ-heavy hitters as the keys whose count exceeds φ · N. The usual pattern pairs the sketch with a small heap of candidates: after each update, query x's estimate; if it passes φ · N, keep x in the heap.

pythonCountMin · 33 lines
import hashlib, heapq

class CountMin:
    def __init__(self, width: int = 2719, depth: int = 5, conservative: bool = True):
        self.w, self.d, self.cu = width, depth, conservative
        self.rows = [[0] * width for _ in range(depth)]
        self.total = 0

    def _cols(self, key: str):
        return [int.from_bytes(hashlib.blake2b(key.encode(), digest_size=8,
                salt=bytes([j])).digest(), "big") % self.w for j in range(self.d)]

    def add(self, key: str, c: int = 1) -> int:
        cols, self.total = self._cols(key), self.total + c
        if self.cu:                                   # conservative update: only lift the low counters
            target = min(self.rows[j][col] for j, col in enumerate(cols)) + c
            for j, col in enumerate(cols):
                self.rows[j][col] = max(self.rows[j][col], target)
        else:
            for j, col in enumerate(cols):
                self.rows[j][col] += c
        return self.estimate(key)

    def estimate(self, key: str) -> int:
        return min(self.rows[j][col] for j, col in enumerate(self._cols(key)))

def heavy_hitters(stream, phi=0.001, k=10):
    cms, candidates = CountMin(), {}
    for product_id in stream:
        est = cms.add(product_id)
        if est >= phi * cms.total:                    # passes the phi * N threshold
            candidates[product_id] = est
    return heapq.nlargest(k, candidates.items(), key=lambda kv: kv[1])

Part V · Stage 5 of 8 · 3 min

Part V: Top-K#

Chapter 5: Space-Saving, Misra-Gries and Windows#

5.1 Counter-Based Summaries#

A more direct family keeps a fixed number of (key, counter) pairs and decides what to evict.

Misra-Gries (Misra & Gries, 1982) keeps up to k − 1 counters. A monitored key increments its counter; a new key takes a free counter; if none is free, every counter is decremented by one (and zeros are freed). Each decrement round removes k counts at once (the k − 1 counters plus the new event), so it can happen at most N/k times: every count is an under-estimate by at most N/k, and any key occurring more than N/k times is guaranteed to be kept. Apache DataSketches' frequent-items sketch "implements a variant of what is commonly known as the 'Misra-Gries algorithm'"; its bounds satisfy (UB − LB) ≤ W · ε with ε = 3.5/M for a map of M entries, and it can "merge itself with another sketch" (Apache DataSketches docs).

Space-Saving (Metwally, Agrawal & El Abbadi, ICDT 2005) keeps m counters and, when a new key arrives with no free counter, replaces the key with the minimum counter, gives the newcomer that minimum + 1, and records the inherited amount as its possible over-estimation ε. Its guarantees:

  • The counters always sum to the stream length N (Lemma 1).
  • Every monitored key's count is over-estimated by at most the current minimum: fᵢ ≤ countᵢ ≤ fᵢ + min (Lemma 3), and min ≤ ⌊N/m⌋.
  • Any key whose true frequency exceeds min "must exist in Stream-Summary" (Theorem 1), and count − ε gives a guaranteed lower bound, so you can tell which of the reported top-k are certain.
DiagramSpace-Saving: one update with m counters
%% title: Space-Saving: one update with m counters
flowchart TD
  E(["Event: product x"]):::core
  M{"Is x already<br/>monitored?"}
  F{"Is a counter<br/>free?"}
  I["count[x] += 1"]:::ok
  N["Add x with<br/>count 1, err 0"]:::ok
  R["Evict y with the minimum count<br/>x gets count = min + 1<br/>err[x] = min"]:::bad
  Q(["Top-k: highest counts<br/>certain if count - err beats<br/>the (k+1)th count"]):::service
  E --> M
  M -->|"yes"| I
  M -->|"no"| F
  F -->|"yes"| N
  F -->|"no"| R
  I --> Q
  N --> Q
  R --> Q
pythonSpaceSaving · 19 lines
class SpaceSaving:
    """m counters; counts over-estimate by at most err[key] <= N/m."""
    def __init__(self, m: int = 1000):
        self.m, self.count, self.err = m, {}, {}

    def add(self, key: str, c: int = 1) -> None:
        if key in self.count:
            self.count[key] += c
        elif len(self.count) < self.m:
            self.count[key], self.err[key] = c, 0
        else:                                            # O(m) here; the paper's Stream-Summary makes it O(1)
            victim = min(self.count, key=self.count.get)
            floor = self.count.pop(victim); self.err.pop(victim)
            self.count[key], self.err[key] = floor + c, floor

    def top(self, k: int):
        ranked = sorted(self.count.items(), key=lambda kv: kv[1], reverse=True)
        kth_next = ranked[k][1] if len(ranked) > k else 0
        return [(key, n, n - self.err[key] >= kth_next) for key, n in ranked[:k]]   # (key, count, guaranteed?)

For the trending tile, m = 1,000 counters (tens of KB) track the top 10 of a million products, each count off by at most N/1,000, and far less on skewed traffic.

Redis's Top-K type instead uses HeavyKeeper's "count-with-exponential-decay", "biased against mouse (small) flows", plus "a min heap that holds the K items with the highest counts" (Redis docs).

5.2 Top-K Over Windows#

"Trending this hour" is a windowed question. A typical design:

  • Panes: keep one summary per one-minute event-time window, keyed by Day 4's watermarks. A late event updates its own minute's pane until the watermark closes it.
  • Sliding hour: every minute, merge the last 60 panes and read the top-k. Drop the oldest pane, never "subtract" it: Space-Saving and Misra-Gries summaries merge but don't subtract cleanly.
  • Trending, not just popular: rank this hour's count against a baseline (the same hour last week), or the phone case sits at #1 forever.
  • Distinct, not raw: to stop one bot pushing an item up, count unique users per candidate with an HLL on top of the top-k.
Your turn · checkpoint 6not answered

What breaks if each partition sends its own top 10?

Product views are spread over 8 Kafka partitions by user_id. Each Flink subtask computes its own top 10 products for the hour, and a final operator merges the eight lists and keeps the top 10. What can go wrong, and how do you fix it?

Think it through, or jot it down. Then compare.

One strong answer

  • The miss: a product that is 11th on every partition appears in no list, yet its total can beat a product that is 1st on one partition only. Merging top-k lists loses everything below each cut-off.
  • Fix 1: key by product before counting (keyBy(product_id)), so each product's views land on one subtask and the per-subtask counts are complete.
  • Fix 2: if you must pre-aggregate per partition, send summaries, not lists: a Space-Saving or frequent-items sketch with m ≫ k counters (say 1,000 for a top 10), and merge the sketches (DataSketches' frequent-items sketch supports this merge).

Part VI · Stage 6 of 8 · 4 min

Part VI: Choosing and Combining#

Chapter 6: Which Sketch, and Where It Sits#

6.1 The Trade-off#

Exact structure vs sketch, by question

Exact

Question Memory Error
Seen x? (hash set) ~40 B per key None
Distinct count (set) ~40 B per key None
Count of x (map) a counter per key None
Top-k (map + sort) a counter per key None

Mergeable by union or sum, but the cost grows with every distinct key, slice and day.

Sketch

Question Sketch Memory Error direction
Seen x? Bloom / cuckoo 9.6 bits per key at 1% False "yes" only
Distinct count HyperLogLog 12 KB at 0.81% Both ways, ±1.04/√m
Count of x Count-Min ~53 KB for ε = 0.1% Over only, ≤ εN
Top-k Space-Saving m counters Over only, ≤ N/m

All four merge; Bloom and HLL are also idempotent under replays.

Three questions settle most choices: which direction of error can you live with; must it combine across time or partitions (then the same parameters and hash everywhere); and is the number audited (billing and finance stay exact in Day 2's batch layer; dashboards and trending take sketches).

6.2 In the Week's Pipeline#

DiagramSketches in the week's pipeline
%% title: Sketches in the week's pipeline
flowchart TD
  K@{ icon: "tech:kafka", label: "Kafka<br/>validated events" }
  F@{ icon: "tech:flink", label: "Flink<br/>keyed by event time" }
  BL["Dedup gate<br/>Bloom says #quot;new#quot; → accept<br/>#quot;maybe#quot; → exact state check"]:::core
  HL["HLL per minute ×<br/>category × country"]:::core
  TK["Space-Saving per minute<br/>(keyed by product)"]:::core
  CH@{ icon: "tech:clickhouse", label: "Real-time store<br/>sketch columns" }
  BQ@{ icon: "tech:bigquery", label: "Warehouse<br/>HLL_COUNT sketches per day" }
  R@{ icon: "tech:redis", label: "Redis<br/>trending top 10, PFCOUNT tiles" }
  DB@{ icon: "sd:dashboard", label: "Dashboards<br/>merge at query time" }
  K --> F
  F --> BL
  BL --> HL
  BL --> TK
  HL -->|"minute sketches"| CH
  HL -->|"daily rollup (merge)"| BQ
  TK -->|"merge last 60 panes"| R
  CH --> DB
  BQ --> DB
  R --> DB
  class K queue
  class F service
  class CH,BQ store
  class R cache
  class DB client

How the week's ideas meet the sketches:

  • Exactly-once (Day 1, Day 2). HLL and Bloom inserts are idempotent, so a replay after a restart can't inflate them. CMS, Space-Saving and plain counters are not: a replayed event is counted twice. Keep them in Flink's checkpointed state so a restart rolls them back with the offsets, or deduplicate first.
  • Late data (Day 4). A late event updates the sketch of its own event-time window. Because sketches merge, a correction is a merge of a small "late" sketch into the stored one, not a recompute of the day.
  • Data modeling and queries (Days 3 and 5). Store sketches as rollup columns (visitors_hll BYTES) at the finest grain you'll merge from: you can merge minutes into days, never split days into minutes. A range query on uniques becomes as cheap as one on sums.
WorksheetUnique-visitor tiles for the week's store (inputs assumed, sketch sizes from the docs)
Value
Daily visitors (assumed) 10 million
Slices: 200 categories × 50 countries (assumed) 10,000 cells a day
Cells per visitor a day (assumed) 3 → 30 million (user, cell) entries
Exact sets at ~40 B per entry (Redis docs' set estimate) ~1.2 GB a day
Keep 90 days to answer any range exactly ~108 GB, and range queries union up to 90 sets per cell
HLL at p = 14: 12 KB dense, 0.81% standard error (Redis docs) 10,000 cells × 12 KB = ~120 MB a day at most
Typical cell: 30M ÷ 10,000 = 3,000 visitors ±24 visitors at 1σ; small cells stay sparse and nearly exact
90 days of daily sketches ~11 GB at most; any range = merge 1–90 sketches of 12 KB
One 28-day tile for one cell Exact: union of 28 sets, ~3.4 MB read · Sketch: merge of 28 × 12 KB = ~340 KB

Conclusion: sketches cut the tile's storage only about tenfold here, because most cells are small (BigQuery's 16 KiB sketches would make it about 7×); the bigger win is that any date range becomes a merge of a few kilobytes per day instead of a union of sets, so new date ranges cost nothing and returning visitors are never double-counted. Keep exact COUNT(DISTINCT) in the batch layer for the few numbers someone audits.

Chapter 7: Edge Cases and Failure Modes#

FAILURE MATRIX
Failure Symptom Contained by
Bloom filter over capacity False positives climb from 1% toward 40%+, silently Size for peak, rotate by time or scale in layers, alert on fill ratio
Sketches built with different hashes, seeds or precisions Merges return nonsense, or (BigQuery) quietly lose precision One shared sketch spec per metric, versioned with the schema
CMS read for rare keys Rare products show thousands of phantom views Only report estimates well above εN; exact counters where it matters
Replay after restart CMS and top-k counts inflated; HLL unaffected Checkpointed state with exactly-once sinks, or dedup upstream

Privacy: a sketch keeps hashed bits, not ids, but it is built from personal identifiers, so a typical design gives stored sketches the same retention and deletion rules as the events (Week 9 covers deletion).

Part VII · Stage 7 of 8 · 2 min

Part VII: Real-World Application#

Chapter 8: How Real Systems Do It#

Source What it has published
Bloom (CACM, 1970) Accept a few false positives for a far smaller in-memory table; an exact secondary test catches them
Flajolet et al. (2007) · Google HLL++ (2013) Error ≈ 1.04/√m; 64-bit hash, bias correction and sparse mode, built for PowerDrill
Cormode & Muthukrishnan (2005) · Estan & Varghese (2002) Count-Min bounds; conservative update
Metwally et al. (2005) · Misra & Gries (1982) Counter-based top-k with guaranteed bounds
Akamai (2015) Cache-on-second-hit with a Bloom filter: byte hit rate ~74% → 83%, disk writes −44%
Cassandra, RocksDB (docs) A Bloom filter per SSTable; ~10 bits per key for 1%
Redis, BigQuery, DataSketches (docs) HLL in 12 KB at 0.81%; mergeable HLL++ BYTES sketches; Theta and frequent-items sketches

Chapter 9: Common Mistakes to Avoid#

9.1 Mistake 1: Adding Up Unique Counts#

Weekly and regional uniques

WRONG: sum daily or per-region unique counts

  • Returning visitors counted once per day or region
  • The total is visitor-days, not visitors

CORRECT: merge sketches, then count

  • Store HLL sketches per day and slice
  • Any range or rollup is a merge, counted once

9.2 Mistake 2: Trusting the Error in the Wrong Direction#

Using a Bloom filter for dedup

WRONG: drop whatever the filter says it has seen

  • 1% of new events silently discarded

CORRECT: accept "definitely new", verify "maybe"

  • The filter removes the cost, the exact store keeps the correctness

9.3 Mistake 3: Reading Small Counts From a Count-Min Sketch#

Per-product views

WRONG: show every product's CMS estimate

  • Rare products inherit collisions: thousands of phantom views

CORRECT: use CMS for heavy hitters only

  • Report keys well above εN; keep exact counters (Day 3's rollups) for the long tail

9.4 Mistake 4: Merging Sketches That Don't Match#

Sketch parameters

WRONG: each team picks its own precision and hash

  • Sketches can't merge, or merge into garbage

CORRECT: one versioned sketch spec per metric

  • Same hash, seed, precision and library format everywhere it is built

Part VIII · Stage 8 of 8 · 5 min

Part VIII: Interview Preparation#

Chapter 10: Interview Tips and Phrases#

10.1 When to Bring It Up#

  • "Unique users", "DAU/MAU" or "reach", sliced or over arbitrary ranges: HLL.
  • "Top / trending" over a stream: Space-Saving, or CMS plus a heap, in windows.
  • "Have we seen this?" in front of something slow (disk, a database, a crawler, a cache fill): Bloom filter.
  • "How does the job's state grow?": sketches for counting state.
❯ Say this

10.2 Key Phrases#

Key phrases
FRAMING

"Exact counts cost memory per distinct key, per slice, per day. Dashboards tolerate ~1%, so sketches there; exact only where audited."

HYPERLOGLOG

"One HLL per day per slice at precision 14: 12 KB, 0.81% standard error. Weekly or custom ranges are merges, never sums of daily uniques."

BLOOM FILTER

"No false negatives; 1% false positives costs ~10 bits per key. It skips work; its 'maybe' goes to an exact check if a wrong 'yes' loses data."

TOP-K

"Key by item, Space-Saving per one-minute pane with m ≫ k, merge the last 60 panes for a sliding hour."

10.3 Questions to Ask the Interviewer#

  • Which numbers are audited or billed, and which are for dashboards? How much error is acceptable on each?
  • Do we need arbitrary date ranges and slices, or a fixed set of reports?
  • Do counts ever decrease (cancellations, deletions)?

10.4 Common Follow-Up Questions#

Question Strong answer
"How accurate is HyperLogLog?" Standard error 1.04/√m: 0.81% at 16,384 registers (12 KB); small sets nearly exact in HLL++'s sparse mode.
"Can you get users who did A and B?" Inclusion-exclusion on HLLs is noisy for small overlaps; Theta sketches support intersections, or compute exactly in batch.
"Count-Min vs Space-Saving for top-k?" Space-Saving keeps keys and gives guaranteed bounds directly; CMS needs a heap for keys but answers point queries for any key.
"How do you delete from a Bloom filter?" You can't; use a counting Bloom (~4× space) or cuckoo filter, or rotate filters by time.

Chapter 11: Practice Problems#

Practice · problem 1

Problem 1: Reach for an Ad Platform#

Advertisers want "unique people reached" per campaign, per day and for any custom date range, across 50,000 campaigns and 20 countries.

Hint: an HLL++ sketch per (campaign, country, day); merge for ranges and regions; label estimates.

Practice · problem 2

Problem 2: A Crawler's "Already Fetched?" Check#

A web crawler fetches 2 billion URLs a month and must not fetch a URL twice in a week. A wrong "already fetched" means a page is skipped for a week.

Hint: a Bloom filter per day, seven checked, sized for peak; is a 1% skip acceptable, or does "maybe" need an exact lookup for important domains?

Practice · problem 3

Problem 3: Heavy API Keys for Rate Limiting#

Find the API keys that send more than 0.1% of all requests in any minute, across 40 gateway nodes, without a counter for each of 10 million keys.

Hint: per-node Space-Saving (m ≈ 2,000) or Count-Min per minute, merged centrally; combine with Week 1's rate limiting.

Summary#

DAY 6 KEY TAKEAWAYS
  • Why: exact counting costs memory per distinct key, multiplied by slices and days; sketches cost a fixed amount with a known error
  • Bloom filter: no false negatives; ~9.6 bits per key for 1%; never over-fill; cuckoo or counting variants delete
  • HyperLogLog: error 1.04/√m (0.81% in 12 KB); merge by max; never sum uniques
  • Count-Min Sketch: never under-counts, over by ≤ εN; good for heavy hitters only
  • Top-K: Space-Saving or Misra-Gries with m ≫ k; key by item; merge per-minute panes for sliding windows
  • In the pipeline: sketch columns in rollups, merge at query time; HLL and Bloom survive replays, counters need exactly-once state

Week 8 Complete!#

Week 8 summary: Analytics pipeline
  1. Day 1: EVENT INGESTION — Event schema design · Kafka as backbone · Validation and enrichment · Exactly-once delivery
  2. Day 2: STREAMING VS BATCH — Lambda vs Kappa architecture · Flink for streaming · Spark for batch · Watermarks and state
  3. Day 3: DATA MODELING — Star schema · Fact and dimension tables · SCD Type 2 · Columnar storage
  4. Day 4: LATE DATA — Event time vs processing time · Watermarks deep dive · Versioned results · Reprocessing patterns
  5. Day 5: QUERY LAYER — OLAP databases · Pre-aggregation · Caching strategies · Multi-tenant management
  6. Day 6: COUNTING AT SCALE — Bloom filters · HyperLogLog · Count-Min Sketch · Top-K and mergeable sketches

You now understand the complete analytics pipeline from event capture to fast dashboard queries!

What's Next#

The Week 8 Capstone will integrate all these concepts into designing a complete analytics system for a video streaming platform.

Capstone preview: Streaming analytics platform
Design Netflix-style analytics
  • Track video plays, pauses, completions
  • Real-time "trending now" dashboard
  • Historical viewing patterns
  • Content performance for creators
  • A/B test analysis
  • Handle 10M concurrent viewers
This will test everything from Week 8
  • Event ingestion at scale
  • Streaming + batch processing
  • Star schema for viewing data
  • Late data from mobile apps
  • Fast queries for dashboards
  • Unique viewers and trending titles with sketches

Further Reading#

Papers#

Official Documentation#

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 06 · Counting at Scale: Bloom Filters, HyperLogLog, Count-Min Sketch and Top-K
Finish Day 6Stages clear as you read them (0/8 so far). Skipping ahead doesn't count: marking complete unlocks after ~6 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

RedisBlog post · ~12 min

Redis new data structure: the HyperLogLog

Salvatore Sanfilippo · 2014
What
Redis's creator explains how he added HyperLogLog to Redis and the commands PFADD, PFCOUNT and PFMERGE.
Key idea
16,384 six-bit registers addressed by 14 bits of a 64-bit hash give a 0.81% standard error in about 12 KB, and merging takes the maximum of each register.
Why here
The lesson's HyperLogLog at precision 14: fixed memory, a known error, and a register-wise max merge so daily sketches combine into any range.
taught here: HyperLogLog
GooglePaper · ~35 min

HyperLogLog in Practice: Algorithmic Engineering of a State of The Art Cardinality Estimation Algorithm

Stefan Heule, Marc Nunkesser, Alex Hall · 2013
What
Google's EDBT 2013 paper on the HyperLogLog++ changes, built and evaluated inside Google's infrastructure.
Key idea
A 64-bit hash, empirical bias correction and a sparse representation cut memory and make small and mid-range counts far more accurate, still in one parallel pass.
Why here
The lesson's HLL++ section and the BigQuery HLL_COUNT sketches: why small slices stay nearly exact and big ones need no large-range correction.
taught here: HyperLogLog++
AkamaiPaper · ~30 min

Algorithmic Nuggets in Content Delivery

Bruce M. Maggs, Ramesh K. Sitaraman · 2015
What
A tour of the algorithms inside Akamai's content delivery network, from load balancing to cache filling, by two of its builders.
Key idea
74% of ~400 million cached objects were requested once; caching only on the second request, remembered in a Bloom filter, cut disk writes by 44% and raised the byte hit rate.
Why here
The lesson's Bloom filter used where a false yes is cheap: an occasional one-hit object gets cached anyway, and no data is lost.
taught here: Bloom filter

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…