Bloom filters and sketches
The problem it solves
Section titled “The problem it solves”A web crawler has found 10 billion URLs. It must check every newly found link against them: “have we seen this one?” As strings, the set is about 800 GB. Even as 8 byte fingerprints in a hash table, it’s about 160 GB with overhead. That’s too much for one machine’s RAM. So each of tens of thousands of link checks a second becomes a disk read or a network call.
The same wall shows up for three questions that every large system asks:
| Question | Exact answer costs | Probabilistic answer costs | And gives up |
|---|---|---|---|
| Have we seen this URL? (10 billion URLs) | 160 GB of fingerprints, 800 GB of strings | 12 GB Bloom filter | 1% of new URLs wrongly say “seen” |
| How many distinct visitors today? (200 million) | 3.2 GB of 16 byte IDs, several times that in a set | 12 KB HyperLogLog | About ±0.8% on the count |
| How often did each item appear? (1 billion events) | A counter per distinct item: GBs for 100 million keys | 5.4 MB Count-Min | Any count may be up to 10,000 too high |
These structures answer a narrower question than a set or a map, with a small error of known bound. Memory drops by 10 times or more for membership and by over 100,000 times for distinct counts. Three more properties make them fit distributed systems:
- Fixed size. Memory is chosen up front from the error you can accept, not from how much data arrives.
- Mergeable. Sketches built on different shards, regions or hours combine into the sketch of the whole, by OR, addition or max.
- One-sided or bounded errors. A Bloom filter never says “no” to an item it holds. A Count-Min Sketch never counts low. The error has a direction you can design around.
The cost: you cannot list the items, and you usually cannot delete. And the error is a probability, so some answers are wrong by more than the typical figure.
How it works
Section titled “How it works”Step 1: One hash, one bit array
Section titled “Step 1: One hash, one bit array”Start with the simplest membership structure that is not a set: an array of m bits, all zero, and one hash function. To add an item, hash it to a position and set that bit. To check an item, look at its bit. If the bit is 0, the item was never added, guaranteed. If it is 1, either this item set it or some other item hashed to the same place.
With n items in m bits, a fraction of about 1 − e^(−n/m) of the bits is set, and that is the
false-positive rate. At 10 bits per item it is 9.5%. Getting to 1% with one hash needs about 100
bits per item, worse than storing a 64 bit fingerprint. One hash is too weak.
Step 2: k hashes, the Bloom filter
Section titled “Step 2: k hashes, the Bloom filter”Burton Bloom’s 1970 idea: use k hash functions. Adding an item sets k bits. A check reports “maybe present” only if all k bits are set, and “definitely not present” if any is 0. A stranger now has to hit k set bits by chance, not one.
- Color key
- Service
- Failure
More hashes cut both ways. Each one adds a bit a stranger must find set, but each also sets more bits per item, so the array fills faster. With n items, m bits and k hashes:
- Fraction of bits set:
1 − e^(−kn/m) - False-positive rate:
p ≈ (1 − e^(−kn/m))^k - The best k is
(m/n) ln 2, about 0.7 times the bits per item. At that k, half the bits are set when the filter holds its design capacity. - Solving for m:
m = −n ln p / (ln 2)², which is 1.44 × log₂(1/p) bits per item.
| Target false positives | Bits per item | Best k | 1 billion items |
|---|---|---|---|
| 10% | 4.8 | 3 | 0.6 GB |
| 1% | 9.6 | 7 | 1.2 GB |
| 0.1% | 14.4 | 10 | 1.8 GB |
| 0.01% | 19.2 | 13 | 2.4 GB |
Each 10 times fewer false positives costs a constant 4.8 more bits per item. And the size depends only on n and p, not on item size: 10 billion URLs cost the same as 10 billion user IDs.
Where the k hashes come from. Computing seven independent hashes per lookup is wasteful. Kirsch
and Mitzenmacher showed in 2006 that two are enough. Take one 64 bit hash, and split it into halves
h1 and h2. Then use positions h1 + i × h2 mod m, for i from 0 to k − 1. The false-positive rate
is asymptotically the same as with k independent hashes. Guava and most libraries do this.
Try it. Add 10 random items a few times at m = 256 and k = 3, and watch the predicted and measured false-positive rates climb together. Then drag k to 10 and see the filter fill faster, or check a word you never added until you find a false positive. Last, delete an item and count the damage.
set probed by the last add or check
- Items added, n
- 0
- Bits per item, m/n
- -
- Bits set
- 0%
- Best k for this m and n
- -
- False positives, predicted vs measured on 2,000 strangers
- 0% vs 0%
- False negatives among added items
- 0
An empty filter: m bits, all zero. Add a word, or add 10 random items a few times and watch the predicted and measured false-positive rates climb together.
Error of the estimate (%) against true distinct count, log scale. Shaded: ±1 and ±2 standard errors.
Each bar is one register: the longest run of leading zeros (+1) seen among the hashes routed to it.
- True distinct
- 0
- Estimate
- 0
- Error
- -
- Standard error, 1.04/√m
- 6.5%
- Memory: sketch vs exact set of 8 byte IDs
- 192 B vs 0 B
256 registers of 6 bits: 192 bytes. Stream visitors and watch the estimate track the true count.
The code sizes the filter from n and p, derives k, and runs it to twice its design capacity. Its hash is FNV-1a plus a 64 bit mixer, short enough to write the same way in five languages. Production filters use MurmurHash3 or xxHash.
import java.nio.charset.StandardCharsets;import java.util.BitSet;
public class Main { // The splitmix64 finalizer: spreads every input bit over all 64 output bits. Every filter // and sketch assumes its hash outputs look uniformly random; a weak hash breaks the math. static long mix64(long z) { z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; return z ^ (z >>> 31); }
// FNV-1a over the UTF-8 bytes, then mixed. Production code uses MurmurHash3 or xxHash; this // pair is short enough to write identically in every language, so all clients agree on bits. // (Never String.hashCode(): 32 bits, poorly mixed, and Java-only.) static long hash64(String s) { long h = 0xCBF29CE484222325L; for (byte b : s.getBytes(StandardCharsets.UTF_8)) { h = (h ^ (b & 0xFF)) * 0x100000001B3L; } return mix64(h); }
static final class BloomFilter { final long m; final int k; final BitSet bits;
BloomFilter(int expectedItems, double fpRate) { // Size from the two numbers you actually know: how many items, and how wrong you can be. // m = -n ln p / (ln 2)^2 bits, about 9.6 bits per item at 1%. m = (long) Math.ceil(-expectedItems * Math.log(fpRate) / (Math.log(2) * Math.log(2))); // k = (m / n) ln 2 hashes leaves about half the bits set at capacity, which minimizes // false positives. More hashes is not better: each one also fills the filter faster. k = (int) Math.max(1, Math.round((double) m / expectedItems * Math.log(2))); bits = new BitSet((int) m); }
// Double hashing (Kirsch and Mitzenmacher): k positions from one 64 bit hash, split into // two 32 bit halves. As good as k independent hashes for a Bloom filter, and k times cheaper. long[] positions(String item) { long h = hash64(item); long h1 = h & 0xFFFFFFFFL, h2 = h >>> 32; long[] out = new long[k]; for (int i = 0; i < k; i++) out[i] = (h1 + i * h2) % m; return out; }
void add(String item) { for (long p : positions(item)) bits.set((int) p); }
// False is certain: some bit the item would have set is clear. True only means "maybe". boolean mightContain(String item) { for (long p : positions(item)) if (!bits.get((int) p)) return false; return true; } }
// Chance that all k probed bits were set by the n items already added. static double predictedFp(long m, int k, int n) { return Math.pow(1 - Math.exp(-(double) k * n / m), k); }
static double measured(BloomFilter bf, int n) { int hits = 0; for (int i = 0; i < n; i++) if (bf.mightContain("stranger:" + i)) hits++; return 100.0 * hits / n; }
public static void main(String[] args) { int n = 100_000; BloomFilter bf = new BloomFilter(n, 0.01); System.out.printf("m = %d bits (%.0f KB), k = %d%n", bf.m, bf.m / 8.0 / 1024, bf.k); // m = 958506 bits (117 KB), k = 7 for (int i = 0; i < n; i++) bf.add("user:" + i); // Never a false negative: every added item is still reported. boolean all = true; for (int i = 0; i < n; i++) all &= bf.mightContain("user:" + i); System.out.println(all); // true
System.out.printf("at capacity: measured %.2f%%, predicted %.2f%%%n", measured(bf, n), 100 * predictedFp(bf.m, bf.k, n)); // at capacity: measured 1.00%, predicted 1.00%
// Twice the design capacity: nothing breaks loudly, the filter just says "maybe" far more often. for (int i = n; i < 2 * n; i++) bf.add("user:" + i); System.out.printf("at 2x capacity: measured %.2f%%, predicted %.2f%%%n", measured(bf, n), 100 * predictedFp(bf.m, bf.k, 2 * n)); // at 2x capacity: measured 15.79%, predicted 15.75% }}import math
MASK64 = (1 << 64) - 1 # Python ints never overflow, so wrap to 64 bits by hand
def mix64(z: int) -> int: # The splitmix64 finalizer: spreads every input bit over all 64 output bits. Every filter # and sketch assumes its hash outputs look uniformly random; a weak hash breaks the math. z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK64 z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK64 return z ^ (z >> 31)
def hash64(s: str) -> int: # FNV-1a over the UTF-8 bytes, then mixed. Production code uses MurmurHash3 or xxHash; this # pair is short enough to write identically in every language, so all clients agree on bits. h = 0xCBF29CE484222325 for b in s.encode("utf-8"): h = ((h ^ b) * 0x100000001B3) & MASK64 return mix64(h)
class BloomFilter: def __init__(self, expected_items: int, fp_rate: float): # Size from the two numbers you actually know: how many items, and how wrong you can be. # m = -n ln p / (ln 2)^2 bits, about 9.6 bits per item at 1%. self.m = math.ceil(-expected_items * math.log(fp_rate) / math.log(2) ** 2) # k = (m / n) ln 2 hashes leaves about half the bits set at capacity, which minimizes # false positives. More hashes is not better: each one also fills the filter faster. self.k = max(1, round(self.m / expected_items * math.log(2))) self.bits = bytearray((self.m + 7) // 8)
def _positions(self, item: str): # Double hashing (Kirsch and Mitzenmacher): k positions from one 64 bit hash, split into # two 32 bit halves. As good as k independent hashes for a Bloom filter, and k times cheaper. h = hash64(item) h1, h2 = h & 0xFFFFFFFF, h >> 32 for i in range(self.k): yield (h1 + i * h2) % self.m
def add(self, item: str) -> None: for p in self._positions(item): self.bits[p >> 3] |= 1 << (p & 7)
def might_contain(self, item: str) -> bool: # False is certain: some bit the item would have set is clear. True only means "maybe". return all(self.bits[p >> 3] & (1 << (p & 7)) for p in self._positions(item))
def predicted_fp(m: int, k: int, n: int) -> float: # Chance that all k probed bits were set by the n items already added. return (1 - math.exp(-k * n / m)) ** k
if __name__ == "__main__": n = 100_000 bf = BloomFilter(n, 0.01) print(f"m = {bf.m} bits ({bf.m / 8 / 1024:.0f} KB), k = {bf.k}") # m = 958506 bits (117 KB), k = 7 for i in range(n): bf.add(f"user:{i}") # Never a false negative: every added item is still reported. print(all(bf.might_contain(f"user:{i}") for i in range(n))) # True
def measured() -> float: return sum(bf.might_contain(f"stranger:{i}") for i in range(n)) / n
print(f"at capacity: measured {measured():.2%}, predicted {predicted_fp(bf.m, bf.k, n):.2%}") # at capacity: measured 1.00%, predicted 1.00%
# Twice the design capacity: nothing breaks loudly, the filter just says "maybe" far more often. for i in range(n, 2 * n): bf.add(f"user:{i}") print(f"at 2x capacity: measured {measured():.2%}, predicted {predicted_fp(bf.m, bf.k, 2 * n):.2%}") # at 2x capacity: measured 15.79%, predicted 15.75%using System;using System.Collections;using System.Text;
public static class Hashing{ // The splitmix64 finalizer: spreads every input bit over all 64 output bits. Every filter // and sketch assumes its hash outputs look uniformly random; a weak hash breaks the math. public static ulong Mix64(ulong z) { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; return z ^ (z >> 31); }
// FNV-1a over the UTF-8 bytes, then mixed. Production code uses MurmurHash3 or xxHash; this // pair is short enough to write identically in every language, so all clients agree on bits. // (Never string.GetHashCode(): it is randomized per process in .NET.) public static ulong Hash64(string s) { ulong h = 0xCBF29CE484222325UL; foreach (byte b in Encoding.UTF8.GetBytes(s)) h = (h ^ b) * 0x100000001B3UL; return Mix64(h); }}
public sealed class BloomFilter{ public long M { get; } public int K { get; } private readonly BitArray bits;
public BloomFilter(int expectedItems, double fpRate) { // Size from the two numbers you actually know: how many items, and how wrong you can be. // m = -n ln p / (ln 2)^2 bits, about 9.6 bits per item at 1%. M = (long)Math.Ceiling(-expectedItems * Math.Log(fpRate) / (Math.Log(2) * Math.Log(2))); // k = (m / n) ln 2 hashes leaves about half the bits set at capacity, which minimizes // false positives. More hashes is not better: each one also fills the filter faster. K = (int)Math.Max(1, Math.Round((double)M / expectedItems * Math.Log(2))); bits = new BitArray((int)M); }
// Double hashing (Kirsch and Mitzenmacher): k positions from one 64 bit hash, split into // two 32 bit halves. As good as k independent hashes for a Bloom filter, and k times cheaper. private int[] Positions(string item) { ulong h = Hashing.Hash64(item); ulong h1 = h & 0xFFFFFFFFUL, h2 = h >> 32; var output = new int[K]; for (int i = 0; i < K; i++) output[i] = (int)((h1 + (ulong)i * h2) % (ulong)M); return output; }
public void Add(string item) { foreach (int p in Positions(item)) bits[p] = true; }
// False is certain: some bit the item would have set is clear. True only means "maybe". public bool MightContain(string item) { foreach (int p in Positions(item)) if (!bits[p]) return false; return true; }
// Chance that all k probed bits were set by the n items already added. public static double PredictedFp(long m, int k, int n) => Math.Pow(1 - Math.Exp(-(double)k * n / m), k);}
public static class Program{ private static double Measured(BloomFilter bf, int n) { int hits = 0; for (int i = 0; i < n; i++) if (bf.MightContain($"stranger:{i}")) hits++; return 100.0 * hits / n; }
public static void Main() { const int n = 100_000; var bf = new BloomFilter(n, 0.01); Console.WriteLine($"m = {bf.M} bits ({bf.M / 8.0 / 1024:F0} KB), k = {bf.K}"); // m = 958506 bits (117 KB), k = 7 for (int i = 0; i < n; i++) bf.Add($"user:{i}"); // Never a false negative: every added item is still reported. bool all = true; for (int i = 0; i < n; i++) all &= bf.MightContain($"user:{i}"); Console.WriteLine(all); // True
Console.WriteLine($"at capacity: measured {Measured(bf, n):F2}%, predicted {100 * BloomFilter.PredictedFp(bf.M, bf.K, n):F2}%"); // at capacity: measured 1.00%, predicted 1.00%
// Twice the design capacity: nothing breaks loudly, the filter just says "maybe" far more often. for (int i = n; i < 2 * n; i++) bf.Add($"user:{i}"); Console.WriteLine($"at 2x capacity: measured {Measured(bf, n):F2}%, predicted {100 * BloomFilter.PredictedFp(bf.M, bf.K, 2 * n):F2}%"); // at 2x capacity: measured 15.79%, predicted 15.75% }}package main
import ( "fmt" "math")
// mix64 is the splitmix64 finalizer: it spreads every input bit over all 64 output bits. Every// filter and sketch assumes its hash outputs look uniformly random; a weak hash breaks the math.func mix64(z uint64) uint64 { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 z = (z ^ (z >> 27)) * 0x94D049BB133111EB return z ^ (z >> 31)}
// hash64 is FNV-1a over the UTF-8 bytes, then mixed. Production code uses MurmurHash3 or xxHash;// this pair is short enough to write identically in every language, so all clients agree on bits.func hash64(s string) uint64 { h := uint64(0xCBF29CE484222325) for _, b := range []byte(s) { h = (h ^ uint64(b)) * 0x100000001B3 } return mix64(h)}
type BloomFilter struct { m, k uint64 bits []uint64}
func NewBloomFilter(expectedItems int, fpRate float64) *BloomFilter { n := float64(expectedItems) // Size from the two numbers you actually know: how many items, and how wrong you can be. // m = -n ln p / (ln 2)^2 bits, about 9.6 bits per item at 1%. m := uint64(math.Ceil(-n * math.Log(fpRate) / (math.Ln2 * math.Ln2))) // k = (m / n) ln 2 hashes leaves about half the bits set at capacity, which minimizes // false positives. More hashes is not better: each one also fills the filter faster. k := uint64(math.Max(1, math.Round(float64(m)/n*math.Ln2))) return &BloomFilter{m: m, k: k, bits: make([]uint64, (m+63)/64)}}
// positions uses double hashing (Kirsch and Mitzenmacher): k positions from one 64 bit hash,// split into two 32 bit halves. As good as k independent hashes for a Bloom filter, and k times// cheaper.func (f *BloomFilter) positions(item string) []uint64 { h := hash64(item) h1, h2 := h&0xFFFFFFFF, h>>32 out := make([]uint64, f.k) for i := uint64(0); i < f.k; i++ { out[i] = (h1 + i*h2) % f.m } return out}
func (f *BloomFilter) Add(item string) { for _, p := range f.positions(item) { f.bits[p/64] |= 1 << (p % 64) }}
// MightContain: false is certain, some bit the item would have set is clear. True only means// "maybe".func (f *BloomFilter) MightContain(item string) bool { for _, p := range f.positions(item) { if f.bits[p/64]&(1<<(p%64)) == 0 { return false } } return true}
// predictedFP is the chance that all k probed bits were set by the n items already added.func predictedFP(m, k uint64, n int) float64 { return math.Pow(1-math.Exp(-float64(k)*float64(n)/float64(m)), float64(k))}
func main() { const n = 100_000 bf := NewBloomFilter(n, 0.01) fmt.Printf("m = %d bits (%.0f KB), k = %d\n", bf.m, float64(bf.m)/8/1024, bf.k) // m = 958506 bits (117 KB), k = 7 for i := 0; i < n; i++ { bf.Add(fmt.Sprintf("user:%d", i)) } // Never a false negative: every added item is still reported. all := true for i := 0; i < n; i++ { all = all && bf.MightContain(fmt.Sprintf("user:%d", i)) } fmt.Println(all) // true
measured := func() float64 { hits := 0 for i := 0; i < n; i++ { if bf.MightContain(fmt.Sprintf("stranger:%d", i)) { hits++ } } return 100 * float64(hits) / n } fmt.Printf("at capacity: measured %.2f%%, predicted %.2f%%\n", measured(), 100*predictedFP(bf.m, bf.k, n)) // at capacity: measured 1.00%, predicted 1.00%
// Twice the design capacity: nothing breaks loudly, the filter just says "maybe" far more often. for i := n; i < 2*n; i++ { bf.Add(fmt.Sprintf("user:%d", i)) } fmt.Printf("at 2x capacity: measured %.2f%%, predicted %.2f%%\n", measured(), 100*predictedFP(bf.m, bf.k, 2*n)) // at 2x capacity: measured 15.79%, predicted 15.75%}const MASK64 = (1n << 64n) - 1n; // BigInt never overflows, so wrap to 64 bits by hand
// The splitmix64 finalizer: spreads every input bit over all 64 output bits. Every filter// and sketch assumes its hash outputs look uniformly random; a weak hash breaks the math.function mix64(z: bigint): bigint { z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & MASK64; z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & MASK64; return z ^ (z >> 31n);}
// FNV-1a over the UTF-8 bytes, then mixed. Production code uses MurmurHash3 or xxHash; this// pair is short enough to write identically in every language, so all clients agree on bits.const utf8 = new TextEncoder();function hash64(s: string): bigint { let h = 0xcbf29ce484222325n; for (const b of utf8.encode(s)) h = ((h ^ BigInt(b)) * 0x100000001b3n) & MASK64; return mix64(h);}
class BloomFilter { readonly m: number; readonly k: number; private readonly bits: Uint8Array;
constructor(expectedItems: number, fpRate: number) { // Size from the two numbers you actually know: how many items, and how wrong you can be. // m = -n ln p / (ln 2)^2 bits, about 9.6 bits per item at 1%. this.m = Math.ceil((-expectedItems * Math.log(fpRate)) / Math.LN2 ** 2); // k = (m / n) ln 2 hashes leaves about half the bits set at capacity, which minimizes // false positives. More hashes is not better: each one also fills the filter faster. this.k = Math.max(1, Math.round((this.m / expectedItems) * Math.LN2)); this.bits = new Uint8Array(Math.ceil(this.m / 8)); }
// Double hashing (Kirsch and Mitzenmacher): k positions from one 64 bit hash, split into // two 32 bit halves. As good as k independent hashes for a Bloom filter, and k times cheaper. private positions(item: string): number[] { const h = hash64(item); const h1 = Number(h & 0xffffffffn); const h2 = Number(h >> 32n); // h1 + i * h2 stays below 2^53, so plain numbers are exact here. return Array.from({ length: this.k }, (_, i) => (h1 + i * h2) % this.m); }
add(item: string): void { for (const p of this.positions(item)) this.bits[p >> 3] |= 1 << (p & 7); }
// False is certain: some bit the item would have set is clear. True only means "maybe". mightContain(item: string): boolean { return this.positions(item).every((p) => (this.bits[p >> 3] & (1 << (p & 7))) !== 0); }}
// Chance that all k probed bits were set by the n items already added.function predictedFp(m: number, k: number, n: number): number { return (1 - Math.exp((-k * n) / m)) ** k;}
const n = 100_000;const bf = new BloomFilter(n, 0.01);console.log(`m = ${bf.m} bits (${(bf.m / 8 / 1024).toFixed(0)} KB), k = ${bf.k}`); // m = 958506 bits (117 KB), k = 7for (let i = 0; i < n; i++) bf.add(`user:${i}`);// Never a false negative: every added item is still reported.let all = true;for (let i = 0; i < n; i++) all &&= bf.mightContain(`user:${i}`);console.log(all); // true
const pct = (x: number) => `${(100 * x).toFixed(2)}%`;function measured(): number { let hits = 0; for (let i = 0; i < n; i++) if (bf.mightContain(`stranger:${i}`)) hits++; return hits / n;}console.log( `at capacity: measured ${pct(measured())}, predicted ${pct(predictedFp(bf.m, bf.k, n))}`,);// at capacity: measured 1.00%, predicted 1.00%
// Twice the design capacity: nothing breaks loudly, the filter just says "maybe" far more often.for (let i = n; i < 2 * n; i++) bf.add(`user:${i}`);console.log( `at 2x capacity: measured ${pct(measured())}, predicted ${pct(predictedFp(bf.m, bf.k, 2 * n))}`,);// at 2x capacity: measured 15.79%, predicted 15.75%
export {};Step 3: Deletes, counting filters and cuckoo filters
Section titled “Step 3: Deletes, counting filters and cuckoo filters”A Bloom filter cannot delete. Clearing an item’s k bits also clears bits that other items share, and those items then test “definitely not present”. That is a false negative, the one error a Bloom filter promises never to make. The demo’s delete button shows it: in a half-full filter, deleting one item usually breaks several others.
Counting Bloom filter. Replace each bit with a small counter, usually 4 bits. Add increments k counters. Delete decrements them. A check asks whether all k are non-zero. It deletes correctly, at 4 times the memory. The catch: deleting an item that was never added decrements counters that belong to others. So it is only safe when you know the item is present.
Cuckoo filter (Fan, Andersen, Kaminsky and Mitzenmacher, 2014). Store a short fingerprint of
each item, say 8 to 16 bits, in a hash table of buckets with 4 slots each. Each item has two
candidate buckets: i1 = hash(x) and i2 = i1 XOR hash(fingerprint).
The XOR trick means the alternate bucket can be computed from the fingerprint alone. So when both buckets are full, inserting kicks an existing fingerprint to its other bucket. That may kick another, cuckoo style. Lookup reads two buckets. Delete removes one copy of the fingerprint.
| Filter | Delete | Bits per item at 1% | Lookup cost | Watch out for |
|---|---|---|---|---|
| Bloom | No | 9.6 | k = 7 random bit reads | Cannot remove, cannot grow |
| Counting Bloom (4 bit) | Yes | 38 | k counter reads | 4 times the memory, deleting absent items corrupts |
| Cuckoo (4 per slot) | Yes | About 9 to 11 | 2 bucket reads | Inserts fail near 95% full, at most 8 duplicates |
| Rebuild periodically | Yes, by not copying | 9.6 | Same as Bloom | Needs the source of truth and a rebuild job |
Below a few percent, cuckoo filters are also smaller than Bloom filters. Each extra fingerprint bit halves the false-positive rate. A Bloom filter pays 1.44 bits for the same halving. Their hard failure is the insert. Near 95% occupancy, it can run out of its kick limit (500 moves in the reference code), and fail. An overfilled Bloom filter degrades. A full cuckoo filter refuses.
Step 4: Counting how often, the Count-Min Sketch
Section titled “Step 4: Counting how often, the Count-Min Sketch”The next question is “how many times?”: requests per client, views per video, searches per term. An exact map needs a counter per distinct key, and the long tail of keys seen once is most of it.
The Count-Min Sketch (Cormode and Muthukrishnan, 2005) is d rows of w counters, each row with its own hash. Adding an item increments one counter per row, in the column its row’s hash picks. Estimating an item reads those d counters and returns the minimum.
- Color key
- Service
Every counter holds the item’s own count plus whatever else hashed there, so each is an overestimate, and the smallest is the tightest. The guarantee, with N the total of all counts:
width w = ⌈e / ε⌉keeps the overcount at most ε × Ndepth d = ⌈ln(1/δ)⌉makes that hold with probability 1 − δ for each query- The estimate is never below the true count, as long as counts only go up.
| ε (error as a share of N) | δ | Width × depth | Memory, 4 byte counters | At N = 1 billion, overcount at most |
|---|---|---|---|---|
| 1% | 1% | 272 × 5 | 5.4 KB | 10 million |
| 0.1% | 1% | 2,719 × 5 | 54 KB | 1 million |
| 0.001% | 1% | 271,829 × 5 | 5.4 MB | 10,000 |
Read the last column carefully. The error is a share of the whole stream, not of the item. An overcount of 10,000 is 0.02% of an item with 50 million views. It’s 100,000% of an item with 10. A Count-Min Sketch is accurate for heavy items, and meaningless for light ones. That’s exactly what a top list or a hot-key detector needs, and exactly what billing does not.
Heavy hitters need a heap beside it. The sketch stores no item names, so it cannot list the top 100. So each update also offers the item’s new estimate to a min-heap of the top k candidates. An item already there is updated. A new one replaces the minimum, if its estimate is higher.
Conservative update (Estan and Varghese, 2002). When adding c to an item, compute the new
estimate min + c. Raise each of its d counters only up to that value, never past it. Counters that
were already higher, inflated by other items, are left alone. The estimate is still never below the
truth. And overcounts on light items typically fall by half or more. The price: counters no longer
hold plain sums, so the sketch cannot be subtracted. That breaks sliding windows built by
subtracting the oldest minute.
import java.nio.charset.StandardCharsets;import java.util.ArrayList;import java.util.HashMap;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;
public class Main { // The splitmix64 finalizer: spreads every input bit over all 64 output bits. static long mix64(long z) { z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; return z ^ (z >>> 31); }
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. static long hash64(String s) { long h = 0xCBF29CE484222325L; for (byte b : s.getBytes(StandardCharsets.UTF_8)) { h = (h ^ (b & 0xFF)) * 0x100000001B3L; } return mix64(h); }
static final class CountMinSketch { final int width, depth; final long[][] rows; final boolean conservative; long total; // N in the error bound: every count ever added
CountMinSketch(double epsilon, double delta, boolean conservative) { // width = e / epsilon bounds the overcount at epsilon x (total of all counts); // depth = ln(1 / delta) rows make that bound hold with probability 1 - delta. width = (int) Math.ceil(Math.E / epsilon); depth = (int) Math.ceil(Math.log(1 / delta)); rows = new long[depth][width]; this.conservative = conservative; }
// One column per row. Each row needs its own hash, so remix the item's hash with a // different constant per row instead of hashing the string d times. int[] columns(String item) { long h = hash64(item); int[] cols = new int[depth]; for (int r = 0; r < depth; r++) { cols[r] = (int) Long.remainderUnsigned(mix64(h + (r + 1) * 0x9E3779B97F4A7C15L), width); } return cols; }
long minOf(int[] cols) { long m = Long.MAX_VALUE; for (int r = 0; r < depth; r++) m = Math.min(m, rows[r][cols[r]]); return m; }
long add(String item, long count) { int[] cols = columns(item); total += count; if (conservative) { // Conservative update: raise each counter only as far as the new estimate needs. // Overcounts shrink a lot, but the sketch can no longer be subtracted. long est = minOf(cols) + count; for (int r = 0; r < depth; r++) rows[r][cols[r]] = Math.max(rows[r][cols[r]], est); return est; } for (int r = 0; r < depth; r++) rows[r][cols[r]] += count; return minOf(cols); }
// Every counter holds the item's count plus whatever collided with it, so the smallest // is the best guess. Never below the true count, often above it. long estimate(String item) { return minOf(columns(item)); } }
// The sketch answers "how many for this item?" but cannot list items. A small candidate set // beside it remembers which items looked heaviest when they arrived. Production code keeps // a min-heap; for k = 10 a scan for the smallest is just as fast and easier to read. static final class TopK { final int k; final CountMinSketch sketch; final Map<String, Long> top = new HashMap<>();
TopK(int k, CountMinSketch sketch) { this.k = k; this.sketch = sketch; }
void add(String item, long count) { long est = sketch.add(item, count); if (top.containsKey(item) || top.size() < k) { top.put(item, est); return; } String smallest = null; for (var e : top.entrySet()) if (smallest == null || e.getValue() < top.get(smallest)) smallest = e.getKey(); if (est > top.get(smallest)) { top.remove(smallest); top.put(item, est); } }
List<Map.Entry<String, Long>> items() { var out = new ArrayList<>(top.entrySet()); out.sort((a, b) -> Long.compare(b.getValue(), a.getValue())); return out; } }
public static void main(String[] args) { // A skewed stream: item:i appears 10,000 / i times, 2,000 distinct items, interleaved in // 10 rounds so heavy and light items arrive mixed together. Map<String, Long> truth = new LinkedHashMap<>(); for (int i = 1; i <= 2000; i++) truth.put("item:" + i, (long) (10_000 / i)); CountMinSketch plain = new CountMinSketch(0.005, 0.01, false); // 544 x 5 counters CountMinSketch careful = new CountMinSketch(0.005, 0.01, true); TopK top = new TopK(10, plain); for (int rnd = 0; rnd < 10; rnd++) { for (var e : truth.entrySet()) { long c = e.getValue(); long part = c / 10 + (rnd < c % 10 ? 1 : 0); if (part > 0) { top.add(e.getKey(), part); careful.add(e.getKey(), part); } } }
long n = plain.total; System.out.printf("N = %d, bound = %.0f%n", n, 0.005 * n); // N = 80835, bound = 404 for (var e : top.items().subList(0, 3)) { System.out.println(e.getKey() + " " + e.getValue() + " " + truth.get(e.getKey())); // item:1 10025 10000, item:2 5015 5000, item:3 3344 3333 } System.out.printf("tail overcount: plain %.0f, conservative %.0f%n", avgOver(plain, truth), avgOver(careful, truth)); // tail overcount: plain 18, conservative 9, on true counts of 5 to 9. Light items suffer most. }
static double avgOver(CountMinSketch s, Map<String, Long> truth) { long sum = 0; for (int i = 1001; i <= 2000; i++) sum += s.estimate("item:" + i) - truth.get("item:" + i); // true counts 5 to 9 return sum / 1000.0; }}import math
MASK64 = (1 << 64) - 1 # Python ints never overflow, so wrap to 64 bits by hand
def mix64(z: int) -> int: # The splitmix64 finalizer: spreads every input bit over all 64 output bits. z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK64 z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK64 return z ^ (z >> 31)
def hash64(s: str) -> int: # FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. h = 0xCBF29CE484222325 for b in s.encode("utf-8"): h = ((h ^ b) * 0x100000001B3) & MASK64 return mix64(h)
class CountMinSketch: def __init__(self, epsilon: float, delta: float, conservative: bool = False): # width = e / epsilon bounds the overcount at epsilon x (total of all counts); # depth = ln(1 / delta) rows make that bound hold with probability 1 - delta. self.width = math.ceil(math.e / epsilon) self.depth = math.ceil(math.log(1 / delta)) self.rows = [[0] * self.width for _ in range(self.depth)] self.conservative = conservative self.total = 0 # N in the error bound: every count ever added
def _columns(self, item: str) -> list[int]: # One column per row. Each row needs its own hash, so remix the item's hash with a # different constant per row instead of hashing the string d times. h = hash64(item) return [ mix64((h + (r + 1) * 0x9E3779B97F4A7C15) & MASK64) % self.width for r in range(self.depth) ]
def add(self, item: str, count: int = 1) -> int: cols = self._columns(item) self.total += count if self.conservative: # Conservative update: raise each counter only as far as the new estimate needs. # Overcounts shrink a lot, but the sketch can no longer be subtracted. est = min(self.rows[r][c] for r, c in enumerate(cols)) + count for r, c in enumerate(cols): self.rows[r][c] = max(self.rows[r][c], est) return est for r, c in enumerate(cols): self.rows[r][c] += count return min(self.rows[r][c] for r, c in enumerate(cols))
def estimate(self, item: str) -> int: # Every counter holds the item's count plus whatever collided with it, so the smallest # is the best guess. Never below the true count, often above it. return min(self.rows[r][c] for r, c in enumerate(self._columns(item)))
class TopK: # The sketch answers "how many for this item?" but cannot list items. A small candidate set # beside it remembers which items looked heaviest when they arrived. Production code keeps # a min-heap; for k = 10 a scan for the smallest is just as fast and easier to read. def __init__(self, k: int, sketch: CountMinSketch): self.k, self.sketch, self.top = k, sketch, {}
def add(self, item: str, count: int = 1) -> None: est = self.sketch.add(item, count) if item in self.top or len(self.top) < self.k: self.top[item] = est return smallest = min(self.top, key=self.top.get) if est > self.top[smallest]: del self.top[smallest] self.top[item] = est
def items(self) -> list[tuple[str, int]]: return sorted(self.top.items(), key=lambda kv: -kv[1])
if __name__ == "__main__": # A skewed stream: item:i appears 10,000 / i times, 2,000 distinct items, interleaved in # 10 rounds so heavy and light items arrive mixed together. true = {f"item:{i}": 10_000 // i for i in range(1, 2001)} plain = CountMinSketch(0.005, 0.01) # 544 x 5 counters careful = CountMinSketch(0.005, 0.01, conservative=True) top = TopK(10, plain) for rnd in range(10): for item, c in true.items(): part = c // 10 + (1 if rnd < c % 10 else 0) if part: top.add(item, part) careful.add(item, part)
n = plain.total print(f"N = {n}, bound = {0.005 * n:.0f}") # N = 80835, bound = 404 for item, est in top.items()[:3]: print(item, est, true[item]) # item:1 10025 10000, item:2 5015 5000, item:3 3344 3333 tail = [f"item:{i}" for i in range(1001, 2001)] # true counts 5 to 9
def avg_over(s: CountMinSketch) -> float: return sum(s.estimate(x) - true[x] for x in tail) / len(tail)
print(f"tail overcount: plain {avg_over(plain):.0f}, conservative {avg_over(careful):.0f}") # tail overcount: plain 18, conservative 9, on true counts of 5 to 9. Light items suffer most.using System;using System.Collections.Generic;using System.Linq;using System.Text;
public static class Hashing{ // The splitmix64 finalizer: spreads every input bit over all 64 output bits. public static ulong Mix64(ulong z) { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; return z ^ (z >> 31); }
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. public static ulong Hash64(string s) { ulong h = 0xCBF29CE484222325UL; foreach (byte b in Encoding.UTF8.GetBytes(s)) h = (h ^ b) * 0x100000001B3UL; return Mix64(h); }}
public sealed class CountMinSketch{ public int Width { get; } public int Depth { get; } public long Total { get; private set; } // N in the error bound: every count ever added private readonly long[][] rows; private readonly bool conservative;
public CountMinSketch(double epsilon, double delta, bool conservative = false) { // width = e / epsilon bounds the overcount at epsilon x (total of all counts); // depth = ln(1 / delta) rows make that bound hold with probability 1 - delta. Width = (int)Math.Ceiling(Math.E / epsilon); Depth = (int)Math.Ceiling(Math.Log(1 / delta)); rows = Enumerable.Range(0, Depth).Select(_ => new long[Width]).ToArray(); this.conservative = conservative; }
// One column per row. Each row needs its own hash, so remix the item's hash with a // different constant per row instead of hashing the string d times. private int[] Columns(string item) { ulong h = Hashing.Hash64(item); var cols = new int[Depth]; for (int r = 0; r < Depth; r++) cols[r] = (int)(Hashing.Mix64(h + (ulong)(r + 1) * 0x9E3779B97F4A7C15UL) % (ulong)Width); return cols; }
private long MinOf(int[] cols) { long m = long.MaxValue; for (int r = 0; r < Depth; r++) m = Math.Min(m, rows[r][cols[r]]); return m; }
public long Add(string item, long count = 1) { var cols = Columns(item); Total += count; if (conservative) { // Conservative update: raise each counter only as far as the new estimate needs. // Overcounts shrink a lot, but the sketch can no longer be subtracted. long est = MinOf(cols) + count; for (int r = 0; r < Depth; r++) rows[r][cols[r]] = Math.Max(rows[r][cols[r]], est); return est; } for (int r = 0; r < Depth; r++) rows[r][cols[r]] += count; return MinOf(cols); }
// Every counter holds the item's count plus whatever collided with it, so the smallest // is the best guess. Never below the true count, often above it. public long Estimate(string item) => MinOf(Columns(item));}
// The sketch answers "how many for this item?" but cannot list items. A small candidate set// beside it remembers which items looked heaviest when they arrived. Production code keeps// a min-heap; for k = 10 a scan for the smallest is just as fast and easier to read.public sealed class TopK{ private readonly int k; private readonly CountMinSketch sketch; private readonly Dictionary<string, long> top = new();
public TopK(int k, CountMinSketch sketch) { this.k = k; this.sketch = sketch; }
public void Add(string item, long count = 1) { long est = sketch.Add(item, count); if (top.ContainsKey(item) || top.Count < k) { top[item] = est; return; } var smallest = top.MinBy(e => e.Value); if (est > smallest.Value) { top.Remove(smallest.Key); top[item] = est; } }
public List<KeyValuePair<string, long>> Items() => top.OrderByDescending(e => e.Value).ToList();}
public static class Program{ public static void Main() { // A skewed stream: item:i appears 10,000 / i times, 2,000 distinct items, interleaved in // 10 rounds so heavy and light items arrive mixed together. var truth = new Dictionary<string, long>(); for (int i = 1; i <= 2000; i++) truth[$"item:{i}"] = 10_000 / i; var plain = new CountMinSketch(0.005, 0.01); // 544 x 5 counters var careful = new CountMinSketch(0.005, 0.01, conservative: true); var top = new TopK(10, plain); for (int rnd = 0; rnd < 10; rnd++) { for (int i = 1; i <= 2000; i++) { string item = $"item:{i}"; long c = truth[item]; long part = c / 10 + (rnd < c % 10 ? 1 : 0); if (part > 0) { top.Add(item, part); careful.Add(item, part); } } }
long n = plain.Total; Console.WriteLine($"N = {n}, bound = {0.005 * n:F0}"); // N = 80835, bound = 404 foreach (var e in top.Items().Take(3)) Console.WriteLine($"{e.Key} {e.Value} {truth[e.Key]}"); // item:1 10025 10000, item:2 5015 5000, item:3 3344 3333
var tail = Enumerable.Range(1001, 1000).Select(i => $"item:{i}").ToList(); // true counts 5 to 9 double AvgOver(CountMinSketch s) => tail.Average(x => (double)(s.Estimate(x) - truth[x])); Console.WriteLine($"tail overcount: plain {AvgOver(plain):F0}, conservative {AvgOver(careful):F0}"); // tail overcount: plain 18, conservative 9, on true counts of 5 to 9. Light items suffer most. }}package main
import ( "fmt" "math" "sort")
// mix64 is the splitmix64 finalizer: it spreads every input bit over all 64 output bits.func mix64(z uint64) uint64 { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 z = (z ^ (z >> 27)) * 0x94D049BB133111EB return z ^ (z >> 31)}
// hash64 is FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language.func hash64(s string) uint64 { h := uint64(0xCBF29CE484222325) for _, b := range []byte(s) { h = (h ^ uint64(b)) * 0x100000001B3 } return mix64(h)}
type CountMinSketch struct { width, depth int rows [][]int64 conservative bool Total int64 // N in the error bound: every count ever added}
func NewCountMinSketch(epsilon, delta float64, conservative bool) *CountMinSketch { // width = e / epsilon bounds the overcount at epsilon x (total of all counts); // depth = ln(1 / delta) rows make that bound hold with probability 1 - delta. w := int(math.Ceil(math.E / epsilon)) d := int(math.Ceil(math.Log(1 / delta))) rows := make([][]int64, d) for r := range rows { rows[r] = make([]int64, w) } return &CountMinSketch{width: w, depth: d, rows: rows, conservative: conservative}}
// columns gives one column per row. Each row needs its own hash, so remix the item's hash with a// different constant per row instead of hashing the string d times.func (s *CountMinSketch) columns(item string) []int { h := hash64(item) cols := make([]int, s.depth) for r := range cols { cols[r] = int(mix64(h+uint64(r+1)*0x9E3779B97F4A7C15) % uint64(s.width)) } return cols}
func (s *CountMinSketch) minOf(cols []int) int64 { m := int64(math.MaxInt64) for r, c := range cols { m = min(m, s.rows[r][c]) } return m}
func (s *CountMinSketch) Add(item string, count int64) int64 { cols := s.columns(item) s.Total += count if s.conservative { // Conservative update: raise each counter only as far as the new estimate needs. // Overcounts shrink a lot, but the sketch can no longer be subtracted. est := s.minOf(cols) + count for r, c := range cols { s.rows[r][c] = max(s.rows[r][c], est) } return est } for r, c := range cols { s.rows[r][c] += count } return s.minOf(cols)}
// Estimate: every counter holds the item's count plus whatever collided with it, so the smallest// is the best guess. Never below the true count, often above it.func (s *CountMinSketch) Estimate(item string) int64 { return s.minOf(s.columns(item)) }
// TopK: the sketch answers "how many for this item?" but cannot list items. A small candidate set// beside it remembers which items looked heaviest when they arrived. Production code keeps a// min-heap; for k = 10 a scan for the smallest is just as fast and easier to read.type TopK struct { k int sketch *CountMinSketch top map[string]int64}
func (t *TopK) Add(item string, count int64) { est := t.sketch.Add(item, count) if _, ok := t.top[item]; ok || len(t.top) < t.k { t.top[item] = est return } smallest, low := "", int64(math.MaxInt64) for it, v := range t.top { if v < low { smallest, low = it, v } } if est > low { delete(t.top, smallest) t.top[item] = est }}
type entry struct { item string est int64}
func (t *TopK) Items() []entry { out := make([]entry, 0, len(t.top)) for it, v := range t.top { out = append(out, entry{it, v}) } sort.Slice(out, func(i, j int) bool { return out[i].est > out[j].est }) return out}
func main() { // A skewed stream: item:i appears 10,000 / i times, 2,000 distinct items, interleaved in // 10 rounds so heavy and light items arrive mixed together. truth := map[string]int64{} for i := 1; i <= 2000; i++ { truth[fmt.Sprintf("item:%d", i)] = int64(10_000 / i) } plain := NewCountMinSketch(0.005, 0.01, false) // 544 x 5 counters careful := NewCountMinSketch(0.005, 0.01, true) top := &TopK{k: 10, sketch: plain, top: map[string]int64{}} for rnd := int64(0); rnd < 10; rnd++ { for i := 1; i <= 2000; i++ { item := fmt.Sprintf("item:%d", i) c := truth[item] part := c / 10 if rnd < c%10 { part++ } if part > 0 { top.Add(item, part) careful.Add(item, part) } } }
n := plain.Total fmt.Printf("N = %d, bound = %.0f\n", n, 0.005*float64(n)) // N = 80835, bound = 404 for _, e := range top.Items()[:3] { fmt.Println(e.item, e.est, truth[e.item]) // item:1 10025 10000, item:2 5015 5000, item:3 3344 3333 } avgOver := func(s *CountMinSketch) float64 { sum := int64(0) for i := 1001; i <= 2000; i++ { // true counts 5 to 9 item := fmt.Sprintf("item:%d", i) sum += s.Estimate(item) - truth[item] } return float64(sum) / 1000 } fmt.Printf("tail overcount: plain %.0f, conservative %.0f\n", avgOver(plain), avgOver(careful)) // tail overcount: plain 18, conservative 9, on true counts of 5 to 9. Light items suffer most.}const MASK64 = (1n << 64n) - 1n; // BigInt never overflows, so wrap to 64 bits by hand
// The splitmix64 finalizer: spreads every input bit over all 64 output bits.function mix64(z: bigint): bigint { z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & MASK64; z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & MASK64; return z ^ (z >> 31n);}
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language.const utf8 = new TextEncoder();function hash64(s: string): bigint { let h = 0xcbf29ce484222325n; for (const b of utf8.encode(s)) h = ((h ^ BigInt(b)) * 0x100000001b3n) & MASK64; return mix64(h);}
class CountMinSketch { readonly width: number; readonly depth: number; private readonly rows: number[][]; total = 0; // N in the error bound: every count ever added
constructor( epsilon: number, delta: number, private readonly conservative = false, ) { // width = e / epsilon bounds the overcount at epsilon x (total of all counts); // depth = ln(1 / delta) rows make that bound hold with probability 1 - delta. this.width = Math.ceil(Math.E / epsilon); this.depth = Math.ceil(Math.log(1 / delta)); this.rows = Array.from({ length: this.depth }, () => new Array<number>(this.width).fill(0)); }
// One column per row. Each row needs its own hash, so remix the item's hash with a // different constant per row instead of hashing the string d times. private columns(item: string): number[] { const h = hash64(item); const w = BigInt(this.width); return Array.from({ length: this.depth }, (_, r) => Number(mix64((h + BigInt(r + 1) * 0x9e3779b97f4a7c15n) & MASK64) % w), ); }
private minOf(cols: number[]): number { return Math.min(...cols.map((c, r) => this.rows[r][c])); }
add(item: string, count = 1): number { const cols = this.columns(item); this.total += count; if (this.conservative) { // Conservative update: raise each counter only as far as the new estimate needs. // Overcounts shrink a lot, but the sketch can no longer be subtracted. const est = this.minOf(cols) + count; cols.forEach((c, r) => (this.rows[r][c] = Math.max(this.rows[r][c], est))); return est; } cols.forEach((c, r) => (this.rows[r][c] += count)); return this.minOf(cols); }
// Every counter holds the item's count plus whatever collided with it, so the smallest // is the best guess. Never below the true count, often above it. estimate(item: string): number { return this.minOf(this.columns(item)); }}
// The sketch answers "how many for this item?" but cannot list items. A small candidate set// beside it remembers which items looked heaviest when they arrived. Production code keeps// a min-heap; for k = 10 a scan for the smallest is just as fast and easier to read.class TopK { private readonly top = new Map<string, number>();
constructor( private readonly k: number, private readonly sketch: CountMinSketch, ) {}
add(item: string, count = 1): void { const est = this.sketch.add(item, count); if (this.top.has(item) || this.top.size < this.k) { this.top.set(item, est); return; } let smallest = ''; let low = Infinity; for (const [it, v] of this.top) if (v < low) [smallest, low] = [it, v]; if (est > low) { this.top.delete(smallest); this.top.set(item, est); } }
items(): [string, number][] { return [...this.top].sort((a, b) => b[1] - a[1]); }}
// A skewed stream: item:i appears 10,000 / i times, 2,000 distinct items, interleaved in// 10 rounds so heavy and light items arrive mixed together.const truth = new Map<string, number>();for (let i = 1; i <= 2000; i++) truth.set(`item:${i}`, Math.floor(10_000 / i));const plain = new CountMinSketch(0.005, 0.01); // 544 x 5 countersconst careful = new CountMinSketch(0.005, 0.01, true);const top = new TopK(10, plain);for (let rnd = 0; rnd < 10; rnd++) { for (const [item, c] of truth) { const part = Math.floor(c / 10) + (rnd < c % 10 ? 1 : 0); if (part > 0) { top.add(item, part); careful.add(item, part); } }}
const n = plain.total;console.log(`N = ${n}, bound = ${(0.005 * n).toFixed(0)}`); // N = 80835, bound = 404for (const [item, est] of top.items().slice(0, 3)) { console.log(item, est, truth.get(item)); // item:1 10025 10000, item:2 5015 5000, item:3 3344 3333}const tail = Array.from({ length: 1000 }, (_, i) => `item:${1001 + i}`); // true counts 5 to 9const avgOver = (s: CountMinSketch) => tail.reduce((sum, x) => sum + s.estimate(x) - truth.get(x)!, 0) / tail.length;console.log( `tail overcount: plain ${avgOver(plain).toFixed(0)}, conservative ${avgOver(careful).toFixed(0)}`,);// tail overcount: plain 18, conservative 9, on true counts of 5 to 9. Light items suffer most.
export {};Step 5: Counting how many distinct, HyperLogLog
Section titled “Step 5: Counting how many distinct, HyperLogLog”“How many distinct users visited today?” needs every ID seen, because a counter double counts repeat visits. 200 million 16 byte IDs is 3.2 GB, per day and per slice.
The trick behind HyperLogLog (Flajolet, Fusy, Gandouet and Meunier, 2007) starts with a coin flip. Hash each item to a uniformly random 64 bit number. Half of all hashes start with a 1, a quarter with 01, and an eighth with 001. A hash that starts with 20 zeros turns up about once in 2²⁰ distinct items. So the longest run of leading zeros you have seen says roughly how many distinct items you have seen. Seeing the same item again changes nothing, because it has the same hash.
One such maximum is a terrible estimate: a single lucky hash ruins it. So HyperLogLog splits the stream into m buckets and averages:
- Hash the item to 64 bits.
- The first p bits pick one of m = 2^p registers.
- In the remaining bits, find the position of the first 1 (leading zeros + 1). That is the item’s rank.
- Store
max(register, rank). Registers only ever grow. - To estimate, take the harmonic mean of 2^register across registers, times m, times a bias constant of about 0.72. The harmonic mean keeps one register with a freak rank from dragging the estimate up.
- While many registers are still empty (estimate below 2.5m), it switches to linear counting: m × ln(m / empty registers). That’s far more accurate for small counts. HyperLogLog++ (Google, 2013) adds an empirical bias table for the middle range, where the raw estimate runs high. It also uses a 64 bit hash, which removes the paper’s large-range correction.
The standard error is 1.04 / √m. Each register needs only 6 bits, because a rank can never exceed 64 − p + 1, which is under 64. Quadrupling the registers halves the error:
| p | Registers | Standard error | Memory at 6 bits | Used by |
|---|---|---|---|---|
| 10 | 1,024 | 3.25% | 768 bytes | Coarse counts for millions of long-tail keys |
| 12 | 4,096 | 1.63% | 3 KB | Tighter than Presto’s approx_distinct default, 2.3% |
| 14 | 16,384 | 0.81% | 12 KB | Redis PFADD and PFCOUNT |
| 16 | 65,536 | 0.41% | 48 KB | When a sketch per key is affordable and 0.8% is not |
The memory does not grow with the count. A 12 KB HyperLogLog counts 1,000 or 10 billion distinct items, with at most the same relative error. Redis goes further. Small sketches use a sparse encoding that starts at a few bytes. It switches to the dense 12 KB form only past about 3 KB. So a million keys holding small counts do not cost 12 GB.
The second half of the demo above streams visitors into a HyperLogLog. Switch the register count, and watch the error band shrink by half for every four times the registers. Then click “1,000 revisits”, and see that the estimate does not move.
import java.nio.charset.StandardCharsets;
public class Main { // The splitmix64 finalizer: spreads every input bit over all 64 output bits. static long mix64(long z) { z = (z ^ (z >>> 30)) * 0xBF58476D1CE4E5B9L; z = (z ^ (z >>> 27)) * 0x94D049BB133111EBL; return z ^ (z >>> 31); }
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. static long hash64(String s) { long h = 0xCBF29CE484222325L; for (byte b : s.getBytes(StandardCharsets.UTF_8)) { h = (h ^ (b & 0xFF)) * 0x100000001B3L; } return mix64(h); }
static final class HyperLogLog { final int p, m; final byte[] registers; // 6 bits each would do; Redis packs them into 12 KB
// 2^p registers. p = 14 is Redis's choice: 16,384 registers, 0.81% standard error. HyperLogLog(int p) { this.p = p; this.m = 1 << p; this.registers = new byte[m]; }
void add(String item) { long h = hash64(item); int idx = (int) (h >>> (64 - p)); // the first p bits pick a register long rest = h << p; // the other 64 - p bits, shifted to the top // Rank = position of the first 1 bit. A rank of r turns up about once in 2^r items, so // the largest rank a register has seen says roughly how many distinct items reached it. int rank = Math.min(Long.numberOfLeadingZeros(rest), 64 - p) + 1; // Max, not add: seeing the same item again changes nothing. That is what makes it distinct. if (rank > registers[idx]) registers[idx] = (byte) rank; }
long count() { double alpha = 0.7213 / (1 + 1.079 / m); // bias correction from the HyperLogLog paper // Harmonic mean of 2^rank across registers: one unlucky register with a huge rank // cannot drag the estimate up the way it would with an ordinary mean. double sum = 0; int zeros = 0; for (byte r : registers) { sum += Math.pow(2, -r); if (r == 0) zeros++; } double est = alpha * m * m / sum; if (est <= 2.5 * m && zeros > 0) { // Small counts: many registers are still empty, and linear counting on the empty // ones is far more accurate than the raw estimate. est = m * Math.log((double) m / zeros); } return Math.round(est); }
// Union is register-wise max, exact and lossless: merged sketches equal one sketch of // both streams. Only sketches with the same p and the same hash can be merged. void merge(HyperLogLog other) { if (other.p != p) throw new IllegalArgumentException("cannot merge sketches with different precision"); for (int i = 0; i < m; i++) registers[i] = (byte) Math.max(registers[i], other.registers[i]); } }
public static void main(String[] args) { // Two shards see overlapping visitors: users 0 to 149,999 and 100,000 to 249,999. HyperLogLog a = new HyperLogLog(14), b = new HyperLogLog(14); for (int i = 0; i < 150_000; i++) a.add("user:" + i); for (int i = 100_000; i < 250_000; i++) b.add("user:" + i); for (int i = 0; i < 100_000; i++) a.add("user:" + i); // repeat visits do not move the count System.out.println(a.count() + " " + b.count()); // 151285 151915, true 150,000 each System.out.println(a.count() + b.count()); // 303200: adding counts double counts the 50,000 in both a.merge(b); System.out.println(a.count()); // 252911, true 250,000: 1.2% high, about 1.4 standard errors, from 16 KB of registers }}import math
MASK64 = (1 << 64) - 1 # Python ints never overflow, so wrap to 64 bits by hand
def mix64(z: int) -> int: # The splitmix64 finalizer: spreads every input bit over all 64 output bits. z = ((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9) & MASK64 z = ((z ^ (z >> 27)) * 0x94D049BB133111EB) & MASK64 return z ^ (z >> 31)
def hash64(s: str) -> int: # FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. h = 0xCBF29CE484222325 for b in s.encode("utf-8"): h = ((h ^ b) * 0x100000001B3) & MASK64 return mix64(h)
class HyperLogLog: def __init__(self, p: int = 14): # 2^p registers. p = 14 is Redis's choice: 16,384 registers, 0.81% standard error. self.p = p self.m = 1 << p self.registers = bytearray(self.m) # 6 bits each would do; Redis packs them into 12 KB
def add(self, item: str) -> None: h = hash64(item) idx = h >> (64 - self.p) # the first p bits pick a register rest = (h << self.p) & MASK64 # the other 64 - p bits, shifted to the top # Rank = position of the first 1 bit. A rank of r turns up about once in 2^r items, so # the largest rank a register has seen says roughly how many distinct items reached it. rank = min(64 - rest.bit_length(), 64 - self.p) + 1 # Max, not add: seeing the same item again changes nothing. That is what makes it distinct. if rank > self.registers[idx]: self.registers[idx] = rank
def count(self) -> int: m = self.m alpha = 0.7213 / (1 + 1.079 / m) # bias correction from the HyperLogLog paper # Harmonic mean of 2^rank across registers: one unlucky register with a huge rank # cannot drag the estimate up the way it would with an ordinary mean. est = alpha * m * m / sum(2.0 ** -r for r in self.registers) zeros = self.registers.count(0) if est <= 2.5 * m and zeros: # Small counts: many registers are still empty, and linear counting on the empty # ones is far more accurate than the raw estimate. est = m * math.log(m / zeros) return round(est)
def merge(self, other: "HyperLogLog") -> None: # Union is register-wise max, exact and lossless: merged sketches equal one sketch of # both streams. Only sketches with the same p and the same hash can be merged. if other.p != self.p: raise ValueError("cannot merge sketches with different precision") for i, r in enumerate(other.registers): if r > self.registers[i]: self.registers[i] = r
if __name__ == "__main__": # Two shards see overlapping visitors: users 0 to 149,999 and 100,000 to 249,999. a, b = HyperLogLog(), HyperLogLog() for i in range(150_000): a.add(f"user:{i}") for i in range(100_000, 250_000): b.add(f"user:{i}") for i in range(100_000): a.add(f"user:{i}") # repeat visits do not move the count print(a.count(), b.count()) # 151285 151915, true 150,000 each print(a.count() + b.count()) # 303200: adding counts double counts the 50,000 in both a.merge(b) print(a.count()) # 252911, true 250,000: 1.2% high, about 1.4 standard errors, from 16 KB of registersusing System;using System.Numerics;using System.Text;
public static class Hashing{ // The splitmix64 finalizer: spreads every input bit over all 64 output bits. public static ulong Mix64(ulong z) { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL; z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL; return z ^ (z >> 31); }
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language. public static ulong Hash64(string s) { ulong h = 0xCBF29CE484222325UL; foreach (byte b in Encoding.UTF8.GetBytes(s)) h = (h ^ b) * 0x100000001B3UL; return Mix64(h); }}
public sealed class HyperLogLog{ public int P { get; } private readonly int m; private readonly byte[] registers; // 6 bits each would do; Redis packs them into 12 KB
// 2^p registers. p = 14 is Redis's choice: 16,384 registers, 0.81% standard error. public HyperLogLog(int p = 14) { P = p; m = 1 << p; registers = new byte[m]; }
public void Add(string item) { ulong h = Hashing.Hash64(item); int idx = (int)(h >> (64 - P)); // the first p bits pick a register ulong rest = h << P; // the other 64 - p bits, shifted to the top // Rank = position of the first 1 bit. A rank of r turns up about once in 2^r items, so // the largest rank a register has seen says roughly how many distinct items reached it. int rank = Math.Min(BitOperations.LeadingZeroCount(rest), 64 - P) + 1; // Max, not add: seeing the same item again changes nothing. That is what makes it distinct. if (rank > registers[idx]) registers[idx] = (byte)rank; }
public long Count() { double alpha = 0.7213 / (1 + 1.079 / m); // bias correction from the HyperLogLog paper // Harmonic mean of 2^rank across registers: one unlucky register with a huge rank // cannot drag the estimate up the way it would with an ordinary mean. double sum = 0; int zeros = 0; foreach (byte r in registers) { sum += Math.Pow(2, -r); if (r == 0) zeros++; } double est = alpha * m * m / sum; if (est <= 2.5 * m && zeros > 0) { // Small counts: many registers are still empty, and linear counting on the empty // ones is far more accurate than the raw estimate. est = m * Math.Log((double)m / zeros); } return (long)Math.Round(est); }
// Union is register-wise max, exact and lossless: merged sketches equal one sketch of // both streams. Only sketches with the same p and the same hash can be merged. public void Merge(HyperLogLog other) { if (other.P != P) throw new ArgumentException("cannot merge sketches with different precision"); for (int i = 0; i < m; i++) registers[i] = Math.Max(registers[i], other.registers[i]); }}
public static class Program{ public static void Main() { // Two shards see overlapping visitors: users 0 to 149,999 and 100,000 to 249,999. var a = new HyperLogLog(); var b = new HyperLogLog(); for (int i = 0; i < 150_000; i++) a.Add($"user:{i}"); for (int i = 100_000; i < 250_000; i++) b.Add($"user:{i}"); for (int i = 0; i < 100_000; i++) a.Add($"user:{i}"); // repeat visits do not move the count Console.WriteLine($"{a.Count()} {b.Count()}"); // 151285 151915, true 150,000 each Console.WriteLine(a.Count() + b.Count()); // 303200: adding counts double counts the 50,000 in both a.Merge(b); Console.WriteLine(a.Count()); // 252911, true 250,000: 1.2% high, about 1.4 standard errors, from 16 KB of registers }}package main
import ( "errors" "fmt" "math" "math/bits")
// mix64 is the splitmix64 finalizer: it spreads every input bit over all 64 output bits.func mix64(z uint64) uint64 { z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 z = (z ^ (z >> 27)) * 0x94D049BB133111EB return z ^ (z >> 31)}
// hash64 is FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language.func hash64(s string) uint64 { h := uint64(0xCBF29CE484222325) for _, b := range []byte(s) { h = (h ^ uint64(b)) * 0x100000001B3 } return mix64(h)}
type HyperLogLog struct { p uint m int registers []uint8 // 6 bits each would do; Redis packs them into 12 KB}
// NewHyperLogLog makes 2^p registers. p = 14 is Redis's choice: 16,384 registers, 0.81% standard// error.func NewHyperLogLog(p uint) *HyperLogLog { return &HyperLogLog{p: p, m: 1 << p, registers: make([]uint8, 1<<p)}}
func (h *HyperLogLog) Add(item string) { x := hash64(item) idx := x >> (64 - h.p) // the first p bits pick a register rest := x << h.p // the other 64 - p bits, shifted to the top // Rank = position of the first 1 bit. A rank of r turns up about once in 2^r items, so // the largest rank a register has seen says roughly how many distinct items reached it. rank := uint8(min(bits.LeadingZeros64(rest), 64-int(h.p)) + 1) // Max, not add: seeing the same item again changes nothing. That is what makes it distinct. if rank > h.registers[idx] { h.registers[idx] = rank }}
func (h *HyperLogLog) Count() int64 { m := float64(h.m) alpha := 0.7213 / (1 + 1.079/m) // bias correction from the HyperLogLog paper // Harmonic mean of 2^rank across registers: one unlucky register with a huge rank // cannot drag the estimate up the way it would with an ordinary mean. sum, zeros := 0.0, 0 for _, r := range h.registers { sum += math.Pow(2, -float64(r)) if r == 0 { zeros++ } } est := alpha * m * m / sum if est <= 2.5*m && zeros > 0 { // Small counts: many registers are still empty, and linear counting on the empty // ones is far more accurate than the raw estimate. est = m * math.Log(m/float64(zeros)) } return int64(math.Round(est))}
// Merge: union is register-wise max, exact and lossless, so merged sketches equal one sketch of// both streams. Only sketches with the same p and the same hash can be merged.func (h *HyperLogLog) Merge(other *HyperLogLog) error { if other.p != h.p { return errors.New("cannot merge sketches with different precision") } for i, r := range other.registers { h.registers[i] = max(h.registers[i], r) } return nil}
func main() { // Two shards see overlapping visitors: users 0 to 149,999 and 100,000 to 249,999. a, b := NewHyperLogLog(14), NewHyperLogLog(14) for i := 0; i < 150_000; i++ { a.Add(fmt.Sprintf("user:%d", i)) } for i := 100_000; i < 250_000; i++ { b.Add(fmt.Sprintf("user:%d", i)) } for i := 0; i < 100_000; i++ { a.Add(fmt.Sprintf("user:%d", i)) // repeat visits do not move the count } fmt.Println(a.Count(), b.Count()) // 151285 151915, true 150,000 each fmt.Println(a.Count() + b.Count()) // 303200: adding counts double counts the 50,000 in both if err := a.Merge(b); err != nil { panic(err) } fmt.Println(a.Count()) // 252911, true 250,000: 1.2% high, about 1.4 standard errors, from 16 KB of registers}const MASK64 = (1n << 64n) - 1n; // BigInt never overflows, so wrap to 64 bits by hand
// The splitmix64 finalizer: spreads every input bit over all 64 output bits.function mix64(z: bigint): bigint { z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & MASK64; z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & MASK64; return z ^ (z >> 31n);}
// FNV-1a over the UTF-8 bytes, then mixed: short enough to match in every language.const utf8 = new TextEncoder();function hash64(s: string): bigint { let h = 0xcbf29ce484222325n; for (const b of utf8.encode(s)) h = ((h ^ BigInt(b)) * 0x100000001b3n) & MASK64; return mix64(h);}
function leadingZeros64(x: bigint): number { return x === 0n ? 64 : 64 - x.toString(2).length;}
class HyperLogLog { readonly m: number; readonly registers: Uint8Array; // 6 bits each would do; Redis packs them into 12 KB
// 2^p registers. p = 14 is Redis's choice: 16,384 registers, 0.81% standard error. constructor(readonly p = 14) { this.m = 1 << p; this.registers = new Uint8Array(this.m); }
add(item: string): void { const h = hash64(item); const p = BigInt(this.p); const idx = Number(h >> (64n - p)); // the first p bits pick a register const rest = (h << p) & MASK64; // the other 64 - p bits, shifted to the top // Rank = position of the first 1 bit. A rank of r turns up about once in 2^r items, so // the largest rank a register has seen says roughly how many distinct items reached it. const rank = Math.min(leadingZeros64(rest), 64 - this.p) + 1; // Max, not add: seeing the same item again changes nothing. That is what makes it distinct. if (rank > this.registers[idx]) this.registers[idx] = rank; }
count(): number { const m = this.m; const alpha = 0.7213 / (1 + 1.079 / m); // bias correction from the HyperLogLog paper // Harmonic mean of 2^rank across registers: one unlucky register with a huge rank // cannot drag the estimate up the way it would with an ordinary mean. let sum = 0; let zeros = 0; for (const r of this.registers) { sum += 2 ** -r; if (r === 0) zeros++; } let est = (alpha * m * m) / sum; if (est <= 2.5 * m && zeros > 0) { // Small counts: many registers are still empty, and linear counting on the empty // ones is far more accurate than the raw estimate. est = m * Math.log(m / zeros); } return Math.round(est); }
// Union is register-wise max, exact and lossless: merged sketches equal one sketch of // both streams. Only sketches with the same p and the same hash can be merged. merge(other: HyperLogLog): void { if (other.p !== this.p) throw new Error('cannot merge sketches with different precision'); other.registers.forEach((r, i) => (this.registers[i] = Math.max(this.registers[i], r))); }}
// Two shards see overlapping visitors: users 0 to 149,999 and 100,000 to 249,999.const a = new HyperLogLog();const b = new HyperLogLog();for (let i = 0; i < 150_000; i++) a.add(`user:${i}`);for (let i = 100_000; i < 250_000; i++) b.add(`user:${i}`);for (let i = 0; i < 100_000; i++) a.add(`user:${i}`); // repeat visits do not move the countconsole.log(a.count(), b.count()); // 151285 151915, true 150,000 eachconsole.log(a.count() + b.count()); // 303200: adding counts double counts the 50,000 in botha.merge(b);console.log(a.count()); // 252911, true 250,000: 1.2% high, about 1.4 standard errors, from 16 KB of registers
export {};Step 6: Merge, the property that makes them distributed
Section titled “Step 6: Merge, the property that makes them distributed”Each structure has a merge operation that gives exactly the structure you would have built from the combined input:
| Structure | Merge | So you can | But not |
|---|---|---|---|
| Bloom | Bitwise OR | Union filters built by different workers | Intersect reliably: AND gives extra positives |
| Count-Min | Add counters cell by cell | Sum per-minute sketches into an hour, regions into a global count | Subtract once conservative update is on |
| HyperLogLog | Register-wise max | Union shards, then days into a week (PFMERGE) |
Subtract a day, or intersect without large error |
The merge only works when both sides were built identically:
- Same m and k, for a Bloom filter.
- Same width, depth and per-row hashes, for Count-Min.
- Same precision, hash function and seed, for HyperLogLog.
Store those parameters with every serialized sketch, and refuse to merge mismatches.
- Color key
- Database
- Service
Daily counts cannot be added, because a Monday and Tuesday visitor is in both. Seven daily sketches merged by max give the weekly count with the same 0.81% error.
Options and tradeoffs
Section titled “Options and tradeoffs”Which question are you asking
Section titled “Which question are you asking”- Color key
- Service
- Database
Membership filters
Section titled “Membership filters”| Filter | Bits per item at 1% | Delete | Lookup | Wins when |
|---|---|---|---|---|
| Bloom | 9.6 | No | 7 scattered bits | Append-only sets, the default |
| Blocked Bloom (one cache line) | About 10 to 11 | No | One 64 byte line | Hot lookups where cache misses dominate, such as LSM reads |
| Cuckoo | About 9 to 11 | Yes | 2 buckets | Items expire or are removed, rates below a few percent |
| Static xor or ribbon filters | About 8 | No | 3 reads (xor), a few (ribbon) | The set is built once and never changes, such as an SSTable |
Frequency and heavy hitters
Section titled “Frequency and heavy hitters”| Approach | Memory | Error | Can subtract | Wins when |
|---|---|---|---|---|
| Exact hash map | One counter per distinct key | None | Yes | Distinct keys fit: tens of millions on one box |
| Count-Min + heap | w × d counters + k names | Over by ≤ εN, with probability 1 − δ | Yes | Sliding windows and cross-region merges |
| Count-Min, conservative update | Same | Same bound, typically half or less in practice | No | One long window, accuracy on light items |
| Space-Saving (Metwally, 2005) | k counters with names | Over by ≤ N/k, deterministic | No | Only the top list is needed, from one stream |
Space-Saving’s summaries merge only approximately, which is why the top-k problem picks Count-Min for windows that slide and regions that merge.
Distinct counts
Section titled “Distinct counts”| Approach | Memory for 100 million distinct | Error | Merge | Wins when |
|---|---|---|---|---|
| Exact set of IDs | 1.6 GB raw, more in a hash set | None | Union of sets | Counts are small, or the result is billed |
| Bitmap of dense integer IDs (Roaring) | 12.5 MB per 100 million of range | None | Bitwise OR | IDs are integers in a known range |
| HyperLogLog (p = 14) | 12 KB | 0.81% standard error | Register max | Large cardinalities, many keys, unions |
| Theta sketch (Apache DataSketches) | Tens of KB | Similar, configurable | Union, intersect, difference | You need “visited A and not B” |
When exact is required
Section titled “When exact is required”| Situation | Why a sketch is wrong here | Use instead |
|---|---|---|
| Billing, payouts, quotas that cost money | Overcounts are charges the customer did not incur; ±1.6% is a dispute | Exact counts from an idempotent event log |
| Access control (“is this token revoked?”) | A false positive locks out a valid user; a stale filter misses a revocation | The filter as a pre-check, exact lookup on “maybe” |
| Uniqueness that must hold (usernames) | “Maybe taken” is fine, but “not taken” must be confirmed by a unique constraint | Bloom to skip the DB for free names, unique index to enforce |
| Small sets | 1 million 8 byte IDs is 8 MB; a sketch saves nothing worth an error | A hash set |
| You need the items, not a count | Sketches cannot enumerate | A set, a log, or a sample |
Go deeperMore filters and sketches: blocked, Space-Saving, quantiles, similarity3 min
Blocked Bloom filters. Hash each item to one 512 bit block (a 64 byte cache line) first. Then set all k bits inside it. So a lookup is one cache miss, instead of seven. The false-positive rate rises a little, because blocks fill unevenly. That’s repaid many times over in CPU. RocksDB’s newer filter formats use this layout. RocksDB also offers a ribbon filter. It’s about 30% smaller for the same rate, at a higher construction cost. That’s a good fit for SSTables, which are written once.
Space-Saving (Metwally, Agrawal and El Abbadi, 2005) keeps exactly k (item, count) pairs. An untracked item replaces the pair with the smallest count, and inherits that count plus one. So every count is an overestimate by at most N/k. And any item with more than N/k occurrences is guaranteed to be in the list.
Exact below a threshold. Elasticsearch’s cardinality aggregation is close to exact below a
precision_threshold (3,000 by default) and switches to HyperLogLog++ above it. Redis’s sparse
encoding gets much of the same benefit for small counts.
The same idea covers two more questions, and both appear in problem pages on this site.
Quantiles. “What is the p99 latency?” over billions of samples. There are three approaches:
- A t-digest keeps clusters of samples: small near the tails, and large in the middle. So p99 and p99.9 are accurate in a few KB.
- KLL keeps a hierarchy of sampled buffers. Its error is a uniform share of the rank. That’s fine for a median, and coarse for the extreme tail.
- Prometheus native histograms and HDR histograms use fixed exponential buckets, with a known relative error.
All of them merge. That’s why per-host latency summaries can be combined into a fleet p99, while averages of per-host p99s cannot.
Similarity. MinHash keeps the minimum hash of a document’s shingles, under each of a few hundred hash functions. The fraction of matching minimums estimates the Jaccard similarity of two documents. Simhash folds a document’s features into one 64 bit fingerprint, where near duplicates differ in few bits. The web crawler uses simhash for near duplicate pages.
How it fails
Section titled “How it fails”| Failure | What you see | Blast radius | Fix |
|---|---|---|---|
| Filter past its design capacity | False positives climb from 1% to 16% at twice the items, 44% at three times | Every lookup’s fallback path: disk, DB, network | Alarm on fill ratio; rebuild larger from the source; scalable filters |
| Sketches with different configs merged | Merge errors, or worse, silently wrong counts | Every report built on the merge | Store m, k, p, width, depth and hash seed with every sketch; refuse mismatches |
| Weak or inconsistent hash | Measured error above predicted; false negatives across processes | Every lookup | One fixed 64 bit hash (Murmur3, xxHash) in every language; test vectors |
| Naive delete from a Bloom filter | Items that are present test “definitely not” | Correctness: the one guarantee breaks | Never clear bits; counting or cuckoo filter, or rebuild |
| Error read as a guarantee in billing | Customers billed for traffic they did not send | Money and trust | Exact counts for anything invoiced |
| Cuckoo filter near full | Inserts fail | New items not recorded | Size for 80 to 90% load; rebuild larger on failure |
Overfilling is the common one, and it is silent. Nothing throws when a filter sized for 100 million gets 200 million items. The math just keeps going:
| Items vs design capacity | Bits set | False-positive rate (sized for 1%, k = 7) |
|---|---|---|
| 1× | 52% | 1.0% |
| 1.25× | 60% | 2.8% |
| 1.5× | 67% | 5.8% |
| 2× | 77% | 15.8% |
| 3× | 89% | 44% |
If the filter guards a disk read, that is 16 times the wasted reads, with no error in any log.
Detect it by the fraction of bits set. A correctly sized filter at capacity sits near 50%. And
n ≈ −(m/k) × ln(1 − bits set / m) recovers how many items it really holds. Fix it by rebuilding
larger, from the source of truth. Or plan for growth with a scalable Bloom filter. When one
fills, add another with twice the capacity and a tighter error, and check all of them. RedisBloom’s
BF.RESERVE does this by default (EXPANSION 2). Each layer adds a lookup.
Inconsistent hashing turns false positives into false negatives. Python’s hash() of a string
and .NET’s string.GetHashCode() are randomized per process. So a filter built by one worker, and
checked by another, reports “definitely not” for items it holds. A library upgrade that changes the
hash or seed makes every stored sketch unmergeable with new ones. Pin one 64 bit hash, and version
the format. Ship test vectors (keys, and the bits each must set) that every client checks.
Where attackers choose the items and know the hash, they can craft items that collide with a target’s bits or cells. Use a keyed hash such as SipHash with a secret seed for hostile input.
At scale
Section titled “At scale”| Operation | Cost, roughly |
|---|---|
| Bloom check, in RAM | 7 cache misses scattered over GBs, 100 to 500 ns; blocked: one miss |
| Bloom check vs the SSD read it saves | 0.1 to 0.5 µs against 50 to 100 µs |
| Count-Min update, d = 5 | One hash, 5 counter increments, tens to a hundred ns |
| HyperLogLog add | One hash, one register max, tens of ns |
PFCOUNT over 7 keys |
A 16,384 register merge, microseconds |
| Sizes | 1.2 GB per billion items at 1%; 12 KB per HLL; 5.4 MB per CMS at ε = 10⁻⁵ |
At 10 times the items, a Bloom filter grows 10 times. It is linear in n, with a much smaller constant than a set. Past tens of GB, shard it like the data: one filter per partition, routed by the same key hash, and rebuildable from that partition’s records. HyperLogLog does not grow with the count. But the number of keys does (pages × days × countries). So sparse encodings, and lower precision for long-tail keys, matter. Count-Min’s absolute error grows with N. Widen it, or keep a sketch per window so each N stays small.
At 100 times, the merge path becomes the design. Sketches move between machines every minute. So their parameters become a schema. A change of width, precision or seed is a migration with a cutover date, because old and new sketches cannot be merged. Rebuilding filters becomes scheduled work too. Rebuilding a 12 GB filter means reading every key in the source.
What to monitor:
- Bloom filters: items against design capacity, and fraction of bits set (alarm near 55 to 60%).
- Measured false-positive rate: the share of “maybe” answers the exact lookup then rejected
(RocksDB:
bloom.filter.full.positiveagainstbloom.filter.full.true.positive). Above the design rate means overfull or a broken hash. - Sketch error against ground truth: exact counts for a small sample of keys, compared daily. HyperLogLog should land within 2 standard errors about 95% of the time. Count-Min should never be below.
- Count-Min’s N per window, with εN next to the smallest count you report.
- Cuckoo filter load factor and insert failures, and sketch format versions in flight.
In the interview
Section titled “In the interview”Interviewer asksSize a Bloom filter for 1 billion keys at 1% false positives. How many hash functions?
The formula is m = −n ln p / (ln 2)². That’s about 9.6 bits per key at 1%, so 9.6 billion bits, or 1.2 GB. The best k is (m/n) ln 2, about 7. At 0.1%, it’s 14.4 bits per key, 1.8 GB and 10 hashes. Every 10 times fewer false positives costs 4.8 bits per key. The seven positions come from one 64 bit hash, split in two halves: h1 + i·h2. At capacity, about half the bits are set. I would alarm on the fill ratio, because at twice the keys, the rate is about 16%.
Interviewer asksUsers can delete their accounts. How does a Bloom filter of taken usernames handle that?
It cannot remove a name. Clearing its bits clears bits that other names share. Then they would test “definitely not taken”, which is a false negative. Usually that does not matter. Say the filter only lets signup skip the database for names that are certainly free. Then a deleted name staying “maybe taken” costs one lookup, and the unique index decides. I would rebuild the filter from the users table on a schedule, to shed stale entries. If deletions are frequent and stale entries are expensive, a cuckoo filter supports real deletes in about the same space. The cost is inserts that can fail near 95% full. A counting Bloom filter also works, at 4 times the memory.
Interviewer asksShow unique visitors per page per day, and per week.
One HyperLogLog per page per day, with PFADD on each view. That’s at most 12 KB per key, at 0.81% standard error, and far less for small pages, thanks to the sparse encoding. Daily numbers cannot be added, since repeat visitors would count twice. But PFCOUNT over the seven daily keys merges them by register max, and gives the week with the same error. A rolling 30 days is a merge of the last 30 daily sketches, recomputed daily, because a sketch cannot subtract a day. If advertisers are billed on it, the official figure is computed exactly, in batch.
Seen in
Section titled “Seen in”- Design a web crawler: a 12 GB Bloom filter at 1% for 10 billion URLs, in front of 8 byte fingerprints on SSD. A Bloom-only false positive would mean a URL is never crawled.
- Design a key-value store: one Bloom filter per SSTable at 10 bits a key. That’s 1.1 GB per node for 860 million keys, so most reads touch one file.
- Design top-k heavy hitters: Count-Min sketches per minute with a candidate heap, windows that slide by subtraction, and regional sketches merged cell by cell.
- Design a URL shortener: a HyperLogLog per short code per day for unique visitors, merged into weekly counts.
- Design search autocomplete: daily aggregates store distinct users per query as HyperLogLog, which feeds the score and the 50 distinct users privacy threshold.
- Design an ad click aggregator: unique users per ad from mergeable HyperLogLogs, and a live top 100 ads from Count-Min with a heap.
- Design a distributed cache: W-TinyLFU, one of the eviction policies it compares, admits keys by a frequency sketch of a few bits per key.
- Design a leaderboard: the counterexample, where a uniform error quantile sketch would be wrong for rank estimates and exact bucket counts are used instead.
Outside these problems, the best known use is at the CDN edge. Akamai’s Maggs and Sitaraman (2015) reported that about 74% of the objects in a sample of its edge traffic were requested only once. Akamai uses a Bloom filter to cache an object only on its second request, so those one-hit wonders never reach disk.
- Color key
- Service
A false positive here only means caching a one-hit wonder. That’s what happened before the filter existed, so a high error rate is cheap. That is the pattern to look for in every use above. Put the sketch where its error costs little, and back it with something exact where it costs a lot.
Go deeperMore systems that ship these structures1 min
Bigtable, HBase, Cassandra and RocksDB keep a Bloom filter per SSTable. (Cassandra exposes the rate
per table as bloom_filter_fp_chance.) Redis has HyperLogLog built in (PFADD, PFCOUNT,
PFMERGE). Its Bloom, cuckoo, Count-Min, top-k and t-digest types (BF.*, CF.*, CMS.*,
TOPK.*, TDIGEST.*) came with Redis Stack. They’re part of Redis Open Source from Redis 8.
BigQuery, Presto and Druid offer approximate distinct counts built on HyperLogLog. And Caffeine,
the standard Java cache, uses a frequency sketch for admission.
The idea. Answer one narrow question in fixed memory, with an error of known bound and often known direction.
Bloom filter. m bits, k hashes. “No” is certain, “maybe” is wrong with probability (1 − e^(−kn/m))^k. 9.6 bits per item and k = 7 for 1%, plus 4.8 bits for each 10 times fewer false positives. Half the bits are set at capacity. k positions from two hashes. No deletes.
Deletes. Counting Bloom filters: 4 bit counters, at 4 times the memory. Or cuckoo filters: fingerprints in two candidate buckets, with real deletes, but inserts can fail near 95% full.
Count-Min Sketch. d rows of w counters, take the minimum. Never under, over by at most εN with probability 1 − δ, where N is the whole stream. Add a heap to list heavy hitters. Conservative update tightens it but cannot subtract.
HyperLogLog. Registers keep the longest leading-zero run of the hashes routed to them. It uses a harmonic mean, and linear counting for small counts. Error is 1.04/√m: 12 KB and 0.81% in Redis. It merges by max across shards and days. It cannot subtract or list.
Failures. Overfilled filters (16% at twice capacity, so watch the fill ratio). Configs or hashes that differ between writers. Naive deletes. And errors treated as guarantees in billing.
Further reading
Section titled “Further reading”- Caching for Bloom filters against cache penetration and TinyLFU’s frequency sketch, and indexing for the Bloom filter on every SSTable in an LSM tree. The top-k and web crawler problems take Count-Min and Bloom filters through a full design.
- An improved data stream summary: the count-min sketch and its applications, Cormode and Muthukrishnan: the sketch, its error bound and its proofs.
- HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm, Flajolet, Fusy, Gandouet and Meunier, 2007. It covers registers, the harmonic mean, the 1.04/√m error, and the small and large range corrections.
- Cuckoo Filter: Practically Better Than Bloom, Fan, Andersen, Kaminsky and Mitzenmacher, CoNEXT 2014. It covers partial-key cuckoo hashing, deletes, and space against Bloom filters by target error rate.
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.