+3

Cache Stampede: Khi Redis "Phản Lưới Nhà" & Hiệu Ứng Bầy Đàn Dẫm Đạp Database

Hãy tưởng tượng hệ thống của bạn đang chạy một chiến dịch Flash Sale cực lớn. Để bảo vệ con MySQL già cỗi, bạn áp dụng chiến thuật chuẩn sách giáo khoa: Cache toàn bộ danh sách "Top 100 Sản phẩm Sale" vào Redis và set TTL (hạn sử dụng) là 5 phút. Trong 4 phút 59 giây đầu tiên, hệ thống chạy mượt như lụa. Redis gánh 10.000 requests mỗi giây (RPS), MySQL nhịp tim ổn định ở mức 1%. Bạn tựa lưng vào ghế, nhấp một ngụm cà phê, tận hưởng cái "vibe" của một Senior Developer.

Nhưng đúng vào khoảnh khắc đồng hồ điểm phút thứ 5, Giây thứ 01... BÙM!

MySQL giật CPU lên 100%, Connection Pool cạn kiệt, Server báo lỗi 500 Internal Server Error hàng loạt.

Chuyện quái quỷ gì vừa xảy ra vậy? Chào mừng bạn đến với thảm họa mang tên Cache Stampede (Hiệu ứng bầy đàn), hay còn gọi là Thundering Herd Problem (Vấn đề đàn trâu sấm sét).

1. Giải phẫu một vụ dẫm đạp

Để hiểu tại sao hệ thống sập, chúng ta soi kỹ vào giây thứ 01 đó:

  1. Đúng phút thứ 5, TTL của key top_100_products trong Redis hết hạn. Redis xóa key này đi.
  2. Ngay mili-giây tiếp theo, có 5.000 user cùng lúc F5 trang web.
  3. 5.000 requests đập vào Redis. Cả 5.000 requests đều nhận được kết quả Cache Miss (Không tìm thấy data).
  4. Theo logic thông thường: "Nếu không thấy trong Cache, hãy chọc xuống Database lấy data, rồi lưu ngược lại vào Cache".
  5. Thế là 5.000 requests đó "nắm tay nhau" lao thẳng vào MySQL cùng một mili-giây để chạy câu query cực nặng.
  6. Database bị đấm bất ngờ với lực sát thương x5000. Nó sập ngay lập tức trước khi kịp trả về kết quả cho request đầu tiên để lưu vào Redis.

Redis lúc này không những không giúp bảo vệ DB, mà cơ chế Expire (hết hạn) đồng loạt của nó lại trở thành mồi lửa kích hoạt quả bom.

2. Nghệ thuật Phòng thủ của Vibe Coder: "Khóa Phân Tán" (Distributed Lock)

Một Vibe Coder không bao giờ để hệ thống chạy theo bản năng. Để trị đám đông hoảng loạn, chúng ta cần một "Anh bảo vệ" chặn ở cửa Database. Kỹ thuật này gọi là Mutex Lock (Khóa độc quyền).

Ý tưởng rất đơn giản: Khi 5.000 requests cùng nhận được Cache Miss, ta không cho cả 5.000 thằng chạy xuống DB. Ta chỉ cho đúng 1 thằng xuống DB thôi. 4.999 thằng còn lại bắt buộc phải đứng ngoài cửa chờ!

Trong Redis, chúng ta dùng lệnh SETNX (Set if Not eXists) để tạo khóa:

// Pseudo-code minh họa logic

async function getTopProducts() {
    let data = await redis.get("top_100");
    if (data) return data; // Cache Hit -> Trả về ngay

    // Cache Miss -> Cố gắng giành lấy "Khóa bảo vệ"
    const isLocked = await redis.setnx("lock_top_100", "locked", "EX", 10); // Khóa trong 10 giây
    
    if (isLocked) {
        // MÌNH LÀ THẰNG ĐẦU TIÊN! ĐƯỢC PHÉP XUỐNG DB
        data = await db.query("SELECT ...");
        await redis.set("top_100", data, "EX", 300); // Lưu lại cache 5 phút
        await redis.del("lock_top_100"); // Mở khóa cho anh em khác
        return data;
    } else {
        // 4999 THẰNG ĐẾN SAU -> KHÔNG CÓ KHÓA
        // Đứng chờ 50ms rồi thử đọc lại Cache xem thằng đầu tiên làm xong chưa
        await sleep(50);
        return getTopProducts(); // Đệ quy thử lại
    }
}

Nhờ ổ khóa SETNX này, MySQL của bạn chỉ phải chịu đúng 1 query, trong khi 4.999 request kia chỉ đang sleep nhẹ nhàng trên RAM của Backend.

3. Cấp độ cao hơn: Stale-While-Revalidate (Cho khách xài đồ cũ)

Việc bắt 4.999 người chờ đợi (dù chỉ là 50ms) vẫn làm giảm UX (Trải nghiệm người dùng). Một chiến thuật thông minh hơn là: Chấp nhận trả về dữ liệu cũ (Stale Data) trong lúc âm thầm cập nhật dữ liệu mới.

  • Ta không set TTL tự động xóa trong Redis nữa.
  • Ta gắn thêm một trường expire_at vào data. (Ví dụ: expire_at = 12:05:00).
  • Lúc 12:05:01, 5.000 user bay vào. App thấy thời gian hiện tại đã vượt qua expire_at.
  • App vẫn trả về data cũ cho cả 5.000 user (Response time 2ms, mượt mà).
  • NHƯNG, App âm thầm dùng Mutex Lock, bắn đúng 1 Background Job (Worker) đi xuống DB tính toán lại Top 100, và cập nhật cái cache mới cho tương lai. Khách hàng không hề cảm nhận được sự chậm trễ nào!

4. Background Cronjob (Gốc rễ của sự nhàn nhã)

Cách cuối cùng và cũng là cách "nhàn" nhất: Đừng cho phép API trực tiếp đụng vào việc cập nhật Cache. Hãy viết một con Worker (Cronjob) chạy ngầm độc lập. Cứ đúng 4 phút 50 giây, con Worker này tự động chui xuống MySQL lấy data và đè lên cái Cache cũ.

Lúc này, User API chỉ có một nhiệm vụ duy nhất: Đọc từ Redis. Không bao giờ có chuyện Cache bị rỗng, và cũng chẳng bao giờ có cái request nào bị đẩy xuống DB. Tách biệt hoàn toàn luồng Đọc (Read) và Ghi (Write).

Lời kết

Database là trái tim của hệ thống, và nó vô cùng mong manh. Khi bạn bắt đầu chơi với Traffic lớn (High Availability), việc code đúng logic thôi là chưa đủ. Bạn phải học cách kiểm soát dòng chảy của dữ liệu, biết chặn cửa, biết phân luồng. Đó mới là trạng thái thăng hoa nhất của The Vibe Coder Mindset.

Chủ đề tiếp theo: Kẻ Thù Số 1 Của Public API - Bot Cào Data & Đòn Trừng Phạt Từ Rate Limiting

Hệ thống của chúng ta giờ đã "mình đồng da sắt", Cache chạy mượt mà, DB an toàn. Nhưng nếu đối thủ cạnh tranh không thèm đánh sập bạn, mà họ dùng Bot gọi API tìm kiếm của bạn 2.000 lần/giây chỉ để... cào trộm sạch sẽ data sản phẩm và giá bán thì sao?

Chi phí Server tăng phi mã để phục vụ cho lũ Bot này! Ở bài viết tới, chúng ta sẽ tiếp tục tận dụng sức mạnh của Redis để xây dựng Rate Limiting (Giới hạn tỷ lệ). Làm sao để nhận diện một IP đang spam và "khóa mõm" nó bằng lỗi HTTP 429 Too Many Requests một cách thanh lịch nhất? Cùng đón đọc nhé!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí