System Design
B10

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.

Not startedSaved in this browser only.
  1. 1Each structure gives a bounded error for a fixed, small memory. Know the direction of the error.
  2. 2A Bloom filter never says "no" to an item it holds. About 10 bits per item give 1% false positives.
  3. 3HyperLogLog counts distinct items with about 1% error in 12 KB, and daily counters merge.
  4. 4Count-min never undercounts. It is accurate for heavy keys and useless for rare ones.
B10
    A

    Exact answers cost memory

    memory per question, log scale
    Memory for one answer, in bytes1k10k100k1M10M100M1B10B100BURLs seen, exact hashes: 8 GBURLs seen, exact hashes8 GBURLs seen, Bloom 1%: 1.2 GBURLs seen, Bloom 1%1.2 GBVisitors, Redis set: 4.0 GBVisitors, Redis set4.0 GBVisitors, HyperLogLog: 12.8 kBVisitors, HyperLogLog12.8 kBCounts, exact map: 1.2 GBCounts, exact map1.2 GBCounts, count-min: 54 kBCounts, count-min54 kBbytes, log scale

    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.

    B

    Which structure, for which question

    error side and limits
    structureanswerserrorstatus
    Bloom filterseen before?False yes onlyNo deletes needed
    Counting Bloom filterseen, with deletesFalse yes; 4× memoryDeletes are rare
    Cuckoo filterseen, with deletesFalse yes; inserts can fail near fullBelow 3% error
    HyperLogLoghow many distinct?About ±1%, either wayApproved
    Count-min sketchhow often?Overcount only, up to εNHeavy keys only
    Space-Savingtop kOvercount, bounded per key10 counters per k
    Reservoir samplea fair sample of a streamSampling error onlyApproved
    Exact set or mapall of the aboveNoneIt 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.

    C

    A filter in front of the cache

    click a step; its path lights up
    Clientor a botProduct servicestateless, N copiesRebuild jobnightlyBloom filterRedis bitmap of valid idsCacheRedis, cache-asidePostgresproducts

    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.

    D

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisPFADD, PFCOUNT, PFMERGEA distinct count per key in at most 12 KB with 0.81% standard error. Merge days into weeks.Unique searches, unique players per song
    RedisBitmaps: SETBIT, GETBITThe bits of a Bloom filter that every service process shares.Daily active flags, feature flags per user
    RedisServer-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
    RedisKey expiry (EXPIRE)One sketch per day or per window, removed by itself.Sessions, windows
    RedisBloom, 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".
    RedisSorted sets: ZINCRBY, ZREVRANGEAn exact top k while the keys fit in memory.Leaderboards
    ServiceSketches in process memoryA Bloom lookup in about 70 ns and a HyperLogLog add in about 14 ns, with no network trip.Local pre-aggregation
    ServiceMergeable sketchesEach shard keeps its own counter or sketch. Merge HyperLogLogs by max, count-min by sum.Stream processing
    PostgresUnique index, count(DISTINCT)The exact answer, and the source to rebuild every filter from.Idempotency keys
    PostgresThe 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 storeA Bloom filter per data fileA 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.

    E

    Bloom filter

    illustration, then pseudo code
    illustration: m = 16 bits, k = 3 positions per item00011203140506170809010111012113014015catdogfoxowladd: set 3 bitsmaybe (wrong)no (sure)
    size, add, askpseudo code
    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
    1. 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.
    2. 2The best k fills about half of the bits. More positions make a full filter; fewer make each check weak.
    3. 3Double hashing: positions h1 + i·h2 from one hash behave like k independent hashes.
    4. 4A sure answer. Every added item set all of its bits, and bits are never cleared.
    5. 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
    Go: sizing and formulago
    
    // 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))
    }
    
    Go: add and askgo
    
    // 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
    }
    
    Redis script: add, say if newlua
    -- 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
    Redis script: asklua
    -- 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 1

    Add 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.

    F

    Try it: bits per item and k

    recorded from the Go filter
    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
    false positive rate against size, k = 7
    100%10%1%0.1%0.01%0.001%2468101214161820bits per item
    against k, at 10 bits per item
    100%10%1%0.1%0.01%0.001%123456789101112hash count k
    • 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.

    G

    HyperLogLog

    pseudo code, then recorded
    count distinctpseudo code
    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
    1. 1Redis uses p = 14: 16,384 registers of 6 bits, 12 KB.
    2. 2r leading zeros happen about once in 2^r distinct hashes. A large rank means many distinct items.
    3. 3The same item gives the same hash and the same rank, so re-adding is free.
    4. 4It damps the few registers that saw a lucky long run. Standard error 1.04 / √m.
    5. 5While many registers are still 0, count the empty ones instead. Redis also starts with a sparse encoding for small sets.
    6. 6Register-wise max. Merge is exact, so 7 daily keys give the weekly count.
    Tested source Go: add, count, merge
    Go: add, count, mergego
    
    // 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
    }
    
    relative error of the count, log scale0.5%1%2%5%10%20%50%1612 B6448 B256192 B1,024768 B4,0963 KB16,38412 KBregisters m, and memory at 6 bits eachRedis: 16,384 registers, 12 KB, 0.81%16 registers: 24% off
    • measured error (root mean square)
    • worst of 20 runs
    • formula 1.04 / √m
    • Redis PFCOUNT, cited
    distinctRedis PFCOUNTGo, p = 14HLL keyRedis set
    100100 (+0.00%)99 (-1.00%)459 B1,227 B
    1,000990 (-1.00%)1,005 (+0.50%)2,076 B37,169 B
    10,0009,951 (-0.49%)9,980 (-0.20%)12,829 B495,586 B
    100,000100,479 (+0.48%)98,716 (-1.28%)12,830 B4.7 MB
    1,000,000986,721 (-1.33%)1,000,619 (+0.06%)12,831 B40.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.

    H

    Count-min sketch

    pseudo code, then recorded
    frequencypseudo code
    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
    1. 1Width sets the size of the error: ε = 0.1% needs 2,719 counters a row.
    2. 2Depth sets how often the bound fails: 5 rows give δ = e^-5 = 0.7%.
    3. 3Any key can share a counter with others, so counters only overcount.
    4. 4The smallest counter has the fewest collisions. It is never below the true count.
    5. 5N is the total of all counts. The bound is absolute, not relative to the key.
    Tested source Go: add and estimate
    Go: add and estimatego
    
    // 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
    }
    
    share of the 64,670 distinct keys, by how much the estimate overshootsw = 272, d = 55.4 kB · εN = 9,994 · δ = 0.7%above εN: 0.008% of keys · largest overshoot 135,280w = 2,719, d = 554.4 kB · εN = 1,000 · δ = 0.7%above εN: 0% of keys · largest overshoot 490w = 2,719, d = 110.9 kB · εN = 1,000 · δ = 36.8%above εN: 3.5% of keys · largest overshoot 134,552
    • ≤ 0.1 εN
    • ≤ 0.25 εN
    • ≤ 0.5 εN
    • ≤ εN
    • above εN
    sketchtop 10 keys, median errorkeys seen ≤ 5 times, median error
    272 × 55.48%82,400%
    2,719 × 50.26%3,800%
    2,719 × 10.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.

    I

    Deletes: counting and cuckoo filters

    pseudo code, then recorded
    cuckoo filterpseudo code
    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
    1. 1A short fingerprint, not the item. Two items can share one, which is the false positive.
    2. 2Partial-key cuckoo hashing. A kicked fingerprint finds its other bucket without the original item.
    3. 3Near full, the kicks run long. With 4 slots a bucket, inserts work up to about 95% load.
    4. 4The filter refuses the item. Size it with headroom, or start a second filter.
    5. 5Delete only items you added. Deleting a stranger can remove another item’s fingerprint.
    Tested source Go: cuckoo filter
    Go: cuckoo filtergo
    
    // 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
    }
    
    fingerprintbits per itemmeasuredbound 8 / 2^fBloom, same rate
    8 bits8.42.9%3.1%7.3 bits
    12 bits12.60.18%0.20%13.2 bits
    16 bits16.80.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.

    J

    Top k and sampling

    pseudo code, then recorded
    heavy hitters, samplingpseudo code
    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
    1. 1Any key with more than N / c occurrences is always in the table.
    2. 2The new key inherits the evicted count. Its true count is between count − error and count.
    3. 3The heap keeps the k largest estimates. Count-min overcounts, so a rare key can slip in only when εN is large.
    4. 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
    Go: Space-Savinggo
    
    // 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)
    }
    
    Go: count-min plus a heapgo
    
    // 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})
      }
    }
    
    Go: reservoirgo
    
    // 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
      }
    }
    
    methodkcountersprecision
    Space-Saving101040%
    Space-Saving10100100%
    Count-min + heap1013,605100%
    Space-Saving10010048%
    Space-Saving1001,000100%
    Count-min + heap10013,69599%

    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.

    K

    Where each one is used

    the problem, the structure, the error you accept
    problemstructurewherethe error, and why it is acceptable
    Cache penetration: bots ask for ids that do not existBloom filter of valid idsRedis bitmapA false yes costs one database query. A real id is never refused.
    Crawler: skip URLs already fetchedBloom filter, add and test in one stepScriptA false yes skips about 1% of new URLs. A later crawl finds them through other links.
    Unique visitors per page per dayHyperLogLog per page and dayPFADDAbout 1% either way on a dashboard. Weeks come from PFMERGE.
    Trending topics in the last hourCount-min plus a heap, or Space-Saving, per shardServiceHeavy topics are counted within εN. Rare topics never appear in the list.
    Rate limit by key over millions of keysCount-min per windowServiceOvercount only: a quiet key may be refused early, and no key passes its limit.
    Database reads of keys that are absentBloom filter per data fileLSM storeA false yes reads one file for nothing.
    Keep 1,000 traced requests out of millionsReservoir sampleServiceEvery 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.

    L

    Failure cases

    what breaks, and what limits the damage
    eventresultwhy it stays safesaved by
    A filter holds twice the items it was sized forAt 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 itemClearing 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 fullInserts 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 addedAnother item can lose its counter or fingerprint.Only delete what the source of truth says you added.Postgres
    The hash function changes in a deployOld 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 keyThe 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 persistenceFilters 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
    M

    Scale ladder

    start exact; add a sketch on a signal
    Each step adds one component1Postgres, exact2+ Redis sets3+ HyperLogLog4+ local sketches5+ stream jobmore load →
    Capacity against demand1001k10k100k1M10M100MDemand, average: 1,157 visits per secondDemand, average1,157Demand, 10× peak: 11,574 visits per secondDemand, 10× peak11,574Postgres, unique insert: 44,706 visits per secondPostgres, unique insert44,706Redis PFADD, 1 per call: 52,803 visits per secondRedis PFADD, 1 per call52,803Redis PFADD, 100 per call: 2,023,667 visits per secondRedis PFADD, 100 per call2,023,667Go HyperLogLog, 1 core: 71,000,000 visits per secondGo HyperLogLog, 1 core71,000,000visits per second, log scale
    stepaddit handlesmove up when you see
    1Postgres, 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.
    2Redis 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.
    3HyperLogLog 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.
    4Sketches 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.
    5A 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.

    N

    Drill

    predict, then reveal

    0 of 9 known

    1. A Bloom filter answers "no". Can the item still be in the set?

    2. Size a Bloom filter for 100 million ids at a 1% false positive rate.

    3. Why can you not delete from a Bloom filter?

    4. A filter was sized for 10 million ids at 10 bits each. It now holds 20 million. What happened to its error?

    5. How does HyperLogLog count a billion distinct visitors in 12 KB?

    6. You have one HyperLogLog per day. How do you get unique visitors for the week?

    7. Count-min reports 1,200 for a key. What is the true count?

    8. Space-Saving with 10 counters found only 4 of the true top 10. Why, and what is the fix?

    9. You limit requests per IP with a count-min sketch per minute. What is the worst error?

    O

    Numbers to say

    measured, derived or cited
    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.