Skip to main content

Command Palette

Search for a command to run...

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

Updated
7 min read
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-saferace 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.

thundering herd

(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 service

  • Clean 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

  1. Single Flight Pattern giúp đạt được perfect deduplication - chỉ 1 request duy nhất cho mỗi key

  2. 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:

  1. #6 - Điểm nóng - by Qreuang Hoang

  2. Bytebytego - Caching Pitfalls Every Developer Should Know

  3. 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)

  4. https://discord.com/blog/how-discord-stores-trillions-of-messages (link gốc)