Probabilistic structures: filters, sketches and samples
Some questions only need an answer that is almost right: was this URL seen, how many distinct visitors, which topics are hot. A sketch answers them in fixed memory with an error you can compute in advance.
- 1Each structure gives a bounded error for a fixed, small memory. Know the direction of the error.
- 2A Bloom filter never says "no" to an item it holds. About 10 bits per item give 1% false positives.
- 3HyperLogLog counts distinct items with about 1% error in 12 KB, and daily counters merge.
- 4Count-min never undercounts. It is accurate for heavy keys and useless for rare ones.
URLs: 1 billion, 8-byte hashes or 9.6 bits each (derived). Visitors: 100 million, 40 bytes per set member and 12,831 bytes per HyperLogLog (measured). Counts: 100 million keys at 12 bytes, or 2,719 × 5 counters (derived).
- Membership: was this item seen? Cardinality: how many distinct? Frequency: how often?
- An exact answer grows with the number of distinct items. A sketch does not.
- Each sketch errs in one known direction, so you can design around it.
An exact answer needs memory for every distinct item. A sketch uses fixed memory and gives an error I can state before I build it.
| structure | answers | error | status |
|---|---|---|---|
| Bloom filter | seen before? | False yes only | No deletes needed |
| Counting Bloom filter | seen, with deletes | False yes; 4× memory | Deletes are rare |
| Cuckoo filter | seen, with deletes | False yes; inserts can fail near full | Below 3% error |
| HyperLogLog | how many distinct? | About ±1%, either way | Approved |
| Count-min sketch | how often? | Overcount only, up to εN | Heavy keys only |
| Space-Saving | top k | Overcount, bounded per key | 10 counters per k |
| Reservoir sample | a fair sample of a stream | Sampling error only | Approved |
| Exact set or map | all of the above | None | It fits in memory |
ε and N: count-min error is a fraction ε of the total count N of all events.
I pick the structure by the question and by which error I can accept: a false yes, an overcount, or about 1% either way.
Step 1: Ask the filter
- Hash the id to k bit positions. Read them in one script.
- One 0 bit: the id does not exist. Return 404 here.
- All bits set: the id may exist. Go on.
If it fails
Redis is down: skip the filter and go to the cache. The filter only removes load; it never decides alone.
Bots that ask for ids that do not exist miss the cache every time. A Bloom filter of valid ids answers no in one Redis call, and only false positives reach Postgres.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | PFADD, PFCOUNT, PFMERGE | A distinct count per key in at most 12 KB with 0.81% standard error. Merge days into weeks. | Unique searches, unique players per song |
| Redis | Bitmaps: SETBIT, GETBIT | The bits of a Bloom filter that every service process shares. | Daily active flags, feature flags per user |
| Redis | Server-side scripts (EVAL) | Set k bits and learn whether the item was new, as one step. Two workers never both see "new". | Rate limits, holds |
| Redis | Key expiry (EXPIRE) | One sketch per day or per window, removed by itself. | Sessions, windows |
| Redis | Bloom, cuckoo, count-min and top-k commands (BF, CF, CMS, TOPK) | Build has them Ready-made filters and sketches. The lab build lacks them; BF.ADD answers "unknown command". | |
| Redis | Sorted sets: ZINCRBY, ZREVRANGE | An exact top k while the keys fit in memory. | Leaderboards |
| Service | Sketches in process memory | A Bloom lookup in about 70 ns and a HyperLogLog add in about 14 ns, with no network trip. | Local pre-aggregation |
| Service | Mergeable sketches | Each shard keeps its own counter or sketch. Merge HyperLogLogs by max, count-min by sum. | Stream processing |
| Postgres | Unique index, count(DISTINCT) | The exact answer, and the source to rebuild every filter from. | Idempotency keys |
| Postgres | The bloom index (contrib extension) | A signature index over many columns. It is lossy, so Postgres rechecks each row it returns. | Equality search on any column subset |
| LSM store | A Bloom filter per data file | A read skips every file that cannot hold the key. RocksDB and Cassandra do this. | Sheet D1 |
Redis gives me HyperLogLog commands, bitmaps and scripts for a shared filter. The service keeps the cheap sketches in memory. Postgres keeps the truth that rebuilds them.
size(n, p): // n items, false positive rate p
m = -n · ln(p) / (ln 2)²1 // bits
k = (m / n) · ln 22 // positions per item
add(item):
h1, h2 = hash(item) // one 64-bit hash, split in two3
FOR i IN 0 .. k-1:
SET bit (h1 + i·h2) mod m
has(item):
FOR i IN 0 .. k-1:
IF bit (h1 + i·h2) mod m == 0:
RETURN no4 // sure
RETURN maybe // wrong with chance p5- 1About 1.44 × log2(1/p) bits per item: 9.6 at 1%, 14.4 at 0.1%. The size of the item does not matter.
- 2The best k fills about half of the bits. More positions make a full filter; fewer make each check weak.
- 3Double hashing: positions h1 + i·h2 from one hash behave like k independent hashes.
- 4A sure answer. Every added item set all of its bits, and bits are never cleared.
- 5The chance is (1 − e^(−kn/m))^k. It grows as items are added beyond n.
Tested source Go: sizing and formula · Go: add and ask · Redis script: add, say if new · Redis script: ask
// BloomFor sizes a filter for n items at false positive rate p:
// m = -n ln p / (ln 2)^2 bits, and k = (m/n) ln 2 positions per item.
func BloomFor(n int, p float64) *Bloom {
m := uint64(math.Ceil(-float64(n) * math.Log(p) / (math.Ln2 * math.Ln2)))
return NewBloom(m, OptimalK(float64(m)/float64(n)))
}
// OptimalK is the k that minimises the false positive rate at b bits per item: b ln 2, rounded.
func OptimalK(b float64) int {
return max(1, int(math.Round(b*math.Ln2)))
}
// FalsePositiveRate is the chance that an item never added finds all its k bits set, after n
// items went into m bits: (1 - e^(-kn/m))^k.
func FalsePositiveRate(m uint64, n int, k int) float64 {
return math.Pow(1-math.Exp(-float64(k)*float64(n)/float64(m)), float64(k))
}
// Positions returns the k bit positions of a hashed item: h1 + i*h2 mod m (double hashing).
func (b *Bloom) Positions(h uint64) []uint64 {
h1, h2 := pair(h)
out := make([]uint64, b.k)
for i := range b.k {
out[i] = (h1 + uint64(i)*h2) % b.m
}
return out
}
// AddHash sets the k bits of a hashed item.
func (b *Bloom) AddHash(h uint64) {
h1, h2 := pair(h)
for i := range b.k {
p := (h1 + uint64(i)*h2) % b.m
b.bits[p/64] |= 1 << (p % 64)
}
b.n++
}
// HasHash reports whether all k bits of a hashed item are set. It stops at the first clear bit,
// so a definite "no" usually costs one or two reads.
func (b *Bloom) HasHash(h uint64) bool {
h1, h2 := pair(h)
for i := range b.k {
p := (h1 + uint64(i)*h2) % b.m
if b.bits[p/64]&(1<<(p%64)) == 0 {
return false
}
}
return true
}
-- Add an item to a Bloom filter kept in a Redis bitmap, and say whether it was new.
-- KEYS[1] the bitmap. ARGV holds the item's k bit positions, computed by the client.
-- Returns the number of bits that were 0 before. 0 means every bit was set: the item was
-- probably seen before. Redis runs the script as one step, so two callers that add the
-- same new item get one 'new' answer between them.
local new = 0
for i = 1, #ARGV do
if redis.call('SETBIT', KEYS[1], ARGV[i], 1) == 0 then
new = new + 1
end
end
return new-- Ask a Bloom filter kept in a Redis bitmap whether it may hold an item.
-- KEYS[1] the bitmap. ARGV holds the item's k bit positions, computed by the client.
-- Returns 0 at the first clear bit (never added) and 1 if every bit is set (maybe added).
for i = 1, #ARGV do
if redis.call('GETBIT', KEYS[1], ARGV[i]) == 0 then
return 0
end
end
return 1Add sets k bits; a lookup with any 0 bit is a sure no. At 1% I need 9.6 bits per item and 7 hash positions, whatever the item size.
- measured
- 0.83% (834 of 100,000 answered yes)
- formula
- 0.82%
- best k here
- 7
- 1M items
- 1.25 MB of bits, 7 reads per lookup at most
- measured, Go filter
- formula (1 − e−kn/m)k
- best k at each size
- no false positive in 100,000
10,000 items per filter, 100,000 items never added. The Redis bitmap filter gives the same answers as the Go filter for the same m and k; a test checks this.
At 10 bits per item and k = 7 the lab measured 0.83% false positives, against 0.82% from the formula.
add(item):
x = hash(item) // 64 bits
j = first p bits of x1 // one of m = 2^p registers
rank = position of the first 1 in the rest2
reg[j] = max(reg[j], rank) // duplicates change nothing3
count():
E = α · m² / Σ 2^(-reg[j]) // harmonic mean4 of the registers
IF E <= 2.5·m AND some reg[j] == 0:
E = m · ln(m / zeros) // small sets: linear counting5
RETURN E
merge(a, b): reg[j] = max(a[j], b[j]) // the union of both sets6- 1Redis uses p = 14: 16,384 registers of 6 bits, 12 KB.
- 2r leading zeros happen about once in 2^r distinct hashes. A large rank means many distinct items.
- 3The same item gives the same hash and the same rank, so re-adding is free.
- 4It damps the few registers that saw a lucky long run. Standard error 1.04 / √m.
- 5While many registers are still 0, count the empty ones instead. Redis also starts with a sparse encoding for small sets.
- 6Register-wise max. Merge is exact, so 7 daily keys give the weekly count.
Tested source Go: add, count, merge
// AddHash routes the hash to register j (its first p bits) and keeps the largest rank seen there.
// The rank is the position of the first 1 bit in the remaining 64 - p bits.
func (h *HLL) AddHash(x uint64) {
j := x >> (64 - h.p)
rest := x<<h.p | 1<<(h.p-1) // the guard bit caps the rank at 64 - p + 1
rank := uint8(bits.LeadingZeros64(rest)) + 1
if rank > h.reg[j] {
h.reg[j] = rank
}
}
// Count is the harmonic-mean estimate α·m² / Σ 2^-reg. While many registers are still 0, it uses
// linear counting, m·ln(m / zeros), which is more accurate for small sets.
func (h *HLL) Count() uint64 {
m := float64(len(h.reg))
sum, zeros := 0.0, 0
for _, r := range h.reg {
sum += math.Ldexp(1, -int(r))
if r == 0 {
zeros++
}
}
est := alpha(len(h.reg)) * m * m / sum
if est <= 2.5*m && zeros > 0 {
est = m * math.Log(m/float64(zeros))
}
return uint64(math.Round(est))
}
// Merge keeps the larger value of each register, so the result counts the union of both sets.
func (h *HLL) Merge(o *HLL) error {
if o.p != h.p {
return fmt.Errorf("merge hll: p %d into p %d", o.p, h.p)
}
for i, r := range o.reg {
h.reg[i] = max(h.reg[i], r)
}
return nil
}
- measured error (root mean square)
- worst of 20 runs
- formula 1.04 / √m
- Redis PFCOUNT, cited
| distinct | Redis PFCOUNT | Go, p = 14 | HLL key | Redis set |
|---|---|---|---|---|
| 100 | 100 (+0.00%) | 99 (-1.00%) | 459 B | 1,227 B |
| 1,000 | 990 (-1.00%) | 1,005 (+0.50%) | 2,076 B | 37,169 B |
| 10,000 | 9,951 (-0.49%) | 9,980 (-0.20%) | 12,829 B | 495,586 B |
| 100,000 | 100,479 (+0.48%) | 98,716 (-1.28%) | 12,830 B | 4.7 MB |
| 1,000,000 | 986,721 (-1.33%) | 1,000,619 (+0.06%) | 12,831 B | 40.3 MB |
Recorded from Redis 8 with MEMORY USAGE. A key holds a sparse encoding until it outgrows about 3 kB, then the dense 12 KB form.
Redis keeps 16,384 six-bit registers per key: 12 KB, 0.81% standard error, and daily keys merge into weekly ones.
size(ε, δ): w = ⌈e / ε⌉1, d = ⌈ln(1 / δ)⌉2
add(key, c):
FOR row IN 1 .. d:
counter[row][hash_row(key) mod w] += c3
estimate(key): // true ≤ estimate
RETURN MIN over rows4 of counter[row][hash_row(key) mod w]
// estimate ≤ true + ε·N, except with chance δ5- 1Width sets the size of the error: ε = 0.1% needs 2,719 counters a row.
- 2Depth sets how often the bound fails: 5 rows give δ = e^-5 = 0.7%.
- 3Any key can share a counter with others, so counters only overcount.
- 4The smallest counter has the fewest collisions. It is never below the true count.
- 5N is the total of all counts. The bound is absolute, not relative to the key.
Tested source Go: add and estimate
// AddHash adds c to one counter in each row.
func (s *CountMin) AddHash(h uint64, c uint32) {
h1, h2 := pair(h)
for i := range s.d {
s.rows[i][(h1+uint64(i)*h2)%uint64(s.w)] += c
}
s.total += uint64(c)
}
// EstimateHash is the smallest of the item's d counters: never below the true count.
func (s *CountMin) EstimateHash(h uint64) uint32 {
h1, h2 := pair(h)
est := uint32(math.MaxUint32)
for i := range s.d {
est = min(est, s.rows[i][(h1+uint64(i)*h2)%uint64(s.w)])
}
return est
}
- ≤ 0.1 εN
- ≤ 0.25 εN
- ≤ 0.5 εN
- ≤ εN
- above εN
| sketch | top 10 keys, median error | keys seen ≤ 5 times, median error |
|---|---|---|
| 272 × 5 | 5.48% | 82,400% |
| 2,719 × 5 | 0.26% | 3,800% |
| 2,719 × 1 | 0.88% | 7,400% |
1,000,000 events over 100,000 keys, Zipf skew 1.1. One row (2,719 × 1) puts 3.5% of keys past εN; five rows put none.
Count-min never undercounts, and its overcount is at most εN with probability 1 − δ. That is small for heavy keys and huge for rare ones.
add(item):
fp = fingerprint(item) // f bits of the hash1
i1 = hash(item) mod buckets // 4 slots per bucket
i2 = i1 XOR hash(fp)2 // each bucket finds the other
IF i1 or i2 has a free slot: store fp
ELSE REPEAT up to 500 times3:
swap fp with a random resident
move the resident to its other bucket
IF that bucket has a free slot: stop
no slot after 500 kicks: RETURN full4
remove(item): delete one fp5 from i1 or i2- 1A short fingerprint, not the item. Two items can share one, which is the false positive.
- 2Partial-key cuckoo hashing. A kicked fingerprint finds its other bucket without the original item.
- 3Near full, the kicks run long. With 4 slots a bucket, inserts work up to about 95% load.
- 4The filter refuses the item. Size it with headroom, or start a second filter.
- 5Delete only items you added. Deleting a stranger can remove another item’s fingerprint.
Tested source Go: cuckoo filter
// index returns the item's fingerprint (never 0, since 0 marks an empty slot) and both buckets.
func (c *Cuckoo) index(h uint64) (fp uint16, i1, i2 uint64) {
fp = uint16(h>>(64-c.fpBits)) & (1<<c.fpBits - 1)
if fp == 0 {
fp = 1
}
i1 = h & c.mask
return fp, i1, c.alt(i1, fp)
}
// alt is the other bucket of a fingerprint in bucket i. alt(alt(i, fp), fp) == i.
func (c *Cuckoo) alt(i uint64, fp uint16) uint64 {
return (i ^ mix(uint64(fp))) & c.mask
}
// Add stores the fingerprint of s in a free slot of either bucket. When both are full, it evicts
// a random fingerprint, moves it to its other bucket, and repeats up to maxKicks times.
func (c *Cuckoo) Add(s string) error {
fp, i1, i2 := c.index(Hash(s))
if c.put(i1, fp) || c.put(i2, fp) {
c.n++
return nil
}
i := i1
if c.rnd.IntN(2) == 1 {
i = i2
}
for range c.maxKicks {
slot := c.rnd.IntN(4)
fp, c.buckets[i][slot] = c.buckets[i][slot], fp
i = c.alt(i, fp)
if c.put(i, fp) {
c.n++
return nil
}
}
return ErrFull // fp, the last evicted fingerprint, is lost; the filter now has a false negative
}
// Has reports whether either bucket of s holds its fingerprint.
func (c *Cuckoo) Has(s string) bool {
fp, i1, i2 := c.index(Hash(s))
return c.holds(i1, fp) || c.holds(i2, fp)
}
// Remove deletes one copy of the fingerprint of s. Remove only items that were added.
func (c *Cuckoo) Remove(s string) bool {
fp, i1, i2 := c.index(Hash(s))
for _, i := range []uint64{i1, i2} {
for j, v := range c.buckets[i] {
if v == fp {
c.buckets[i][j] = 0
c.n--
return true
}
}
}
return false
}
| fingerprint | bits per item | measured | bound 8 / 2^f | Bloom, same rate |
|---|---|---|---|---|
| 8 bits | 8.4 | 2.9% | 3.1% | 7.3 bits |
| 12 bits | 12.6 | 0.18% | 0.20% | 13.2 bits |
| 16 bits | 16.8 | 0.011% | 0.012% | 18.9 bits |
Filled to 95% of 65,536 slots, 1,000,000 probes. A counting Bloom filter with 4-bit counters used 25,000 bytes against 6,250, and all 2,500 removed items then answered no.
If I must delete, a cuckoo filter stores a fingerprint per item. Below about 3% error it uses fewer bits than a Bloom filter.
space_saving.add(key): // c counters, c ≥ 10·k1
IF key has a counter: count += 1
ELSE IF a counter is free: count(key) = 1
ELSE:
low = the counter with the smallest count
low.key = key; low.count += 1 // the old count is the error2
cms_topk.add(key): // a sketch plus a heap of k keys3
est = cms.add(key) then cms.estimate(key)
IF est > smallest in the heap: replace it
reservoir.add(item): // k of a stream of unknown length
n = n + 1
IF n <= k: keep item
ELSE: j = random(0, n-1); IF j < k: slot[j] = item4- 1Any key with more than N / c occurrences is always in the table.
- 2The new key inherits the evicted count. Its true count is between count − error and count.
- 3The heap keeps the k largest estimates. Count-min overcounts, so a rare key can slip in only when εN is large.
- 4Item n stays with chance k / n. Every item of the stream ends up kept with the same chance.
Tested source Go: Space-Saving · Go: count-min plus a heap · Go: reservoir
// Add counts one occurrence of key. A key in the table gets +1. A new key takes a free counter,
// or replaces the key with the smallest count and inherits that count + 1. The inherited part is
// its error bound.
func (s *SpaceSaving) Add(key string) {
s.n++
if i, ok := s.h.at[key]; ok {
s.h.items[i].Count++
heap.Fix(&s.h, i)
return
}
if s.h.Len() < s.cap {
heap.Push(&s.h, &Item{Key: key, Count: 1})
return
}
low := s.h.items[0]
delete(s.h.at, low.Key)
s.h.at[key] = 0
*low = Item{Key: key, Count: low.Count + 1, Err: low.Count}
heap.Fix(&s.h, 0)
}
// Add counts key in the sketch, then offers its new estimate to the heap. The heap admits a key
// whose estimate beats the smallest of the current k.
func (t *CMSTopK) Add(key string) {
h := Hash(key)
t.cms.AddHash(h, 1)
est := uint64(t.cms.EstimateHash(h))
if i, ok := t.h.at[key]; ok {
t.h.items[i].Count = est
heap.Fix(&t.h, i)
return
}
if t.h.Len() < t.k {
heap.Push(&t.h, &Item{Key: key, Count: est})
return
}
if est > t.h.items[0].Count {
heap.Pop(&t.h)
heap.Push(&t.h, &Item{Key: key, Count: est})
}
}
// Add offers item number n (counting from 1). The first k items fill the sample. After that, item
// n replaces a random slot with probability k/n.
func (r *Reservoir[T]) Add(item T) {
r.seen++
if len(r.out) < r.k {
r.out = append(r.out, item)
return
}
if j := r.rnd.IntN(r.seen); j < r.k {
r.out[j] = item
}
}
| method | k | counters | precision |
|---|---|---|---|
| Space-Saving | 10 | 10 | 40% |
| Space-Saving | 10 | 100 | 100% |
| Count-min + heap | 10 | 13,605 | 100% |
| Space-Saving | 100 | 100 | 48% |
| Space-Saving | 100 | 1,000 | 100% |
| Count-min + heap | 100 | 13,695 | 99% |
Precision: the share of the reported k keys that are in the true top k, on the same skewed stream. A test checks that a 10,000-run reservoir keeps each of 100 items about 10% of the time.
For heavy hitters I keep about 10 counters for each key I report, with Space-Saving or count-min plus a heap. For a fair sample I use a reservoir.
| problem | structure | where | the error, and why it is acceptable |
|---|---|---|---|
| Cache penetration: bots ask for ids that do not exist | Bloom filter of valid ids | Redis bitmap | A false yes costs one database query. A real id is never refused. |
| Crawler: skip URLs already fetched | Bloom filter, add and test in one step | Script | A false yes skips about 1% of new URLs. A later crawl finds them through other links. |
| Unique visitors per page per day | HyperLogLog per page and day | PFADD | About 1% either way on a dashboard. Weeks come from PFMERGE. |
| Trending topics in the last hour | Count-min plus a heap, or Space-Saving, per shard | Service | Heavy topics are counted within εN. Rare topics never appear in the list. |
| Rate limit by key over millions of keys | Count-min per window | Service | Overcount only: a quiet key may be refused early, and no key passes its limit. |
| Database reads of keys that are absent | Bloom filter per data file | LSM store | A false yes reads one file for nothing. |
| Keep 1,000 traced requests out of millions | Reservoir sample | Service | Every request has the same chance. Memory stays at 1,000 entries. |
I name the error direction for each use: a filter may let a few bad ids through, a sketch may overcount, and both stay cheap.
| event | result | why it stays safe | saved by |
|---|---|---|---|
| A filter holds twice the items it was sized for | At k = 7, the rate goes from 0.83% to 13.5% (measured). | Still no false negatives. More bad ids reach the cache and the database. | Rebuild, larger |
| A plain Bloom filter must forget an item | Clearing its bits would hide other items. | Never clear bits. Rebuild from the source of truth, or use a cuckoo filter. | Rebuild source |
| A cuckoo filter nears full | Inserts fail after 500 kicks, at about 95% load. | The insert reports failure; a test shows it. Start a second filter and ask both. | Headroom |
| Remove is called for an item never added | Another item can lose its counter or fingerprint. | Only delete what the source of truth says you added. | Postgres |
| The hash function changes in a deploy | Old bits and registers no longer match new hashes. | Put the hash version in the key name. Rebuild under the new name, then switch. | Versioned key |
| Count-min is asked about a rare key | The estimate is many times too high: 3,800% median error, recorded. | Use it to find heavy keys and to cap rates. Read exact counts from the store. | Exact count |
| Redis restarts without persistence | Filters and HyperLogLogs are empty. | A filter that answers "no" for all wrongly refuses ids. Fall back to the cache path until the rebuild ends. | Fallback, rebuild |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | Postgres, exact. A row per visitor per day, a unique key, count(*). | About 45,000 unique inserts a second. Exact answers and ad hoc queries. | The table grows by a row per visitor per day, and counts scan millions of rows. |
| 2 | Redis sets per page and day, SADD and SCARD. | Exact counts in memory, about 55,000 adds a second. 40 bytes per member. | Memory: 100 million members is about 4 GB per day. |
| 3 | HyperLogLog per page and day, PFADD and PFCOUNT. | 12 KB per key, about 1% error, about 53,000 calls a second. 100 items per PFADD reach 2 million items a second. | One Redis node spends most of its time on PFADD calls. |
| 4 | Sketches in each service process, merged every few seconds. | About 70 million HyperLogLog adds a second per core. Redis sees one merge per process. | Questions span many services and time windows. |
| 5 | A stream job that keeps sketches per key and window, sharded by key. | Add partitions as events grow. Sketches merge, so windows and shards combine. | Top of the ladder. |
Demand example: 100 million page views a day is 1,157 a second. Capacity measured in the lab with 32 clients; the Go figure is one core with no network. Read the bars as orders of magnitude.
I count unique visitors exactly in Postgres first. I move to Redis HyperLogLogs when the exact sets cost too much memory, and to in-process sketches when one Redis is busy.
0 of 9 known
A Bloom filter answers "no". Can the item still be in the set?
Size a Bloom filter for 100 million ids at a 1% false positive rate.
Why can you not delete from a Bloom filter?
A filter was sized for 10 million ids at 10 bits each. It now holds 20 million. What happened to its error?
How does HyperLogLog count a billion distinct visitors in 12 KB?
You have one HyperLogLog per day. How do you get unique visitors for the week?
Count-min reports 1,200 for a key. What is the true count?
Space-Saving with 10 counters found only 4 of the true top 10. Why, and what is the fix?
You limit requests per IP with a count-min sketch per minute. What is the worst error?
- Bloom
- 9.6 bits per item and k = 7 for 1%; 14.4 bits for 0.1%, derived. Measured 0.83% at 10 bits.
- 1B items
- A 1% Bloom filter is 1.2 GB; 8-byte hashes alone are 8 GB, derived.
- cuckoo
- 12-bit fingerprints: 0.18% at 12.6 bits per item, measured.
- HyperLogLog
- 12 KB, 0.81% standard error (Redis docs). 1,000,000 visitors counted as 986,721, measured.
- exact set
- 40 bytes per member in a Redis set, measured.
- count-min
- ε = 0.1%, δ = 0.7%: 2,719 × 5 counters, 54,380 bytes, derived.
- top k
- Space-Saving with k counters: 40% precision; with 10k: 100%, measured.
- speed
- Bloom lookup about 70 ns, HyperLogLog add about 14 ns, in process. PFADD about 53,000 calls a second, measured.
Go and Redis 8 on an 8-core laptop, loopback, 32 clients, median of three runs. Use these as orders of magnitude.