🧱 سطح 1️⃣ : اضافه کردن ConcurrentDictionary
اولین فکری که احتمالاً به ذهنت میرسد این است که نرخها را داخل یک ConcurrentDictionary ذخیره کنی 🧠.
این ساختار thread-safe است، پس در نگاه اول ابزار درستی به نظر میرسد ✅.
private static readonly ConcurrentDictionary<string, decimal> Cache = new();
// In the Handler:
if (Cache.TryGetValue(currencyCode, out var cachedRate))
{
return cachedRate;
}
var rate = await currencyClient.GetExchangeRateAsync(currencyCode);
Cache.TryAdd(currencyCode, rate.Value);
این کار قطعاً تحت بار بالا به بهبود performance کمک میکند 🚀.
چندین thread میتوانند همزمان از دیکشنری بخوانند یا در آن بنویسند بدون اینکه برنامه کرش کند 💪.
اما ConcurrentDictionary فقط از ساختار دیکشنری محافظت میکند، نه از منطق شما ⚠️.
اگر ۱۰۰ کاربر دقیقاً در یک لحظه نرخ "EUR" را درخواست کنند 👥👥👥، TryGetValue برای همهی آنها false برمیگرداند.
در نتیجه، همهشان همزمان API را صدا میزنند 📡📡📡.
این یک race condition کلاسیک است 🏁.
شما حافظه را امن کردهاید، اما از API خارجی محافظت نکردهاید ❌.
یک مشکل دیگر هم وجود دارد:
نرخها هیچوقت expire نمیشوند ⏳❌.
⏰ سطح 2️⃣: اضافه کردن انقضای Cache
نرخ ارزها برای همیشه ثابت نمیمانند 💱.
ما به راهی نیاز داریم که بعد از مدتی آنها را منقضی کنیم.
از آنجایی که ConcurrentDictionary مفهوم Time To Live (TTL) ندارد،
باید دادههایمان را wrap کنیم 📦.
// Store both the rate and the time it was created
private record CacheEntry(decimal Rate, DateTime CreatedAt);
// Our cache now stores CacheEntry objects
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new();
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
// Check: Is it there? And is it still "fresh"?
if (Cache.TryGetValue(currencyCode, out var entry) &&
(DateTime.UtcNow - entry.CreatedAt) < CacheDuration)
{
return entry.Rate;
}
حالا expiration داریم ✅.
اما در عوض، یک مشکل جدید ساختهایم 😬:
🐘 Thundering Herd (یا Cache Stampede)
هر ۵ دقیقه یکبار، وقتی cache منقضی میشود ⏱️،
تمام درخواستهای ورودی بهصورت همزمان دادهی «منقضیشده» میبینند
و همگی تلاش میکنند آن را refresh کنند 💥.
پس باید این مشکل را در مرحلهی بعدی حل کنیم.
🚦 سطح 3️⃣: حل مشکل «Cache Stampede»
برای حل این مشکل، باید مطمئن شویم که
فقط یک نفر اجازه دارد داده را بهروزرسانی کند
و بقیه منتظر بمانند ⏸️.
در #C چطور این کار را انجام میدهیم؟ 🤔
ما از SemaphoreSlim و الگویی به نام Double-Checked Locking استفاده میکنیم 🔐.
اول یک بار cache را چک میکنیم (مسیر سریع 🏃♂️)،
بعد lock میگیریم،
و سپس دوباره چک میکنیم تا ببینیم آیا در این فاصله thread دیگری cache را پر کرده یا نه.
// Basically a mutex but async-friendly
private static readonly SemaphoreSlim Lock = new(1, 1);
public static async Task<decimal> GetRateAsync(string code, CurrencyApiClient client)
{
// Fast path: No locking needed
if (Cache.TryGetValue(code, out var entry) && IsFresh(entry))
{
return entry.Rate;
}
var acquired = await Lock.WaitAsync(TimeSpan.FromSeconds(10)); // Avoid deadlocks
if (!acquired)
{
throw new Exception("Could not acquire lock to fetch exchange rate.");
}
try
{
// Double-check: Did someone else finish the API call while we waited?
if (Cache.TryGetValue(code, out entry) && IsFresh(entry))
{
return entry.Rate;
}
var rate = await client.GetExchangeRateAsync(code);
var newEntry = new CacheEntry(rate.Value, DateTime.UtcNow);
// Atomically update the cache
// This is safe because we're inside the lock
Cache.AddOrUpdate(code, newEntry, (_, _) => newEntry);
return rate.Value;
}
finally
{
// Always release the lock
Lock.Release();
}
}
این کار یک بهبود واقعی است 👍.
اما هنوز یک حس بد وجود دارد… 😐
میتوانی مشکل این کد را پیدا کنی؟ 👀
🔒 این lock مثل یک global lock رفتار میکند.
یعنی اگر یک thread در حال گرفتن نرخ "EUR" باشد،
تمام threadهای دیگر (حتی آنهایی که "JPY" میخواهند 🇯🇵)
تا پایان درخواست "EUR" بلاک میشوند ⛔️.
این مشکل به lock contention معروف است ⚠️.
در مرحلهی بعدی، این مشکل را هم حل میکنیم 🛠✨.