Cache-Aside Pattern Thực Chiến: Triển khai Redis Cache trong Node.js (TypeScript)

VMas-Dev-AnHuynh

Cache-Aside Pattern Thực Chiến: Triển khai Redis Cache trong Node.js (TypeScript)

Bạn đã bao giờ rơi vào tình huống database bị quá tải chỉ vì một cache key đơn giản hết hạn? Hay ứng dụng của bạn liên tục truy vấn database cho những ID không tồn tại do hacker cố tình spam? Đây là những vấn đề thực chiến mà bất kỳ backend developer nào cũng từng đối mặt.

Cache-Aside Pattern (còn gọi là Lazy Loading) là chiến lược caching phổ biến nhất trong thế giới backend. Theo AWS, đây là caching pattern được sử dụng nhiều nhất trong các hệ thống sản xuất. Tuy nhiên, đa số bài viết trên SERP Việt Nam chỉ dừng lại ở sơ đồ khối lý thuyết. Bài viết này sẽ cung cấp cho bạn một hàm Generic Wrapper viết bằng TypeScript có thể tái sử dụng cho mọi Model, kèm theo giải pháp xử lý triệt để Cache PenetrationCache Stampede — những thảm họa cache thường gặp trong môi trường production.

💡 Nếu bạn chưa nắm vững kiến thức nền tảng về Redis trong Node.js, hãy tham khảo bài viết Caching với Redis trong Node.js: Tăng tốc ứng dụng lên 10 lần trước khi đọc tiếp.

Kiến trúc tổng thể Cache-Aside Pattern trong ứng dụng Node.js với Redis Cluster

1. Bản chất của Cache-Aside Pattern: Khi nào ứng dụng của bạn cần nó?

Cache-Aside Pattern là một chiến lược caching lười biếng (lazy loading). Ứng dụng của bạn chủ động quản lý cache: kiểm tra cache trước, nếu có dữ liệu (cache hit) thì trả về ngay, nếu không (cache miss) thì truy vấn database, ghi vào cache rồi mới trả về.

Sơ đồ luồng xử lý Cache-Aside Pattern với distributed lock: check cache → miss → lock → query DB → ghi cache → unlock → trả về

Sơ đồ luồng dữ liệu

┌─────────────┐
│   Request   │
└──────┬──────┘
       ▼
┌─────────────────────────────────┐
│  1. Kiểm tra Redis Cache        │
│     GET {cacheKey}              │
└──────┬──────────────────────────┘
       │
   ┌───┴───┐
   ▼       ▼
┌─────────┐ ┌──────────────────────────────────┐
│ Cache   │ │ 2. Cache Miss                    │
│ Hit     │ │    Query Database                │
└────┬────┘ │    SET {cacheKey} = data, EX ttl │
     │      └────────────┬─────────────────────┘
     ▼                   ▼
┌─────────────┐ ┌─────────────┐
│ Trả về data │ │ Trả về data │
│ từ cache    │ │ từ database │
└─────────────┘ └─────────────┘

Khi nào nên dùng Cache-Aside?

  • ✅ Ứng dụng của bạn có lượng đọc nhiều hơn ghi (read-heavy)
  • ✅ Dữ liệu được truy cập lặp đi lặp lại (ví dụ: thông tin sản phẩm, bài viết, profile người dùng)
  • ✅ Bạn muốn kiểm soát hoàn toàn logic caching thay vì phụ thuộc vào cơ chế tự động của cache layer
  • ✅ Bạn cần linh hoạt trong việc quyết định dữ liệu nào được cache và dữ liệu nào không

Khi nào KHÔNG nên dùng?

  • ❌ Dữ liệu thay đổi liên tục (write-heavy) — mỗi lần ghi sẽ phải xóa cache, gây lãng phí
  • ❌ Yêu cầu nhất quán mạnh (strong consistency) giữa cache và database — Cache-Aside không đảm bảo điều này
  • ❌ Ứng dụng có độ trễ cực thấp (ultra-low latency) — cache miss sẽ tăng thêm một round-trip đến database

2. Xây dựng hàm Generic Cache Service bọc logic dữ liệu

Phần này sẽ hướng dẫn bạn viết một Cache Service bằng TypeScript, đóng gói toàn bộ logic Cache-Aside vào một hàm getOrSet dùng chung cho mọi Model.

2.1. Khởi tạo ioredis client

Đầu tiên, cài đặt ioredis:

npm install ioredis

⚠️ Lưu ý kiểm chứng: ioredis phiên bản v5.x đã tích hợp sẵn TypeScript types, không cần cài @types/ioredis riêng. Tuy nhiên, cần kiểm tra kỹ kiểu trả về của lệnh kết nối lỗi để bọc try/catch an toàn.

// src/lib/redis.ts
import Redis from 'ioredis';
import logger from './logger';

let redisClient: Redis | null = null;

export function getRedisClient(): Redis {
  if (!redisClient) {
    const isServerless = process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.VERCEL;

    redisClient = new Redis({
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
      password: process.env.REDIS_PASSWORD,
      db: parseInt(process.env.REDIS_DB || '0'),
      retryStrategy: (times) => Math.min(times * 100, 3000),
      maxRetriesPerRequest: 3,
      enableReadyCheck: true,
      lazyConnect: true,
      // Trong môi trường serverless, nên đóng kết nối sau mỗi request
      ...(isServerless && { 
        enableOfflineQueue: false,
        connectTimeout: 5000,
      }),
    });

    redisClient.on('error', (err) => {
      logger.error('Redis connection error:', err);
    });

    redisClient.on('connect', () => {
      logger.info('Redis connected successfully');
    });

    // Nếu là serverless, đóng kết nối khi process kết thúc
    if (isServerless) {
      process.on('beforeExit', () => {
        redisClient?.quit();
      });
    }
  }
  return redisClient;
}

Giải thích:

  • Sử dụng Singleton pattern để tái sử dụng một kết nối Redis xuyên suốt vòng đời ứng dụng
  • retryStrategymaxRetriesPerRequest giúp ứng dụng tự phục hồi khi Redis gặp sự cố
  • lazyConnect: true giúp trì hoãn kết nối đến khi thực sự cần, tránh lỗi khởi động
  • Trong môi trường serverless, enableOfflineQueue: false giúp tránh tích tụ queue khi không có kết nối

2.2. Thiết kế hàm getOrSet phục vụ cơ chế Cache-Aside

Đây là trái tim của toàn bộ bài viết. Hàm getOrSet nhận vào:

  • key: cache key
  • ttl: thời gian sống của cache (giây)
  • fetchFn: callback function chứa câu lệnh query DB gốc

Hàm sẽ tự động: Check Cache → Hit (trả về) → Miss (gọi fetchFn → ghi Cache → trả về).

// src/lib/cache.service.ts
import { getRedisClient } from './redis';
import logger from './logger';

export interface CacheOptions {
  ttl?: number;          // Thời gian sống của cache (giây), mặc định 3600
  nullTTL?: number;      // TTL cho null value (chống cache penetration), mặc định 60
  nullSentinel?: string; // Giá trị đánh dấu null trong Redis, mặc định '__NULL__'
  lockTTL?: number;      // TTL cho distributed lock (chống cache stampede), mặc định 5
  retryDelay?: number;   // Thời gian chờ retry khi không lấy được lock, mặc định 100ms
  maxRetries?: number;   // Số lần retry tối đa, mặc định 10
}

const DEFAULT_TTL = 3600;            // 1 giờ
const DEFAULT_NULL_TTL = 60;         // 60 giây
const DEFAULT_NULL_SENTINEL = '__NULL__';
const DEFAULT_LOCK_TTL = 5;          // 5 giây
const DEFAULT_RETRY_DELAY = 100;     // 100ms
const DEFAULT_MAX_RETRIES = 10;

export class CacheService {
  private redis = getRedisClient();

  /**
   * Hàm generic getOrSet - áp dụng Cache-Aside Pattern
   * Tích hợp distributed lock để chống Cache Stampede
   * 
   * @param key - Cache key
   * @param fetchFn - Callback lấy dữ liệu từ database
   * @param options - Tùy chọn TTL, null handling, lock
   * @returns Dữ liệu kiểu T hoặc null
   */
  async getOrSet
<T>(
    key: string,
    fetchFn: () => Promise<T | null>,
    options: CacheOptions = {}
  ): Promise<T | null> {
    const {
      ttl = DEFAULT_TTL,
      nullTTL = DEFAULT_NULL_TTL,
      nullSentinel = DEFAULT_NULL_SENTINEL,
      lockTTL = DEFAULT_LOCK_TTL,
      retryDelay = DEFAULT_RETRY_DELAY,
      maxRetries = DEFAULT_MAX_RETRIES,
    } = options;

    let retries = 0;

    while (retries < maxRetries) {
      try {
        // Bước 1: Kiểm tra cache
        const cached = await this.redis.get(key);

        if (cached !== null) {
          // Cache Hit: Kiểm tra xem có phải null sentinel không
          if (cached === nullSentinel) {
            return null; // Đã cache null value, trả về null ngay
          }
          // Parse và trả về dữ liệu thật (có bọc try/catch an toàn)
          try {
            return JSON.parse(cached) as T;
          } catch (parseError) {
            // Dữ liệu trong Redis bị hỏng → xóa cache và fallback
            logger.warn(`Corrupted cache data for key "${key}", invalidating...`);
            await this.redis.del(key);
            // Không return, tiếp tục xuống phần fetch dữ liệu mới
          }
        }

        // Bước 2: Cache Miss → Thử lấy distributed lock để chống Cache Stampede
        const lockKey = `lock:${key}`;
        const lockAcquired = await this.redis.setnx(lockKey, '1');

        if (lockAcquired) {
          // Lấy được lock → thực hiện fetch dữ liệu
          try {
            await this.redis.expire(lockKey, lockTTL);

            // Gọi callback lấy dữ liệu từ DB
            const data = await fetchFn();

            // Bước 3: Ghi vào cache
            if (data === null || data === undefined) {
              // 🔥 Xử lý Cache Penetration: Lưu null sentinel với TTL ngắn
              await this.redis.setex(key, nullTTL, nullSentinel);
            } else {
              // Lưu dữ liệu thật với TTL
              await this.redis.setex(key, ttl, JSON.stringify(data));
            }

            return data;
          } finally {
            // Giải phóng lock
            await this.redis.del(lockKey);
          }
        } else {
          // Không lấy được lock → có request khác đang fetch dữ liệu
          // Chờ và retry đọc cache
          retries++;
          if (retries >= maxRetries) {
            // Đã retry quá số lần → fallback fetch thẳng DB
            logger.warn(`Max retries reached for key "${key}", falling back to direct fetch`);
            return fetchFn();
          }
          await this.sleep(retryDelay);
          // Tiếp tục vòng lặp, quay lại kiểm tra cache
        }
      } catch (error) {
        // Redis failure → fallback to database (circuit breaker pattern)
        logger.error(`Cache error for key "${key}":`, error);
        return fetchFn();
      }
    }

    // Fallback cuối cùng
    return fetchFn();
  }

  /**
   * Xóa cache khi dữ liệu được cập nhật
   */
  async invalidate(key: string): Promise
<void> {
    await this.redis.del(key);
  }

  /**
   * Xóa nhiều cache theo pattern (sử dụng SCAN để tránh block Redis)
   */
  async invalidatePattern(pattern: string): Promise
<void> {
    const stream = this.redis.scanStream({
      match: pattern,
      count: 100,
    });

    for await (const keys of stream) {
      if (keys.length > 0) {
        await this.redis.del(...keys);
      }
    }
  }

  /**
   * Lấy cache hit rate
   */
  async getStats(): Promise<{ keys: number; memory: string }> {
    const info = await this.redis.info('stats');
    const memory = await this.redis.info('memory');
    return {
      keys: parseInt(info.match(/keyspace_hits:(\d+)/)?.[1] || '0'),
      memory: memory.match(/used_memory_human:([^\r\n]+)/)?.[1] || 'N/A',
    };
  }

  private sleep(ms: number): Promise
<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Giải thích chi tiết:

  1. Generic Type: Hàm sử dụng kiểu generic, đảm bảo dữ liệu trả ra giữ nguyên Type ban đầu của Model DB.

  2. Null Sentinel: Khi DB trả về null, thay vì để cache trống, ta lưu một giá trị đặc biệt (__NULL__) với TTL ngắn (60 giây). Điều này chặn đứng các request tiếp theo cho cùng key khỏi việc chạy xuống database.

  3. Distributed Lock cho Cache Stampede: Khi cache miss, thay vì tất cả request cùng đổ vào DB, chỉ một request duy nhất lấy được lock và fetch dữ liệu. Các request khác chờ và retry đọc cache.

  4. Fallback an toàn: Nếu Redis gặp lỗi (mất kết nối, timeout…) hoặc retry quá số lần, hàm sẽ gọi thẳng fetchFn() để đảm bảo ứng dụng vẫn hoạt động.

  5. Corrupted cache handling: Bọc JSON.parse trong try/catch để xử lý dữ liệu hỏng trong Redis.

2.3. Ví dụ sử dụng CacheService trong ứng dụng thực tế

// src/services/user.service.ts
import { CacheService } from '../lib/cache.service';
import { db } from '../lib/database';

const cacheService = new CacheService();

interface User {
  id: string;
  name: string;
  email: string;
  createdAt: Date;
}

export class UserService {
  async getUserById(id: string): Promise<User | null> {
    const cacheKey = `v1:user:${id}`;

    // Cache-Aside Pattern: Tự động check cache → DB → ghi cache
    return cacheService.getOrSet
<User>(
      cacheKey,
      async () => {
        // Câu lệnh query DB gốc
        return db.user.findUnique({
          where: { id },
        });
      },
      { 
        ttl: 3600,      // Cache 1 giờ
        nullTTL: 120,   // Cache null 2 phút (lâu hơn một chút cho user)
      }
    );
  }

  async updateUser(id: string, data: Partial
<User>): Promise<User> {
    // 1. Update database trước
    const updated = await db.$transaction(async (tx) => {
      return tx.user.update({
        where: { id },
        data,
      });
    });

    // 2. Xóa cache sau khi update thành công
    try {
      await cacheService.invalidate(`v1:user:${id}`);
    } catch (error) {
      // Log lỗi và push vào hàng đợi retry
      logger.error(`Failed to invalidate cache for user ${id}:`, error);
      await retryQueue.add('invalidate-cache', { key: `v1:user:${id}` });
    }

    return updated;
  }

  async getUsersByPage(page: number, limit: number): Promise<User[]> {
    const cacheKey = `v1:users:page:${page}:limit:${limit}`;

    return cacheService.getOrSet<User[]>(
      cacheKey,
      async () => {
        return db.user.findMany({
          skip: (page - 1) * limit,
          take: limit,
        });
      },
      { ttl: 600 } // Cache 10 phút cho danh sách
    );
  }

  async deleteUser(id: string): Promise
<void> {
    // 1. Delete database
    await db.user.delete({ where: { id } });

    // 2. Xóa cache
    await cacheService.invalidate(`v1:user:${id}`);
  }
}

3. Kỹ thuật chặn đứng thảm họa Cache Penetration bằng code

Minh họa Cache Penetration khi ID không tồn tại và giải pháp Null Sentinel với TTL ngắn

Cache Penetration là tình huống hacker (hoặc request bất thường) liên tục gửi các ID không tồn tại, ép hệ thống truy vấn database mỗi lần — khiến database quá tải dù cache đã hoạt động.

Ví dụ: API /users/999999 với ID không tồn tại. Nếu không có cơ chế bảo vệ, mỗi request đều chạy xuống database và nhận về null.

3.1. Bẫy dữ liệu Null/Undefined

Trong hàm getOrSet ở trên, chúng ta đã xử lý tình huống này:

if (data === null || data === undefined) {
  // Lưu null sentinel với TTL ngắn
  await this.redis.setex(key, nullTTL, nullSentinel);
}

Khi một request tiếp theo đến cùng key, cache trả về __NULL__, hàm lập tức trả về nullkhông chạy xuống database.

3.2. Giải pháp: Cache Null Value với TTL cực ngắn

// Ví dụ: Lấy user với ID không tồn tại
const user = await userService.getUserById('999999');
// Lần 1: Cache miss → Query DB → Lưu '__NULL__' với TTL 60s
// Lần 2 (trong 60s): Cache hit → Trả về null ngay, không chạm DB
// Sau 60s: Cache hết hạn → Query DB lại (cho phép kiểm tra nếu user mới được tạo)

Tại sao TTL ngắn (60 giây)?

  • Đủ để chặn các request spam trong thời gian ngắn
  • Đủ ngắn để nếu dữ liệu thực sự được tạo sau đó, người dùng có thể truy xuất sớm
  • Không ảnh hưởng đáng kể đến bộ nhớ cache vì key chỉ tồn tại trong thời gian ngắn

📖 Để hiểu sâu hơn về các thảm họa cache khác như Cache Avalanche, mời bạn đọc bài viết Chiến Lược Caching Với Redis: Phòng Chống Cache Avalanche.

3.3. Cache Penetration với nhiều key khác nhau

Nếu hacker spam với nhiều ID khác nhau (ví dụ: 1, 2, 3, 4, …), mỗi ID sẽ tạo một cache key riêng. Để phòng chống, có thể áp dụng Bloom Filter:

// Sử dụng Bloom Filter để kiểm tra ID có tồn tại trước khi query DB
import { BloomFilter } from 'bloom-filters';

// Khởi tạo Bloom Filter với 1 triệu phần tử, tỷ lệ false positive 0.01
const bloomFilter = new BloomFilter(1000000, 0.01);

// Khi tạo user mới, thêm vào bloom filter
async function createUser(data: UserInput): Promise
<User> {
  const user = await db.user.create({ data });
  bloomFilter.add(user.id);
  return user;
}

// Khi get user, kiểm tra bloom filter trước
async function getUserById(id: string): Promise<User | null> {
  if (!bloomFilter.has(id)) {
    return null; // Chắc chắn không tồn tại → không cần query DB
  }
  // Có thể tồn tại → query DB bình thường
  return cacheService.getOrSet
<User>(`user:${id}`, async () => {
    return db.user.findUnique({ where: { id } });
  });
}

4. Phòng chống Cache Stampede với Distributed Lock

So sánh Cache Stampede khi không có lock (100 request query DB) và có lock (1 request query DB)

Cache Stampede (hay còn gọi là Thundering Herd) xảy ra khi một cache key phổ biến hết hạn cùng lúc, hàng trăm request đồng thời gọi database, gây quá tải.

4.1. Vấn đề

Khi cache key user:popular hết hạn vào lúc 12:00:00:

⏰ 12:00:00.000 - Cache hết hạn
⏰ 12:00:00.001 - Request 1: Cache miss → Query DB (mất 100ms)
⏰ 12:00:00.002 - Request 2: Cache miss → Query DB (mất 100ms)
⏰ 12:00:00.003 - Request 3: Cache miss → Query DB (mất 100ms)
...
⏰ 12:00:00.100 - Request 100: Cache miss → Query DB (mất 100ms)

💀 Kết quả: 100 request đồng thời đâm vào DB → DB quá tải → ứng dụng chậm hoặc crash.

4.2. Giải pháp: Distributed Lock với SETNX

Trong hàm getOrSet đã tích hợp cơ chế lock:

const lockKey = `lock:${key}`;
const lockAcquired = await this.redis.setnx(lockKey, '1');

if (lockAcquired) {
  // Chỉ request đầu tiên lấy được lock mới fetch dữ liệu
  const data = await fetchFn();
  await this.redis.setex(key, ttl, JSON.stringify(data));
  await this.redis.del(lockKey);
  return data;
} else {
  // Các request khác chờ và retry đọc cache
  await this.sleep(100);
  return this.getOrSet(key, fetchFn, options);
}

Luồng xử lý với Lock:

⏰ 12:00:00.000 - Cache hết hạn
⏰ 12:00:00.001 - Request 1: Lock acquired ✅ → Query DB (mất 100ms)
⏰ 12:00:00.002 - Request 2: Lock failed → Chờ 100ms
⏰ 12:00:00.003 - Request 3: Lock failed → Chờ 100ms
...
⏰ 12:00:00.100 - Request 1 ghi xong cache → Giải phóng lock
⏰ 12:00:00.102 - Request 2 retry → Cache hit ✅ (trả về ngay)
⏰ 12:00:00.103 - Request 3 retry → Cache hit ✅ (trả về ngay)

Kết quả: Chỉ 1 request chạy xuống DB → Database an toàn.

4.3. Các phương pháp bổ sung chống Cache Stampede

Kỹ thuật Jitter: Thêm một lượng thời gian ngẫu nhiên nhỏ vào TTL để tránh toàn bộ cache cùng hết hạn một lúc.

// Thêm jitter vào TTL
const jitter = Math.floor(Math.random() * 300); // 0-300 giây ngẫu nhiên
const effectiveTTL = baseTTL + jitter;
await this.redis.setex(key, effectiveTTL, JSON.stringify(data));

Probabilistic Early Expiration: Tự động làm mới cache trước khi hết hạn dựa trên xác suất.

// Khi cache sắp hết hạn, có thể tự động refresh
const remainingTTL = await this.redis.ttl(key);
if (remainingTTL < 60 && Math.random() < 0.1) {
  // 10% request sẽ refresh cache sớm
  this.refreshCacheInBackground(key, fetchFn);
}

5. Bài toán Data Consistency: Nên xóa cache hay ghi đè khi sửa dữ liệu?

Đây là câu hỏi kinh điển trong Cache-Aside Pattern: Khi update database, nên xóa cache hay ghi đè giá trị mới vào cache?

Theo Microsoft Azure Architecture Center, câu trả lời là: XÓA (invalidate) cache sau khi update database thành công.

5.1. Tại sao XÓA thay vì CẬP NHẬT?

❌ Cập nhật cache trực tiếp:
   Client A: Update user { name: "Alice" } → DB thành công → Redis SET "user:1" = { name: "Alice" }
   ❌ Rủi ro: Nếu có nhiều service cùng update, hoặc update không thành công, cache có thể chứa dữ liệu sai lệch.

✅ Xóa cache (Cache Invalidation):
   Client A: Update user { name: "Alice" } → DB thành công → Redis DEL "user:1"
   Client B: GET /user/1 → Cache miss → Query DB lấy dữ liệu mới nhất → Ghi cache
   ✅ Đảm bảo dữ liệu luôn nhất quán với database

5.2. Thứ tự thực hiện: CẬP NHẬT DB trước, XÓA CACHE sau

async updateUser(id: string, data: Partial
<User>): Promise<User> {
  // 1️⃣ Update database TRƯỚC
  const updated = await db.user.update({ where: { id }, data });

  // 2️⃣ Xóa cache SAU KHI update thành công
  await cacheService.invalidate(`v1:user:${id}`);

  return updated;
}

Tại sao phải theo thứ tự này?

  • Nếu xóa cache trước, một request đọc có thể lọt vào giữa, đọc dữ liệu cũ từ DB (chưa kịp update) và ghi vào cache → dữ liệu cache bị sai
  • Update DB trước đảm bảo khi cache được xóa, dữ liệu trong DB đã là mới nhất

5.3. Xử lý lỗi: Nếu DB update thành công nhưng lệnh xóa Redis thất bại?

Đây là tình huống “nguy hiểm” có thể khiến cache chứa dữ liệu cũ vĩnh viễn. Giải pháp:

async updateUser(id: string, data: Partial
<User>): Promise<User> {
  // 1. Update database trong transaction
  const updated = await db.$transaction(async (tx) => {
    return tx.user.update({ where: { id }, data });
  });

  // 2. Xóa cache, nếu thất bại thì retry hoặc log để xử lý sau
  try {
    await cacheService.invalidate(`v1:user:${id}`);
  } catch (error) {
    // Log lỗi và push vào hàng đợi retry (ví dụ: BullMQ, SQS)
    logger.error(`Failed to invalidate cache for user ${id}:`, error);
    await retryQueue.add('invalidate-cache', { key: `v1:user:${id}` });
  }

  return updated;
}

5.4. Pattern “Refresh-Ahead” cho dữ liệu nóng

Đối với dữ liệu được truy cập cực kỳ thường xuyên (ví dụ: product của top seller), bạn có thể áp dụng Refresh-Ahead Pattern:

async getProductWithRefresh(id: string): Promise<Product | null> {
  const cacheKey = `v1:product:${id}`;
  const cached = await this.redis.get(cacheKey);

  if (cached) {
    // Kiểm tra remaining TTL
    const remaining = await this.redis.ttl(cacheKey);
    if (remaining < 300) { // Còn 5 phút nữa hết hạn
      // Refresh cache trong background
      this.refreshProductCache(id).catch(err => logger.error(err));
    }
    return JSON.parse(cached);
  }

  // Cache miss → fetch bình thường
  return this.getOrSet(cacheKey, async () => {
    return db.product.findUnique({ where: { id } });
  });
}

private async refreshProductCache(id: string): Promise
<void> {
  const product = await db.product.findUnique({ where: { id } });
  if (product) {
    await this.redis.setex(`v1:product:${id}`, 3600, JSON.stringify(product));
  }
}

6. Lỗi thường gặp khi triển khai Cache-Aside

❌ 1. Lỗi tràn bộ nhớ Redis (OOM) do không đặt TTL

Nguyên nhân: Khi triển khai Cache-Aside, lập trình viên quên set tham số EX (Expire) cho các key ít dùng, khiến data tích tụ vô hạn nuốt chửng RAM server.

Cách khắc phục: Bắt buộc mọi lệnh set dữ liệu vào Redis trong hàm wrapper đều phải đi kèm tham số TTL. Trong code của chúng ta, tham số ttl mặc định là 3600 giây (1 giờ), tránh tình trạng key bị lưu vĩnh viễn.

❌ 2. Cache Stampede (Thundering Herd)

Nguyên nhân: Khi một cache key phổ biến hết hạn cùng lúc, hàng trăm request đồng thời gọi database, gây quá tải.

Cách khắc phục: Đã tích hợp Distributed Lock trong hàm getOrSet và có thể thêm Jitter vào TTL.

❌ 3. Cache key không nhất quán

Nguyên nhân: Sử dụng key khác nhau cho cùng một dữ liệu ở các nơi khác nhau, dẫn đến cache miss không đáng có.

Cách khắc phục: Xây dựng Cache Key Factory thống nhất toàn ứng dụng:

// src/constants/cache-keys.ts
export const CacheKeys = {
  user: (id: string) => `v1:user:${id}`,
  userPosts: (userId: string, page: number) => `v1:user:${userId}:posts:page:${page}`,
  product: (id: string) => `v1:product:${id}`,
  productList: (category: string, page: number) => `v1:products:${category}:page:${page}`,
} as const;

// Usage
const key = CacheKeys.user('123');
const postKey = CacheKeys.userPosts('123', 1);

❌ 4. Cache Penetration không được xử lý

Nguyên nhân: Không lưu cache cho các giá trị null/undefined, hacker spam ID không tồn tại → DB quá tải.

Cách khắc phục: Đã tích hợp Null Sentinel trong hàm getOrSet.

❌ 5. Không có fallback khi Redis down

Nguyên nhân: Khi Redis mất kết nối, ứng dụng throw exception và crash.

Cách khắc phục: Đã tích hợp try/catch trong getOrSet, fallback gọi trực tiếp fetchFn().

❌ 6. Lỗi “MGET” không parse được JSON

Nguyên nhân: Khi lấy nhiều key cùng lúc với MGET, các key bị hỏng sẽ gây lỗi parse.

Cách khắc phục: Luôn bọc JSON.parse trong try/catch:

async getMultiple
<T>(keys: string[]): Promise<(T | null)[]> {
  const values = await this.redis.mget(keys);
  return values.map(val => {
    if (!val) return null;
    try {
      return JSON.parse(val) as T;
    } catch {
      return null;
    }
  });
}

7. Best Practices cho Cache-Aside Pattern trong Node.js

  1. Luôn xóa cache (Cache Invalidation) ngay sau khi Update/Delete DB thành công — Đây là nguyên tắc vàng của Cache-Aside.

  2. Sử dụng kỹ thuật Jitter cho TTL — Thêm một lượng thời gian ngẫu nhiên nhỏ vào TTL để tránh hiện tượng toàn bộ cache cùng hết hạn một lúc (Cache Avalanche).

  3. Đặt giá trị trả về rỗng hợp lý — Sử dụng Null Sentinel với TTL ngắn để bảo vệ database khỏi Cache Penetration.

  4. Luôn đặt TTL cho mọi key — Không bao giờ để key tồn tại vĩnh viễn trong Redis, tránh OOM.

  5. Sử dụng Redis pipeline cho batch operations — Khi cần get/set nhiều key cùng lúc, sử dụng pipeline để giảm round-trip.

  6. Monitor cache hit rate — Theo dõi tỷ lệ cache hit/miss để điều chỉnh TTL phù hợp. Cache hit rate thấp có nghĩa TTL quá ngắn hoặc cache key không hiệu quả.

  7. Fallback an toàn — Luôn có cơ chế fallback khi Redis không khả dụng (ví dụ: gọi thẳng database).

  8. Sử dụng Connection Pooling — Trong ứng dụng đa luồng (cluster mode), sử dụng pooling để tối ưu kết nối.

  9. Version cache key — Sử dụng version prefix (ví dụ: v1:user:123) để dễ dàng invalidate toàn bộ cache khi schema thay đổi.

  10. Compression cho dữ liệu lớn — Với dữ liệu > 1MB, cân nhắc compress (LZ4, Gzip) trước khi lưu vào Redis để tiết kiệm bộ nhớ.

  11. Sử dụng Redis Cluster cho production — Để đảm bảo high availability và khả năng scale:

// Redis Cluster mode
const redis = new Redis.Cluster([
  { host: 'redis-node1', port: 6379 },
  { host: 'redis-node2', port: 6379 },
  { host: 'redis-node3', port: 6379 },
], {
  scaleReads: 'slave', // Đọc từ replica để giảm tải master
  maxRedirections: 3,
});

8. So sánh Cache-Aside với các caching pattern khác

Pattern Cách hoạt động Ưu điểm Nhược điểm Khi nào dùng
Cache-Aside App tự quản lý cache, check cache trước, query DB khi miss Linh hoạt, kiểm soát code, phù hợp read-heavy Cache miss gây latency cao lần đầu, code phức tạp hơn Read-heavy, dữ liệu ít thay đổi
Read-Through Cache layer tự động load từ DB khi miss App không cần quản lý cache logic, code đơn giản Phụ thuộc vào cache provider, khó tùy chỉnh Dữ liệu ít thay đổi, app đơn giản
Write-Through Ghi đồng thời vào DB và cache Đảm bảo cache luôn đồng bộ Write latency cao, tăng tải cho cache Write-heavy cần consistency
Write-Behind (Write-Back) Ghi vào cache trước, đồng bộ DB sau (async) Write latency cực thấp, giảm tải DB Rủi ro mất dữ liệu nếu cache crash Write-heavy, có thể chấp nhận eventual consistency
Refresh-Ahead Tự động refresh cache trước khi hết hạn Giảm cache miss, latency thấp Phức tạp, cần dự đoán dữ liệu hot Dữ liệu hot (top products, trending)

8.1. Khi nào chọn Cache-Aside?

Cache-Aside là lựa chọn tối ưu cho đa số ứng dụng vì:

  • Kiểm soát hoàn toàn logic caching
  • Không phụ thuộc vào cache provider
  • Dễ dàng debug và tối ưu
  • Phù hợp với đa số use case read-heavy

9. FAQ

1. Tại sao trong Cache-Aside, người ta khuyên xóa cache khi update DB thay vì ghi đè giá trị mới vào cache?

Theo Microsoft Azure Architecture Center, xóa cache (invalidation) đảm bảo rằng dữ liệu trong cache luôn đồng bộ với database. Nếu ghi đè trực tiếp, có thể xảy ra tình huống:

  • Có nhiều service cùng update dữ liệu
  • Update database thành công nhưng ghi cache thất bại → cache chứa dữ liệu cũ
  • Dữ liệu được ghi vào cache không khớp với database do transaction không thành công

Xóa cache sau khi update thành công là cách an toàn nhất để đảm bảo tính nhất quán.

2. Làm thế nào để xử lý nếu DB update thành công nhưng lệnh xóa Redis cache sau đó bị thất bại?

Đây là tình huống khó xử lý trong distributed systems. Một số giải pháp:

  • Retry với exponential backoff: Thực hiện lại lệnh xóa cache với thời gian chờ tăng dần
  • Hàng đợi retry: Push task xóa cache vào hàng đợi (BullMQ, SQS) để xử lý bất đồng bộ
  • TTL là “lưới an toàn cuối cùng”: Nếu cache không được xóa, nó sẽ tự động hết hạn sau TTL, đảm bảo không bị “stale forever”
  • Sử dụng pattern “Write-Through”: Ghi đồng thời vào DB và cache, nhưng phức tạp hơn và có thể ảnh hưởng hiệu năng

3. TTL nên set bao nhiêu là hợp lý?

Không có con số “one size fits all”. TTL phụ thuộc vào tần suất thay đổi của dữ liệu:

  • Dữ liệu tĩnh (cấu hình, danh mục): 24-72 giờ
  • Dữ liệu bán tĩnh (thông tin user, product): 1-6 giờ
  • Dữ liệu động (trạng thái order, inventory): 1-15 phút
  • Dữ liệu realtime (stock price, live score): 5-30 giây

Nguyên tắc chung: TTL = (khoảng thời gian dữ liệu được phép cũ) × 2

4. Cache-Aside có ảnh hưởng đến performance không?

Cache-Aside có thể gây latency spike khi cache miss (vì phải query DB và ghi cache). Tuy nhiên, với distributed lock, chỉ một request duy nhất bị ảnh hưởng, các request khác chờ và đọc cache. Trong thực tế, với cache hit rate > 90%, hiệu năng cải thiện rõ rệt (thường 5-10x nhanh hơn so với query DB).

5. Làm sao để biết cache hit rate của tôi đang bao nhiêu?

Sử dụng Redis INFO command:

redis-cli INFO stats | grep keyspace

Hoặc trong code:

const info = await redis.info('stats');
const hitRate = info.match(/keyspace_hits:(\d+)/)?.[1];
const missRate = info.match(/keyspace_misses:(\d+)/)?.[1];
console.log(`Hit rate: ${hitRate / (hitRate + missRate) * 100}%`);

10. Kết luận

Cache-Aside Pattern không chỉ là một khái niệm lý thuyết — nó là giải pháp thực chiến giúp ứng dụng Node.js của bạn đạt hiệu năng cao, bảo vệ database khỏi quá tải. Với hàm getOrSet generic bằng TypeScript tích hợp distributed lock, bạn có thể dễ dàng bọc bất kỳ truy vấn database nào với chỉ vài dòng code.

Ba điểm mấu chốt cần nhớ:

  1. Cache-Aside = Check Cache → Miss → Lock → Query DB → Ghi Cache → Trả về
  2. Luôn XÓA cache sau khi update DB, không bao giờ ghi đè
  3. Dùng Null Sentinel với TTL ngắn để chặn Cache Penetration
  4. Dùng Distributed Lock để chống Cache Stampede

Hãy bắt đầu áp dụng ngay hôm nay cho những truy vấn “nặng” nhất trong hệ thống của bạn. Chỉ một thay đổi nhỏ trong kiến trúc code cũng có thể tạo ra sự khác biệt lớn về hiệu năng.


🌐 Tham khảo thêm

Chia sẻ bài viết này
By VMas-Dev-AnHuynh Software Engineer
Follow:
SOFTWARE ENGINEER / FULL-STACK DEVELOPER