lumo

Module 07 · Lesson 7 of 12Free

Cache stampede: many requests hitting the database at once

Level
Advanced
Topic
Caching and CDNs
Reading time
19 min

When you finish this page you can

  1. Estimate how much duplicate database work accumulates while a cache entry is rebuilt.
  2. Implement per-key request coalescing and explain its process boundary.
  3. Choose between waiting for fresh data and serving bounded stale data.
  4. Design refresh ownership, deadlines and failure recovery without promising exactly one rebuild.

Builds on 3 lessons

Start with lesson 218 min
  1. Caching patterns: cache-aside, write-through and write-back18 min
  2. TTLs: expiring cached data18 min
  3. Distributed caching: sharing a cache across servers18 min

Video lesson · coming soon

At the festival-sale peak, 2,000 requests a second ask for the same product description. Its cached entry expires. Before the replacement arrives, hundreds of requests ask the database to rebuild the same answer.

How can Kartly refill one missing entry without turning every waiting customer into another database query?

These are illustrative numbers for one product within Kartly’s many-service stage. This lesson description data, not checkout prices or stock.

One missing entry multiplies work

Start with the cache-aside read path. The catalog service checks the . On a miss, it reads the database and stores the result.

That sequence has no owner. Two requests can both observe “missing” before either stores an answer. Each request follows the rule correctly, yet together they repeat work.

A is a burst of overlapping attempts to rebuild missing or expired cached data. It can affect one popular entry or many entries expiring together.

Check yourselfAnswer in your head first, then reveal
Kartly gives every product a randomly varied expiry time. Will that stop hundreds of requests rebuilding this one product when its entry expires?Show answerHide answer
No. Different expiry times spread misses across different products. All requests for this product still see the same missing entry until its replacement is stored.

Diagram not drawn yet: cache-stampede-unowned-miss.svg

Requests for product 42 reach catalog workers A and B. Both check the shared cache and receive missing. Both independently ask the catalog database to rebuild the same description before either stores a replacement.

A shared cache shares completed values. It does not automatically share work that is still running.

Deleting an entry after a seller edit creates the same gap. So does eviction under memory pressure. Expiry is one trigger, not the underlying cause.

The gap lasts until a replacement becomes visible. Its duration determines how many requests can enter the rebuild path.

Count arrivals during the gap

Assume rebuilding this description takes about 200 ms without contention. It includes several reads and assembly work, not one simple indexed lookup.

Convert the rebuild time

200 ms ÷ 1,000 = 0.2 seconds

Requests keep arriving throughout this interval.

Estimate duplicate attempts

2,000 requests/second × 0.2 seconds = about 400 attempts

Without coordination, about 400 arrivals can observe the missing entry before the first replacement appears.

Estimate the work admitted

400 queries × 0.2 seconds/query = 80 query-seconds

With only 40 database execution slots available to these rebuilds, that work needs at least 2 seconds of slot time.

The last calculation assumes every attempt reaches the database and keeps the same execution time. A connection limit may make requests wait earlier. Contention may make execution slower.

Either way, customers pay for repeated work through a frozen product page. Unrelated catalog requests can also lose access to database connections.

A longer rebuild creates a larger missing-entry window. That admits more duplicate work, which can lengthen the rebuild again. A small expiry event can therefore continue beyond the first burst.

Share the work before adding locks

The simplest correction is to keep a local map of work already running. Its key is the . Its value is a shared asynchronous result.

The first request creates that result and becomes the leader. Later requests become followers: they wait for the same result instead of starting another query.

This is , also called singleflight. Go’s singleflight package provides this duplicate-call suppression for overlapping calls using the same key.1

Should Kartly spread expiry times or combine requests for the same missing entry?

Add expiry jitter only.

Independent expiry times reduce synchronized misses across products. They do not reduce duplicate work for one missing product.

Why this choice

Kartly’s immediate problem is repeated work for one description. Coalescing addresses that cause. The expiry policy from TTLs addresses a different pattern: many descriptions becoming missing together.

If every product receives one request a minute, duplicate rebuilds may be negligible. If a bulk import gives thousands of entries the same expiry, spreading expiry times becomes important too.

The catalog worker owns this map

A worker follows this sequence:

  1. Read the shared cache.
  2. On a miss, look up the key in its local work map.
  3. Join an existing result, or install a new shared result.
  4. Inside the leader’s operation, check the cache again.
  5. If still missing, read the database and store the replacement.
  6. Complete the shared result, then remove it from the map.

The second cache check closes a useful race. Another worker may have filled the entry since the first check.

The key must include every input affecting the answer. A description in one language must not share work with another language. Include the same , locale and representation fields used by the cache key.

Implement the sharing mechanism

These implementations share overlapping calls within one . The supplied load operation owns the second cache check, database read and cache write.

python
import asyncio
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar

T = TypeVar("T")

class SingleFlight(Generic[T]):
    def __init__(self) -> None:
        self._tasks: dict[str, asyncio.Task[T]] = {}

    async def run(
        self, key: str, load: Callable[[], Awaitable[T]]
    ) -> T:
        task = self._tasks.get(key)
        if task is None:
            async def invoke() -> T:
                return await load()

            task = asyncio.create_task(invoke())
            self._tasks[key] = task

            def finish(done: asyncio.Task[T]) -> None:
                if self._tasks.get(key) is done:
                    del self._tasks[key]
                if not done.cancelled():
                    done.exception()

            task.add_done_callback(finish)

        return await asyncio.shield(task)
ts
class SingleFlight<T> {
  private readonly tasks = new Map<string, Promise<T>>();

  run(key: string, load: () => Promise<T>): Promise<T> {
    const existing = this.tasks.get(key);
    if (existing !== undefined) return existing;

    const pending: Promise<T> = Promise.resolve()
      .then(load)
      .finally(() => {
        if (this.tasks.get(key) === pending) {
          this.tasks.delete(key);
        }
      });

    this.tasks.set(key, pending);
    return pending;
  }
}

Removal happens after success or failure. Otherwise a failed operation could remain in the map and fail every future request.

Python’s shield keeps one cancelled waiter from cancelling the shared task. The TypeScript example passes no caller cancellation signal into the shared operation. Give that operation its own deadline rather than inheriting the first customer’s deadline.

Neither implementation stores completed values. That is the cache’s responsibility. Neither limits waiting callers or distinct keys; those limits belong around this small mechanism.

There is also a boundary: a local map coordinates only callers that can access that map.

Ownership must match the deployment

Suppose Kartly has 20 catalog worker processes. Each owns a separate work map, even when several processes run on one machine.

For one overlapping burst, local coalescing permits up to one rebuild per process. That means up to 20 rebuilds here, not one across the fleet.

This may be acceptable. It fits the scenario’s spare database capacity. Expanding to 80 processes changes that calculation.

The shared-cache topology from Distributed caching does not change this boundary. Sharing values and sharing refresh ownership are separate choices.

Does Kartly need refresh ownership across processes?

Add a shared refresh lease when the measured rebuild cost requires it.

Workers compete for a short-lived shared ownership record. This reduces fleet-wide duplicate work but adds network failures and ownership expiry to the read path.

Why this choice

A lease is ownership that expires unless renewed. Use one when duplicate rebuilds across the fleet are too expensive, not merely because the cache is distributed.

A cheap description lookup may tolerate several copies. An expensive assembled description may not. Revisit the choice when worker count, rebuild duration or database pressure rises.

A lease coordinates local leaders

If needed, only each process’s local leader competes for the shared lease. Followers remain attached to their local result.

For a single Redis instance, an acquisition can use SET refresh:42 unique-token NX PX 1000. It creates the record only when absent and expires it after one second.2

The token identifies this attempt. Release must atomically compare the token and delete only a matching record. Unconditional deletion could remove a newer owner’s lease.2

Kartly’s proposed lease flow is:

  1. Acquire the lease, then check the data cache again.
  2. If still missing, start a database read with a shorter operation deadline.
  3. Publish the replacement only through an atomic operation that checks current ownership.
  4. Release matching ownership after publication.
  5. If acquisition fails, recheck for a completed value after a short randomized delay.

For this design, the ownership check and publication happen on the same cache instance. A separate “check token, then write” sequence leaves a race between those operations.

A lease is not a promise of exactly one rebuild. Its owner can pause past expiry. Redis can also lose ownership state before completes.2

Accept occasional duplicate reads here, but reject publication by an expired owner. Concurrent seller updates require separate protection, covered in Cache race conditions.

Put the pieces on the request path

Diagram not drawn yet: cache-stampede-coalesced-refresh.svg

Requests enter catalog workers containing per-key local work maps. Workers read description values from a shared cache. An optional lease record in that cache coordinates local leaders. A winning leader reads the catalog database, publishes the replacement and completes its local shared result. Followers never query the database.

Local maps combine callers. An optional shared lease combines local leaders when fleet-wide duplicate work becomes too expensive.
Component Responsibility Talks to State kept Failure behavior
Request handler Enforce caller deadline Work map, cache Caller context Return stale data or an error
Local work map Combine one key’s callers Leader operation Running results Process crash loses local waiters
Shared cache Serve values; optionally coordinate refresh Workers Entries, lease tokens Stop lease-dependent fills
Catalog database Supply description data Admitted leaders Durable product records Refresh fails; preserve old entry

With leases enabled, losing local leaders recheck the cache, not the database. Bound their polling and stop at the caller’s deadline.

Without such a bound, coordination protects database execution but leaves customers waiting indefinitely. The next decision is what they should see during that wait.

Keep old data for readers

Hard expiry removes the only answer just before Kartly starts producing its replacement.

For description text, Kartly can instead keep a short period where an old answer remains usable. means serving that answer while refreshing it in the background. RFC 5861 describes this behavior for caches.3

Kartly can apply the same idea inside its catalog service. The service must implement it; an HTTP header does not configure an application cache.

What should customers receive while a description refresh runs?

Make every reader wait for fresh data.

Readers receive the new result or an error. They also wait through the rebuild, and a slow rebuild accumulates waiting requests.

Why this choice

Description wording can tolerate a short delay in this scenario. Stock reservations cannot use this policy merely because it improves page speed.

If legal removals or other urgent changes must take effect immediately, stale serving must respect that requirement. A freshness decision belongs to each kind of data, not the cache as a whole.

Store two deadlines, not one

For this scenario, keep descriptions fresh for one minute. Permit stale serving for another half-minute.

The entry stores the payload, a freshness deadline and a stale-serving deadline. Physical expiry must retain the payload until at least the stale-serving deadline.

Entry condition Response Refresh behavior
Before freshness deadline Return cached value None
After freshness deadline, before stale limit Return old value Start or join one refresh
Missing, or past stale limit Wait within a deadline Start or join one refresh
Refresh fails Preserve old payload Apply retry cooldown

At 65 seconds after publication, a customer receives the old description immediately. One background operation refreshes it. Another customer arriving during that operation receives the old description too, without starting another operation.

After the stale limit, the old payload is no longer an acceptable response under this policy. Wait briefly for a refresh or return an explicit unavailable response.

A failed refresh must not move either deadline forward. Otherwise repeated failures can make an old description appear usable forever.

Do not attach this background work to a response that has already completed. A worker-level supervisor must retain the task, observe errors and enforce its deadline.

This removes much of the waiting during normal expiry. A cold start still has no old value to return.

Failure needs a separate budget

Coalescing limits duplicate work for one key. It does not limit work across different missing keys.

If thousands of descriptions become missing after a cache restart, each can legitimately elect a different leader. The database still needs an independent rebuild budget.

Choose that budget from measured spare database capacity. Allocate it across workers, and account for process growth. A per-process limit silently multiplies when the fleet expands.

Failure What customers see Detection Recovery rule
Database refresh fails Old description, or unavailable Refresh errors and duration Cooldown before another attempt
Lease owner crashes Cold readers wait briefly Lease age and request timeouts New owner after expiry
Cache write fails Leader may have data, but later readers still miss Publication errors Cooldown; do not admit unlimited fills
Shared cache fails Stale data only if separately available, otherwise unavailable Cache timeouts Use a bounded fallback policy

A cooldown records a short “do not retry before” deadline. It prevents each newly arriving request from becoming the next failed leader. Randomizing that delay reduces synchronized retries.

Do not cache a temporary database failure as “product not found”. Those answers mean different things to a customer.

A caller timeout must not become permission to query the database independently. That would rebuild the original stampede precisely when the dependency is already slow.

Broader cache-outage handling belongs in Cache failures and database fallback.

Measure rebuilds, not just hits

Record refresh starts, joined callers, duration, publication failures and stale responses. Track how often the stale limit is reached.

Compare refresh starts with logical misses during expiry. Many joined callers and few starts suggest coalescing works. Growing joins with no successful publication suggest a stuck or failing refresh.

Avoid a metric label for every product ID. Use bounded dimensions and sampled traces to investigate individual keys.

To exercise the design, remove one test entry during steady traffic. Check that rebuild starts match the intended coordination scope. Then fail the refresh and confirm that cooldown prevents repeated immediate attempts.

Prewarming can reduce cold starts for known sale products. It does not replace coordination: the warmup itself needs a budget, and entries can disappear later.

Where this shows up

Even after duplicate rebuilds disappear, every for this product may still reach one overloaded cache node—the next problem in Hot keys.

Common mistakes

  • Using one global refresh lock. A slow description then blocks unrelated products. Coordinate per key and separately bound total database work.
  • Letting every stale reader schedule a job. Moving duplicate work into the background does not remove it. Coalesce before scheduling.
  • Setting the lease from average rebuild time. Slow operations can outlive ownership. Use deadlines and reject publication after ownership expires.
  • Extending stale lifetime after failure. That hides an outage by weakening the freshness promise. Keep the original stale limit.
  • Claiming one rebuild across the fleet. Local maps have a process boundary. Shared leases also have expiry and failure limits.

Check your understanding

Check yourselfAnswer in your head first, then reveal
A product receives 5,000 requests a second and takes 100 ms to rebuild. About how many requests can observe it missing before the first replacement appears?Show answerHide answer
About 500, assuming steady arrivals and no earlier replacement. Multiply the arrival rate by 0.1 seconds; these are potential rebuild attempts, not necessarily simultaneous database executions.
Check yourselfAnswer in your head first, then reveal
Kartly expands from 20 worker processes to 80. Each uses local coalescing. Why can database rebuild load rise even though traffic stays unchanged?Show answerHide answer
There are more independent work maps. An overlapping burst can elect one leader in each process, so the fleet-wide duplicate budget grows.
Check yourselfAnswer in your head first, then reveal
A stale description is past its permitted stale limit, and refreshing it fails. Should the service reset the stale deadline?Show answerHide answer
No. Resetting it would turn a bounded freshness policy into unlimited stale serving. Return an unavailable response unless a separate, explicit policy permits more staleness.
Check yourselfAnswer in your head first, then reveal
Worker A pauses until its lease expires. Worker B acquires a new lease. What must stop A from publishing its old result?Show answerHide answer
The publication operation must atomically verify that A still owns the lease. A local ownership check or token-safe release alone does not protect the cache write.

Summary

  1. Refill a missing entry through shared work, rather than letting every waiting request rebuild it.
  2. Estimate uncoordinated attempts as requests per second multiplied by rebuild time.
  3. Local coalescing combines callers within one process; add shared ownership only when fleet-wide duplication exceeds the budget.
  4. Serve bounded stale data where the product permits it, without extending stale deadlines after failure.
  5. Bound refresh work, waiting and retries independently; leases reduce duplication but do not guarantee exactly one rebuild.

Report a mistake

Tell us what’s wrong in this lesson and we’ll fix it.