Đời Sống & Sự Cố • 10 phút đọc • 25/08/2026

Khi Redis Cache Bị Stale: Bài Học Về Cache Invalidation Trong Hệ Thống E-commerce

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Giàn server trong trung tâm dữ liệu
Ảnh: Derrick Coetzee from Berkeley, CA, USA · CC0

Một buổi chiều thứ Sáu, đội Sales nhắn lên nhóm chung. Khách phàn nàn giá một tour trên web thấp hơn giá thật gần 30%. Admin đã sửa giá từ sáng. Sáu tiếng sau, trang vẫn hiển thị giá cũ.

Mình kể lại chuyện này không phải vì Redis có gì lạ. Cách sửa nằm ở một chỗ khác hẳn.

Chuyện gì đã xảy ra

Hệ thống bên mình dùng cache-aside, kiểu ai cũng làm: request hỏi Redis trước, miss thì query PostgreSQL rồi ghi ngược lại cache kèm TTL một tiếng.

Theo lý thuyết thì giá sai không có cửa sống quá một tiếng. Cập nhật ở đâu đi nữa, cache cũng tự hết hạn rồi được làm mới.

Thực tế nó sống sáu tiếng. Gấp sáu lần TTL.

Con số đó gạt phăng giả thuyết “cache chưa kịp hết hạn”. Phải có thứ gì đó ghi lại giá cũ vào Redis sau mỗi lần hết hạn, và ghi đều đặn.

Thủ phạm nằm ở một background job

Mình lần log rồi trace request. Ra một job đồng bộ nội dung từ Strapi, chạy mỗi 15 phút. Job đọc tour từ CMS, ghi vào PostgreSQL. Rồi như một tác dụng phụ không ai để ý, nó warm lại cache bằng đúng dữ liệu vừa đọc.

Vấn đề nằm ở đây: admin sửa giá qua trang quản trị nội bộ, không qua CMS. Hai nguồn ghi khác nhau, nhưng chỉ CMS sync có quyền ghi cache.

Trình tự diễn ra đúng như sau:

  1. Cache cũ hết hạn sau một tiếng, đúng cấu hình.
  2. Request kế tiếp miss. Service đọc giá mới từ DB, ghi giá mới vào cache. Lúc này cache đã đúng.
  3. Mười lăm phút sau, CMS sync chạy. Nó đọc tour từ CMS — nơi giá chưa bao giờ được sửa — rồi ghi đè bằng giá cũ.
sequenceDiagram
  participant U as Request
  participant S as Service
  participant R as Redis
  participant DB as PostgreSQL
  participant J as CMS sync (15 phút một lần)
  participant C as Strapi

  Note over R: cache tour hết hạn sau một tiếng
  U->>S: request lấy giá tour
  S->>R: đọc cache
  R-->>S: miss
  S->>DB: query giá mới
  DB-->>S: giá mới
  S->>R: ghi giá mới vào cache
  Note over R: lúc này cache đã đúng

  Note over J: 15 phút sau
  J->>C: đọc tour (giá chưa bao giờ được sửa ở đây)
  C-->>J: dữ liệu cũ
  J->>DB: ghi vào PostgreSQL
  J->>R: warm lại cache bằng đúng dữ liệu vừa đọc
  Note over R: giá cũ quay lại cache

Rồi lặp. Cứ mười lăm phút, cache đúng được vài phút rồi lại sai.

Cái bug này có tên: nhiều writer cùng ghi vào một cache key, và không writer nào biết writer kia tồn tại. Cache-aside giả định chỉ có một nguồn sự thật. Hệ thống mình có hai, lệch pha nhau.

TTL không cứu được ca này

TTL là lưới an toàn cho trường hợp cache bị quên invalidate. Nó không giải quyết trường hợp cache bị invalidate sai — bị ghi đè bằng dữ liệu cũ từ một nguồn khác.

TTL hứa “cuối cùng cache sẽ đúng”. Nó không hứa “cache đúng ngay khi dữ liệu gốc đổi”. Với một hệ thống bán hàng, sai giá nghĩa là thiệt tiền hoặc tranh chấp với khách. “Cuối cùng sẽ đúng” không đủ.

Sửa: chỉ một kênh được phép xoá cache

Bọn mình bỏ mô hình “ai cũng ghi cache được”. Thay bằng một kênh invalidation duy nhất, kích hoạt bởi sự kiện thay đổi dữ liệu — bất kể thay đổi đến từ admin panel, CMS sync hay script backfill.

  • Mọi service update giá hoặc tồn kho phải publish event tour.price.updated lên Redis Pub/Sub, thay vì tự ghi cache.
  • Một subscriber riêng lắng nghe, và nó có đúng một việc: xoá cache key liên quan.
  • CMS sync job mất quyền ghi cache. Nó ghi DB rồi publish event như mọi nguồn khác.
// Sau khi update giá trong cùng DB transaction
async function updateTourPrice(tourId: string, newPrice: number) {
  await db.transaction(async (trx) => {
    await trx("tours").where({ id: tourId }).update({ price: newPrice });

    // Publish invalidation event trong cùng transaction logic,
    // không phụ thuộc vào ai gọi hàm này (admin panel hay CMS sync)
    await redisPubSub.publish(
      "tour.price.updated",
      JSON.stringify({ tourId, updatedAt: Date.now() }),
    );
  });
}

// Subscriber riêng, là nơi DUY NHẤT được phép xoá cache
redisPubSub.subscribe("tour.price.updated", async (message) => {
  const { tourId } = JSON.parse(message);
  await redis.del(`tour:${tourId}`);
});

Điểm đáng nói không phải là dùng Pub/Sub. Là tách cho rõ hai câu hỏi: ai được đổi dữ liệu, và ai được xoá cache. Khi chỉ còn một chỗ được xoá cache, hai luồng ghi không còn giẫm chân nhau.

TTL vẫn giữ, nhưng lùi về đúng vai trò: lưới an toàn thứ hai, phòng khi event rơi mất (Redis Pub/Sub không đảm bảo delivery) hoặc subscriber chết tạm. Bọn mình rút TTL từ một tiếng xuống mười phút. Đổi lại là query DB nhiều hơn một chút, nhưng cửa sổ dữ liệu sai ngắn hơn hẳn.

Mấy nguyên tắc mình giữ lại

  • Ai được ghi cache? Nếu câu trả lời có nhiều hơn một luồng, đó là dấu hiệu sớm của đúng loại bug này.
  • Invalidate theo sự kiện, không chỉ theo thời gian. TTL trị “cache cũ”. Event-driven invalidation trị “cache bị ghi đè sai”. Hai bệnh khác nhau, cần hai liều khác nhau.
  • Background job là nguồn ghi ẩn. Job sync CMS ban đầu chỉ để đồng bộ nội dung. Logic warm cache lọt vào lúc nào không ai nhớ, và cũng không ai review lại tác dụng phụ của nó. Job nào chạy định kỳ mà chạm vào dữ liệu đã cache đều nên được soi lại định kỳ.
  • Soi độ lệch giữa cache và DB. Sau sự cố, bọn mình thêm một job so sánh ngẫu nhiên một tập giá trong cache với DB, lệch thì cảnh báo. Rẻ, và là lớp phòng thủ thứ ba.

Nếu phải gói lại một câu: hệ thống nào có nhiều hơn một đường ghi dữ liệu thì cần chiến lược invalidation rõ ràng từ đầu. Rút ngắn TTL mỗi lần có sự cố không phải là chiến lược.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.