Skip to main content
Create your own
Lesson illustration

Cache Penetration: Diagnosis & Mitigation with Bloom Filters

Welcome back. In our previous lesson, we tackled the "thundering herd" problem of cache stampedes, where the sudden expiration of a popular item can overwhelm a database. Today, we'll continue our exploration of caching pitfalls by examining a more insidious threat: requests for data that doesn't exist at all.

This brings us to the focus of this lesson: to diagnose and mitigate cache penetration using techniques like Bloom filters. Understanding and solving this problem is a key differentiator for engineers building resilient, high-performance systems. It demonstrates a proactive approach to system defense, which is highly valued in senior engineering roles and a common topic in system design interviews.

The Problem: Death by a Thousand Paper Cuts

In a typical cache-aside pattern, a request for a key first checks the cache. If it's a miss, the application queries the database, populates the cache with the result, and returns the data. But what if the requested key corresponds to data that never existed in the first place?

This scenario is called cache penetration. The request will always miss the cache and always hit the database, only for the database to do work to determine the data is not there. If a high volume of requests for non-existent keys occurs—either due to a bug or a malicious attack—the cache becomes useless as a protective layer. Your database is then forced to handle a flood of pointless queries, leading to high CPU usage, I/O load, and potential service degradation.

Caching Pitfalls Every Developer Should Know

The ByteByteGo channel provides a concise, animated explanation of this problem.

Please watch the segment on Cache Penetration. It clearly illustrates how queries for non-existent data bypass the cache and can destabilize the system.

A simple mitigation is to cache the "not found" result itself, often as a special placeholder value. The next time the same non-existent key is requested, the application gets a cache hit on the placeholder and immediately returns, protecting the database. However, this can pollute your cache with a potentially vast number of negative entries, consuming valuable memory that could be used for valid data. This is especially problematic if an attacker is generating random keys.

We need a more memory-efficient way to know if a key is definitively not in our dataset before we even consider hitting the cache or database. This is the perfect use case for a Bloom filter.

The Bloom Filter: A Probabilistic Gatekeeper

A Bloom filter is a space-efficient probabilistic data structure that can answer one question: "Is this item a member of a set?" It answers with one of two responses:

  • "Definitely No": The item is 100% not in the set.
  • "Probably Yes": The item might be in the set. There is a small, configurable probability of a false positive.

Crucially, a Bloom filter never has false negatives. If it says an item isn't in the set, it's telling the truth. This property is exactly what we need to fend off cache penetration.

Bloom Filters

This video from the mCoding channel offers a fantastic conceptual introduction to Bloom filters.

First, watch the introductory scenario about blocking malicious links, which is a perfect analogy for filtering non-existent keys. Then, watch the explanation of the core principles, including the "possibly yes, definitely no" guarantee and the "shadows" analogy for how it works.

As the video explains, the filter acts as a gatekeeper. We can pre-load it with all the valid keys from our database. When a request arrives, we first ask the Bloom filter. If it says "Definitely No," we can immediately reject the request without touching the cache or database. If it says "Probably Yes," we proceed with our normal cache-aside logic. The small percentage of false positives means a few unnecessary queries will still get through, but we will have blocked the overwhelming majority.

How Bloom Filters Work

At its core, a Bloom filter consists of two things:

  1. A bit array of m bits, all initially set to 0.
  2. A set of k different hash functions.

To add an item:
You hash the item k times. Each hash function produces an index into the bit array. You then set the bits at all k of these indices to 1.

To check if an item exists:
You hash the item k times using the same hash functions. You then look at the bits at all k resulting indices.

  • If any of the bits is 0, the item was definitively not added.
  • If all of the bits are 1, the item was probably added. It's possible that those bits were set by other items that were added to the filter. This is a false positive.
This diagram shows the flow of using a Bloom filter to prevent cache penetration. On startup, the filter is populated with all valid order IDs from the database. When a user queries for an order, the system first checks the Bloom filter. If the filter reports a "miss" (meaning "definitively no"), the system can immediately respond with "NOT FOUND," saving a useless cache and database lookup.

Building a High-Performance Bloom Filter in Go

Given your background in Go, let's dive into what a practical, production-ready implementation looks like. The gestrada.dev blog has an excellent deep-dive that builds a concurrent-safe Bloom filter from scratch, and we will use it as our guide.

The Magic of Bloom Filters | gestrada.dev

This article is a comprehensive guide to building a production-grade Bloom filter in Go. It covers the theory, implementation details, and performance benchmarks.

Please read the following parts of the article: Start with the sections The "Hmmm, Probably?" Data Structure and Enter the Bloom Filter for a great introduction to the problem and the solution. Read the analogy in The VIP Bouncer, which provides an excellent mental model. Next, focus on the implementation details. Read through the sections Creating the Filter, The Kirsch-Mitzenmacher Optimization, and Adding and Checking Data. As you read, pay close attention to these key engineering decisions: The formulas for optimalM and optimalK that allow you to create a filter based on your desired capacity (n) and false-positive rate (p). The Kirsch-Mitzenmacher optimization, a clever trick to simulate k independent hash functions using only one fast hash function, which drastically improves performance. The use of atomic.Uint64 for the bitset to create a filter that is safe for concurrent reads and writes without needing a mutex, which is essential for a high-throughput backend service.

The article walks through a masterclass in practical library design for Go. The implementation is not only correct but also highly performant and easy to use, providing a constructor that takes the expected number of items and the desired false-positive rate—the two parameters you would care about as a system designer.

For example, to create a filter for 1 million items with a 1% false positive rate, you'd simply call:
filter := NewFilterFromProbability(1_000_000, 0.01)

The library handles the math of calculating the optimal bit array size (m) and number of hash functions (k) for you. The result is a structure that is incredibly fast (lookups in tens of nanoseconds) and memory-efficient (the filter for 1M items takes up just over 1 MB).

Living with the "No-Delete" Constraint

A standard Bloom filter has one major limitation: you cannot delete an item from it. Because multiple items can set the same bit, clearing a bit to "remove" one item could inadvertently corrupt the membership test for other items, introducing false negatives.

So, how do real-world systems handle changing data?

  1. Rebuild Periodically: The most common strategy. The filter is treated as immutable. You build it from a snapshot of your database. When the data changes significantly, you build a new filter from a new snapshot and atomically swap it in. This is what systems like Apache Cassandra do.
  2. Counting Bloom Filters: A variant that uses a small counter (e.g., 4 bits) for each slot instead of a single bit. Adding an item increments the counters, and deleting decrements them. The trade-off is a 4x increase in memory usage.
  3. Time-Based Rotation: Maintain two filters, a current and a previous one. New items are added to current. Queries check both. Periodically, you discard previous, promote current to previous, and create a new, empty current. This is effective for datasets where old data naturally becomes irrelevant.
This diagram illustrates a more advanced, distributed setup. Each application server has a local Bloom filter for recently accessed keys, while a global Bloom filter is maintained in the distributed cache layer (e.g., Redis). This layered approach can offer even better performance and efficiency in a large-scale system.

Conclusion

In this lesson, we've addressed cache penetration, a critical vulnerability in caching systems. We've seen how malicious or buggy requests for non-existent data can bypass the cache and overload the database.

Key Takeaways:

  • Cache Penetration: Occurs when requests for non-existent keys repeatedly hit the database, as they will never be found in the cache.
  • Bloom Filter: A probabilistic data structure that provides a highly space-efficient way to test for set membership.
  • "Definitely No" Guarantee: The key property of a Bloom filter is that it has no false negatives. If it says a key is not in the set, it is 100% correct. This allows us to safely reject requests for non-existent keys.
  • Practical Design: When implementing a Bloom filter, key considerations include choosing the right size (m) and number of hash functions (k) for your desired false-positive rate, and using optimizations like Kirsch-Mitzenmacher for performance.
  • Immutability: Standard Bloom filters do not support deletion. Systems must handle dynamic data by periodically rebuilding the filter or using variants like counting filters.

You now have a powerful tool for building more resilient and efficient systems. This is the last lesson in our module on Advanced Distributed Concepts. You've tackled unique ID generation, cache stampedes, and now cache penetration—three advanced topics that are crucial for designing systems at scale.

In our next module, we will shift gears to focus on the system design interview process itself. We'll begin by tackling a classic interview problem: designing a distributed rate limiter. You'll apply the patterns we've learned to a practical design challenge, preparing you to articulate your design choices under pressure.

Can't find a good explanation? Sign up and we'll make it for you

Sign up