正在加载,请稍候…

Redis Caching Patterns: The Complete Guide for Backend Developers

Master Redis caching strategies: cache-aside, write-through, write-behind, and read-through. Covers TTL strategies, cache invalidation, pub/sub, and avoiding common pitfalls.

Why Cache? And Why Redis?

Database queries are typically the slowest part of a web request — a single page might trigger 20-50 queries. At low traffic this is fine. At scale, it's catastrophic.

Redis is an in-memory data structure store that can serve millions of requests per second with sub-millisecond latency. It's not just a cache: it's also a message broker, session store, rate limiter, and real-time leaderboard. But this guide focuses on what it's most commonly used for: caching.

Core Redis Data Types for Caching

# String — most common, stores any value as a string
SET user:123 '{"id":123,"name":"Alice","email":"alice@example.com"}'
GET user:123
SETEX user:123 3600 '...'  # Set with 60-minute expiry

# Hash — ideal for objects (avoid serialization overhead)
HSET user:123 name "Alice" email "alice@example.com" age 30
HGET user:123 name          # Get single field
HGETALL user:123            # Get all fields
HINCRBY user:123 loginCount 1  # Increment a field

# List — ordered collections, queues
RPUSH queue:emails "email1" "email2"
LPOP queue:emails           # Dequeue
LRANGE queue:emails 0 -1   # Get all

# Set — unique members, tags
SADD user:123:tags "premium" "beta-tester"
SMEMBERS user:123:tags
SISMEMBER user:123:tags "premium"  # O(1) membership check

# Sorted Set — ranking/leaderboards
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZRANGE leaderboard 0 -1 REV WITHSCORES  # Top players
ZRANK leaderboard "player:alice"         # Alice's rank

Setting Up Redis with Node.js

import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL ?? 'redis://localhost:6379',
  
  socket: {
    reconnectStrategy: (retries) => Math.min(retries * 50, 2000),
    connectTimeout: 5000,
  },
});

redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));
redis.on('reconnecting', () => console.log('Redis reconnecting...'));

await redis.connect();

// Helper: get or set with automatic JSON serialization
async function getOrSet(key, ttlSeconds, fetchFn) {
  const cached = await redis.get(key);
  if (cached !== null) {
    return JSON.parse(cached);
  }
  
  const value = await fetchFn();
  await redis.setEx(key, ttlSeconds, JSON.stringify(value));
  return value;
}

// Usage
const user = await getOrSet(
  `user:${userId}`,
  3600, // 1 hour TTL
  () => db.users.findById(userId)
);

The Four Caching Patterns

1. Cache-Aside (Lazy Loading) — Most Common

Application code manages the cache:

async function getUser(userId) {
  // 1. Check cache first
  const cacheKey = `user:${userId}`;
  const cached = await redis.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached); // Cache hit
  }
  
  // 2. Cache miss: fetch from database
  const user = await db.users.findById(userId);
  
  if (!user) return null;
  
  // 3. Store in cache for next time
  await redis.setEx(cacheKey, 3600, JSON.stringify(user));
  
  return user;
}

// On update: invalidate the cache
async function updateUser(userId, data) {
  await db.users.update(userId, data);
  await redis.del(`user:${userId}`); // Force re-fetch next time
}

Pros: Only caches what's actually requested. Cache failures don't break the app. Cons: First request is slow (cache miss). Potential for stale data.

2. Write-Through

Write to cache and database simultaneously:

async function updateUserWriteThrough(userId, data) {
  // Update both simultaneously
  const [updatedUser] = await Promise.all([
    db.users.update(userId, data),
    redis.setEx(`user:${userId}`, 3600, JSON.stringify({ ...data, id: userId }))
  ]);
  
  return updatedUser;
}

Pros: Cache is always fresh. Reads are always fast. Cons: Write latency increases. Warms cache for data that might never be read.

3. Write-Behind (Write-Back)

Write to cache immediately, asynchronously persist to database:

// Write to cache immediately, queue DB write
async function updateUserWriteBehind(userId, data) {
  const key = `user:${userId}`;
  
  // Instant write to cache
  await redis.setEx(key, 3600, JSON.stringify(data));
  
  // Queue background DB write
  await redis.lPush('write-queue', JSON.stringify({ 
    type: 'update_user', 
    userId, 
    data 
  }));
  
  return data;
}

// Background worker drains the queue
async function processWriteQueue() {
  while (true) {
    const item = await redis.brPop('write-queue', 0); // Blocking pop
    const { type, userId, data } = JSON.parse(item.element);
    
    if (type === 'update_user') {
      await db.users.update(userId, data);
    }
  }
}

Pros: Extremely fast writes. Handles write bursts. Cons: Risk of data loss if Redis crashes before DB write. Complex implementation.

4. Read-Through

Cache sits in front of the database, transparent to the application:

// In a read-through setup, the cache layer itself fetches from DB on miss
// Often implemented as a middleware or proxy layer

class ReadThroughCache {
  constructor(redis, db) {
    this.redis = redis;
    this.db = db;
  }
  
  async get(key, fetchFn, ttl = 3600) {
    const cached = await this.redis.get(key);
    if (cached !== null) return JSON.parse(cached);
    
    // Cache itself fetches from the source
    const value = await fetchFn();
    if (value !== null) {
      await this.redis.setEx(key, ttl, JSON.stringify(value));
    }
    return value;
  }
}

const cache = new ReadThroughCache(redis, db);
const user = await cache.get(`user:${id}`, () => db.users.findById(id));

Cache Invalidation Strategies

Cache invalidation is notoriously hard. Here are the main approaches:

// 1. TTL-based (simplest) — let cache expire naturally
await redis.setEx('product:123', 300, JSON.stringify(product)); // 5 min TTL

// 2. Event-driven invalidation — delete on update
async function updateProduct(productId, data) {
  await db.products.update(productId, data);
  
  // Invalidate related cache keys
  await redis.del(`product:${productId}`);
  await redis.del(`category:${data.categoryId}:products`); // Also invalidate category listing
}

// 3. Cache versioning — change key prefix on schema change
const CACHE_VERSION = 'v2';
const key = `${CACHE_VERSION}:user:${userId}`;

// 4. Tag-based invalidation using Redis sets
async function setWithTags(key, value, ttl, tags) {
  const pipeline = redis.multi();
  pipeline.setEx(key, ttl, JSON.stringify(value));
  
  // Track which keys have each tag
  for (const tag of tags) {
    pipeline.sAdd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttl + 60);
  }
  
  await pipeline.exec();
}

async function invalidateByTag(tag) {
  const keys = await redis.sMembers(`tag:${tag}`);
  if (keys.length > 0) {
    await redis.del([...keys, `tag:${tag}`]);
  }
}

// Usage: invalidate all "user-profile" tagged keys at once
await invalidateByTag('user-profile');

Avoiding Cache Stampede

When cache expires, many requests simultaneously hit the database:

// Problem: Many requests hit the DB simultaneously when cache expires

// Solution 1: Mutex lock
import { Mutex } from 'async-mutex';
const locks = new Map();

async function getWithLock(key, ttl, fetchFn) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  
  // Only one fetcher at a time
  if (!locks.has(key)) locks.set(key, new Mutex());
  
  return locks.get(key).runExclusive(async () => {
    // Double-check after acquiring lock
    const cached2 = await redis.get(key);
    if (cached2) return JSON.parse(cached2);
    
    const value = await fetchFn();
    await redis.setEx(key, ttl, JSON.stringify(value));
    return value;
  });
}

// Solution 2: Probabilistic early expiry
async function getWithEarlyExpiry(key, ttl, fetchFn) {
  const data = await redis.get(key);
  if (data) {
    const { value, expiry } = JSON.parse(data);
    const now = Date.now() / 1000;
    const timeLeft = expiry - now;
    
    // Randomly recompute before expiry (avoids synchronized stampede)
    if (timeLeft > 0 && Math.random() > timeLeft / (ttl * 0.1)) {
      return value; // Use cached value
    }
  }
  
  const value = await fetchFn();
  await redis.setEx(key, ttl, JSON.stringify({ 
    value, 
    expiry: Date.now() / 1000 + ttl 
  }));
  return value;
}

Redis for Session Storage

// express-session with Redis
import session from 'express-session';
import { RedisStore } from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
    sameSite: 'lax',
  },
  name: '__session', // Don't expose 'connect.sid'
}));

Redis for Rate Limiting

// Sliding window rate limiter
async function checkRateLimit(identifier, limit, windowSeconds) {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowStart = now - windowSeconds * 1000;
  
  const pipeline = redis.multi();
  pipeline.zRemRangeByScore(key, '-inf', windowStart); // Remove old entries
  pipeline.zAdd(key, { score: now, value: now.toString() });
  pipeline.zCard(key);                                  // Count requests
  pipeline.expire(key, windowSeconds);
  
  const results = await pipeline.exec();
  const requestCount = results[2];
  
  return {
    allowed: requestCount <= limit,
    count: requestCount,
    remaining: Math.max(0, limit - requestCount),
  };
}

// Usage in Express middleware
app.use(async (req, res, next) => {
  const identifier = req.ip;
  const { allowed, remaining } = await checkRateLimit(identifier, 100, 60);
  
  res.set('X-RateLimit-Remaining', remaining.toString());
  
  if (!allowed) {
    return res.status(429).json({ error: 'Rate limit exceeded' });
  }
  
  next();
});

Redis Pub/Sub for Real-Time Features

// Publisher
async function publishUserUpdate(userId, event) {
  await redis.publish(`user:${userId}:events`, JSON.stringify(event));
}

// Subscriber (separate connection — subscribers can only subscribe)
const subscriber = redis.duplicate();
await subscriber.connect();

await subscriber.subscribe('user:*:events', (message, channel) => {
  const event = JSON.parse(message);
  const userId = channel.split(':')[1];
  console.log(`User ${userId} event:'`, event);
  
  // Broadcast to WebSocket clients
  wsServer.to(`user-${userId}`).emit('update', event);
});

Key Naming Conventions

# ✅ Good key naming — hierarchical, descriptive
user:123                        # User object
user:123:sessions               # User's sessions
user:123:preferences            # User preferences
product:456:details             # Product details
product:456:reviews:page:1      # Paginated reviews
search:q:javascript:page:1     # Search results cache
ratelimit:192.168.1.1          # Rate limit tracker
lock:payment:order:789         # Distributed lock

# ❌ Bad key naming — flat, unstructured
user123data
product_456
search_javascript_1

→ View and explore complex JSON data structures with the JSON Viewer.