The Cache That Lied

A version control service I worked on maintained a small list in Redis: it tracked which version of a code bundle resided on the shared NFS volume. Every incoming request first checked this list. A 'hit' meant the file was present locally, allowing the executor to load it directly. A 'miss' triggered a download from object storage.

One morning, a routine cleanup job executed. Its purpose was to delete old versions from NFS when space ran low. This job removed v1.42. However, the Redis list still indicated that v1.42 existed. The cache had lied. A lying cache is far more dangerous than a missing one. A miss directs you to the source of truth. A false hit, however, asserts the file is present. The loader attempts to open it, leading to an error several steps later, with no obvious culprit.

The fundamental question wasn't about choosing between Redis, Caffeine, or Memcached. The real challenge was ensuring that the list in Redis remained synchronized with the actual state of the NFS volume. Every cache, across all systems, grapples with this identical problem. Three distinct patterns offer solutions, and each comes with its own set of costs and complexities.

Diagram illustrating a request flow hitting a cache before accessing NFS

Pattern 1: Time-To-Live (TTL)

The simplest approach to cache invalidation is Time-To-Live (TTL). With TTL, you set an expiration duration for each cache entry. Once this duration passes, the cache entry is automatically considered stale and is removed or refreshed upon the next request. This is akin to setting an expiration date on milk: you know it's good until that date, and after that, you assume it's spoiled and get fresh milk.

Pros:

  • Simplicity: Easy to implement and manage. Most caching systems have built-in support for TTL.
  • Low Overhead: No complex logic is required to determine when an item is stale. The system handles it automatically.
  • Predictable Behavior: You know the maximum staleness an item can have.

Cons:

  • Potential for Staleness: The primary drawback is that data can be stale for the entire TTL duration. If real-time accuracy is critical, TTL alone is insufficient. In the version control example, if the NFS file was updated or deleted *before* the TTL expired, the cache would still serve the old or non-existent file.
  • Inefficiency: If data changes infrequently, you might be discarding and re-fetching perfectly good data long before it's actually stale. Conversely, if data changes very frequently, TTL might not be short enough to prevent significant staleness.

TTL is best suited for data that can tolerate a certain degree of staleness or data that changes at a relatively predictable rate. For many web application components, like user session data or product catalog listings that are updated hourly or daily, TTL is a practical and effective solution.

Pattern 2: Write-Through Cache

The Write-Through cache pattern ensures that data is written to both the cache and the primary data store simultaneously. When a write operation occurs, the data is first written to the cache, and then, asynchronously or synchronously, to the underlying database or storage. This pattern aims to keep the cache closely synchronized with the source of truth.

Think of this like a diligent student who immediately takes notes in both their main notebook and a separate study guide the moment they learn something new. Both sources are updated at the same time.

Pros:

  • Data Consistency: The cache is generally consistent with the data store. Reads from the cache are highly likely to return the most up-to-date information.
  • Reduced Read Latency: Subsequent reads for the same data will be fast, served directly from the cache.

Cons:

  • Increased Write Latency: Every write operation incurs the latency of writing to both the cache and the data store, which can significantly slow down write-heavy applications.
  • Cache and Data Store Failures: If the write to the data store fails after the write to the cache succeeds, the cache will contain stale data. Conversely, if the write to the cache fails but the write to the data store succeeds, the cache will be out of sync. This requires careful error handling and reconciliation mechanisms.
  • Complexity: Implementing robust write-through logic, especially with distributed caches and databases, can be complex, requiring careful consideration of transactionality and failure modes.

Write-through is a good choice when data consistency is paramount, and the application can tolerate slightly higher write latencies. E-commerce product details that need to be instantly updated for all users, or critical configuration settings, might benefit from this pattern.

Pattern 3: Cache-Aside (Lazy Loading)

The Cache-Aside pattern, also known as lazy loading or lazy initialization, involves checking the cache first during a read operation. If the data is found in the cache (a cache hit), it's returned directly. If the data is not found (a cache miss), the application then retrieves the data from the primary data store, serves it to the user, and crucially, writes it to the cache for future requests. The responsibility for populating and managing the cache lies with the application code rather than the data store or cache layer itself.

This pattern is like a librarian who first checks the recently returned books shelf (the cache) before going to the main stacks (the data store). If the book is on the shelf, they give it to you. If not, they retrieve it from the stacks, give it to you, and then place a copy on the recently returned shelf for the next person.

Pros:

  • Optimized for Reads: Only data that is actually requested is loaded into the cache, making it efficient for read-heavy workloads where not all data is frequently accessed.
  • Reduced Load on Data Store: The data store is only accessed when there is a cache miss, reducing its overall load.
  • Simpler Cache Management: The application logic handles cache updates, which can be simpler than managing simultaneous writes across multiple systems in a write-through model.

Cons:

  • Cache Miss Latency: The first request for a piece of data will experience higher latency due to the need to fetch it from the primary data store and then populate the cache.
  • Stale Data Risk (The Heart of the Problem): This is where the version control service's failure lies. If the data in the primary store is updated or deleted, and the application doesn't explicitly invalidate the corresponding cache entry, the cache will continue to serve stale or incorrect data until the entry naturally expires (if TTL is used) or is manually cleared. The example with v1.42 on NFS illustrates this perfectly: the NFS volume was cleaned, but the Redis cache was not informed, leading to a false positive.
  • Complexity in Invalidation: While the initial load is simple, correctly invalidating stale entries when the source of truth changes is the hardest part. This often requires custom logic, event listeners, or background jobs to monitor the source of truth and clear the cache accordingly.

Cache-aside is a very common pattern, especially in web applications. It balances performance and efficiency. However, it places a significant burden on developers to implement robust invalidation strategies. Failure to do so results in the exact scenario described in the source: a cache that actively misleads the application.

The Cost of Getting It Wrong

The cost of incorrect cache invalidation is not merely performance degradation; it's data corruption and application failure. A stale cache can lead to users seeing outdated information, making incorrect decisions based on faulty data, or experiencing application errors when expected data is missing. In critical systems, this can translate to financial loss, reputational damage, and a complete erosion of user trust.

For developers, the challenge is multifaceted. It involves not only choosing the right caching strategy but also implementing the accompanying invalidation mechanisms with meticulous care. This often means building custom listeners, employing event-driven architectures, or carefully orchestrating background processes to detect changes in the source of truth and purge stale cache entries promptly. The simplicity of a cache is deceptive; its reliability hinges entirely on the discipline of its invalidation.

The version control service's Redis list was a small, seemingly innocuous cache. Yet, its failure to synchronize with the NFS state caused real-world problems. This underscores a universal truth in software engineering: the most subtle components can harbor the most significant risks. Developers must treat cache invalidation not as an afterthought, but as a first-class concern, demanding rigorous design and implementation.