HIT
TTL
DB
Cache Strategy
aside • write-through • ttl

Chiến lược caching giảm tải database và API

Các chiến lược caching thực tế để giảm tải database và API: cache-aside, write-through, write-behind, TTL và cache invalidation.

13 phút đọc16/06/2026

Triệu chứng: database trở thành bottleneck

Hệ thống đang chậm. Bạn mở monitoring và thấy:

  • query count tăng tuyến tính theo số user
  • cùng một query chạy hàng trăm lần mỗi giây với kết quả không đổi
  • database CPU liên tục ở mức 80–90%
  • response time API tăng theo từng giờ cao điểm

Đây là dấu hiệu hệ thống đang đẩy mọi request xuống database, kể cả những data không hề thay đổi.

Cache giải quyết bằng cách giữ kết quả đã tính ở tầng nhanh hơn (memory), để lần sau không cần chạy lại query tốn kém đó.

Caching giải quyết bài toán nào

Cache không phải thuốc chữa bách bệnh. Nó chỉ có ích khi:

  • Data ít thay đổi hoặc thay đổi có thể chấp nhận độ trễ vài giây đến vài phút
  • Cùng data được đọc nhiều lần bởi nhiều user hoặc nhiều request
  • Chi phí tính toán cao: query phức tạp, join nhiều bảng, aggregate lớn
  • External API có rate limit hoặc latency cao

Cache không hợp khi data cần real-time tuyệt đối: số dư tài khoản, trạng thái đơn hàng đang xử lý, inventory theo từng giao dịch.


Cache-Aside (Lazy Loading)

Chiến lược phổ biến nhất. Application tự quản lý cache theo flow:

1. Đọc từ cache
2. Nếu có → trả về ngay (cache hit)
3. Nếu không có → query database → lưu vào cache → trả về
async function getUserById(id: string) {
  const cacheKey = `user:${id}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const user = await db.users.findUnique({ where: { id } });
  if (user) {
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 300); // TTL 5 phút
  }

  return user;
}

Ưu điểm

  • Chỉ cache data thực sự được đọc — không lãng phí memory
  • Cache miss không crash hệ thống, chỉ chậm hơn bình thường
  • Dễ implement, không cần thay đổi database layer

Nhược điểm

  • Cache miss đầu tiên luôn chậm vì phải đi xuống DB
  • Thundering herd: khi cache expire, nhiều request đến cùng lúc cùng cache miss, tất cả đổ xuống DB cùng một thời điểm
  • Data trong cache có thể stale nếu DB bị update ngoài luồng code chính

Xử lý thundering herd

Dùng lock khi populate cache để chỉ một request được phép query DB:

async function getUserById(id: string) {
  const cacheKey = `user:${id}`;
  const lockKey = `lock:${cacheKey}`;

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const lock = await redis.set(lockKey, '1', 'NX', 'EX', 5);
  if (!lock) {
    // Có request khác đang populate, đợi rồi retry
    await sleep(50);
    return getUserById(id);
  }

  try {
    const user = await db.users.findUnique({ where: { id } });
    if (user) await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);
    return user;
  } finally {
    await redis.del(lockKey);
  }
}

Write-Through

Khi write vào database, đồng thời update cache luôn:

Write request → write DB → write cache → trả về OK
async function updateUser(id: string, data: UpdateUserDto) {
  const user = await db.users.update({ where: { id }, data });

  const cacheKey = `user:${id}`;
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 300);

  return user;
}

Ưu điểm

  • Cache luôn có data mới nhất sau mỗi write — không có stale data
  • Read sau write ngay lập tức luôn hit cache

Nhược điểm

  • Write latency tăng vì phải ghi cả hai nơi
  • Cache chứa data của mọi record kể cả những record hiếm khi đọc — tốn memory
  • Nếu write DB thành công nhưng write cache thất bại, cần xử lý inconsistency

Dùng khi nào

Phù hợp với data vừa read-heavy vừa hay được update, và bạn cần đảm bảo cache không bao giờ stale. Ví dụ: profile user, setting hệ thống, config hay thay đổi.


Write-Behind (Write-Back)

Write vào cache trước, sau đó async flush xuống database:

Write request → write cache → trả về OK ngay
                            → background job write DB

Ưu điểm

  • Write latency cực thấp — chỉ tốn thời gian ghi cache
  • Phù hợp với hệ thống cần throughput write cao: view count, like count, analytics event

Nhược điểm

  • Risk mất data: nếu cache server chết trước khi flush xuống DB, data mất
  • Phức tạp hơn để implement đúng
  • Khó debug khi DB và cache không đồng bộ

Ví dụ phù hợp: đếm view bài viết

// Tăng counter trong Redis
async function incrementPostView(postId: string) {
  await redis.incr(`post:${postId}:views`);
}

// Background job chạy mỗi 5 phút: flush về DB
async function flushViewCountsToDb() {
  const keys = await redis.keys('post:*:views');
  for (const key of keys) {
    const postId = key.split(':')[1];
    const views = parseInt(await redis.get(key) ?? '0');
    await db.posts.update({
      where: { id: postId },
      data: { views: { increment: views } },
    });
    await redis.del(key);
  }
}

Read-Through

Cache tự động fetch từ DB khi miss, thay vì để application xử lý. Thường được implement qua library hoặc caching layer (như Cacheable trong NestJS, hoặc cache provider trong ORMs).

// NestJS với @CacheTTL decorator
@Injectable()
export class UserService {
  @Cacheable('user', { ttl: 300 })
  async getUserById(id: string) {
    return this.db.users.findUnique({ where: { id } });
  }
}

Về logic giống Cache-Aside nhưng application code không cần tự viết cache hit/miss logic — library xử lý.


Cache Invalidation — bài toán khó nhất

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

Có 3 cách invalidate cache:

1. TTL-based (expire tự động)

Đơn giản nhất. Data tự expire sau thời gian định sẵn.

await redis.set(key, value, 'EX', 300); // expire sau 5 phút

Hợp khi: data có thể stale vài phút không ảnh hưởng nghiêm trọng (catalog sản phẩm, bài viết, config ít thay đổi).

Không hợp khi: data cần nhất quán ngay lập tức.

2. Event-based invalidation

Khi có write, xóa hoặc update cache key liên quan:

async function updateProductPrice(productId: string, price: number) {
  await db.products.update({ where: { id: productId }, data: { price } });

  // Xóa các cache key liên quan
  await redis.del(`product:${productId}`);
  await redis.del(`category:${product.categoryId}:products`); // cache list cũng bị stale
  await redis.del(`homepage:featured`);
}

Điểm khó: phải biết hết những cache key nào bị ảnh hưởng bởi một write. Khi hệ thống lớn lên, danh sách này dài và dễ sót.

3. Tag-based invalidation

Gán tag cho cache key, invalidate theo tag:

// Lưu cache kèm tag
await cacheWithTag(`product:${id}`, data, ['products', `category:${categoryId}`]);

// Khi category thay đổi, invalidate tất cả key có tag đó
await invalidateByTag(`category:${categoryId}`);

Phức tạp hơn để tự implement nhưng một số cache library hỗ trợ sẵn (Next.js có revalidateTag, một số Redis wrapper có tags).


Chọn TTL bao lâu

Không có con số chung cho mọi trường hợp. Nguyên tắc:

Loại data TTL gợi ý Lý do
Static config, feature flag 5–15 phút Ít thay đổi, có thể stale ngắn
Catalog sản phẩm, bài viết 1–5 phút Update không quá thường xuyên
Kết quả search, list bài 30–60 giây Có thể stale ngắn, query nặng
Profile user 5 phút + invalidate on write Thay đổi ít nhưng cần đúng khi đổi
Session, auth token Theo thời gian session Phải khớp với auth logic
Real-time data (giá, tồn kho) Không cache hoặc TTL rất ngắn ≤5s Độ trễ ảnh hưởng nghiệp vụ

Các lỗi hay gặp

Cache toàn bộ response API

Caching cả response kể cả data user-specific dẫn đến user A thấy data của user B. Chỉ cache data không phụ thuộc user hoặc phải bao gồm user ID trong cache key.

Cache key không đủ unique

// Sai: hai query khác nhau dùng cùng key
await redis.set('products', JSON.stringify(products));

// Đúng: key phải phản ánh đủ tham số
const key = `products:category:${categoryId}:page:${page}:limit:${limit}`;

Không handle cache stampede khi restart

Khi deploy lại hoặc cache server restart, mọi key đều expire cùng lúc, toàn bộ traffic đổ vào DB. Giải pháp: dùng stale-while-revalidate pattern hoặc warm cache trước khi traffic vào.

Lưu object quá lớn vào cache

Redis phù hợp cho object nhỏ đến trung bình. Object vài MB nên được lưu ở object storage và cache chỉ giữ metadata hoặc signed URL.

Không monitor cache hit rate

Cache hit rate dưới 80% thường là dấu hiệu TTL quá ngắn, cache key sai, hoặc data thực sự không phù hợp để cache.


Checklist chốt bài

  • Data này có thực sự được đọc nhiều lần với cùng tham số không
  • Data này có thể chấp nhận stale bao lâu
  • Cache key có đủ unique để tránh serve sai data không
  • Khi write, đã invalidate đủ cache key liên quan chưa
  • Đã có metric theo dõi hit rate chưa
  • Đã nghĩ đến thundering herd khi cache mass expire chưa
  • Object lưu vào cache có quá lớn không