0Pricing
Node.js Backend Development Bootcamp · Lekcja

Strategie Cache-Aside, Write-Through i TTL

Wybieraj właściwy wzorzec buforowania i zasadę wygasania, aby równoważyć aktualność danych z obciążeniem bazy

Strategie Cache-Aside, Write-Through i TTL to bezpłatna lekcja Node.js Backend Development Bootcamp na CoddyKit. To lekcja 1 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej Node.js Backend Development Bootcamp, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs Node.js Backend Development Bootcamp zawiera 4 lekcji w sumie.

Części tej lekcji nie zostały jeszcze przetłumaczone i są wyświetlane po angielsku.

Why Caching Exists: The Database Bottleneck

Every Node.js backend eventually hits the same wall: the database becomes the bottleneck. As traffic grows, repeated reads for the same data — user profiles, product listings, config values — hammer your DB with identical queries.

Caching solves this by storing the result of an expensive operation so subsequent requests can be served from a fast in-memory store instead of hitting the database again.

  • Redis is the industry-standard caching layer for Node.js — it operates entirely in memory, supports rich data structures, and handles hundreds of thousands of operations per second.
  • A cache hit returns data in under 1ms; a DB query can take 10–200ms or more under load.
  • The trade-off is freshness: cached data may not reflect the latest DB state.

Choosing the right caching pattern determines how well your system balances speed against consistency.

Connecting to Redis with ioredis

Before implementing any caching pattern, you need a reliable Redis client. ioredis is the most popular choice for Node.js — it supports clustering, Sentinel, pipelining, and Lua scripting out of the box.

Install it and create a reusable client instance:

  • Use a singleton module so every part of your app shares one connection pool.
  • Configure maxRetriesPerRequest and lazyConnect for production resilience.
  • The client emits connect, error, and reconnecting events — always handle errors.
// redis/client.js
const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  lazyConnect: true,
});

redis.on('connect', () => console.log('[Redis] Connected'));
redis.on('error', (err) => console.error('[Redis] Error:', err.message));
redis.on('reconnecting', () => console.log('[Redis] Reconnecting...'));

module.exports = redis;

The Cache-Aside Pattern (Lazy Loading)

Cache-Aside (also called lazy loading) is the most common caching pattern. The application itself manages the cache — data is only loaded into the cache when it is first requested.

The flow is straightforward:

  • Read: Check the cache first. On a cache hit, return the cached value. On a cache miss, query the database, store the result in the cache, then return it.
  • Write: Update the database, then invalidate (delete) the cache entry so the next read fetches fresh data.

Cache-Aside is resilient — if Redis goes down, your app degrades gracefully by falling back to the DB. It also prevents loading unused data (no wasted memory).

The downside: the first request after a cache miss is always slow. Under high concurrency, multiple requests can trigger simultaneous DB queries for the same key — this is called a cache stampede.

Implementing Cache-Aside in Node.js

Here is a practical implementation of Cache-Aside for a user profile lookup. Notice the pattern: GET → miss? → DB query → SET → return.

Key decisions shown in the code:

  • Cache key includes the entity type and ID to avoid namespace collisions.
  • EX sets a TTL (time-to-live) in seconds — data auto-expires even if never explicitly invalidated.
  • JSON serialization is needed because Redis stores strings.
  • On cache miss, DB result is written back before returning.
// services/userService.js
const redis = require('../redis/client');
const db = require('../db'); // hypothetical DB module

const USER_CACHE_TTL = 300; // 5 minutes

async function getUserById(userId) {
  const cacheKey = `user:${userId}`;

  // 1. Try cache first
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log(`[Cache HIT] ${cacheKey}`);
    return JSON.parse(cached);
  }

  // 2. Cache miss — query DB
  console.log(`[Cache MISS] ${cacheKey}`);
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  if (!user) return null;

  // 3. Populate cache with TTL
  await redis.set(cacheKey, JSON.stringify(user), 'EX', USER_CACHE_TTL);

  return user;
}

async function updateUser(userId, data) {
  // Update DB first
  await db.query('UPDATE users SET name=$1 WHERE id=$2', [data.name, userId]);

  // Invalidate cache so next read fetches fresh data
  await redis.del(`user:${userId}`);
  console.log(`[Cache INVALIDATED] user:${userId}`);
}

module.exports = { getUserById, updateUser };

The Write-Through Pattern

Write-Through takes the opposite approach to cache invalidation: whenever data is written to the database, it is simultaneously written to the cache. The cache always mirrors the DB state.

Characteristics of Write-Through:

  • Reads are always fast — the cache is guaranteed to have current data after every write.
  • No stale data — cache and DB are updated atomically in the same write operation.
  • Write penalty — every write incurs two round-trips (DB + cache), slightly increasing write latency.
  • Cold start problem — data written before Redis was introduced won't be in the cache; combine with Cache-Aside reads for a warm-up effect.

Write-Through is ideal when read-to-write ratios are high and you cannot tolerate stale reads (e.g., inventory counts, pricing data, session state).

Implementing Write-Through in Node.js

With Write-Through, the service layer writes to both the DB and the cache in every mutation. Notice that unlike Cache-Aside, the cache is populated on write — not on read.

The example below shows a product price update where stale reads would cause real financial harm:

// services/productService.js
const redis = require('../redis/client');
const db = require('../db');

const PRODUCT_TTL = 3600; // 1 hour

async function updateProductPrice(productId, newPrice) {
  // 1. Write to database
  const updated = await db.query(
    'UPDATE products SET price=$1, updated_at=NOW() WHERE id=$2 RETURNING *',
    [newPrice, productId]
  );

  // 2. Write-through: update cache immediately
  const cacheKey = `product:${productId}`;
  await redis.set(cacheKey, JSON.stringify(updated), 'EX', PRODUCT_TTL);
  console.log(`[Write-Through] ${cacheKey} updated in cache`);

  return updated;
}

async function getProduct(productId) {
  const cacheKey = `product:${productId}`;

  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log(`[Cache HIT] ${cacheKey}`);
    return JSON.parse(cached);
  }

  // Cold-start fallback (Cache-Aside hybrid)
  console.log(`[Cache MISS - Cold Start] ${cacheKey}`);
  const product = await db.query('SELECT * FROM products WHERE id=$1', [productId]);
  if (product) {
    await redis.set(cacheKey, JSON.stringify(product), 'EX', PRODUCT_TTL);
  }
  return product;
}

module.exports = { updateProductPrice, getProduct };

TTL Strategies: How Long Should Data Live?

TTL (Time-To-Live) is the expiration window for a cache entry. Getting it right is critical — too short and you hammer the DB; too long and users see stale data.

Common TTL tiers by data volatility:

  • Static config / feature flags: 3600s–86400s (1 hour to 1 day). Changes rarely, expensive to compute.
  • User profiles / preferences: 300s–900s (5–15 minutes). Updated occasionally; short staleness acceptable.
  • Product listings / search results: 60s–300s (1–5 minutes). Moderate change rate.
  • Shopping cart / session data: 1800s–3600s (30 min to 1 hour). Should survive browser refresh but expire naturally.
  • Real-time data (stock prices, live scores): 1s–10s or NO cache — fetch fresh every time.

A practical rule: start with a conservative short TTL, measure cache hit rates, then extend TTL only where the hit rate justifies it.

// config/cacheTTL.js
// Centralize TTL constants — one place to tune
module.exports = Object.freeze({
  USER_PROFILE:    5  * 60,       //  5 minutes
  PRODUCT_DETAIL:  3  * 60,       //  3 minutes
  PRODUCT_LIST:    1  * 60,       //  1 minute
  FEATURE_FLAGS:   60 * 60,       //  1 hour
  SESSION:         30 * 60,       // 30 minutes
  LEADERBOARD:     10,            // 10 seconds
});

// Usage example:
// const TTL = require('../config/cacheTTL');
// redis.set(key, value, 'EX', TTL.USER_PROFILE);

Sliding vs. Fixed TTL Windows

There are two ways to manage expiration timing:

  • Fixed TTL: The timer starts when the key is SET and counts down regardless of access. Simple and predictable — use for data that must expire by a wall-clock deadline (e.g., session tokens, rate-limit windows).
  • Sliding TTL (refresh-on-read): Each time the key is read, its TTL resets. Active users keep their cache warm; idle entries expire naturally. Use for frequently-accessed hot data.

Redis does not have native sliding TTL — you implement it by calling EXPIRE on every cache hit to reset the countdown.

// Sliding TTL — reset expiry on every successful cache read
async function getUserWithSlidingTTL(userId) {
  const cacheKey = `user:${userId}`;
  const SLIDING_TTL = 300; // 5-minute inactivity window

  const cached = await redis.get(cacheKey);
  if (cached) {
    // Reset TTL on each access — extends life for active users
    await redis.expire(cacheKey, SLIDING_TTL);
    return JSON.parse(cached);
  }

  const user = await db.query('SELECT * FROM users WHERE id=$1', [userId]);
  if (user) {
    await redis.set(cacheKey, JSON.stringify(user), 'EX', SLIDING_TTL);
  }
  return user;
}

Preventing Cache Stampedes with Mutex Locks

A cache stampede occurs when a popular key expires and hundreds of concurrent requests all experience a cache miss at the same moment — each fires a DB query, overloading your database.

The standard solution is a distributed mutex: only the first request acquires a lock and fetches from DB. The others either wait or serve a slightly stale value.

Redis supports atomic lock acquisition via SET key value NX EX timeout:

  • NX — only set if the key does NOT exist (atomic compare-and-set).
  • EX — auto-release after timeout so locks can't be held indefinitely.
  • Non-lock-holders retry after a short sleep until the lock is released and the cache is warm.
// utils/cacheWithLock.js
const redis = require('../redis/client');

async function getOrFetchWithLock(cacheKey, fetchFn, ttl = 60) {
  // 1. Try cache
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${cacheKey}`;
  const lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10); // 10s lock

  if (lockAcquired) {
    try {
      // 2. We hold the lock — fetch from DB
      const data = await fetchFn();
      await redis.set(cacheKey, JSON.stringify(data), 'EX', ttl);
      return data;
    } finally {
      await redis.del(lockKey); // Always release lock
    }
  } else {
    // 3. Lock held by another request — wait and retry
    await new Promise((r) => setTimeout(r, 50));
    const retried = await redis.get(cacheKey);
    return retried ? JSON.parse(retried) : fetchFn(); // fallback
  }
}

module.exports = { getOrFetchWithLock };

Cache Invalidation Patterns

Phil Karlton famously said: "There are only two hard things in computer science: cache invalidation and naming things." Getting invalidation right keeps your cache consistent without over-flushing.

The main approaches:

  • Key-based invalidation (DEL): Delete the exact key when underlying data changes. Fast and surgical — use this for single-entity mutations.
  • Prefix-based invalidation (SCAN + DEL): Find and delete all keys matching a pattern. Useful when one write invalidates many related cache entries (e.g., updating a category invalidates all product-list caches).
  • TTL-only (no explicit invalidation): Let data expire naturally. Acceptable when brief staleness is tolerable and writes are infrequent.
  • Cache tagging: Associate multiple cache keys with a tag; invalidate by tag. Requires a set-of-keys index in Redis.
// Prefix-based invalidation using SCAN (safe for production, non-blocking)
async function invalidateProductListCaches(categoryId) {
  const pattern = `products:list:category:${categoryId}:*`;
  let cursor = '0';
  let deleted = 0;

  do {
    // SCAN is non-blocking unlike KEYS — safe for production
    const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
    cursor = nextCursor;

    if (keys.length > 0) {
      await redis.del(...keys);
      deleted += keys.length;
    }
  } while (cursor !== '0');

  console.log(`[Cache] Invalidated ${deleted} product-list keys for category ${categoryId}`);
}

// Call after a category rename or product re-assignment:
// await invalidateProductListCaches(42);

Choosing the Right Pattern: Decision Framework

No single pattern fits all use cases. Here is a quick decision framework:

  • Cache-Aside — Default choice. Works for any read-heavy workload where occasional staleness is acceptable. The cache only grows with actually-requested data, saving memory.
  • Write-Through — Choose when reads vastly outnumber writes AND you need guaranteed freshness on reads (pricing, inventory, permissions). Accept the write-latency penalty.
  • TTL-only (no invalidation) — Use for reference data that changes on a predictable schedule: feature flags refreshed hourly, currency rates refreshed every 15 minutes. Simple and low-maintenance.
  • Hybrid (Cache-Aside reads + Write-Through writes) — The most robust production approach. Write-Through keeps the cache warm for hot entities; Cache-Aside handles cold-start for rarely-read data.

Measure your cache hit rate (target: above 90% for hot paths) using Redis INFO stats → keyspace_hits / keyspace_misses to validate your choice.

// Measuring cache hit rate from Node.js
async function getCacheStats() {
  const info = await redis.info('stats');
  const hits   = parseInt(info.match(/keyspace_hits:(\d+)/)[1]);
  const misses = parseInt(info.match(/keyspace_misses:(\d+)/)[1]);
  const total  = hits + misses;
  const hitRate = total > 0 ? ((hits / total) * 100).toFixed(2) : '0.00';

  return {
    hits,
    misses,
    hitRate: `${hitRate}%`,
    recommendation: parseFloat(hitRate) < 90
      ? 'Consider extending TTL or switching to Write-Through'
      : 'Cache performance is healthy',
  };
}

module.exports = { getCacheStats };
// Output example:
// { hits: 9823, misses: 177, hitRate: '98.23%', recommendation: 'Cache performance is healthy' }

Knowledge Check: Pattern Selection

Test your understanding of caching pattern trade-offs.

Lesson Recap: Caching Patterns and TTL Strategies

You have covered the core caching toolkit for Node.js backends with Redis. Here is what to take away:

  • Cache-Aside (Lazy Loading): Check cache → miss → query DB → populate cache → return. Resilient to Redis failures; risk of cache stampedes under high concurrency.
  • Write-Through: Every DB write also updates the cache. Guarantees freshness on reads; adds write latency. Best for high-read, low-write data where correctness matters.
  • TTL Strategies: Match expiration windows to data volatility — seconds for real-time data, hours for static config. Use sliding TTL (EXPIRE on read) for active-user sessions.
  • Stampede Prevention: Use a Redis-based distributed mutex (SET NX EX) to serialise concurrent misses for the same key.
  • Invalidation: Prefer surgical DEL on known keys; use SCAN + DEL (never KEYS) for pattern-based invalidation in production.
  • Measure first: Track keyspace_hits / keyspace_misses — a hit rate below 90% signals a misconfigured TTL or wrong pattern choice.

The hybrid approach — Cache-Aside reads with Write-Through writes — is the most battle-tested setup for production Node.js services.

Często zadawane pytania

Czy lekcja „Strategie Cache-Aside, Write-Through i TTL” jest bezpłatna?

Tak — pełny tekst „Strategie Cache-Aside, Write-Through i TTL” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu Node.js Backend Development Bootcamp, przejdź na CoddyKit PRO. Kurs Node.js Backend Development Bootcamp zawiera 4 lekcji w sumie.

Co nauczysz się w „Strategie Cache-Aside, Write-Through i TTL”?

Wybieraj właściwy wzorzec buforowania i zasadę wygasania, aby równoważyć aktualność danych z obciążeniem bazy Ćwiczysz Node.js Backend Development Bootcamp z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć Node.js Backend Development Bootcamp?

Nie wymagamy żadnego doświadczenia. Node.js Backend Development Bootcamp w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 1 z 4.

Ile czasu zajmuje lekcja „Strategie Cache-Aside, Write-Through i TTL”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji Node.js Backend Development Bootcamp?

Tak. Każda lekcja Node.js Backend Development Bootcamp zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Strategie Cache-Aside, Write-Through i TTL
  2. Blokady rozproszone i algorytm Redlock
  3. Pub/Sub, strumienie i ograniczanie przepustowości z Redisem
  4. Zapobieganie lawinom żądań do cache i efektowi thundering herd
← Powrót do Node.js Backend Development Bootcamp