Bài viết này là một phần của C# Advent 2025.
Bạn có một truy vấn cơ sở dữ liệu hoặc lệnh gọi API trả phí, và bạn cache kết quả? Bạn đang sử dụng ConcurrentDictionary hay MemoryCache để caching?
Các cache được xây dựng trên những lớp này có một vấn đề: thiếu sự 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 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.
Mục lụ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 dài. Các client truy cập kết quả này hàng chục lần mỗi giây. Khi giá trị còn trong cache, mọi thứ đều ổn: mỗi yêu cầu chỉ đơn giản đọ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 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ũng có thể xảy ra sau khi ứng dụng khởi động lại, khi cache chưa được làm ấm và còn trống.
Kết quả là, thay vì một thao tác “nặng”, bạ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 truy vấn cơ sở dữ liệu hoặc lệnh 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 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 thông số đo lường:
- Thời gian thực thi trung bình (Median) — thời gian thực thi benchmark trung bình.
- Bộ nhớ được cấp phát — lượng bộ nhớ đã được cấp phát.
- Số lần thao tác đượ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
Một cache dựa trên ConcurrentDictionary thường trông như thế này:
- Thử đọc giá trị theo key bằng TryGetValue. Nếu giá trị tồn tại, trả về nó.
- 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 chấp (races) trong từng 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ả, chúng ta thấy:
- Đối với thao tác CPU-bound, khi số lượng yêu cầu song song tăng lên, thời gian trung bình tăng khoảng 2-3 lần. Giá trị Operations executed tăng lên đến 15.
- Đối với 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 chỉ gọi delegate một lần. Hành vi không hiển nhiên 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 rất 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ó cơ chế 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:
- Đối với thao tác CPU-bound, khi số lượng yêu cầu song song tăng lên, thời gian trung bình tăng 3-4 lần.
- Đối với 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 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ó cơ chế bảo vệ cache stampede tích hợp sẵn, nhưng chỉ trong một tiến trình đơn lẻ. Nếu bạn quan tâm đến cách nó được triển khai, hãy xem các phương thức GetOrCreateAsync và GetOrCreateStampedeState.
Khi chỉ sử dụng L1, cuối cùng chúng ta cũng có được sự bảo vệ cache stampede – số lần gọi ExecuteOperation luôn là 1.
Nhưng, như tôi đã đề cập, sự 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.
Việc có cấp độ L2 sẽ không giúp ích gì 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 trong tương lai.
Kết luận
- Đừng sử dụng ConcurrentDictionary và MemoryCache mà không có cơ chế 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”.
- 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à đủ.
- Nếu bạn có nhiều bản sao và việc thực thi thao tác “nặng” vài lần là chấp nhận được, HybridCache vẫn phù hợp.
- Nếu bạn có nhiều bản sao và việc thực thi thêm các thao tác “nặng” là không thể chấp nhận, 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 cache key để TTL không giống nhau. Điều này không đảm bảo bảo vệ hoàn toàn 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 khi cài đặt.
- Áp dụng mẫu Single Flight ở cấp độ 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ẽ đợi 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 có sẵn cho distributed cache nào triển khai chức năng này. Nếu bạn biết, xin hãy viết trong phần bình luận.



