Request Coalescing: Chặn Đứng Cache Stampede Với Singleflight

Cách kỹ thuật gom nhóm request (singleflight) gộp các cache miss đồng thời thành một truy vấn duy nhất, bảo vệ database khỏi thảm họa quá tải.

Request Coalescing: Chặn Đứng Cache Stampede Với Singleflight
Trong bài viết này

Lớp bộ đệm (cache) vốn bảo vệ cơ sở dữ liệu của bạn suốt cả ngày có thể trở thành chính nguyên nhân đánh sập hệ thống vào đúng thời điểm một khóa dữ liệu quan trọng (hot key) hết hạn. Khi hàng nghìn người dùng cùng lúc yêu cầu một tài nguyên vừa hết hạn, toàn bộ các tiến trình xử lý đều ghi nhận cache miss và đồng loạt xả truy vấn xuống database trong cùng một phần nghìn giây.

Hiện tượng này—thường được gọi là Cache Stampede hoặc Thundering Herd—biến một sự kiện xoay vòng bộ đệm thông thường thành sự cố sập nguồn dịch vụ. Các giải pháp phân tán như Redis Distributed Lock có thể ngăn việc đọc trùng lặp, nhưng chúng lại làm tăng độ trễ mạng, phức tạp hóa việc đồng bộ và dễ gây tắc nghẽn dây chuyền. Kỹ thuật Gom nhóm yêu cầu (Request Coalescing hay mô hình Singleflight) giải quyết triệt để bài toán này ngay tại bộ nhớ ứng dụng một cách gọn gàng.

Bản Chất Của Thảm Họa Cache Stampede

Hầu hết các ứng dụng web hiện nay đều triển khai cơ chế đọc bộ đệm (read-through cache) theo kịch bản kinh điển:

  1. Kiểm tra dữ liệu trong Redis hoặc In-memory cache.
  2. Nếu có (cache hit), lập tức trả dữ liệu về cho client.
  3. Nếu không có (cache miss), thực thi câu lệnh SQL xuống cơ sở dữ liệu hoặc gọi API bên thứ ba, ghi kết quả vào cache rồi phản hồi.

Ở điều kiện tải bình thường, quy trình này hoạt động hoàn hảo. Nhưng hãy hình dung trong một đợt flash sale, endpoint chi tiết sản phẩm nhận 3.000 lượt gọi mỗi giây. Khóa sản phẩm đó có thời gian sống (TTL) là 5 phút. Khi bước sang giây thứ 301, khóa hết hạn.

Trong khoảng 50 mili-giây mà request đầu tiên cần để truy vấn PostgreSQL và nạp lại vào Redis, có thêm 200 request khác ồ ạt đổ tới. Vì chưa thấy dữ liệu trong cache, toàn bộ 200 request này đều coi mình là kẻ đến sau gặp sự cố và cùng lúc nã 200 câu lệnh SQL giống hệt nhau xuống PostgreSQL. Bể kết nối (connection pool) cạn kiệt, CPU database chạm ngưỡng 100%, độ trễ truy vấn vọt từ 10ms lên vài giây, và máy chủ ứng dụng bắt đầu trả về lỗi 504 Gateway Timeout hàng loạt.

Nguyên Lý Hoạt Động Của Singleflight

Sơ đồ minh họa gom nhóm nhiều yêu cầu đồng thời thành một truy vấn cơ sở dữ liệu duy nhất

Request coalescing hoạt động dựa trên một ý tưởng rất trực quan: triệt tiêu những tác vụ trùng lặp đang được thực thi cùng thời điểm. Mô hình này trở nên phổ biến trong giới kỹ thuật thông qua thư viện golang.org/x/sync/singleflight của Go [0].

Thay vì để mỗi lần cache miss tự do truy cập hạ tầng phía sau, ứng dụng sẽ đặt một trạm điều phối ngay trên bộ nhớ tiến trình:

  • Request số 1 đến, phát hiện cache miss, đăng ký một token tác vụ (Promise trong JavaScript hoặc Channel trong Go) cho khóa product:101 vào bảng tra cứu nội bộ, rồi bắt đầu truy vấn cơ sở dữ liệu.
  • Request từ 2 đến 200 cùng hỏi khóa product:101. Khi kiểm tra bảng tra cứu thấy một tác vụ đang chạy, chúng không gọi xuống database nữa. Thay vào đó, toàn bộ 199 request này đăng ký lắng nghe kết quả từ Promise đang chờ của request số 1.
  • Khi câu lệnh SQL hoàn tất, request số 1 ghi dữ liệu vào Redis và trả kết quả cho tất cả 200 client đang đợi cùng một lúc.
  • Khóa product:101 được xóa khỏi bảng theo dõi nội bộ để sẵn sàng cho các chu kỳ tiếp theo.

Thay vì chịu áp lực từ 200 truy vấn đồng thời, database chỉ cần xử lý đúng một tác vụ duy nhất.

Cài Đặt Request Coalescing Trong TypeScript

Bạn hoàn toàn không cần cài đặt các framework phân tán cồng kềnh để áp dụng cơ chế này. Trong một dịch vụ Node.js hoặc Bun, chúng ta có thể dựng một bộ điều phối singleflight hoàn chỉnh chỉ với chưa đầy 20 dòng code:

export class RequestCoalescer<T> {
  private inflight = new Map<string, Promise<T>>();

  async run(key: string, fn: () => Promise<T>): Promise<T> {
    const existing = this.inflight.get(key);
    if (existing) {
      return existing;
    }

    const promise = (async () => {
      try {
        return await fn();
      } finally {
        this.inflight.delete(key);
      }
    })();

    this.inflight.set(key, promise);
    return promise;
  }
}

Khi tích hợp vào tầng truy xuất dữ liệu, cấu trúc đọc cache trở nên an toàn tuyệt đối trước mọi đợt tăng tải đột biến:

const coalescer = new RequestCoalescer<Product>();

async function getProduct(id: string): Promise<Product> {
  const cached = await redis.get(`product:${id}`);
  if (cached) return JSON.parse(cached);

  return coalescer.run(`product:${id}`, async () => {
    // Kiểm tra lại lần hai trong vùng biên để phòng trường hợp vừa được nạp
    const doubleCheck = await redis.get(`product:${id}`);
    if (doubleCheck) return JSON.parse(doubleCheck);

    const product = await db.product.findUnique({ where: { id } });
    await redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
    return product;
  });
}

Những Cái Bẫy Cần Lưu Ý Trong Thực Chiến

Dù logic của kỹ thuật này rất súc tích, việc vận hành trên môi trường production đòi hỏi bạn phải kiểm soát tốt 3 rủi ro tiềm ẩn.

1. Lan truyền lỗi (Error Propagation)

Nếu hàm lấy dữ liệu gốc gặp lỗi (chẳng hạn cơ sở dữ liệu bị timeout mạng), ngoại lệ này sẽ bắn về toàn bộ các request đang cùng chờ Promise đó. Bạn cần đảm bảo khối finally luôn xóa khóa khỏi inflight map để các request đến sau không bị giữ lại trên một Promise đã chết, đồng thời xử lý retry hợp lý.

2. Bẫy hủy ngữ cảnh (Context Cancellation)

Trong Go hoặc các runtime hỗ trợ signal ngắt tác vụ, tuyệt đối không gắn ngữ cảnh (Context) của request đầu tiên vào tác vụ dùng chung. Nếu client đầu tiên bấm tắt trình duyệt, HTTP context của họ sẽ bị hủy. Nếu truy vấn database kế thừa context đó, nó sẽ lập tức bị ngắt giữa chừng, kéo theo 199 người dùng vô tội khác cũng nhận lỗi thất bại. Tác vụ nạp dữ liệu chung phải được chạy trên một context độc lập.

3. Đột biến đối tượng dùng chung (Object Mutation)

Trong môi trường truyền dữ liệu theo tham chiếu (như con trỏ trong Go hoặc Object trong JavaScript), tất cả các caller dùng chung kết quả sẽ nhận cùng một vùng nhớ. Nếu một middleware phía sau vô tình chỉnh sửa trực tiếp một trường dữ liệu trên đối tượng đó, thay đổi sẽ làm biến dạng dữ liệu của tất cả người dùng khác đang cùng nhận phản hồi. Hãy luôn coi kết quả trả về từ coalescer là bất biến (immutable) hoặc clone dữ liệu trước khi can thiệp.

Tấm Khiên Nhỏ Cho Độ Ổn Định Lớn

Request coalescing đóng vai trò như một bộ giảm chấn cục bộ cực kỳ hiệu quả. Nó không thay thế Redis hay CDN, mà là cầu nối bảo vệ hệ thống trong khoảnh khắc mong manh khi dữ liệu vừa hết hạn giữa cơn bão truy cập. Chỉ với một cấu trúc dữ liệu đơn giản đặt trước tầng truy vấn, bạn đã có thể giữ cho cơ sở dữ liệu luôn vận hành ổn định và êm ái bất kể lưu lượng truy cập tăng vọt đến mức nào.

Nguồn tham khảo

  1. pkg.go.dev

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

Đang tải bình luận…