Cache stampede: khi cache quay sang gây hại cho chúng ta

Trong quá trình điều tra một số vấn đề về hiệu năng, chúng tôi phát hiện một API ASP.NET Core đang cache một truy vấn tổng hợp khá tốn tài nguyên trong 60 giây. Khi tải ở mức bình thường, cách làm này hoạt động tốt: một request xây dựng lại cache, còn các request khác đọc dữ liệu từ đó.

Tuy nhiên, khi tải đạt đỉnh, hàng chục request có thể đến trong cùng khoảng thời gian cache hết hạn. Tất cả đều thấy cache bị thiếu và đồng thời thực thi cùng một truy vấn tốn tài nguyên. Cơ sở dữ liệu dĩ nhiên không thích điều này.

Đó là lúc lớp cache ngừng hỗ trợ và bắt đầu gây hại. Một đợt request đến cùng lúc, tất cả đều không tìm thấy dữ liệu trong cache, rồi đồng loạt gây áp lực lên cơ sở dữ liệu hoặc API phía sau. Hiện tượng này được gọi là cache stampede. Cache vốn được tạo ra để bảo vệ hệ thống phía sau, nhưng trong vài trăm mili giây, nó lại làm điều ngược lại.

Vì sao hiện tượng này xảy ra?

IMemoryCache.GetOrCreate, cũng như phiên bản bất đồng bộ của nó, có vẻ như sẽ bảo vệ bạn, nhưng bản thân phương thức này không tự bổ sung cơ chế khóa. Hãy xem phiên bản đơn giản sau:

public async Task<Report> GetReportAsync(string key)
{
    if (_cache.TryGetValue(key, out Report cached))
    {
        return cached;
    }

    var report = await _reportService.BuildExpensiveReportAsync(key);

    _cache.Set(key, report, TimeSpan.FromSeconds(60));

    return report;
}

Nếu mười request chạy đoạn mã này đúng cùng thời điểm sau khi cache hết hạn, cả mười request đều nhận kết quả false từ TryGetValue và đều gọi BuildExpensiveReportAsync. Không có thành phần nào trong đoạn mã trên điều phối các request. Đó chính là bản chất của cache stampede.

Lưu ý: vấn đề này không chỉ xảy ra với IMemoryCache. Các cache phân tán như Redis cũng gặp đúng vấn đề tương tự. Bản thân cache không biết và cũng không quan tâm rằng chín caller khác sắp yêu cầu cùng một key.

Cách khắc phục 1: dùng khóa riêng cho từng key với SemaphoreSlim

Cách đơn giản nhất là buộc các caller đồng thời yêu cầu cùng một key phải chờ request đầu tiên hoàn tất, thay vì cùng chạy đua để xây dựng lại dữ liệu.

private static readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

public async Task<Report> GetReportAsync(string key)
{
    if (_cache.TryGetValue(key, out Report cached))
    {
        return cached;
    }

    var keyLock = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

    await keyLock.WaitAsync();
    try
    {
        // Kiểm tra lại: một request khác có thể đã xây dựng cache
        // trong lúc chúng ta chờ semaphore.
        if (_cache.TryGetValue(key, out cached))
        {
            return cached;
        }

        var report = await _reportService.BuildExpensiveReportAsync(key);
        _cache.Set(key, report, TimeSpan.FromSeconds(60));
        return report;
    }
    finally
    {
        keyLock.Release();
    }
}

Việc kiểm tra hai lần, trước và sau khi lấy khóa, rất quan trọng. Nếu bỏ qua lần kiểm tra thứ hai, mọi request đang chờ vẫn sẽ lần lượt xây dựng lại báo cáo, thay vì dùng lại kết quả mà request đầu tiên vừa tạo ra.

Lưu ý: ConcurrentDictionary<string, SemaphoreSlim> sẽ tăng kích thước mãi nếu bạn không dọn dẹp. Với một tập key nhỏ và có giới hạn, cách này hoàn toàn ổn. Với không gian key lớn hoặc không có giới hạn, bạn nên loại bỏ các semaphore không còn được sử dụng hoặc chuyển sang cách tiếp cận dựa trên thư viện bên dưới.

Cách khắc phục 2: để HybridCache xử lý

Tự viết cơ chế khóa theo key phù hợp với một hoặc hai điểm nóng. Nhưng khi có cùng mẫu xử lý này trong năm service, bạn nên sử dụng một giải pháp đã tích hợp sẵn cơ chế đó. Microsoft.Extensions.Caching.Hybrid.HybridCache là giải pháp của Microsoft. Một trong những lý do chính để sử dụng nó là khả năng chống cache stampede: thư viện chủ động điều phối các caller đồng thời cho cùng một key, để chỉ một lần gọi factory được thực thi tại một thời điểm.

Đăng ký HybridCache như sau:

services.AddHybridCache(options =>
{
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromSeconds(60),
        LocalCacheExpiration = TimeSpan.FromSeconds(60)
    };
});

Sử dụng như sau:

public async Task<Report> GetReportAsync(string key, HybridCache cache)
{
    return await cache.GetOrCreateAsync(
        key,
        async cancel => await _reportService.BuildExpensiveReportAsync(key, cancel),
        cancellationToken: default);
}

GetOrCreateAsync bảo đảm rằng mỗi key chỉ có một lần gọi factory được thực thi tại một thời điểm. Các caller đồng thời khác cho cùng key sẽ chờ task đang chạy, thay vì khởi tạo một lần thực thi mới. Đây chính xác là hành vi mà chúng ta đã tự xây dựng bằng SemaphoreSlim, nhưng không cần tự quản lý danh sách khóa và đã được cung cấp sẵn trong nền tảng, không cần đánh giá thêm một dependency NuGet.

Lưu ý: HybridCache còn cung cấp miễn phí cache hai tầng: một lớp trong tiến trình, gọi là local cache, cùng một lớp phân tán tùy chọn như Redis, SQL Server hoặc bất kỳ backend IDistributedCache nào bạn đã cấu hình.

Cơ chế chống cache stampede được áp dụng ở lớp local trên từng node. Nó không ngăn được hai node khác nhau trong một cluster cùng xây dựng lại một key tại một thời điểm, vì không có khóa dùng chung giữa các máy. Để xử lý trường hợp này, bạn vẫn cần một distributed lock hoặc chấp nhận việc đôi khi có hai lần xây dựng lại trên các node. Đây là một phiên bản nhỏ hơn của cùng vấn đề.

Cách khắc phục 3: không để mọi dữ liệu hết hạn cùng lúc

Ngay cả khi đã có khóa riêng cho từng key, bạn vẫn có thể gặp một phiên bản nhẹ hơn của vấn đề nếu nhiều key khác nhau vô tình hết hạn cùng lúc. Ví dụ, bạn khởi tạo cache cho một nhóm item khi ứng dụng bắt đầu chạy và áp dụng cùng một thời gian sống cố định cho tất cả.

Một mẹo nhỏ là thêm độ lệch ngẫu nhiên vào thời gian hết hạn:

var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 10));
_cache.Set(key, report, TimeSpan.FromSeconds(60) + jitter);

Phân tán thời điểm hết hạn trong vài giây sẽ biến một đợt tăng tải lớn thành nhiều đợt nhỏ hơn.

Kết hợp các giải pháp

Với một key nóng duy nhất, dùng một SemaphoreSlim cho mỗi key sẽ giúp giải quyết phần lớn vấn đề. Với phạm vi rộng hơn, hoặc khi bạn muốn có cơ chế an toàn dự phòng trong trường hợp backend ngừng hoạt động, FusionCache hoặc một thư viện tương tự là dependency đáng cân nhắc.

Dù chọn cách nào, thêm jitter vào thời gian sống của cache vẫn là một biện pháp bảo hiểm rẻ và hiệu quả để chống các lần hết hạn đồng bộ.

Vậy là xong.

Thông tin thêm