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 • Senior Software Engineer @ SmileViet
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:
- Cache cũ hết hạn sau một tiếng, đúng cấu hình.
- 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.
- 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.updatedlê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 tiếp
- Chuyện trực On-call lúc 3h sáng: Khi Database deadlock và bài học về IdempotencyĐời Sống & Sự Cố
- Phỏng Vấn System Design: Thiết Kế Rate Limiter Phân Tán Bằng GoPhỏng Vấn & Sự Nghiệp
- Thiết kế Booking System cho Travel Marketplace: Saga Pattern và Event-Driven Workflow trong Medusa v2Kỹ Thuật Chuyên Sâu
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ờ.