सामग्री पर जाएं
·34 मिनट पढ़ने का समय

Redis Caching Strategies जो Production में वाकई काम करती हैं

Cache-aside, write-through, cache stampede prevention, TTL strategies, और invalidation patterns। Production Node.js apps में मेरे इस्तेमाल किए Redis patterns real code examples के साथ।

साझा करें:X / TwitterLinkedIn

हर कोई बताता है API slow होने पर "बस Redis add कर दो"। कोई नहीं बताता छह महीने बाद क्या होता है जब cache stale data serve कर रहा है, invalidation logic 40 files में बिखरी है, और deploy cache stampede cause करता है जो database को और बुरी तरह गिरा देता है — उससे भी बुरा जैसे cache किया ही नहीं होता।

मैं सालों से Redis production में चला रहा हूं। Toy project में नहीं, tutorial में नहीं — real traffic handle करने वाले systems में जहां caching गलत करने का मतलब 3 AM पर pager alerts है। यहां वो सब है जो मैंने सही तरीके से करने के बारे में सीखा है।

Cache क्यों करें?#

आइए obvious से शुरू करें: databases memory के मुकाबले slow हैं। PostgreSQL query जो 15ms लेती है, database standards से fast है। लेकिन अगर वो query हर API request पर run होती है, और आप 1,000 requests per second handle कर रहे हैं, तो वो 15,000ms cumulative database time per second है। आपका connection pool exhaust हो जाएगा। p99 latency छत तोड़ रही है। Users spinners घूर रहे हैं।

Redis ज़्यादातर reads 1ms से कम में serve करता है। वही data, cached, 15ms operation को 0.3ms में बदल देता है। यह micro-optimization नहीं है। यह 4 database replicas चाहिए और zero चाहिए के बीच का फर्क है।

लेकिन caching free नहीं है। Complexity add होती है, consistency problems आते हैं, और failures की बिल्कुल नई class बनती है। कुछ भी cache करने से पहले, खुद से पूछें:

कब caching मदद करती है:

  • Data write से कहीं ज़्यादा बार read होता है (10:1 ratio या ज़्यादा)
  • Underlying query expensive है (joins, aggregations, external API calls)
  • थोड़ा staleness acceptable है (product catalog, user profiles, config)
  • Predictable access patterns हैं (same keys बार-बार hit होती हैं)

कब caching नुकसान करती है:

  • Data लगातार बदलता है और fresh होना ज़रूरी है (real-time stock prices, live scores)
  • हर request unique है (search queries with many parameters)
  • Dataset छोटा है (अगर पूरा app memory में fit होता है, Redis skip करें)
  • आपके पास cache issues monitor और debug करने की operational maturity नहीं है

Phil Karlton ने मशहूर तौर पर कहा था कि computer science में सिर्फ दो hard things हैं: cache invalidation और naming things। वो दोनों के बारे में सही थे, लेकिन cache invalidation वो है जो आपको रात को जगाती है।

ioredis Setup करना#

Patterns में जाने से पहले, connection establish करते हैं। मैं हर जगह ioredis इस्तेमाल करता हूं — यह Node.js के लिए सबसे mature Redis client है, proper TypeScript support, cluster mode, Sentinel support, और Lua scripting के साथ।

typescript
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  db: Number(process.env.REDIS_DB) || 0,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 200, 5000);
    return delay;
  },
  lazyConnect: true,
  enableReadyCheck: true,
  connectTimeout: 10000,
});
 
redis.on("error", (err) => {
  console.error("[Redis] Connection error:", err.message);
});
 
redis.on("connect", () => {
  console.log("[Redis] Connected");
});
 
export default redis;

कुछ बातें ध्यान देने योग्य हैं। lazyConnect: true का मतलब connection तब तक establish नहीं होता जब तक आप actually कोई command run नहीं करते, जो testing और initialization के दौरान useful है। retryStrategy exponential backoff implement करता है 5 seconds पर capped — इसके बिना, Redis outage पर app reconnection attempts spam करेगी। और maxRetriesPerRequest: 3 ensure करता है कि individual commands forever hang होने की बजाय fast fail हों।

Cache-Aside Pattern#

यह वो pattern है जो आप 80% समय इस्तेमाल करेंगे। इसे "lazy loading" या "look-aside" भी कहते हैं। Flow simple है:

  1. Application एक request receive करती है
  2. Redis में cached value check करें
  3. अगर मिल जाए (cache hit), return करें
  4. अगर नहीं मिले (cache miss), database query करें
  5. Result Redis में store करें
  6. Result return करें

यहां एक typed implementation है:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // seconds
  prefix?: string;
}
 
async function cacheAside<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {}
): Promise<T> {
  const { ttl = 3600, prefix = "cache" } = options;
  const cacheKey = `${prefix}:${key}`;
 
  // Step 1: Cache से read करने की कोशिश
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Corrupted cache entry, delete करें और fall through
      await redis.del(cacheKey);
    }
  }
 
  // Step 2: Cache miss — source से fetch करें
  const result = await fetcher();
 
  // Step 3: Cache में store करें (await मत करें — fire and forget)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

Usage कुछ ऐसी दिखती है:

typescript
interface User {
  id: string;
  name: string;
  email: string;
  plan: "free" | "pro" | "enterprise";
}
 
async function getUser(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    { ttl: 1800 } // 30 minutes
  );
}

ध्यान दें कि मैं redis.set call fire-and-forget कर रहा हूं। यह intentional है। अगर Redis down है या slow है, request फिर भी complete होती है। Cache एक optimization है, requirement नहीं। अगर cache में write fail होता है, अगली request बस database hit करेगी। कोई बड़ी बात नहीं।

बहुत से cache-aside implementations में एक subtle bug है जो लोग miss करते हैं: null values cache करना। अगर user exist नहीं करता और आप वो fact cache नहीं करते, उस user के लिए हर request database hit करती है। Attacker random user IDs request करके इसे exploit कर सकता है, cache को no-op बना देता है। Negative result भी हमेशा cache करें — बस shorter TTL के साथ।

typescript
async function getUserSafe(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    {
      // Null results के लिए shorter TTL memory usage limit करने के लिए
      // लेकिन repeated misses absorb करने के लिए काफी long
      ttl: row ? 1800 : 300,
    }
  );
}

Actually, dynamic TTL ठीक से काम करे इसके लिए restructure करते हैं:

typescript
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
  const cacheKey = `cache:user:${userId}`;
 
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as User | null;
  }
 
  const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
  const user: User | null = row[0] ?? null;
 
  // Existing results 30 min cache करें, null results 5 min
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through और Write-Behind#

Cache-aside read-heavy workloads के लिए बढ़िया काम करता है, लेकिन इसमें consistency problem है: अगर कोई और service या process database directly update करता है, TTL expire होने तक cache stale रहता है। यहां write-through और write-behind patterns आते हैं।

Write-Through#

Write-through में, हर write cache layer से गुज़रती है। Cache पहले update होता है, फिर database। यह guarantee करता है कि cache हमेशा database के साथ consistent है (assuming writes हमेशा आपकी application से गुज़रती हैं)।

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // Step 1: Database update करें
  const updated = await db.query(
    "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
    [userId, updates.name, updates.email]
  );
  const user: User = updated[0];
 
  // Step 2: Cache तुरंत update करें
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

Cache-aside से key difference: हम हर write पर cache में write करते हैं, सिर्फ reads पर नहीं। इसका मतलब recently updated data के लिए cache हमेशा warm रहता है।

Trade-off: Write latency बढ़ती है क्योंकि हर write अब database और Redis दोनों touch करती है। अगर Redis slow है, आपकी writes slow हैं। ज़्यादातर applications में, reads writes से बहुत ज़्यादा होती हैं, तो यह trade-off worth it है।

Write-Behind (Write-Back)#

Write-behind script flip कर देता है: writes पहले Redis में जाती हैं, और database asynchronously update होता है। यह extremely fast writes देता है लेकिन potential data loss का risk है अगर Redis data persist होने से पहले down हो जाए।

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Current state read करें
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // Cache तुरंत update करें
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Database write async processing के लिए queue करें
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

फिर एक separate worker होगा जो queue drain करेगा:

typescript
async function processWriteBehindQueue(): Promise<void> {
  while (true) {
    const item = await redis.blpop("write_behind:users", 5);
 
    if (item) {
      const { userId, updates } = JSON.parse(item[1]);
      try {
        await db.query(
          "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
          [userId, updates.name, updates.email]
        );
      } catch (err) {
        // Failure पर retry count के साथ re-queue करें
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

मैं practice में write-behind rarely इस्तेमाल करता हूं। Data loss risk real है — अगर Redis crash हो जाए worker queue process करने से पहले, वो writes gone हैं। इसे सिर्फ उस data के लिए इस्तेमाल करें जहां eventual consistency genuinely acceptable है, जैसे view counts, analytics events, या non-critical user preferences।

TTL Strategy#

TTL सही करना दिखने से ज़्यादा nuanced है। हर चीज़ पर fixed 1-hour TTL implement करना easy है और almost always wrong है।

Data Volatility Tiers#

मैं data को तीन tiers में categorize करता हूं और accordingly TTLs assign करता हूं:

typescript
const TTL = {
  // Tier 1: Rarely changes, compute करने में expensive
  // Examples: product catalog, site config, feature flags
  STATIC: 86400,       // 24 hours
 
  // Tier 2: कभी-कभी बदलता है, moderate cost
  // Examples: user profiles, team settings, permissions
  MODERATE: 1800,      // 30 minutes
 
  // Tier 3: बार-बार बदलता है, compute करने में cheap लेकिन अक्सर call होता है
  // Examples: feed data, notification counts, session info
  VOLATILE: 300,       // 5 minutes
 
  // Tier 4: Ephemeral, rate limiting और locks के लिए
  EPHEMERAL: 60,       // 1 minute
 
  // Null results: हमेशा short-lived
  NOT_FOUND: 120,      // 2 minutes
} as const;

TTL Jitter: Thundering Herd रोकना#

यहां एक scenario है जिसने मुझे bite किया: आप app deploy करते हैं, cache empty है, और 10,000 requests सब same data 1-hour TTL के साथ cache करती हैं। एक घंटे बाद, 10,000 keys simultaneously expire होती हैं। 10,000 requests एक साथ database hit करती हैं। Database choke हो जाता है। मैंने इसे production Postgres instance down करते देखा है।

Fix है jitter — TTL values में randomness add करना:

typescript
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
  const jitter = baseTtl * jitterPercent;
  const offset = Math.random() * jitter * 2 - jitter;
  return Math.max(1, Math.round(baseTtl + offset));
}
 
// इसके बजाय: redis.set(key, value, "EX", 3600)
// इस्तेमाल करें: redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = 3240 और 3960 के बीच random value

यह expiration एक window में spread कर देता है, तो 10,000 keys same second expire होने की बजाय, 12-minute window में expire होती हैं। Database को gradual increase दिखता है, cliff नहीं।

Critical paths के लिए, मैं 20% jitter इस्तेमाल करता हूं:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 seconds

Sliding Expiry#

Session-like data के लिए जहां TTL हर access पर reset होनी चाहिए, GETEX इस्तेमाल करें (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX atomically value get करता है और TTL reset करता है
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

अगर older Redis version पर हैं, pipeline इस्तेमाल करें:

typescript
async function getWithSlidingExpiryCompat<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  const pipeline = redis.pipeline();
  pipeline.get(key);
  pipeline.expire(key, ttl);
  const results = await pipeline.exec();
 
  if (!results || !results[0] || results[0][1] === null) return null;
  return JSON.parse(results[0][1] as string) as T;
}

Cache Stampede (Thundering Herd)#

TTL jitter mass expiration में मदद करता है, लेकिन single-key stampede solve नहीं करता: जब एक popular key expire होती है और सैकड़ों concurrent requests सब simultaneously उसे regenerate करने की कोशिश करती हैं।

सोचिए आपने homepage feed 5-minute TTL के साथ cache किया है। Expire होता है। Fifty concurrent requests cache miss देखती हैं। सब fifty same expensive query के साथ database hit करती हैं। आपने effectively खुद को DDoS कर लिया।

Solution 1: Mutex Lock#

सिर्फ एक request cache regenerate करती है। बाकी सब wait करते हैं।

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // पहले cache try करें
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Lock acquire करने की कोशिश (NX = only if not exists, EX = auto-expire)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Lock मिल गया — fetch करें और cache करें
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Lock release करें
      await redis.del(lockKey);
    }
  }
 
  // दूसरी request lock hold कर रही है — wait करें और retry करें
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // अभी भी cache नहीं — database से fall through करें
  // (यह उस case को handle करता है जहां lock holder fail हो गया)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

ऊपर lock release में एक subtle race condition है। अगर lock holder 10 seconds से ज़्यादा लेता है (lock TTL), दूसरी request lock acquire करती है, और फिर पहली request दूसरी request का lock delete कर देती है। Proper fix है unique token इस्तेमाल करना:

typescript
import { randomUUID } from "crypto";
 
async function acquireLock(
  lockKey: string,
  ttl: number
): Promise<string | null> {
  const token = randomUUID();
  const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
  return acquired ? token : null;
}
 
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
  // Lua script atomic check-and-delete ensure करता है
  const script = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  const result = await redis.eval(script, 1, lockKey, token);
  return result === 1;
}

यह basically एक simplified Redlock है। Single-instance Redis के लिए, sufficient है। Redis Cluster या Sentinel setups के लिए, full Redlock algorithm देखें — लेकिन honestly, caching stampede prevention के लिए, यह simple version ठीक काम करता है।

Solution 2: Probabilistic Early Expiration#

यह मेरा favorite approach है। Key expire होने का wait करने की बजाय, expiration से थोड़ा पहले randomly regenerate करें। यह idea Vattani, Chierichetti, और Lowenstein के paper से आया है।

typescript
interface CachedValue<T> {
  data: T;
  cachedAt: number;
  ttl: number;
}
 
async function cacheWithEarlyExpiration<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  const cacheKey = `cache:${key}`;
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    const entry = JSON.parse(cached) as CachedValue<T>;
    const age = (Date.now() - entry.cachedAt) / 1000;
    const remaining = entry.ttl - age;
 
    // XFetch algorithm: expiry approach होने पर probabilistically regenerate करें
    // beta * Math.log(Math.random()) एक negative number produce करता है
    // जो expiry approach होने पर बड़ा (ज़्यादा negative) होता जाता है
    const beta = 1; // tuning parameter, 1 अच्छा काम करता है
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Regenerate करने के लिए fall through
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Extra buffer के साथ set करें ताकि Redis regenerate से पहले expire न करे
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

इस approach की खूबी: जैसे-जैसे key की remaining TTL कम होती है, regeneration की probability बढ़ती है। 1,000 concurrent requests में, शायद एक या दो regeneration trigger करेंगी जबकि बाकी cached data serve करते रहेंगे। कोई locks नहीं, कोई coordination नहीं, कोई waiting नहीं।

Solution 3: Stale-While-Revalidate#

Stale value serve करें जबकि background में regenerate हो रहा है। यह best latency देता है क्योंकि कोई भी request fetcher का wait नहीं करती।

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // data कितनी देर "fresh" है
    staleTtl: number;   // stale data कितनी देर serve हो सकती है
  }
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const metaKey = `meta:${key}`;
 
  const [cached, meta] = await redis.mget(cacheKey, metaKey);
 
  if (cached !== null) {
    const parsedMeta = meta ? JSON.parse(meta) : null;
    const isFresh =
      parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
 
    if (!isFresh) {
      // Data stale है — serve करें लेकिन background refresh trigger करें
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Complete cache miss — synchronously fetch करना होगा
  return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
 
async function fetchAndCache<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): Promise<T> {
  const data = await fetcher();
  const totalTtl = options.freshTtl + options.staleTtl;
 
  const pipeline = redis.pipeline();
  pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
  pipeline.set(
    metaKey,
    JSON.stringify({ cachedAt: Date.now() }),
    "EX",
    totalTtl
  );
  await pipeline.exec();
 
  return data;
}
 
function revalidateInBackground<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): void {
  // Multiple background refreshes रोकने के लिए lock इस्तेमाल करें
  const lockKey = `revalidate_lock:${key}`;
 
  redis
    .set(lockKey, "1", "EX", 30, "NX")
    .then((acquired) => {
      if (!acquired) return;
 
      return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
        .finally(() => redis.del(lockKey));
    })
    .catch((err) => {
      console.error(`[SWR] Background revalidation failed for ${key}:`, err);
    });
}

Usage:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 minutes fresh
  staleTtl: 3600,    // revalidate करते हुए 1 hour तक stale serve करें
});

मैं यह pattern user-facing किसी भी चीज़ के लिए इस्तेमाल करता हूं जहां latency absolute freshness से ज़्यादा matter करती है। Dashboard data, profile pages, product listings — सब perfect candidates हैं।

Cache Invalidation#

Phil Karlton मज़ाक नहीं कर रहे थे। Invalidation वो जगह है जहां caching "easy optimization" से "distributed systems problem" बन जाती है।

Simple Key-Based Invalidation#

सबसे easy case: जब user update करें, उनकी cache key delete करें।

typescript
async function updateUserAndInvalidate(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const user = await db.query(
    "UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
    [userId, updates.name]
  );
 
  // Cache invalidate करें
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

यह तब तक काम करता है जब तक user data दूसरे cached results में appear नहीं होता। शायद team members list में embedded है। शायद search result में है। शायद 14 अलग-अलग cached API responses में है। अब आपको track करना होगा कौन सी cache keys में कौन से entities हैं।

Tag-Based Invalidation#

Cache entries को उनमें मौजूद entities से tag करें, फिर tag से invalidate करें।

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Value store करें
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Key को हर tag के set में add करें
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets values से ज़्यादा live रहते हैं
  }
 
  await pipeline.exec();
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
 
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
 
  await pipeline.exec();
  return keys.length;
}

Usage:

typescript
// Team data cache करते समय, सभी member IDs से tag करें
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// जब user 42 profile update करे, उससे जुड़ी हर चीज़ invalidate करें
await invalidateByTag("entity:user:42");

Event-Driven Invalidation#

बड़े systems के लिए, Redis Pub/Sub से invalidation events broadcast करें:

typescript
// Publisher (API service में)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Subscriber (हर app instance में)
const subscriber = new Redis(/* same config */);
 
subscriber.subscribe("cache:invalidate", (err) => {
  if (err) console.error("[PubSub] Subscribe error:", err);
});
 
subscriber.on("message", async (_channel, message) => {
  const { entityType, entityId } = JSON.parse(message);
  await invalidateByTag(`entity:${entityType}:${entityId}`);
  console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});

Multi-instance deployments में यह critical है। अगर load balancer के पीछे 4 app servers हैं, server 1 पर invalidation सब servers तक propagate होनी चाहिए। Pub/Sub यह automatically handle करता है।

Pattern-Based Invalidation (सावधानी से)#

कभी-कभी pattern match करने वाली सभी keys invalidate करनी होती हैं। Production में कभी KEYS इस्तेमाल मत करें। यह Redis server block करता है पूरे keyspace scan करते हुए। Millions of keys के साथ, seconds तक block हो सकता है — Redis terms में एक eternity।

इसकी बजाय SCAN इस्तेमाल करें:

typescript
async function invalidateByPattern(pattern: string): Promise<number> {
  let cursor = "0";
  let deletedCount = 0;
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    if (keys.length > 0) {
      await redis.del(...keys);
      deletedCount += keys.length;
    }
  } while (cursor !== "0");
 
  return deletedCount;
}
 
// किसी specific team का सारा cached data invalidate करें
await invalidateByPattern("cache:team:42:*");

SCAN incrementally iterate करता है — कभी server block नहीं करता। COUNT hint suggest करता है प्रति iteration कितनी keys return करें (hint है, guarantee नहीं)। बड़े keyspaces के लिए, यह एकमात्र safe approach है।

उस ने कहा, pattern-based invalidation एक code smell है। अगर बार-बार scan कर रहे हैं, key structure redesign करें या tags इस्तेमाल करें। SCAN keyspace पर O(N) है और maintenance operations के लिए बना है, hot paths के लिए नहीं।

Strings से परे Data Structures#

ज़्यादातर developers Redis को JSON strings के key-value store के रूप में treat करते हैं। यह Swiss army knife खरीदकर सिर्फ bottle opener इस्तेमाल करने जैसा है। Redis में rich data structures हैं, और सही चुनने से complexity की पूरी categories eliminate हो सकती हैं।

Objects के लिए Hashes#

पूरा object JSON के रूप में serialize करने की बजाय, Redis Hash के रूप में store करें। इससे पूरी चीज़ deserialize किए बिना individual fields read और update कर सकते हैं।

typescript
// User को hash के रूप में store करें
async function setUserHash(user: User): Promise<void> {
  const key = `user:${user.id}`;
  await redis.hset(key, {
    name: user.name,
    email: user.email,
    plan: user.plan,
    updatedAt: Date.now().toString(),
  });
  await redis.expire(key, 1800);
}
 
// Specific fields read करें
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Single field update करें
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// पूरा hash object के रूप में read करें
async function getUserHash(userId: string): Promise<User | null> {
  const data = await redis.hgetall(`user:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
 
  return {
    id: userId,
    name: data.name,
    email: data.email,
    plan: data.plan as User["plan"],
  };
}

Hashes small objects के लिए memory-efficient हैं (Redis internally compact ziplist encoding इस्तेमाल करता है) और serialize/deserialize overhead avoid करते हैं। Trade-off: nested objects पहले flatten किए बिना store करने की ability खो देते हैं।

Leaderboards और Rate Limiting के लिए Sorted Sets#

Sorted Sets Redis की सबसे underappreciated data structure हैं। हर member का एक score होता है, और set हमेशा score से sorted रहता है। Leaderboards, ranking, और sliding window rate limiting के लिए perfect।

typescript
// Leaderboard
async function addScore(
  leaderboard: string,
  userId: string,
  score: number
): Promise<void> {
  await redis.zadd(leaderboard, score, userId);
}
 
async function getTopPlayers(
  leaderboard: string,
  count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
  const results = await redis.zrevrange(
    leaderboard,
    0,
    count - 1,
    "WITHSCORES"
  );
 
  const players: Array<{ userId: string; score: number }> = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      userId: results[i],
      score: parseFloat(results[i + 1]),
    });
  }
  return players;
}
 
async function getUserRank(
  leaderboard: string,
  userId: string
): Promise<number | null> {
  const rank = await redis.zrevrank(leaderboard, userId);
  return rank !== null ? rank + 1 : null; // 0-indexed से 1-indexed
}

Sliding window rate limiting के लिए:

typescript
async function slidingWindowRateLimit(
  identifier: string,
  windowMs: number,
  maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const pipeline = redis.pipeline();
 
  // Window के बाहर entries remove करें
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Current request add करें
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Window में requests count करें
  pipeline.zcard(key);
 
  // पूरी key पर expiry set करें
  pipeline.expire(key, Math.ceil(windowMs / 1000));
 
  const results = await pipeline.exec();
  const count = results?.[2]?.[1] as number;
 
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
  };
}

यह fixed-window counter approach से ज़्यादा accurate है और boundary problem से suffer नहीं करता जहां एक window के end और अगली के start पर burst effectively rate limit double कर देता है।

Queues के लिए Lists#

Redis Lists LPUSH/BRPOP के साथ excellent lightweight job queues बनाती हैं:

typescript
interface Job {
  id: string;
  type: string;
  payload: Record<string, unknown>;
  createdAt: number;
}
 
// Producer
async function enqueueJob(
  queue: string,
  type: string,
  payload: Record<string, unknown>
): Promise<string> {
  const job: Job = {
    id: randomUUID(),
    type,
    payload,
    createdAt: Date.now(),
  };
 
  await redis.lpush(`queue:${queue}`, JSON.stringify(job));
  return job.id;
}
 
// Consumer (job available होने तक block करता है)
async function dequeueJob(
  queue: string,
  timeout: number = 5
): Promise<Job | null> {
  const result = await redis.brpop(`queue:${queue}`, timeout);
  if (!result) return null;
 
  return JSON.parse(result[1]) as Job;
}

Basic queuing से ज़्यादा complex चीज़ के लिए (retries, dead letter queues, priority, delayed jobs), BullMQ इस्तेमाल करें जो Redis पर build होता है लेकिन सभी edge cases handle करता है।

Unique Tracking के लिए Sets#

Unique visitors track करने हैं, events deduplicate करने हैं, या membership check करनी है? Sets add, remove, और membership checks के लिए O(1) हैं।

typescript
// Per day unique visitors track करें
async function trackVisitor(
  page: string,
  visitorId: string
): Promise<boolean> {
  const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  const isNew = await redis.sadd(key, visitorId);
 
  // 48 hours बाद auto-expire
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = new member, 0 = पहले से मौजूद
}
 
// Unique visitor count get करें
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Check करें कि user ने पहले से action perform किया है क्या
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

बहुत बड़े sets (millions of members) के लिए, HyperLogLog consider करें। यह cardinality चाहे जो हो सिर्फ 12KB memory इस्तेमाल करता है, ~0.81% standard error की कीमत पर:

typescript
// Approximate unique counts के लिए HyperLogLog
async function trackVisitorApprox(
  page: string,
  visitorId: string
): Promise<void> {
  const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  await redis.pfadd(key, visitorId);
  await redis.expire(key, 172800);
}
 
async function getApproxUniqueVisitors(
  page: string,
  date: string
): Promise<number> {
  return redis.pfcount(`hll:visitors:${page}:${date}`);
}

Serialization: JSON vs MessagePack#

JSON Redis serialization के लिए default choice है। Readable है, universal है, और ज़्यादातर cases के लिए काफी अच्छा है। लेकिन high-throughput systems के लिए, serialize/deserialize overhead add हो जाता है।

JSON की Problem#

typescript
const user = {
  id: "usr_abc123",
  name: "Ahmet Kousa",
  email: "ahmet@example.com",
  plan: "pro",
  preferences: {
    theme: "dark",
    language: "tr",
    notifications: true,
  },
};
 
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
 
// JSON.parse hot path पर: ~0.02ms per call
// 10,000 requests/sec पर: 200ms total CPU time per second

MessagePack Alternative#

MessagePack एक binary serialization format है जो JSON से smaller और faster है:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Buffer के रूप में store करें
await redis.set("user:123", packed);
 
// Buffer के रूप में read करें
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

get की बजाय getBuffer इस्तेमाल करना critical है। get string return करता है और binary data corrupt कर देगा।

बड़ी Values के लिए Compression#

बड़ी cached values (सैकड़ों items वाले API responses, rendered HTML) के लिए, compression add करें:

typescript
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
 
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
 
async function setCompressed<T>(
  key: string,
  value: T,
  ttl: number
): Promise<void> {
  const json = JSON.stringify(value);
 
  // सिर्फ 1KB से बड़ा होने पर compress करें (छोटी values के लिए compression overhead worth it नहीं)
  if (Buffer.byteLength(json) > 1024) {
    const compressed = await gzipAsync(json);
    await redis.set(key, compressed, "EX", ttl);
  } else {
    await redis.set(key, json, "EX", ttl);
  }
}
 
async function getCompressed<T>(key: string): Promise<T | null> {
  const raw = await redis.getBuffer(key);
  if (!raw) return null;
 
  try {
    // पहले decompress करने की कोशिश
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Compressed नहीं है, regular JSON parse करें
    return JSON.parse(raw.toString()) as T;
  }
}

मेरी testing में, gzip compression typically JSON payload size 70-85% तक reduce करता है। 50KB API response 8KB हो जाता है। यह matter करता है जब Redis memory के लिए pay कर रहे हों — per key कम memory मतलब same instance में ज़्यादा keys।

Trade-off: compression per operation 1-3ms CPU time add करता है। ज़्यादातर applications के लिए, negligible है। Ultra-low-latency paths के लिए, skip करें।

मेरी Recommendation#

JSON इस्तेमाल करें जब तक profiling bottleneck न दिखाए। Redis में JSON की readability और debuggability (redis-cli GET key करके value पढ़ सकते हैं) 95% applications के लिए MessagePack के performance gain से ज़्यादा important है। Compression सिर्फ 1KB से बड़ी values के लिए add करें।

Next.js में Redis#

Next.js की अपनी caching story है (Data Cache, Full Route Cache, etc.), लेकिन Redis वो gaps fill करता है जो built-in caching handle नहीं कर सकती — खासकर जब multiple instances में cache share करना हो या deployments के बीच cache persist करना हो।

API Route Responses Cache करना#

typescript
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") || "all";
  const cacheKey = `api:products:${category}`;
 
  // Cache check करें
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: {
        "X-Cache": "HIT",
        "Cache-Control": "public, s-maxage=60",
      },
    });
  }
 
  // Database से fetch करें
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Jitter के साथ 5 minutes cache करें
  await redis.set(
    cacheKey,
    JSON.stringify(products),
    "EX",
    ttlWithJitter(300)
  );
 
  return NextResponse.json(products, {
    headers: {
      "X-Cache": "MISS",
      "Cache-Control": "public, s-maxage=60",
    },
  });
}

X-Cache header debugging के लिए invaluable है। Latency spike होने पर, एक quick curl -I बता देता है cache काम कर रहा है या नहीं।

Session Storage#

Redis के साथ Next.js sessions stateful applications के लिए JWT को beat करते हैं:

typescript
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
interface Session {
  userId: string;
  role: string;
  createdAt: number;
  data: Record<string, unknown>;
}
 
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
 
export async function createSession(
  userId: string,
  role: string
): Promise<string> {
  const sessionId = randomUUID();
  const session: Session = {
    userId,
    role,
    createdAt: Date.now(),
    data: {},
  };
 
  await redis.set(
    `${SESSION_PREFIX}${sessionId}`,
    JSON.stringify(session),
    "EX",
    SESSION_TTL
  );
 
  return sessionId;
}
 
export async function getSession(
  sessionId: string
): Promise<Session | null> {
  const key = `${SESSION_PREFIX}${sessionId}`;
 
  // हर access पर TTL refresh करने के लिए GETEX इस्तेमाल करें (sliding expiry)
  const raw = await redis.getex(key, "EX", SESSION_TTL);
  if (!raw) return null;
 
  return JSON.parse(raw) as Session;
}
 
export async function destroySession(sessionId: string): Promise<void> {
  await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
 
// User के सभी sessions destroy करें ("logout everywhere" के लिए useful)
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // इसके लिए user->sessions index maintain करना ज़रूरी है
  const sessionIds = await redis.smembers(`user_sessions:${userId}`);
 
  if (sessionIds.length > 0) {
    const pipeline = redis.pipeline();
    for (const sid of sessionIds) {
      pipeline.del(`${SESSION_PREFIX}${sid}`);
    }
    pipeline.del(`user_sessions:${userId}`);
    await pipeline.exec();
  }
}

Rate Limiting Middleware#

typescript
// middleware.ts (या middleware द्वारा इस्तेमाल किया जाने वाला helper)
import redis from "@/lib/redis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function rateLimit(
  identifier: string,
  limit: number = 60,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const key = `rate:${identifier}`;
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - windowSeconds;
 
  // Atomic rate limiting के लिए Lua script
  const script = `
    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
    redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
    local count = redis.call('ZCARD', KEYS[1])
    redis.call('EXPIRE', KEYS[1], ARGV[4])
    return count
  `;
 
  const count = (await redis.eval(
    script,
    1,
    key,
    windowStart,
    now,
    `${now}:${Math.random()}`,
    windowSeconds
  )) as number;
 
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: now + windowSeconds,
  };
}

Lua script यहां important है। इसके बिना, ZREMRANGEBYSCORE + ZADD + ZCARD sequence atomic नहीं है, और high concurrency में, count inaccurate हो सकता है। Lua scripts Redis में atomically execute होती हैं — दूसरे commands के साथ interleave नहीं हो सकतीं।

Next.js के लिए Distributed Locks#

जब multiple Next.js instances हों और ensure करना हो कि सिर्फ एक task process करे (जैसे scheduled email भेजना या cleanup job run करना):

typescript
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
export async function withLock<T>(
  lockName: string,
  fn: () => Promise<T>,
  options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
  const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
  const token = randomUUID();
  const lockKey = `dlock:${lockName}`;
 
  // Lock acquire करने की कोशिश
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Long-running tasks के लिए lock automatically extend करें
        const extender = setInterval(async () => {
          const script = `
            if redis.call("get", KEYS[1]) == ARGV[1] then
              return redis.call("expire", KEYS[1], ARGV[2])
            else
              return 0
            end
          `;
          await redis.eval(script, 1, lockKey, token, ttl);
        }, (ttl * 1000) / 3);
 
        const result = await fn();
        clearInterval(extender);
        return result;
      } finally {
        // Lock सिर्फ तभी release करें जब हम अभी भी own करते हैं
        const releaseScript = `
          if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
          else
            return 0
          end
        `;
        await redis.eval(releaseScript, 1, lockKey, token);
      }
    }
 
    // Retry से पहले wait करें
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // सभी retries के बाद lock acquire नहीं हो पाया
  return null;
}

Usage:

typescript
// Cron-triggered API route में
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // सिर्फ एक instance यह run करता है
    const report = await generateDailyReport();
    await sendReportEmail(report);
    return report;
  });
 
  if (result === null) {
    return NextResponse.json(
      { message: "Another instance is already processing" },
      { status: 409 }
    );
  }
 
  return NextResponse.json({ success: true });
}

ttl/3 पर lock extension interval important है। इसके बिना, अगर task lock TTL से ज़्यादा समय लेता है, lock expire हो जाता है और दूसरा instance grab कर लेता है। Extender task जब तक run हो रहा है lock alive रखता है।

Monitoring और Debugging#

Redis तब तक fast है जब तक नहीं है। जब problems hit करें, visibility ज़रूरी है।

Cache Hit Ratio#

सबसे important single metric। Application में track करें:

typescript
// lib/cache-metrics.ts
import redis from "./redis";
 
const METRICS_KEY = "metrics:cache";
 
export async function recordCacheHit(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "hits", 1);
}
 
export async function recordCacheMiss(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "misses", 1);
}
 
export async function getCacheStats(): Promise<{
  hits: number;
  misses: number;
  hitRate: number;
}> {
  const stats = await redis.hgetall(METRICS_KEY);
  const hits = parseInt(stats.hits || "0", 10);
  const misses = parseInt(stats.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? hits / total : 0,
  };
}
 
// Daily metrics reset करें
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Healthy cache hit ratio 90% से ऊपर है। 80% से नीचे हैं तो या TTLs बहुत short हैं, या cache keys बहुत specific हैं, या access patterns सोचने से ज़्यादा random हैं।

INFO Command#

INFO command Redis का built-in health dashboard है:

bash
redis-cli INFO memory
# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23

Monitor करने योग्य key metrics:

  • used_memory vs maxmemory: Limit approach कर रहे हैं?
  • mem_fragmentation_ratio: 1.5 से ऊपर मतलब Redis logical memory से significantly ज़्यादा RSS इस्तेमाल कर रहा है। Restart consider करें।
  • evicted_keys: Non-zero है और eviction intend नहीं किया था, तो memory कम है।
bash
redis-cli INFO stats

देखें:

  • keyspace_hits / keyspace_misses: Server-level hit rate
  • total_commands_processed: Throughput
  • instantaneous_ops_per_sec: Current throughput

MONITOR (बहुत सावधानी से इस्तेमाल करें)#

MONITOR Redis server पर execute होने वाली हर command real-time stream करता है। Debugging के लिए incredibly useful है और production में incredibly dangerous।

bash
# Production में कभी running मत छोड़ें
# Significant overhead add करता है और sensitive data log कर सकता है
redis-cli MONITOR
1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"

मैं MONITOR सिर्फ दो चीज़ों के लिए इस्तेमाल करता हूं: development में key naming issues debug करना, और verify करना कि specific code path Redis hit कर रहा है जैसा expected है। कभी 30 seconds से ज़्यादा नहीं। कभी production में नहीं जब तक सब debugging options exhaust न हो गए हों।

Keyspace Notifications#

जानना चाहते हैं keys कब expire या delete होती हैं? Redis events publish कर सकता है:

bash
# Expired और evicted events के लिए keyspace notifications enable करें
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* config */);
 
// Key expiration events listen करें
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Important keys proactively regenerate करें
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Proactive cache warming के लिए useful है — user cache miss trigger करने का wait करने की बजाय, critical entries expire होते ही regenerate करें।

Memory Analysis#

जब Redis memory unexpectedly बढ़े, सबसे ज़्यादा consume करने वाली keys find करनी होंगी:

bash
# 10 सबसे बड़ी keys sample करें
redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields

ज़्यादा detailed analysis के लिए:

bash
# Specific key की memory usage (bytes में)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
  let cursor = "0";
  const stats: Array<{ key: string; bytes: number }> = [];
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    for (const key of keys) {
      const bytes = await redis.memory("USAGE", key);
      if (bytes) {
        stats.push({ key, bytes: bytes as number });
      }
    }
  } while (cursor !== "0");
 
  // Size descending sort करें
  stats.sort((a, b) => b.bytes - a.bytes);
 
  console.log("Top 20 keys by memory usage:");
  for (const { key, bytes } of stats.slice(0, 20)) {
    const mb = (bytes / 1024 / 1024).toFixed(2);
    console.log(`  ${key}: ${mb} MB`);
  }
}

Eviction Policies#

अगर Redis instance पर maxmemory limit है (होनी चाहिए), eviction policy configure करें:

bash
# redis.conf या CONFIG SET से
maxmemory 512mb
maxmemory-policy allkeys-lru

Available policies:

  • noeviction: Memory full होने पर error return करता है (default, caching के लिए worst)
  • allkeys-lru: Least recently used key evict करें (caching के लिए best general-purpose choice)
  • allkeys-lfu: Least frequently used key evict करें (better अगर कुछ keys bursts में access होती हैं)
  • volatile-lru: सिर्फ TTL set वाली keys evict करें (useful अगर cache और persistent data mix हैं)
  • allkeys-random: Random eviction (surprisingly decent, कोई overhead नहीं)

Pure caching workloads के लिए, allkeys-lfu usually best choice है। Recently access न होने पर भी frequently accessed keys memory में रखता है।

सब एक साथ: एक Production Cache Module#

यहां वो complete cache module है जो मैं production में इस्तेमाल करता हूं, सब कुछ combine करके:

typescript
// lib/cache.ts
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    return Math.min(times * 200, 5000);
  },
});
 
// TTL tiers
const TTL = {
  STATIC: 86400,
  MODERATE: 1800,
  VOLATILE: 300,
  EPHEMERAL: 60,
  NOT_FOUND: 120,
} as const;
 
type TTLTier = keyof typeof TTL;
 
function ttlWithJitter(base: number, jitter = 0.1): number {
  const offset = base * jitter * (Math.random() * 2 - 1);
  return Math.max(1, Math.round(base + offset));
}
 
// Core cache-aside with stampede protection
async function get<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    tier?: TTLTier;
    ttl?: number;
    tags?: string[];
    swr?: { freshTtl: number; staleTtl: number };
  } = {}
): Promise<T> {
  const { tier = "MODERATE", tags } = options;
  const baseTtl = options.ttl ?? TTL[tier];
  const cacheKey = `c:${key}`;
 
  // Cache check करें
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      const parsed = JSON.parse(cached);
      recordHit();
      return parsed as T;
    } catch {
      await redis.del(cacheKey);
    }
  }
 
  recordMiss();
 
  // Stampede रोकने के लिए lock acquire करें
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // दूसरा process fetch कर रहा है — briefly wait करें और cache retry करें
    await new Promise((r) => setTimeout(r, 150));
    const retried = await redis.get(cacheKey);
    if (retried) return JSON.parse(retried) as T;
  }
 
  try {
    const result = await fetcher();
    const ttl = ttlWithJitter(baseTtl);
 
    const pipeline = redis.pipeline();
    pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
 
    // Tag associations store करें
    if (tags) {
      for (const tag of tags) {
        pipeline.sadd(`tag:${tag}`, cacheKey);
        pipeline.expire(`tag:${tag}`, ttl + 3600);
      }
    }
 
    await pipeline.exec();
    return result;
  } finally {
    await redis.del(lockKey);
  }
}
 
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
  if (keys.length === 0) return;
  await redis.del(...keys.map((k) => `c:${k}`));
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
  await pipeline.exec();
  return keys.length;
}
 
// Metrics
function recordHit(): void {
  redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
 
function recordMiss(): void {
  redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
 
async function stats(): Promise<{
  hits: number;
  misses: number;
  hitRate: string;
}> {
  const raw = await redis.hgetall("metrics:cache");
  const hits = parseInt(raw.hits || "0", 10);
  const misses = parseInt(raw.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
  };
}
 
export const cache = {
  get,
  invalidate,
  invalidateByTag,
  stats,
  redis,
  TTL,
};

पूरी application में usage:

typescript
import { cache } from "@/lib/cache";
 
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Custom TTL के साथ
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 hours
});
 
// Product update करने के बाद
await cache.invalidateByTag("entity:products");
 
// Health check करें
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Common Mistakes जो मैंने कीं (ताकि आपको न करनी पड़ें)#

1. maxmemory set नहीं करना। Redis खुशी-खुशी सारी available memory इस्तेमाल कर लेगा जब तक OS kill नहीं कर देता। हमेशा limit set करें।

2. Production में KEYS इस्तेमाल करना। Server block करता है। SCAN इस्तेमाल करें। मैंने यह तब सीखा जब monitoring script से KEYS * call ने 3 seconds downtime cause किया।

3. बहुत aggressively cache करना। हर चीज़ cache करने की ज़रूरत नहीं। अगर database query 2ms लेती है और minute में 10 बार call होती है, caching negligible benefit के लिए complexity add करती है।

4. Serialization costs ignore करना। एक बार मैंने 2MB JSON blob cache किया और puzzle हुआ कि cache reads slow क्यों हैं। Serialization overhead database query से बड़ा था जो बचाना चाहता था।

5. Graceful degradation नहीं। जब Redis down हो, app अभी भी काम करनी चाहिए — बस slower। हर cache call try/catch में wrap करें जो database पर fall back करे। Cache failure को कभी user-facing error मत बनने दें।

typescript
async function resilientGet<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  try {
    return await cache.get(key, fetcher);
  } catch (err) {
    console.error(`[Cache] Degraded mode for ${key}:`, err);
    return fetcher(); // Cache पूरी तरह bypass करें
  }
}

6. Evictions monitor नहीं करना। अगर Redis keys evict कर रहा है, या तो under-provisioned हैं या बहुत ज़्यादा cache कर रहे हैं। किसी भी तरह, जानना ज़रूरी है।

7. Caching और persistent data के बीच Redis instance share करना। Separate instances (या कम से कम separate databases) इस्तेमाल करें। Cache eviction policy जो job queue entries delete कर दे — सबके लिए बुरा दिन।

Wrapping Up#

Redis caching hard नहीं है, लेकिन गलत करना easy है। Cache-aside से शुरू करें, day one से TTL jitter add करें, hit rate monitor करें, और हर चीज़ cache करने की urge resist करें।

सबसे अच्छी caching strategy वो है जिसके बारे में 3 AM पर जब कुछ टूट जाए तब भी reason कर सकें। Simple रखें, observable रखें, और याद रखें कि हर cached value एक झूठ है जो आपने users को data की state के बारे में बोला — आपका काम उस झूठ को जितना हो सके छोटा और short-lived रखना है।

संबंधित पोस्ट