sehaj/learn
System design/05 Partitioning and IDs

05.08 · with code · 10 min

Bloom filters

A few bits per item that answer "definitely not in the set" or "probably in it". How Bloom filters work, how to size one, a small implementation, and where databases use them.

The problem: an expensive "no"

Before Snip shortens a URL, it checks it against a blocklist of known phishing and malware sites: 50 million entries from a security feed, held by a separate blocklist service. Snip creates about 350 links a second at peak, and almost every URL is fine. So nearly every check is a network call whose answer is "no".

Keeping the whole list in each app server would avoid the call, but 50 million URLs averaging 80 bytes is about 4 GB per server. What Snip needs is something small that can say "definitely not on the list" for the clean URLs, and send only the rest to the service.

How a Bloom filter works

A Bloom filter is an array of m bits, all zero to start, and k hash functions.

  • Add an item: hash it k ways, giving k positions, and set those bits to 1.
  • Check an item: hash it the same k ways. If any of those bits is 0, the item was definitely never added. If all are 1, it was probably added, or its bits happen to have been set by other items.

So a Bloom filter has no false negatives (if it says no, the answer is no) and a tunable rate of false positives (it sometimes says maybe for an item that isn't there). For Snip that's exactly right: a false positive costs one unnecessary call to the blocklist service, which gives the real answer.

Checking a URL against Snip's blocklist
  1. 01

    Hash the URL

    7 hash functions give 7 bit positions.

  2. 02

    Any bit is 0

    Definitely not on the list: shorten it. About 99% of checks end here.

  3. 03

    All bits are 1

    Maybe on the list: ask the blocklist service for the real answer.

  4. 04

    Service answers

    Blocked if it's really there; a false positive costs only this call.

Sizing one

Two numbers decide the size: how many items you'll add (n) and the false positive rate you'll accept (p). The optimal size and number of hashes are:

text
bits  m = −n · ln(p) / (ln 2)²
hashes k = (m / n) · ln 2

That works out to roughly 10 bits per item for 1%, whatever the items are:

Bits per itemHashesFalse positive rate
53about 9%
107about 0.8%
1510about 0.07%

Each extra 5 bits per item cuts false positives about tenfold. For Snip's 50 million URLs at 1%: about 480 million bits, 60 MB, with 7 hashes. That fits comfortably in every app server, against 4 GB for the list itself.

A small implementation

Instead of k different hash functions, a standard trick derives all k positions from two hashes: position i is h1 + i·h2, wrapped to the array's size.

ts
import { createHash } from "node:crypto";

export class BloomFilter {
  readonly m: number; // bits
  readonly k: number; // hashes per item
  private bits: Uint8Array;

  constructor(expectedItems: number, falsePositiveRate: number) {
    this.m = Math.ceil((-expectedItems * Math.log(falsePositiveRate)) / Math.LN2 ** 2);
    this.k = Math.max(1, Math.round((this.m / expectedItems) * Math.LN2));
    this.bits = new Uint8Array(Math.ceil(this.m / 8));
  }

  private positions(item: string): number[] {
    const d = createHash("md5").update(item).digest();
    const h1 = d.readUInt32BE(0);
    const h2 = (d.readUInt32BE(4) | 1) >>> 0; // odd, and unsigned
    return Array.from({ length: this.k }, (_, i) => (h1 + i * h2) % this.m);
  }

  add(item: string) {
    for (const p of this.positions(item)) this.bits[p >> 3] |= 1 << (p & 7);
  }

  /** false: definitely never added. true: probably added. */
  mightContain(item: string): boolean {
    return this.positions(item).every((p) => (this.bits[p >> 3] & (1 << (p & 7))) !== 0);
  }
}

Tested with 200,000 items at a 1% target, it uses 240 KB, reports every added item, and says "maybe" for about 0.98% of items it never saw. (The >>> 0 matters: without it, | turns large hashes negative in JavaScript and positions go out of range.)

What Bloom filters can't do

  • No deletes. Clearing an item's bits could clear bits other items share. Rebuild the filter from the source list instead (Snip rebuilds from the feed every hour and swaps it in), or use a counting Bloom filter or a cuckoo filter, which support removal at a cost in space.
  • Fixed capacity. Add far more than n items and the false positive rate climbs steeply. Size for growth, and rebuild larger when needed.
  • No listing. You can't get the items back out; it only answers "might this be in the set?"

Where they're used

The classic use is inside storage engines. An LSM-tree database (09.03 The LSM write and read path) keeps data in many sorted files, and a lookup for a key might otherwise read every one. A Bloom filter per file, held in memory, rules out the files that don't have the key, so most lookups read one file or none. Cassandra sets a false positive chance per table, 0.01 by default for most tables. Other uses: a web crawler skipping URLs it has already fetched (15.06 Design: a web crawler), a CDN declining to cache objects requested only once, and a cache rejecting lookups for keys that can't exist.

Seen in production

Meta

2021

RocksDB

RocksDB, the storage engine Meta built and many databases embed, can store a Bloom filter in every data file to tell whether the file may contain a key; a zero answer means the key definitely isn't there. The wiki gives the trade-off directly: about 4.9 bits per key for a 10% false positive rate, 9.9 bits for 1% and 15.5 bits for 0.1%. It notes that filters often take around 10% of a system's memory, and that a newer variant, the Ribbon filter, saves about 30% of that space for the same accuracy.

What we foundAbout 10 bits per key in every data file lets most reads skip files that can't contain the key.

RocksDB Bloom Filter (project wiki)github.com ↗

Snip at the end of Module 05

Snip, split across machines
codemaybeSnip app+ Bloom filterRedis ringconsistent hashShard router256 logicalBlocklist50M bad URLsLink shardsby codeOwner shardsby owner

4 steps. Use Next and Back to follow the request.

Text version
  1. Redirects look the code up in a Redis cluster whose nodes are placed with consistent hashing (05.03), so adding a node moves only its share of keys. Viral codes also sit in a tiny in-process cache (05.02).
  2. Misses and new links go through the shard router: links are sharded by a hash of the code across 256 logical shards (05.01), which move between machines as whole units (05.04).
  3. The dashboard reads a copy of each owner's links, sharded by owner, and pages through it with keyset cursors (05.07).
  4. New codes are random base62, checked by the owning shard's primary key (05.05). Each long URL is first checked against a 60 MB Bloom filter; only the rare 'maybe' asks the blocklist service.
  • Snip app → Redis ring: code
  • Snip app → Shard router
  • Shard router → Link shards
  • Shard router → Owner shards
  • Snip app → Blocklist: maybe

Snip's data no longer fits one machine, and nothing depends on a single counter or a single clock. Every request still reaches its data in one hop, because each query was matched to a shard key. The next module goes deep on the layer that answers most of Snip's traffic before any of this is touched: caching (06.01 Where caches live).

Predict first

Snip's blocklist filter says 'definitely not' for a URL. Later the URL is added to the security feed, but the filter isn't rebuilt for an hour. What happens if someone shortens it in that hour?

Commit to an answer before you read on.

Recap

Interview questions

MidfreqGood to knowConcept

What is a Bloom filter, and when would you use one?

Report a mistakeEdit this page