Tối Ưu Cache: Từ 7 Request Đồng Thời Xuống Chỉ 1 Request Với Single Flight Pattern

TL;DR: Từ việc gặp phải hiệu ứng thundering herd trong production, mình đã áp dụng Single Flight pattern với cơ chế lock để đạt được perfect deduplication - từ 7 request đồng thời xuống chỉ 1 request thực sự được gửi đi.
Trong quá trình phát triển ứng dụng microservice, mình đã gặp phải một tình huống thú vị: tại sao cache hit rate lại thấp đến thế khi có nhiều request cùng lúc?
Câu chuyện này không chỉ giúp mình hiểu sâu hơn về Single Flight pattern, mà còn học được cách xử lý hot spot, thread-safe và race condition trong thực tế.
1. Use case thực tế:
App của mình nằm trong một hệ microservice. Do đặc thù của app, mọi API đều cần kiểm tra quyền truy cập bằng cách gửi request tới Auth service.

Vấn đề phát sinh: Khi lần đầu access app, sẽ có nhiều API cần gửi cùng một request kiểm tra "View" permission. Điều này tạo ra một hot spot - nhiều request cùng "săn đón" một resource.
Ngay lập tức mình nhớ tới blog của anh Quang Hoang: #6 - Điểm nóng, lần đầu tiên mình nghe về khái niệm Request Collapsing hay Singleflight. (btw, blog a này viết khá đỉnh, dễ đọc, recommend ae follow)
Ý tưởng cốt lõi: Thay vì mỗi request gửi riêng, ta gom tất cả các request giống nhau lại, chỉ gửi 1 request đi. Các request còn lại sẽ chờ response từ request "đại diện".
2. Triển khai ban đầu
Hiện Trạng Cache System
Trên server mình đã triển khai in-memory cache cho permission của từng user. Tuy nhiên, khi có nhiều request cùng access một key, tỉ lệ cache miss cực cao.
Flow ban đầu đơn giản như sau: ( viết dạng concept để dễ hình dung nhé )

public async Task<bool> CheckPermissions(string permission)
// Tạo cache key từ user token + permission
var cacheKey = userToken + "-" + permission
// Kiểm tra cache trước
IF cache.exists(cacheKey) THEN
log("Thread ID - Cache hit - trả về kết quả từ cache")
RETURN cache.get(cacheKey)
END IF
// Gọi API kiểm tra quyền
apiUrl = "Http://api/permission-check"
var response = await POST(apiUrl, {permission: permission})
// Xử lý response và lấy kết quả
var result = parseAuthResponse(response)
// Lưu vào cache 1 phút
cache.set(cacheKey, result, 1minute)
RETURN result
Đây là kết quả mình nhận được với 7 request cùng lúc: 6 cache miss, 1 cache hit.
Tỉ lệ hit: 1/7 ~ 14% - quá thấp

Nguyên Nhân: Thundering Herd Problem
Hiện tượng này gọi là thundering herd - khi một key hết hạn và đồng thời nhiều request cùng truy cập vào DB/service, dẫn tới quá tải. Đôi khi là nhiều key cùng hết hạn 1 lúc do đặt chung TTL (Time to live). Giải pháp thường là đặt key có TTL random.
Trong case của mình, Auth service bị "tấn công" bởi hàng loạt request kiểm tra permission giống hệt nhau, tại thời điểm user access app lần đầu.

(nguồn: bytebytego)
3. Áp Dụng Single Flight Pattern
Để bảo kê cho Auth service khỏi quá tải, cùng triển khai Single flight nhé. Mình thích cái tên này vì rất dễ liên tưởng.
Giống như một nhóm anh em chiến hữu của bạn book vé máy bay tới Phú Quốc. Thay vì điều 1 chuyến bay riêng cho từng ông, Vietnam Airline chắc chắn sẽ gom hết cả đám lên cùng 1 chuyến bay rồi chờ 1 thể

“Chuyến bay” ở đây chính là request check permission gửi tới Auth service đó.
( Còn tại sao lại là Vietnam Airline? Vì lần trước chuyến bay Vietjet tới Phú Quốc của mình bị delay 2 tiếng lận, request thì ko được phép delay như thế 🤣)
Kiến Trúc Single Flight
Ta sẽ có 3 component chính ở đây:
Request coordination: Nơi điều phối request. Khi 1 request tới, kiểm tra xem có request tương đương nào đang “bay” không?
- Nếu có → xếp hàng chờ
- Ngược lại → cho "cất cánh", gửi tới Auth service.In-flight Registry: Dictionary lưu trữ các "chuyến bay".
- Key:user_token + permission
- Value:Task(C#)/Promise(JS), đại diện cho async request function tới Auth serviceClean Up: Xoá khai báo “chuyến bay” khỏi Registry bên trên sau khi request hoàn thành.

Flow sẽ như này. Chỉ gồm flow mới, code cũ đã được đơn giản hoá
//In-flight Registry mình nhắc bên trên đây. Chú ý value là 1 Task nhé.
Dictionary<string, Task<bool>> _inFlightRequests;
public async Task<bool> CheckPermissions(string permission) {
var cacheKey = userToken + "-" + permission
if (cache.Hit) return cachedValue;
// NEW: Nếu request đang "bay"
if (_inFlightRequests.ContainsKey(key))
log("ThreadId - Permission - Cache in-flight hit")
return await _inFlightRequests[key]; // Chờ request "bay" về
// Tạo request mới và đăng ký. Nhớ là mình chỉ tạo 1 async function,
// chứ chưa await nó nhé. Request chưa thực sự chạy đâu.
var task = ExecuteActualRequest(permissions);
_inFlightRequests[key] = task;
try {
var result = await task; // Tới đây mới await, cho request "bay"
return result;
} finally {
_inFlightRequests.Remove(key); // Bay về rồi thì clean đi
}
}
Kết quả cải thiện
Với 7 request đồng thời: 4 cache hit, 2 cache miss, 1 cache in-flight.
Bingo! Từ 6/7 request miss xuống chỉ 2/7 request miss - giảm 60-70%! (Vẫn còn race condition cần xử lý)
Giả sửa nếu ko phải 7 mà là 100, 200, 1k request, lượng giảm còn nhiều hơn nhiều.

Vấn Đề Còn Tồn Tại: Race Condition
Tại sao vẫn có 2 cache miss? Đó là race condition - các thread đồng thời kiểm tra cache:
ThreadId: 17 - cache in-flight hit // Thread đầu tiên, tìm thấy in-flight request
ThreadId: 14 - cache miss // Race: chưa kịp add vào in-flight registry
ThreadId: 24 - cache miss // Race: chưa kịp add vào in-flight registry
Với quy mô app của mình thì thực ra dừng ở đây là cũng oke. Nhưng tới đây rồi, tại sao lại không tối ưu thêm để với 1 key chỉ có đúng 1 in-flight request thôi nhỉ?
4. Giải Pháp Thread-Safe Hoàn Chỉnh
Nhắc tới giải quyết vấn đề truy cập đồng thời cao, phải nhắc tới lock. Ở đây để đơn giản, mình sẽ sử dụng 1 local lock, được C# built-in.
Với mỗi key sẽ tạo ra 1 lock object . Chỉ key nào acquire được lock mới cho “bay”.
Dictionary<string, object> _lock;
public async Task<bool> CheckPermissions(string permission) {
var cacheKey = userToken + "-" + permission
if (cache.Hit) return cachedValue;
//Tạo lock theo từng key
var lockKey = _lock.GetOrAdd(cacheKey)
var task;
// Chỉ 1 thread lấy được lock
lock (keyLock) {
// double check vì cache có thể hit khi thread trước đó vừa ghi cache
if (cache.Hit) return cachedValue;
// Đem nguyên đống logic cũ bỏ vào trong này
if (_inFlightRequests.ContainsKey(key))
task = _inFlightRequests[key]
else
var task = ExecuteActualRequest(permissions);
_inFlightRequests[key] = task;
}
try {
return await task;
} finally {
_inFlightRequests.Remove(cacheKey);
_lock.Remove(lockKey); // Nhớ nhả lock đó ae
}
}
Kết quả: Perfect! Với lock mechanism, giờ đây chỉ có duy nhất 1 request được gửi tới Auth service cho mỗi key cache, bất kể có bao nhiều request đồng thời.
Test với 7 request đồng thời: Chỉ 1 request thực sự "bay", 6 request còn lại đều chờ và nhận kết quả từ request đầu tiên.

Lưu ý:
- Trong bài mình khai báo Dictionary đơn giản cho In-flight Request và Lock. Nhưng thực tế đây là các object cần share state giữa các request, nên các bạn nhớ khai báo dạng Static hoặc scope Singleton (.net) nhé.
- C# có 1 builtin type là ConcurrentDictionary chuyên để support thread-safe. Nên sử dụng type này.
- Bài viết tập trung vào triển khai pattern. Còn về kiến trúc thì ko phải là best practise, nên sử dụng API Gateway cho việc authen.
Kết
Single Flight Pattern giúp đạt được perfect deduplication - chỉ 1 request duy nhất cho mỗi key
Thread safety với lock mechanism là then chốt để loại bỏ hoàn toàn race condition
Bạn đã từng gặp phải thundering herd problem chưa? Chia sẻ experience của bạn trong comments nhé!
Reference:
Cách Discord Lưu Trữ Hàng Nghìn Tỷ Tin Nhắn Với ScyllaDB | 200Lab Blog (quá trình migrate db của discord cũng sử dụng pattern này)
https://discord.com/blog/how-discord-stores-trillions-of-messages (link gốc)

