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 tương đối tốn kém trong 60 giây. Ở tải thông thường, cách này hoạt động tốt: một request xây dựng lại cache, còn mọi request khác chỉ cần đọc dữ liệu từ đó.
Tuy nhiên, khi tải tăng cao, 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 nhận thấy cache bị thiếu và cùng lúc thực thi một truy vấn tốn kém. Cơ sở dữ liệu đương nhiên không thích điều này.
Đó là lúc tầng cache vốn được tạo ra để hỗ trợ lại bắt đầu gây hại. Một loạt request đến gần như đồng thời, cùng cache miss, rồi cùng dồn tải 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 lẽ ra phải 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.
Mục lục
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ó, trông có vẻ như đã bảo vệ chúng ta, nhưng bản thân nó 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ạm vào đoạn mã này đúng cùng một thời điểm sau khi cache hết hạn, cả mười đều nhận được kết quả false từ TryGetValue và cả mười đều gọi BuildExpensiveReportAsync. Không có thành phần nào ở đây đ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 có chín caller khác sắp yêu cầu cùng một key.
Cách 1: Dùng khóa riêng cho từng key với SemaphoreSlim
Cách sửa đơn giản nhất là buộc các caller đồng thời của cùng một key chờ request đầu tiên hoàn tất, thay vì tất cả cùng cạnh tranh để 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 lại 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, tức TryGetValue trước và sau khi lấy khóa, là 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ì tái sử dụng kết quả mà request đầu tiên vừa tạo ra.
Lưu ý: ConcurrentDictionary<string, SemaphoreSlim> sẽ tiếp tục tăng kích thước 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. Nhưng với không gian key lớn hoặc không 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 giải pháp dựa trên thư viện ở phần tiếp theo.
Cách 2: Để HybridCache xử lý
Tự viết cơ chế khóa theo từng key phù hợp với một hoặc hai điểm nóng. Nhưng khi bạn đã áp dụng cùng một mẫu này trong năm service, nên cân nhắc sử dụng một giải pháp được tích hợp sẵn.
Microsoft.Extensions.Caching.Hybrid.HybridCache là giải pháp của Microsoft cho trường hợp này. Bảo vệ khỏi cache stampede là một trong những lý do chính để thư viện tồn tại: nó điều phối rõ ràng các caller đồng thời của cùng một key, bảo đảm 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)
};
});
Và 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 chỉ một lần gọi factory được chạy cho mỗi key tại một thời điểm. Các caller đồng thời khác của key đó sẽ chờ cùng task đang thực thi, thay vì khởi tạo một lần gọi mới.
Đây chính xác là hành vi chúng ta đã tự xây dựng bằng SemaphoreSlim, nhưng không cần tự quản lý cấu trúc khóa. Tính năng này đã có sẵn, không cần đánh giá thêm một gói NuGet riêng.
Lưu ý: HybridCache cũng cung cấp miễn phí cache hai tầng: một tầng trong tiến trình, còn gọi là tầng cục bộ, cùng một tầng phân tán tùy chọn như Redis, SQL Server hoặc bất kỳ backend IDistributedCache nào đã được cấu hình.
Cơ chế bảo vệ khỏi cache stampede áp dụng ở tầng cục bộ trên từng node. Nó không ngăn được hai node khác nhau trong một cụm cùng lúc xây dựng lại một key, vì không có khóa dùng chung giữa các máy. Trong trường hợp đó, bạn vẫn cần một khóa phân tán hoặc chấp nhận đôi khi có hai lần xây dựng lại trên các node khác nhau. Đây là một phiên bản nhỏ hơn của cùng vấn đề.
Cách 3: Không để mọi dữ liệu hết hạn cùng một thời điểm
Ngay cả khi đã dùng khóa theo 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 tình cờ hết hạn cùng lúc. Ví dụ, bạn làm nóng cache bằng một thời gian sống cố định cho cả một nhóm dữ liệu khi ứng dụng khởi động.
Một thủ thuật đơn giản là thêm độ dao động 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 đã giải quyết được phần lớn vấn đề. Với phạm vi rộng hơn, hoặc khi muốn có cơ chế an toàn dự phòng trong trường hợp backend không hoạt động, FusionCache hoặc một thư viện tương tự là lựa chọn đáng cân nhắc.
Dù chọn phương án nào, thêm độ dao động vào TTL vẫn là một biện pháp bảo hiểm rẻ và hiệu quả để chống lại việc nhiều cache hết hạn đồng bộ.
Đó là toàn bộ giải pháp.



