Lập trình · 19/09/2026

Safe Redis Caching for Backends: TTLs, Invalidation, and Stampede Protection

A cache can sharply reduce latency and database load, but it can also serve stale data, hide failures, and unleash concurrent queries when a hot key expires. Production cache design must define the authoritative source, acceptable staleness, invalidation strategy, and application behavior when Redis is unavailable.

Thiết kế cache Redis an toàn cho backend: TTL, invalidation và chống cache stampede

A cache can sharply reduce latency and database load, but it can also serve stale data, hide failures, and unleash concurrent queries when a hot key expires. Production cache design must define the authoritative source, acceptable staleness, invalidation strategy, and application behavior when Redis is unavailable.

When should you add a cache?

Add caching only after measuring the bottleneck. It fits repeatedly read data that is expensive to compute, changes less often than it is read, and can tolerate bounded staleness. Examples include product catalogs, public configuration, profiles, aggregate queries, and responses from slow dependencies.

Do not use a cache to hide poor queries, missing indexes, or bad API design. Balances, payment state, and authorization require special care; a fast but incorrect cache hit is still a defect.

Cache-aside is easy to reason about

  1. Read Redis using a cache key.
  2. On a hit, deserialize and return.
  3. On a miss, read the authoritative database.
  4. Store the result in Redis with a TTL.
  5. When data changes, write the database and delete the cache key.
async function getProduct(id) {
  const key = `shop:v3:product:${id}`;
  const cached = await redis.get(key);

  if (cached !== null) return JSON.parse(cached);

  const product = await database.products.findById(id);
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}

The cache holds only requested data and the database remains authoritative. The tradeoffs are slower misses, application-managed cache logic, and concurrency concerns.

Namespace and version keys

shop:v3:product:8421
shop:v3:category:18:page:2:sort:newest
billing:v1:customer-summary:991

A schema version enables a new cache format without rewriting old entries. Do not embed secrets or unnormalized user input in keys. Canonicalize and hash complex query parameters to avoid multiple keys for the same query.

TTL is a staleness boundary

Choose TTL from business tolerance and acceptable primary-store load. Product data may tolerate minutes, a critical feature flag seconds, and static content much longer.

Add random jitter so keys do not expire together:

const baseTtl = 300;
const jitter = Math.floor(Math.random() * 60);
await redis.set(key, payload, { EX: baseTtl + jitter });

Every cache-aside entry should have a TTL even with explicit invalidation. It limits stale duration when an invalidation is missed.

Write the database, then invalidate

await database.transaction(async (tx) => {
  await tx.products.update(productId, changes);
});

await redis.del(`shop:v3:product:${productId}`);

Deleting is usually safer than updating the cached value because the next read reloads the authoritative source. If cache deletion happens before a slow or failed database update, another request may repopulate the old database value.

A failure window remains between database commit and DEL. For important data, use a transactional outbox or change-data-capture event that can retry invalidation. TTL bounds staleness if delivery ultimately fails.

Control read-write races

A miss may read an old value, another request may update and invalidate, and then the first request may write the old value back. Mitigations include:

  • Shorter TTL for frequently changing data.
  • A version or updated_at value checked before cache writes.
  • Delayed double deletion for measured race conditions.
  • Post-commit invalidation events with idempotent consumers.
  • Bypassing cache on strict read-after-write paths.

Prevent cache stampedes

When a hot key expires, many concurrent requests may hit the database. Use per-key single-flight or a mutex so only one caller loads:

const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
const acquired = await redis.set(lockKey, token, { NX: true, PX: 5000 });

if (acquired) {
  try {
    const value = await loadFromDatabase();
    await redis.set(key, JSON.stringify(value), { EX: 300 });
    return value;
  } finally {
    await releaseOnlyIfTokenMatches(lockKey, token);
  }
}

return await waitBrieflyAndReadCacheAgain();

The lock TTL must exceed worst-case loading time, use a unique token, and be released only by its owner. Callers that lose the lock should wait briefly, retry the cache, or use a stale value rather than spin forever.

Use stale-while-revalidate selectively

Store a soft and hard expiration. After soft expiry, return stale data while one worker refreshes it; after hard expiry, require the authoritative source. This reduces latency and stampedes but only works when bounded staleness is acceptable.

Do not use stale-while-revalidate for permissions, limits, or transactional values where an old answer can cause an incorrect action.

Negative caching

Repeated requests for missing IDs can bypass the cache and overload the database. Cache a short-lived not-found sentinel:

if (cached === '__NOT_FOUND__') return null;

const value = await loadFromDatabase(id);
if (value === null) {
  await redis.set(key, '__NOT_FOUND__', { EX: 30 });
  return null;
}

Keep negative TTL shorter than positive TTL so newly created records appear quickly. Validate ID formats, rate-limit clients, and prevent attackers from generating unlimited random keys.

Memory limits and eviction policy

Configure Redis maxmemory and a suitable policy. For a cache-only instance, allkeys-lru or allkeys-lfu is often a reasonable starting point depending on access patterns. noeviction returns errors for new writes at the memory limit.

Avoid mixing evictable cache with durable queues or critical sessions in one instance when separation is possible. volatile-* policies evict only keys with TTL and can behave like noeviction when other keys consume memory.

Behavior when Redis fails

  • Use short connect and read timeouts.
  • Circuit-break cache calls after repeated failures.
  • Rate-limit and cap concurrent database fallback.
  • Do not fail a request solely because a cache write failed when business rules allow.
  • Record metrics and alert on failure rate instead of silently swallowing every error.

If Redis stores sessions, locks, or rate-limit state, it is no longer a pure optimization layer; design its availability and failure behavior separately.

Serialization and payload size

Cache small, stable objects containing only needed fields. Large payloads increase network, serialization CPU, and memory fragmentation. Version the value schema, treat deserialization failures as misses, and delete corrupt entries.

Compress only when measurements show that memory or bandwidth savings exceed CPU cost. Do not cache secrets or personal data without evaluating encryption, retention, and Redis access controls.

Production metrics

  • Hit and miss rates by key family.
  • Redis and database-loader p50, p95, and p99 latency.
  • Evictions, expirations, used memory, and fragmentation.
  • Connections, rejected connections, and timeouts.
  • Lock contention, wait time, and loader concurrency.
  • Database fallback rate during Redis failures.
  • Invalidation delay and retry or dead-letter events.

A high hit rate is not automatically good. The cache may be hitting incorrect data or retaining low-value objects. Also measure database load, end-to-end latency, and freshness.

Test production behavior

  1. Hits, misses, expiration, and invalidation after updates.
  2. Concurrent requests for one cold key produce only one database load.
  3. Redis timeout, disconnection, and maxmemory behavior.
  4. A slow database while the lock approaches expiration.
  5. Duplicate or out-of-order invalidation events.
  6. Negative caching does not hide new records for too long.
  7. A new value schema deploy fails safely or produces controlled misses.

Pre-release checklist

  1. The bottleneck was measured and caching is justified.
  2. The database or origin service remains authoritative.
  3. Keys have namespaces and versions and contain no secrets.
  4. Every entry has a TTL based on acceptable staleness.
  5. Updates write the source first and invalidate after commit.
  6. Hot keys use jitter and stampede protection.
  7. Redis has maxmemory, an eviction policy, and monitoring.
  8. Fallback is bounded to protect the database.
  9. Cache failure cannot corrupt business behavior.

Conclusion

Safe Redis caching is more than placing a GET before a query. A sound design assumes cached data can disappear, become stale, or be unavailable. Cache-aside, deliberate TTLs, post-commit invalidation, single-flight loading, and memory limits improve backend performance without turning the cache into an uncontrolled source of failures.

References

Discussion

Comments 0

Sign in to comment

You need an account to join the discussion and reply to other readers.

Sign inRegister

No comments yet. Be the first to share your thoughts.