Cache của bạn không được bảo vệ khỏi cache stampede

Bài viết này được trình bày như một phần của C# Advent 2025.

Bạn có một truy vấn cơ sở dữ liệu hoặc một lời gọi API trả phí, và bạn cache kết quả? Bạn đang sử dụng ConcurrentDictionary hay MemoryCache để caching?

Cache được xây dựng trên các lớp này có một vấn đề: thiếu khả năng bảo vệ chống lại cache stampede. Dưới một tải nhất định, cache sẽ thực thi cùng một yêu cầu nhiều lần do thiếu sự phối hợp giữa các luồng (threads) và các bản sao (replicas). Trong bài viết này, tôi sẽ chỉ cho bạn cách cache stampede ảnh hưởng đến các ứng dụng C# và những gì bạn có thể làm để khắc phục.

Cache stampede là gì?

Hãy tưởng tượng chúng ta đang cache kết quả của một truy vấn cơ sở dữ liệu lâu. Các client truy cập kết quả này hàng chục lần mỗi giây. Khi giá trị đang có trong cache, mọi thứ đều ổn: mỗi yêu cầu chỉ đơn giản là đọc nó từ bộ nhớ.

Vấn đề bắt đầu khi cache hết hạn. Nếu cache không biết cách phối hợp các yêu cầu song song đến cùng một khóa (key), hàng chục luồng đồng thời truy cập vào cơ sở dữ liệu để lấy cùng một giá trị. Một tình huống tương tự có thể xảy ra sau khi khởi động lại ứng dụng, khi cache chưa được làm nóng (warm up) và đang trống.

Kết quả là, thay vì một thao tác “nặng” duy nhất, bạn nhận được hàng chục thao tác. Đây được gọi là cache stampede.

Benchmarks

Tôi đã so sánh hành vi của ConcurrentDictionary, MemoryCache và HybridCache (tương đối mới) trong hai kịch bản:

  • IO-bound – mô phỏng một truy vấn cơ sở dữ liệu hoặc lời gọi API bên ngoài.
async Task<int> IOBoundOperation(CancellationToken ct)
{
    // Mô phỏng thao tác IO
    await Task.Delay(200, ct);

    // Mô phỏng cấp phát 1 KB bộ nhớ
    var result = new byte[1024].Length;

    return result;
}
  • CPU-bound – mô phỏng các tính toán “nặng”.
Task<int> CPUBoundOperation(CancellationToken _)
{
    // Mô phỏng thao tác CPU
    var result = 0;
    for (int i = 0; i < 200_000_000; i++)
    {
        result += i % 7;
    }

    return Task.FromResult(result);
}

Số lượng yêu cầu song song thay đổi từ 1 đến Environment.ProcessorCount * 2 (số lượng bộ xử lý logic).

Các tham số đo lường:

  1. Thời gian thực thi trung vị (Median) — thời gian thực thi benchmark trung vị.
  2. Bộ nhớ được cấp phát — lượng bộ nhớ đã được cấp phát.
  3. Số thao tác đã thực thi — số lần thao tác IO-bound hoặc CPU-bound thực sự được gọi (phương thức ExecuteOperation).

Mã benchmark đầy đủ và kết quả có trong repository.

Kiểm thử ConcurrentDictionary

Cache dựa trên ConcurrentDictionary thường trông như thế này:

  1. Cố gắng đọc giá trị theo khóa bằng TryGetValue. Nếu giá trị tồn tại, trả về nó.
  2. Nếu không, thực thi thao tác “nặng”, lưu kết quả qua TryAdd và trả về nó.
if (!_dictionary.TryGetValue(CacheKey, out var existing))
{
    var value = await ExecuteOperation(Operation, CancellationToken.None);

    _dictionary.TryAdd(CacheKey, value);

    return value;
}

return existing;

Điều quan trọng cần hiểu là các phương thức TryGetValue và TryAdd an toàn cho luồng (thread-safe), nhưng chúng chỉ bảo vệ chúng ta khỏi các điều kiện tranh đua (races) trong các thao tác riêng lẻ. Không có sự đồng bộ hóa luồng nào giữa TryGetValue và TryAdd.

Kết quả benchmark ConcurrentDictionary

Kết quả là, chúng ta thấy:

  • Đối với các thao tác CPU-bound, khi số lượng yêu cầu song song tăng lên, thời gian trung vị tăng khoảng 2-3 lần. Giá trị Operations executed tăng lên đến 15.
  • Đối với các thao tác IO-bound, số lần thực thi tăng tuyến tính: bao nhiêu yêu cầu song song, bấy nhiêu lần ExecuteOperation được thực thi.

ConcurrentDictionary cũng có phương thức GetOrAdd có vẻ như là nguyên tử (atomic). Mã benchmark có thể được viết lại như sau:

private readonly ConcurrentDictionary<string, Task<int>> _dictionary = new();

return await _dictionary.GetOrAdd(CacheKey, _ =>
{
    return ExecuteOperation(Operation, CancellationToken.None);
});

Nhưng nếu bạn nhìn vào cài đặt của GetOrAdd, nó không khác gì ví dụ với TryGetValue và TryAdd ở trên. Điều đó có nghĩa là nó cũng không đảm bảo gọi delegate được truyền vào chỉ một lần. Hành vi không rõ ràng này được thảo luận trong GitHub issue này.

Kiểm thử MemoryCache

MemoryCache cũng có phương thức GetOrCreateAsync tiện lợi:

return await _memoryCache.GetOrCreateAsync(CacheKey, async entry =>
{
    return await ExecuteOperation(Operation, CancellationToken.None);
});

Và, giống như trong ConcurrentDictionary, thoạt nhìn có vẻ như khi sử dụng nó, phương thức ExecuteOperation sẽ chỉ được gọi một lần. Nhưng nếu bạn nhìn vào mã nguồn của GetOrCreateAsync, bạn có thể thấy rằng không có khả năng bảo vệ cache stampede ở đó. Điều này được xác nhận bởi kết quả benchmark.

Các biểu đồ cho thấy gần như giống hệt ConcurrentDictionary:

Kết quả benchmark MemoryCache

  • Đối với các thao tác CPU-bound, khi số lượng yêu cầu song song tăng lên, thời gian trung vị tăng 3-4 lần.
  • Đối với các thao tác IO-bound, số lần thực thi cũng tăng tuyến tính: bao nhiêu yêu cầu song song, bấy nhiêu lần phương thức ExecuteOperation được thực thi.

Kiểm thử HybridCache

Ứng cử viên tiếp theo là HybridCache. Thư viện này xuất hiện cùng với bản phát hành .NET 9 và kết hợp L1 (cục bộ, IMemoryCache) và L2 (bền vững, IDistributedCache). Ở cấp độ API, mọi thứ trông giống như trong IMemoryCache:

return await hybridCache.GetOrCreateAsync(CacheKey, async (cancellationToken) =>
{
    return await ExecuteOperation(Operation, cancellationToken);
});

Sự khác biệt chính là HybridCache có khả năng bảo vệ cache stampede tích hợp sẵn, nhưng chỉ trong một tiến trình (process) duy nhất. Nếu bạn quan tâm đến cách nó được cài đặt chính xác, hãy xem các phương thức GetOrCreateAsyncGetOrCreateStampedeState.

Khi chỉ sử dụng L1, cuối cùng chúng ta cũng có được khả năng bảo vệ cache stampede, được xác nhận bởi kết quả benchmark – số lần gọi ExecuteOperation luôn là 1.

Kết quả benchmark HybridCache với L1

Nhưng, như tôi đã đề cập, khả năng bảo vệ này chỉ ở cấp độ tiến trình. Nếu dịch vụ có nhiều bản sao (replicas), mỗi bản sao sẽ thực thi phương thức ExecuteOperation.

Kết quả benchmark HybridCache với L1 và L2

Có một tầng L2 sẽ không giúp ích cho cache stampede: HybridCache không có cơ chế khóa phân tán (distributed lock). Mặc dù các nhà phát triển .NET đồng ý rằng sẽ rất tốt nếu thêm chức năng như vậy vào một thời điểm nào đó trong tương lai.

Kết luận

  1. Đừng sử dụng ConcurrentDictionary và MemoryCache mà không có khả năng bảo vệ cache stampede. Trong các ứng dụng có tải cao, điều này chắc chắn sẽ dẫn đến việc thực thi quá mức các thao tác “nặng”.
  2. Nếu bạn chỉ có một bản sao (single replica), chỉ cần sử dụng HybridCache – chức năng của nó là đủ.
  3. Nếu bạn có nhiều bản sao và việc thực thi thao tác “nặng” nhiều lần có thể chấp nhận được, HybridCache vẫn phù hợp.
  4. Nếu bạn có nhiều bản sao và việc thực thi các thao tác “nặng” thêm là không thể chấp nhận được, bạn có thể:
    • Áp dụng Cache TTL Jittering – thêm thời gian ngẫu nhiên vào giá trị TTL của khóa cache để TTL không giống nhau. Điều này không đảm bảo bảo vệ khỏi cache stampede, nhưng giảm đáng kể xác suất cập nhật đồng bộ trên tất cả các bản sao. Trong một số thư viện, chẳng hạn như FusionCache, chức năng này có sẵn ngay từ đầu.
    • Áp dụng mẫu Single Flight ở tầng L2. Trong trường hợp này, chỉ một bản sao sẽ thực thi thao tác “nặng”. Các bản sao khác sẽ chờ và đọc giá trị đã được cập nhật từ L2. Thật không may, tôi chưa tìm thấy thư viện nào có sẵn cho cache phân tán thực hiện chức năng này. Nếu bạn biết thư viện nào, xin vui lòng viết trong phần bình luận.
Chỉ mục