Probabilistic early expiry doesn’t actually make your cache expire early; it makes it look like it did to a select few.
Let’s watch this in action. Imagine a popular piece of data, user:123, that’s about to expire from our Redis cache. Without any special handling, what happens when multiple requests for user:123 hit simultaneously right as its TTL (Time To Live) elapses?
Request 1: GET user:123 -> MISS
Request 2: GET user:123 -> MISS
Request 3: GET user:123 -> MISS
...
Each of these requests sees a cache MISS. They all proceed to hit the backend database (or your slow service), fetch the data, and then, crucially, all of them try to write it back to the cache. This is a cache stampede. The database gets hammered, and the application becomes sluggish.
Now, let’s introduce probabilistic early expiry. We’ll configure our cache client (let’s assume it’s a Python client using redis-py for this example) to, with a certain probability, pretend an item is stale even if its TTL hasn’t strictly run out.
Here’s a simplified Python snippet:
import redis
import random
import time
r = redis.Redis(host='localhost', port=6379, db=0)
def get_with_probabilistic_expiry(key, default_value, expiry_prob=0.05):
value = r.get(key)
if value:
# With a 5% chance, we'll treat it as a miss anyway
if random.random() < expiry_prob:
print(f"Probabilistically treating {key} as expired.")
value = None # Force a re-fetch
if not value:
print(f"Cache miss for {key}. Fetching from source...")
# Simulate fetching from a slow database
time.sleep(1)
new_value = default_value # In a real app, this would be a DB query
r.set(key, new_value, ex=60) # Set expiry to 60 seconds
print(f"Stored new value for {key} in cache.")
return new_value
else:
print(f"Cache hit for {key}.")
return value
# Example Usage
# First call - will populate cache
print(get_with_probabilistic_expiry('my_data', 'initial_data'))
# Simulate multiple concurrent requests for the same key shortly after
print("Simulating concurrent requests...")
for i in range(5):
print(f"Request {i+1}: ", end="")
print(get_with_probabilistic_expiry('my_data', 'initial_data'))
time.sleep(0.1) # Small delay to stagger requests slightly
When you run this, you’ll see some requests get a cache hit, while others, due to the random.random() < expiry_prob check, will trigger a "Probabilistically treating my_data as expired." message. These requests still hit the database, but because only a fraction of the requests do this, the database load is significantly reduced. The random.random() check is the "probabilistic" part.
The core problem this solves is the "thundering herd" or cache stampede. When a popular cache entry expires, and many clients simultaneously request it, they all miss the cache and hit the origin data source. This can overwhelm the data source. Probabilistic early expiry is a strategy to mitigate this by ensuring that not all clients experience a cache miss at the exact same moment. Instead, a small, controlled percentage of clients are induced to re-fetch the data slightly before its actual expiry.
Here’s how it works internally. You’re not actually changing the TTL on Redis. You’re adding a check in your application logic before returning a cached value. When your application retrieves a value from Redis, it applies an additional, probabilistic check. If this random check "fails" (meaning the random number is less than your configured probability), your application treats the cached item as if it had expired, even if its TTL hasn’t elapsed. It then proceeds to fetch fresh data from the origin and re-populate the cache.
The key is that this "early expiry" is only observed by the client application, not by Redis itself. Redis continues to serve the item until its actual TTL is reached. By making a small percentage of requests re-fetch data periodically, you ensure that there’s almost always a valid, fresh copy of the data in the cache for the majority of requests. When the actual TTL expires, there’s a good chance that one of the "early expiring" requests has already refreshed it.
The actual levers you control are the expiry_prob (the probability, e.g., 0.05 for 5%) and the default_ttl you set when re-populating the cache. A higher expiry_prob means more re-fetches but a lower chance of stampede. A lower expiry_prob means fewer re-fetches but a higher chance of stampede. The default_ttl should be long enough to benefit from caching but short enough to reflect data freshness requirements.
What most people don’t realize is that this technique doesn’t require any special Redis commands or configuration. It’s entirely an application-level pattern. The "early expiry" is a logical construct within your application code, not a physical change to the data’s lifecycle in Redis. This makes it incredibly flexible and applicable across different Redis clients and languages without needing to modify Redis itself.
The next problem you’ll likely encounter is managing the complexity of this probabilistic logic across many different cache keys and application services.