Đi đến nội dung
·35 phút đọc

Chiến Lược Cache Redis Thực Sự Hoạt Động Trong Production

Cache-aside, write-through, ngăn chặn cache stampede, chiến lược TTL và pattern invalidation. Các pattern Redis tôi đã dùng trong ứng dụng Node.js production với ví dụ code thực tế.

Chia sẻ:X / TwitterLinkedIn

Ai cũng bảo bạn "thêm Redis đi" khi API chậm. Không ai nói cho bạn chuyện gì xảy ra sáu tháng sau khi cache đang phục vụ dữ liệu cũ, logic invalidation nằm rải rác trong 40 file, và một lần deploy gây ra cache stampede làm sập database nặng hơn cả khi bạn chưa bao giờ cache.

Tôi đã chạy Redis trong production nhiều năm. Không phải chơi chơi, không phải trong tutorial — trong các hệ thống xử lý traffic thực, nơi mà cache sai có nghĩa là báo động lúc 3 giờ sáng. Dưới đây là tất cả những gì tôi học được về cách làm đúng.

Tại Sao Cần Cache?#

Hãy bắt đầu với điều hiển nhiên: database chậm so với bộ nhớ. Một truy vấn PostgreSQL mất 15ms đã được coi là nhanh theo tiêu chuẩn database. Nhưng nếu truy vấn đó chạy trên mỗi API request, và bạn đang xử lý 1.000 request mỗi giây, đó là 15.000ms thời gian database tích lũy mỗi giây. Connection pool của bạn cạn kiệt. p99 latency tăng vọt. Người dùng đang nhìn spinner quay.

Redis phục vụ hầu hết các lần đọc dưới 1ms. Cùng dữ liệu đó, khi được cache, biến thao tác 15ms thành thao tác 0.3ms. Đó không phải micro-optimization. Đó là sự khác biệt giữa cần 4 database replica và không cần cái nào.

Nhưng caching không miễn phí. Nó thêm độ phức tạp, tạo ra vấn đề consistency, và sinh ra một lớp failure mode hoàn toàn mới. Trước khi cache bất cứ thứ gì, hãy tự hỏi:

Khi nào cache có ích:

  • Dữ liệu được đọc nhiều hơn ghi (tỷ lệ 10:1 trở lên)
  • Truy vấn nền tảng tốn kém (joins, aggregations, gọi API bên ngoài)
  • Dữ liệu hơi cũ là chấp nhận được (catalog sản phẩm, hồ sơ người dùng, config)
  • Bạn có pattern truy cập dự đoán được (cùng key được truy cập lặp lại)

Khi nào cache gây hại:

  • Dữ liệu thay đổi liên tục và phải luôn mới (giá cổ phiếu realtime, điểm trực tiếp)
  • Mỗi request là duy nhất (truy vấn tìm kiếm với nhiều tham số)
  • Dataset của bạn nhỏ (nếu toàn bộ vừa trong bộ nhớ app, bỏ qua Redis)
  • Bạn chưa đủ năng lực vận hành để giám sát và debug vấn đề cache

Phil Karlton từng nổi tiếng nói rằng chỉ có hai thứ khó trong khoa học máy tính: cache invalidation và đặt tên. Ông ấy đúng về cả hai, nhưng cache invalidation mới là thứ đánh thức bạn lúc nửa đêm.

Cài Đặt ioredis#

Trước khi đi sâu vào các pattern, hãy thiết lập kết nối. Tôi dùng ioredis ở mọi nơi — nó là Redis client trưởng thành nhất cho Node.js, với hỗ trợ TypeScript đầy đủ, cluster mode, Sentinel support, và 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;

Vài điều đáng lưu ý. lazyConnect: true nghĩa là kết nối không được thiết lập cho đến khi bạn thực sự chạy một lệnh, rất hữu ích khi testing và khởi tạo. retryStrategy triển khai exponential backoff với giới hạn 5 giây — nếu không có nó, khi Redis sập, app của bạn sẽ spam các lần thử kết nối lại. Và maxRetriesPerRequest: 3 đảm bảo các lệnh riêng lẻ fail nhanh thay vì treo mãi.

Pattern Cache-Aside#

Đây là pattern bạn sẽ dùng 80% thời gian. Nó còn được gọi là "lazy loading" hay "look-aside." Luồng xử lý đơn giản:

  1. Ứng dụng nhận request
  2. Kiểm tra Redis có giá trị cache không
  3. Nếu có (cache hit), trả về
  4. Nếu không (cache miss), truy vấn database
  5. Lưu kết quả vào Redis
  6. Trả về kết quả

Đây là implementation có type:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // giây
  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}`;
 
  // Bước 1: Thử đọc từ cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Entry cache bị hỏng, xóa và tiếp tục
      await redis.del(cacheKey);
    }
  }
 
  // Bước 2: Cache miss — fetch từ nguồn
  const result = await fetcher();
 
  // Bước 3: Lưu vào cache (không 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;
}

Cách sử dụng như thế này:

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 phút
  );
}

Lưu ý tôi fire-and-forget lệnh redis.set. Đây là cố ý. Nếu Redis sập hoặc chậm, request vẫn hoàn thành. Cache là tối ưu hóa, không phải yêu cầu bắt buộc. Nếu ghi cache fail, request tiếp theo chỉ đơn giản lại truy vấn database. Không vấn đề gì.

Có một bug tinh vi trong nhiều implementation cache-aside mà người ta hay bỏ qua: cache giá trị null. Nếu user không tồn tại và bạn không cache điều đó, mỗi request cho user đó đều hit database. Kẻ tấn công có thể khai thác bằng cách request các user ID ngẫu nhiên, biến cache thành vô dụng. Luôn cache cả kết quả phủ định — chỉ với TTL ngắn hơn.

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;
    },
    {
      // TTL ngắn hơn cho kết quả null để giới hạn bộ nhớ
      // nhưng đủ dài để hấp thụ các lần miss lặp lại
      ttl: row ? 1800 : 300,
    }
  );
}

Thực ra, để tôi tái cấu trúc lại cho TTL động hoạt động đúng:

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;
 
  // Cache kết quả có dữ liệu 30 phút, kết quả null 5 phút
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through và Write-Behind#

Cache-aside hoạt động tốt cho workload đọc nhiều, nhưng có vấn đề consistency: nếu service hoặc process khác cập nhật database trực tiếp, cache của bạn sẽ cũ cho đến khi TTL hết hạn. Đó là lúc cần write-through và write-behind.

Write-Through#

Trong write-through, mọi thao tác ghi đi qua lớp cache. Cache được cập nhật trước, sau đó là database. Điều này đảm bảo cache luôn nhất quán với database (giả sử mọi thao tác ghi đều đi qua ứng dụng của bạn).

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // Bước 1: Cập nhật database
  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];
 
  // Bước 2: Cập nhật cache ngay lập tức
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

Khác biệt chính so với cache-aside: chúng ta ghi vào cache ở mỗi thao tác ghi, không chỉ khi đọc. Điều này có nghĩa cache luôn warm cho dữ liệu vừa được cập nhật.

Đánh đổi: latency ghi tăng vì mỗi thao tác ghi giờ đều chạm cả database và Redis. Nếu Redis chậm, thao tác ghi của bạn chậm. Trong hầu hết ứng dụng, đọc nhiều hơn ghi, nên đánh đổi này đáng giá.

Write-Behind (Write-Back)#

Write-behind đảo ngược: thao tác ghi vào Redis trước, và database được cập nhật bất đồng bộ. Điều này cho bạn tốc độ ghi cực nhanh nhưng có nguy cơ mất dữ liệu nếu Redis sập trước khi dữ liệu được persist.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Đọc trạng thái hiện tại
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // Cập nhật cache ngay lập tức
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Đưa thao tác ghi database vào queue xử lý bất đồng bộ
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Sau đó bạn sẽ có một worker riêng xử lý queue đó:

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) {
        // Đưa lại vào queue khi fail với retry count
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Tôi hiếm khi dùng write-behind trong thực tế. Rủi ro mất dữ liệu là thật — nếu Redis crash trước khi worker xử lý queue, những thao tác ghi đó sẽ mất. Chỉ dùng cho dữ liệu mà eventual consistency thực sự chấp nhận được, như đếm lượt xem, sự kiện analytics, hoặc preferences người dùng không quan trọng.

Chiến Lược TTL#

Thiết lập TTL đúng phức tạp hơn bạn tưởng. TTL cố định 1 giờ cho mọi thứ dễ triển khai nhưng hầu như luôn sai.

Phân Tầng Theo Mức Biến Động Dữ Liệu#

Tôi phân loại dữ liệu thành ba tầng và gán TTL tương ứng:

typescript
const TTL = {
  // Tầng 1: Hiếm khi thay đổi, tốn kém để tính toán
  // Ví dụ: catalog sản phẩm, config site, feature flags
  STATIC: 86400,       // 24 giờ
 
  // Tầng 2: Thay đổi đôi khi, chi phí vừa phải
  // Ví dụ: hồ sơ người dùng, cài đặt team, quyền hạn
  MODERATE: 1800,      // 30 phút
 
  // Tầng 3: Thay đổi thường xuyên, tính toán rẻ nhưng gọi nhiều
  // Ví dụ: dữ liệu feed, đếm thông báo, thông tin session
  VOLATILE: 300,       // 5 phút
 
  // Tầng 4: Tạm thời, dùng cho rate limiting và locks
  EPHEMERAL: 60,       // 1 phút
 
  // Kết quả null: luôn ngắn hạn
  NOT_FOUND: 120,      // 2 phút
} as const;

TTL Jitter: Ngăn Chặn Thundering Herd#

Đây là kịch bản đã làm tôi vấp: bạn deploy app, cache trống, và 10.000 request đều cache cùng dữ liệu với TTL 1 giờ. Một giờ sau, tất cả 10.000 key hết hạn đồng thời. Tất cả 10.000 request đều hit database cùng lúc. Database nghẹn. Tôi đã thấy điều này làm sập một Postgres instance production.

Cách sửa là jitter — thêm tính ngẫu nhiên vào giá trị TTL:

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));
}
 
// Thay vì: redis.set(key, value, "EX", 3600)
// Dùng:    redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = giá trị ngẫu nhiên giữa 3240 và 3960

Điều này phân tán thời điểm hết hạn trong một khoảng, nên thay vì 10.000 key hết hạn cùng giây, chúng hết hạn trong khoảng 12 phút. Database thấy traffic tăng dần, không phải đột ngột.

Với các đường dẫn quan trọng, tôi đẩy lên 20% jitter:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 giây

Sliding Expiry#

Cho dữ liệu kiểu session mà TTL cần reset mỗi lần truy cập, dùng GETEX (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX lấy giá trị VÀ reset TTL nguyên tử
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Nếu bạn dùng phiên bản Redis cũ hơn, dùng 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 giúp với việc hết hạn hàng loạt, nhưng không giải quyết stampede trên một key: khi một key phổ biến hết hạn và hàng trăm request đồng thời đều cố tạo lại nó cùng lúc.

Tưởng tượng bạn cache feed trang chủ với TTL 5 phút. Nó hết hạn. Năm mươi request đồng thời thấy cache miss. Cả năm mươi hit database với cùng truy vấn tốn kém. Bạn vừa tự DDoS mình.

Giải Pháp 1: Mutex Lock#

Chỉ một request tạo lại cache. Tất cả còn lại chờ.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Thử cache trước
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Thử lấy lock (NX = chỉ nếu chưa tồn tại, EX = tự hết hạn)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // Chúng ta có lock — fetch và cache
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Giải phóng lock
      await redis.del(lockKey);
    }
  }
 
  // Request khác đang giữ lock — chờ và thử lại
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Vẫn chưa có cache — fallback về database
  // (xử lý trường hợp lock holder bị fail)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Có một race condition tinh vi trong việc giải phóng lock ở trên. Nếu lock holder mất hơn 10 giây (TTL của lock), request khác lấy được lock, và rồi request đầu tiên xóa lock của request thứ hai. Cách sửa đúng là dùng 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 đảm bảo check-and-delete nguyên tử
  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;
}

Đây về cơ bản là Redlock đơn giản hóa. Với Redis instance đơn, nó đủ dùng. Với Redis Cluster hoặc Sentinel, hãy xem thuật toán Redlock đầy đủ — nhưng thật lòng, cho việc ngăn chặn cache stampede, phiên bản đơn giản này hoạt động tốt.

Giải Pháp 2: Hết Hạn Sớm Theo Xác Suất#

Đây là cách tiếp cận ưa thích của tôi. Thay vì chờ key hết hạn, tái tạo ngẫu nhiên nó ngay trước khi hết hạn. Ý tưởng đến từ bài báo của Vattani, Chierichetti và Lowenstein.

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;
 
    // Thuật toán XFetch: tái tạo theo xác suất khi gần hết hạn
    // beta * Math.log(Math.random()) tạo số âm
    // ngày càng lớn (âm hơn) khi gần hết hạn
    const beta = 1; // tham số điều chỉnh, 1 hoạt động tốt
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Fall through để tái tạo
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Set với buffer thêm để Redis không hết hạn trước khi ta kịp tái tạo
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

Vẻ đẹp của cách tiếp cận này: khi TTL còn lại của key giảm, xác suất tái tạo tăng. Với 1.000 request đồng thời, có thể một hoặc hai sẽ trigger tái tạo trong khi phần còn lại tiếp tục phục vụ dữ liệu cache. Không lock, không phối hợp, không chờ đợi.

Giải Pháp 3: Stale-While-Revalidate#

Phục vụ giá trị cũ trong khi tái tạo ở background. Điều này cho latency tốt nhất vì không request nào phải chờ fetcher.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // dữ liệu "tươi" bao lâu
    staleTtl: number;   // dữ liệu cũ được phục vụ bao lâu
  }
): 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) {
      // Dữ liệu cũ — phục vụ nhưng trigger refresh ở background
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Cache miss hoàn toàn — phải fetch đồng bộ
  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 {
  // Dùng lock để ngăn nhiều background refresh
  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);
    });
}

Cách sử dụng:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 phút tươi
  staleTtl: 3600,    // phục vụ cũ tối đa 1 giờ trong khi revalidate
});

Tôi dùng pattern này cho bất cứ thứ gì hướng người dùng mà latency quan trọng hơn sự tươi mới tuyệt đối. Dữ liệu dashboard, trang profile, danh sách sản phẩm — tất cả đều là ứng cử viên hoàn hảo.

Cache Invalidation#

Phil Karlton không nói đùa. Invalidation là nơi caching từ "tối ưu hóa dễ dàng" biến thành "bài toán hệ thống phân tán."

Invalidation Đơn Giản Theo Key#

Trường hợp dễ nhất: khi cập nhật user, xóa cache key của họ.

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]
  );
 
  // Invalidate cache
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

Điều này hoạt động cho đến khi dữ liệu user xuất hiện trong các kết quả cache khác. Có thể nó nằm trong danh sách thành viên team. Có thể trong kết quả tìm kiếm. Có thể trong 14 API response cache khác nhau. Bây giờ bạn cần theo dõi cache key nào chứa entity nào.

Invalidation Dựa Trên Tag#

Gắn tag cho các entry cache với các entity chúng chứa, rồi invalidate theo tag.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Lưu giá trị
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Thêm key vào set của mỗi tag
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets sống lâu hơn giá trị
  }
 
  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;
}

Cách sử dụng:

typescript
// Khi cache dữ liệu team, gắn tag với tất cả member ID
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// Khi user 42 cập nhật profile, invalidate mọi thứ chứa họ
await invalidateByTag("entity:user:42");

Invalidation Hướng Sự Kiện#

Với hệ thống lớn hơn, dùng Redis Pub/Sub để broadcast sự kiện invalidation:

typescript
// Publisher (trong API service)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Subscriber (trong mỗi app instance)
const subscriber = new Redis(/* cùng 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}`);
});

Đây rất quan trọng trong môi trường multi-instance. Nếu bạn có 4 app server sau load balancer, invalidation trên server 1 cần lan truyền đến tất cả server. Pub/Sub xử lý tự động.

Invalidation Theo Pattern (Cẩn Thận)#

Đôi khi bạn cần invalidate tất cả key khớp pattern. Không bao giờ dùng KEYS trong production. Nó block Redis server khi scan toàn bộ keyspace. Với hàng triệu key, có thể block vài giây — cả đời trong thời gian Redis.

Dùng SCAN thay thế:

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;
}
 
// Invalidate tất cả dữ liệu cache cho team cụ thể
await invalidateByPattern("cache:team:42:*");

SCAN duyệt tăng dần — không bao giờ block server. Gợi ý COUNT đề xuất bao nhiêu key trả về mỗi lần lặp (là gợi ý, không phải đảm bảo). Với keyspace lớn, đây là cách tiếp cận an toàn duy nhất.

Tuy nhiên, invalidation theo pattern là dấu hiệu code smell. Nếu bạn thấy mình scan thường xuyên, hãy thiết kế lại cấu trúc key hoặc dùng tag. SCAN là O(N) trên keyspace và dành cho thao tác bảo trì, không phải đường dẫn nóng.

Cấu Trúc Dữ Liệu Ngoài String#

Hầu hết developer coi Redis như kho key-value cho JSON string. Giống như mua dao đa năng Thụy Sĩ mà chỉ dùng cái mở nắp chai. Redis có cấu trúc dữ liệu phong phú, và chọn đúng loại có thể loại bỏ toàn bộ hạng mục phức tạp.

Hash Cho Object#

Thay vì serialize toàn bộ object thành JSON, lưu nó dưới dạng Redis Hash. Điều này cho phép đọc và cập nhật từng field riêng lẻ mà không cần deserialize toàn bộ.

typescript
// Lưu user dưới dạng hash
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);
}
 
// Đọc field cụ thể
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Cập nhật một field
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Đọc toàn bộ hash thành object
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"],
  };
}

Hash tiết kiệm bộ nhớ cho object nhỏ (Redis dùng ziplist encoding compact bên dưới) và tránh overhead serialize/deserialize. Đánh đổi: bạn mất khả năng lưu object lồng nhau mà không flatten trước.

Sorted Set Cho Bảng Xếp Hạng và Rate Limiting#

Sorted Set là cấu trúc dữ liệu ít được đánh giá đúng nhất của Redis. Mỗi member có score, và set luôn được sắp xếp theo score. Điều này làm chúng hoàn hảo cho bảng xếp hạng, ranking, và sliding window rate limiting.

typescript
// Bảng xếp hạng
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; // từ 0-indexed sang 1-indexed
}

Cho 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();
 
  // Xóa entry ngoài window
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Thêm request hiện tại
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Đếm request trong window
  pipeline.zcard(key);
 
  // Set expiry cho toàn bộ key
  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),
  };
}

Cách này chính xác hơn fixed-window counter và không bị vấn đề boundary khi burst ở cuối window này và đầu window tiếp theo thực tế gấp đôi rate limit.

List Cho Queue#

Redis List với LPUSH/BRPOP tạo nên job queue nhẹ tuyệt vời:

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 (block cho đến khi có job)
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;
}

Cho bất cứ thứ gì phức tạp hơn queue cơ bản (retry, dead letter queue, priority, delayed job), dùng BullMQ — xây dựng trên Redis nhưng xử lý tất cả edge case.

Set Cho Theo Dõi Unique#

Cần theo dõi visitor duy nhất, loại bỏ event trùng lặp, hoặc kiểm tra membership? Set là O(1) cho add, remove, và kiểm tra membership.

typescript
// Theo dõi visitor duy nhất mỗi ngày
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);
 
  // Tự hết hạn sau 48 giờ
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = member mới, 0 = đã tồn tại
}
 
// Lấy số visitor duy nhất
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Kiểm tra user đã thực hiện action chưa
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

Với set rất lớn (hàng triệu member), cân nhắc HyperLogLog thay thế. Nó chỉ dùng 12KB bộ nhớ bất kể cardinality, với chi phí ~0.81% sai số chuẩn:

typescript
// HyperLogLog cho đếm unique xấp xỉ
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 là lựa chọn mặc định cho Redis serialization. Nó dễ đọc, phổ biến, và đủ tốt cho hầu hết trường hợp. Nhưng với hệ thống throughput cao, overhead serialize/deserialize cộng dồn lại.

Vấn Đề Với JSON#

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 trên đường dẫn nóng: ~0.02ms mỗi lần
// Với 10.000 request/giây: tổng 200ms CPU mỗi giây

Lựa Chọn MessagePack#

MessagePack là định dạng serialization nhị phân nhỏ hơn và nhanh hơn JSON:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bytes (nhỏ hơn 25%)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Lưu dưới dạng Buffer
await redis.set("user:123", packed);
 
// Đọc dưới dạng Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

Lưu ý dùng getBuffer thay vì get — điều này quan trọng. get trả về string và sẽ hỏng dữ liệu nhị phân.

Nén Cho Giá Trị Lớn#

Với giá trị cache lớn (API response với hàng trăm item, HTML render), thêm nén:

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);
 
  // Chỉ nén nếu lớn hơn 1KB (overhead nén không đáng cho giá trị nhỏ)
  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 {
    // Thử giải nén trước
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Không được nén, parse như JSON thường
    return JSON.parse(raw.toString()) as T;
  }
}

Trong testing của tôi, nén gzip thường giảm kích thước JSON payload 70-85%. API response 50KB thành 8KB. Điều này quan trọng khi bạn trả tiền cho bộ nhớ Redis — ít bộ nhớ mỗi key nghĩa là nhiều key hơn trong cùng instance.

Đánh đổi: nén thêm 1-3ms CPU mỗi thao tác. Với hầu hết ứng dụng, không đáng kể. Với đường dẫn ultra-low-latency, bỏ qua.

Khuyến Nghị Của Tôi#

Dùng JSON trừ khi profiling chỉ ra nó là bottleneck. Tính dễ đọc và debug của JSON trong Redis (bạn có thể redis-cli GET key và thực sự đọc được giá trị) vượt trội hơn hiệu năng của MessagePack cho 95% ứng dụng. Thêm nén chỉ cho giá trị lớn hơn 1KB.

Redis Trong Next.js#

Next.js có câu chuyện caching riêng (Data Cache, Full Route Cache, v.v.), nhưng Redis lấp đầy khoảng trống mà caching tích hợp không xử lý được — đặc biệt khi bạn cần chia sẻ cache giữa nhiều instance hoặc persist cache qua các lần deploy.

Cache API Route Response#

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}`;
 
  // Kiểm tra cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: {
        "X-Cache": "HIT",
        "Cache-Control": "public, s-maxage=60",
      },
    });
  }
 
  // Fetch từ database
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Cache 5 phút với jitter
  await redis.set(
    cacheKey,
    JSON.stringify(products),
    "EX",
    ttlWithJitter(300)
  );
 
  return NextResponse.json(products, {
    headers: {
      "X-Cache": "MISS",
      "Cache-Control": "public, s-maxage=60",
    },
  });
}

Header X-Cache vô giá cho debugging. Khi latency tăng đột ngột, curl -I nhanh cho bạn biết cache có hoạt động không.

Session Storage#

Next.js với Redis cho session vượt trội JWT cho ứng dụng stateful:

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 giờ
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}`;
 
  // Dùng GETEX để refresh TTL mỗi lần truy cập (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}`);
}
 
// Hủy tất cả session của user (hữu ích cho "logout mọi nơi")
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // Yêu cầu duy trì index user->sessions
  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 (hoặc helper dùng bởi middleware)
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;
 
  // Lua script cho rate limiting nguyên tử
  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 quan trọng ở đây. Nếu không có nó, chuỗi ZREMRANGEBYSCORE + ZADD + ZCARD không nguyên tử, và dưới concurrency cao, count có thể sai. Lua script thực thi nguyên tử trong Redis — chúng không thể bị xen lẫn với lệnh khác.

Distributed Lock Cho Next.js#

Khi bạn có nhiều Next.js instance và cần đảm bảo chỉ một xử lý task (như gửi email theo lịch hoặc chạy cleanup job):

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}`;
 
  // Thử lấy lock
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Tự động gia hạn lock cho task chạy lâu
        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 {
        // Giải phóng lock chỉ nếu ta vẫn sở hữu nó
        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);
      }
    }
 
    // Chờ trước khi thử lại
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Không thể lấy lock sau tất cả retry
  return null;
}

Cách sử dụng:

typescript
// Trong API route trigger bởi cron
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Chỉ một instance chạy cái này
    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 });
}

Khoảng thời gian gia hạn lock ở ttl/3 rất quan trọng. Nếu không có nó, khi task chạy lâu hơn TTL của lock, lock hết hạn và instance khác lấy được. Extender giữ lock sống miễn là task đang chạy.

Giám Sát và Debug#

Redis nhanh cho đến khi không còn nhanh. Khi vấn đề xảy ra, bạn cần khả năng quan sát.

Tỷ Lệ Cache Hit#

Metric quan trọng nhất. Theo dõi nó trong ứng dụng:

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,
  };
}
 
// Reset metrics hàng ngày
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Tỷ lệ cache hit lành mạnh là trên 90%. Nếu dưới 80%, hoặc TTL quá ngắn, cache key quá cụ thể, hoặc pattern truy cập ngẫu nhiên hơn bạn nghĩ.

Lệnh INFO#

Lệnh INFO là dashboard health tích hợp của Redis:

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

Các metric chính cần giám sát:

  • used_memory vs maxmemory: Bạn có gần đạt giới hạn không?
  • mem_fragmentation_ratio: Trên 1.5 nghĩa là Redis đang dùng RSS nhiều hơn đáng kể so với bộ nhớ logic. Cân nhắc restart.
  • evicted_keys: Nếu khác 0 và bạn không chủ đích evict, bạn hết bộ nhớ.
bash
redis-cli INFO stats

Theo dõi:

  • keyspace_hits / keyspace_misses: Hit rate cấp server
  • total_commands_processed: Throughput
  • instantaneous_ops_per_sec: Throughput hiện tại

MONITOR (Dùng Cực Kỳ Cẩn Thận)#

MONITOR stream mọi lệnh được thực thi trên Redis server trong thời gian thực. Cực kỳ hữu ích cho debug và cực kỳ nguy hiểm trong production.

bash
# KHÔNG BAO GIỜ để chạy trong production
# Nó thêm overhead đáng kể và có thể log dữ liệu nhạy cảm
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"

Tôi dùng MONITOR cho đúng hai việc: debug vấn đề đặt tên key khi phát triển, và xác nhận đường dẫn code cụ thể có hit Redis như mong đợi. Không bao giờ quá 30 giây. Không bao giờ trong production trừ khi đã cạn kiệt mọi phương pháp debug khác.

Keyspace Notification#

Muốn biết khi key hết hạn hoặc bị xóa? Redis có thể publish event:

bash
# Bật keyspace notification cho expired và evicted event
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* config */);
 
// Lắng nghe sự kiện key hết hạn
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Chủ động tái tạo key quan trọng
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Điều này hữu ích cho cache warming chủ động — thay vì chờ user trigger cache miss, bạn tái tạo entry quan trọng ngay khi chúng hết hạn.

Phân Tích Bộ Nhớ#

Khi bộ nhớ Redis tăng bất ngờ, bạn cần tìm key nào tiêu thụ nhiều nhất:

bash
# Sample 10 key lớn nhất
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

Phân tích chi tiết hơn:

bash
# Dung lượng bộ nhớ của key cụ thể (bytes)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Phân tích bộ nhớ lập trình
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");
 
  // Sắp xếp theo kích thước giảm dần
  stats.sort((a, b) => b.bytes - a.bytes);
 
  console.log("Top 20 key theo dung lượng bộ nhớ:");
  for (const { key, bytes } of stats.slice(0, 20)) {
    const mb = (bytes / 1024 / 1024).toFixed(2);
    console.log(`  ${key}: ${mb} MB`);
  }
}

Chính Sách Eviction#

Nếu Redis instance có giới hạn maxmemory (nên có), cấu hình chính sách eviction:

bash
# Trong redis.conf hoặc qua CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lru

Các chính sách khả dụng:

  • noeviction: Trả error khi hết bộ nhớ (mặc định, tệ nhất cho caching)
  • allkeys-lru: Evict key ít dùng gần đây nhất (lựa chọn tốt nhất cho caching thông dụng)
  • allkeys-lfu: Evict key ít dùng thường xuyên nhất (tốt hơn nếu một số key được truy cập theo burst)
  • volatile-lru: Chỉ evict key có TTL (hữu ích khi trộn cache và dữ liệu persistent)
  • allkeys-random: Eviction ngẫu nhiên (hiệu quả ngạc nhiên, không overhead)

Cho workload caching thuần túy, allkeys-lfu thường là lựa chọn tốt nhất. Nó giữ key truy cập thường xuyên trong bộ nhớ ngay cả khi chúng chưa được truy cập gần đây.

Kết Hợp Tất Cả: Module Cache Production#

Đây là module cache hoàn chỉnh tôi dùng trong production, kết hợp mọi thứ đã thảo luận:

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);
  },
});
 
// Tầng TTL
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 với bảo vệ stampede
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}`;
 
  // Kiểm tra cache
  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();
 
  // Lấy lock để ngăn stampede
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // Process khác đang fetch — chờ ngắn và thử lại cache
    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);
 
    // Lưu liên kết tag
    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,
};

Sử dụng trong toàn ứng dụng:

typescript
import { cache } from "@/lib/cache";
 
// Cache-aside đơn giản
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// Với TTL tùy chỉnh
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 giờ
});
 
// Sau khi cập nhật sản phẩm
await cache.invalidateByTag("entity:products");
 
// Kiểm tra health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Sai Lầm Phổ Biến Tôi Đã Mắc (Để Bạn Không Phải)#

1. Không set maxmemory. Redis sẽ vui vẻ dùng hết bộ nhớ khả dụng cho đến khi OS kill nó. Luôn đặt giới hạn.

2. Dùng KEYS trong production. Nó block server. Dùng SCAN. Tôi học được khi lệnh KEYS * từ script giám sát gây ra 3 giây downtime.

3. Cache quá mức. Không phải mọi thứ cần cache. Nếu truy vấn database mất 2ms và được gọi 10 lần mỗi phút, cache thêm phức tạp mà lợi ích không đáng kể.

4. Bỏ qua chi phí serialization. Tôi từng cache blob JSON 2MB và thắc mắc sao đọc cache chậm. Overhead serialization lớn hơn truy vấn database mà nó định tiết kiệm.

5. Không graceful degradation. Khi Redis sập, app vẫn phải hoạt động — chỉ chậm hơn. Bọc mọi lệnh cache trong try/catch và fallback về database. Không bao giờ để cache failure trở thành lỗi cho người dùng.

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(); // Bỏ qua cache hoàn toàn
  }
}

6. Không giám sát eviction. Nếu Redis đang evict key, bạn hoặc thiếu tài nguyên hoặc cache quá nhiều. Dù sao, bạn cần biết.

7. Dùng chung Redis instance cho caching và dữ liệu persistent. Dùng instance riêng (hoặc ít nhất database riêng). Chính sách eviction xóa mất entry job queue là một ngày tồi tệ cho tất cả.

Kết Luận#

Redis caching không khó, nhưng dễ làm sai. Bắt đầu với cache-aside, thêm TTL jitter từ ngày đầu, giám sát hit rate, và kìm nén cám dỗ cache mọi thứ.

Chiến lược caching tốt nhất là chiến lược bạn có thể suy luận được lúc 3 giờ sáng khi có gì đó hỏng. Giữ đơn giản, giữ khả năng quan sát, và nhớ rằng mỗi giá trị cache là một lời nói dối bạn kể cho người dùng về trạng thái dữ liệu — công việc của bạn là giữ lời nói dối đó càng nhỏ và ngắn hạn càng tốt.

Bài viết liên quan