١ Why Caching? — ليه أصلاً بنعمل كاش؟
تخيل معايا كده إن عندك سيستم، وكل ما مستخدم يفتح الـ Home Page، بنروح نعمل Query معقدة في الـ Database تاخد ثانية كاملة عشان تجيب قايمة المنتجات. لو دخل 1000 مستخدم في نفس اللحظة، الـ DB هتكح تراب والسيستم هيقع! من هنا جت فكرة الـ Caching.
الـ Caching ببساطة: بدل ما نروح للمكان البطيء أو البعيد (الـ Database) كل شوية، بناخد نسخة من الداتا دي ونحطها في مكان سريع جداً وقريب مننا (الـ RAM)، عشان لما حد يطلبها تاني، نديها له في جزء من الملي ثانية.
٢ In-Memory Cache — التخزين داخل السيرفر
أول نوع هو الـ In-Memory Cache (وفي دوت نت بنستخدم له IMemoryCache). ده ببساطة بيخزن الداتا جوه الـ RAM بتاعة السيرفر اللي شايل الـ Application نفسه — مباشرة في نفس الـ Process.
✅ المميزات
- ⚡ سريع سرعة الصاروخ — الداتا في نفس الـ Process
- 🆓 مجاني — مش محتاج سيرفر تاني
- 🧩 سهل جداً في الـ Setup والاستخدام
- 📦 بيخزن C# Objects زي ما هي — مفيش Serialization
❌ العيوب
- 💀 الداتا بتموت لو السيرفر حصله Restart
- ⚠️ مشكلة Data Inconsistency لو عندك كذا سيرفر (Scale-out)
- 🧠 بياكل من الـ RAM بتاعة الـ App نفسه
- 🔒 مش بيتشارك بين السيرفرات
// Program.cs — تسجيل الـ IMemoryCache في الـ DI Container var builder = WebApplication.CreateBuilder(args); // 👇 سطر واحد بس عشان تفعّل الـ In-Memory Cache builder.Services.AddMemoryCache(); var app = builder.Build(); app.Run();
public class ProductService { // Inject الـ IMemoryCache private readonly IMemoryCache _memoryCache; private readonly AppDbContext _db; public ProductService(IMemoryCache memoryCache, AppDbContext db) { _memoryCache = memoryCache; _db = db; } public async Task<Product> GetProductAsync(int id) { string cacheKey = $"product-{id}"; // مفتاح فريد لكل منتج // GetOrCreateAsync — يشوف الكاش الأول، لو مالقاش يعمل الـ Factory return await _memoryCache.GetOrCreateAsync(cacheKey, async entry => { // ⏱️ AbsoluteExpiration — الكاش يموت بعد 5 دقايق مهما حصل entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); // 🏋️ Priority — لو الـ RAM ضاقت، مبيشيلوش الأول entry.Priority = CacheItemPriority.High; // 👇 روح جيب من الـ DB بس لو الكاش فاضي return await _db.Products.FindAsync(id); })!; } }
مشكلة الـ Scale-out: لو عملت 3 سيرفرات وفي Load Balancer بيوزع عليهم، كل سيرفر هيبقى جواه الـ Cache الخاص بيه. ممكن سيرفر 1 يكون فيه داتا محدثة وسيرفر 2 فيه داتا قديمة — ده الـ Data Inconsistency وهو أخطر عيب في الـ In-Memory Cache.
٣ Distributed Cache — Redis والمخزن المركزي
عشان نحل مشكلة السيرفرات الكتير دي، بنروح للـ Distributed Cache. الفكرة هنا إني بفصل الـ Cache تماماً في سيرفر مستقل برا الـ Application خالص، وأشهر حاجة بتعمل ده هي Redis.
مش هتقعد تنزل Redis وتعمل Setup معقدة! بـ Docker، بسطر واحد بس في الـ Terminal بـ Pull الـ Official Image وبشغلها في Container معزول على بورت 6379.
# سطر واحد بس — بيشغل Redis على بورت 6379 docker run -d -p 6379:6379 --name redis-cache redis:alpine # التحقق إنه شغال docker ps
# تثبيت Package الـ Redis dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis // Program.cs builder.Services.AddStackExchangeRedisCache(options => { // 👇 بنقوله يكلم الـ Redis Container بتاعنا options.Configuration = "localhost:6379"; options.InstanceName = "MyApp_"; });
public class ProductService { private readonly IDistributedCache _distributedCache; private readonly AppDbContext _db; public async Task<Product?> GetProductAsync(int id) { string cacheKey = $"product-{id}"; // 🔍 نشوف في الـ Redis الأول — بيرجع JSON String var cachedData = await _distributedCache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cachedData)) { // Cache Hit — نحول الـ JSON لـ Object return JsonSerializer.Deserialize<Product>(cachedData); } // Cache Miss — نروح للـ DB var product = await _db.Products.FindAsync(id); if (product != null) { // ⏱️ نحدد الـ Expiration ونحفظ في Redis كـ JSON var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }; await _distributedCache.SetStringAsync( cacheKey, JsonSerializer.Serialize(product), options ); } return product; } }
الـ Serialization Overhead: الـ Distributed Cache بيتعامل مع الداتا كـ byte[] أو string (JSON). ده معناه كل عملية قراءة أو كتابة بيحصل معاها CPU Overhead بسبب الـ Serialization والـ Deserialization، على عكس الـ In-Memory اللي بيخزن الـ C# Objects زي ما هي في الـ Managed Heap.
٤ In-Memory vs Distributed — المقارنة الكاملة
| وجه المقارنة | 🔵 In-Memory Cache | 🔴 Distributed Cache (Redis) |
|---|---|---|
| السرعة | ⚡ الأسرع — في نفس الـ Process — نانوثانية | ⚠️ أبطأ قليلاً بسبب Network Call |
| التكلفة | ✅ مجاني — مفيش سيرفر إضافي | 💰 محتاج سيرفر Redis منفصل |
| لو السيرفر عمل Restart | ❌ الداتا بتتمسح — تروح للـ DB من أول | ✅ الداتا محفوظة في Redis مش بتروح |
| مع Scale-out (كذا سيرفر) | ❌ Data Inconsistency — كل سيرفر كاشه مختلف | ✅ داتا موحدة — كلهم بيكلموا نفس Redis |
| Serialization | ✅ مفيش — بيخزن Objects مباشرة | ⚠️ لازم تحول لـ JSON/bytes |
| الاستخدام الأمثل | سيرفر واحد — مشاريع صغيرة — داتا مش حرجة | Microservices — كذا Instance — داتا مهمة ومستقرة |
٥ Hybrid Cache — أفضل العالمين في .NET 9
في .NET 9 نزلوا حاجة عظيمة اسمها Hybrid Cache. من اسمها كده 'هجين'، بتجمع بين مميزات الاتنين! بتعمل طابقين للتخزين (Two-Level Caching).
In-Memory (Level 1)
أول حاجة بيبص فيها — الـ RAM بتاعة السيرفر. لو لقى الداتا هنا رجعها في لمح البصر. ده أسرع مستوى ومحتاج مفيش Network Call.
Redis (Level 2)
لو مالقاش في الـ L1، بيروح يدور في الـ Redis. لو لقى، بيرجعها وبيملي بيها الـ L1 برضو للمرة الجاية.
Database (Last Resort)
لو مالقاش في L1 ولا L2، بيروح للـ DB — بيجيب الداتا، وبيملي L2 (Redis) وL1 (RAM) عشان المرة الجاية تبقى عنده.
Cache Stampede Prevention
لو الكاش خلص وجاء 1000 طلب في نفس اللحظة، الـ HybridCache ذكي — بيعمل Locking داخلي يخلي Request واحد بس يروح للـ DB والـ 999 التانيين يستنوا نتيجته.
# تثبيت الـ Package dotnet add package Microsoft.Extensions.Caching.Hybrid // Program.cs — لازم تسجل الاتنين builder.Services.AddStackExchangeRedisCache(options => options.Configuration = "localhost:6379"); // Redis كـ L2 // 👇 ده اللي بيفعّل الـ HybridCache builder.Services.AddHybridCache();
public class ProductService { private readonly HybridCache _hybridCache; private readonly AppDbContext _db; public async Task<Product> GetProductAsync(int id) { string cacheKey = $"product-{id}"; // 🪄 سطر واحد بيعمل كل حاجة: // 1. يشوف في L1 (RAM) // 2. لو مالقاش يشوف في L2 (Redis) // 3. لو مالقاش يروح للـ DB ويملي الاتنين return await _hybridCache.GetOrCreateAsync<Product>( cacheKey, async cancelToken => await _db.Products.FindAsync(id), new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5), // L2 (Redis) LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 (RAM) أقصر } ); } }
٦ Cache Invalidation — أصعب حاجة في البرمجة!
— Phil Karlton
تخيل كاشت داتا، والداتا دي اتعدلت في الـ DB.. كده الـ Cache شايل داتا قديمة (Stale Data). بنحل ده إزاي؟
// الـ Best Practice — تجمع الاتنين مع بعض var cacheOptions = new MemoryCacheEntryOptions { // لو اتطلب جوا دقيقتين، الوقت بيتجدد SlidingExpiration = TimeSpan.FromMinutes(2), // بس هيموت حتماً بعد 10 دقايق مهما اتطلب! // ده بيمنع الداتا من الإستمرار لأبد الآبدين AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }; _memoryCache.Set(cacheKey, product, cacheOptions);
// لما المنتج يتعدل — امسح الكاش بتاعه فوراً public async Task UpdateProductAsync(Product product) { // 1. عدّل في الـ DB _db.Products.Update(product); await _db.SaveChangesAsync(); // 2. امسح من الـ Cache فوراً — نفس الـ Key اللي استخدمناه string cacheKey = $"product-{product.Id}"; _memoryCache.Remove(cacheKey); // أو في IDistributedCache: // await _distributedCache.RemoveAsync(cacheKey); // 3. الـ Request الجاي هيلاقي Cache Miss ويجيب الجديد من الـ DB }
٧ Response Cache vs Output Cache — كاش الـ HTTP Response
كل اللي فات كنا بنتكلم عن كاش الداتا (Objects). طب لو عاوزين نكاش الـ Web Response نفسه (الـ JSON اللي راجع للعميل)؟ هنا بيجي نوعين مختلفين.
بتعتمد على الـ HTTP Headers (زي Cache-Control). هنا الـ Backend بيقول للمتصفح: "يا متصفح، الـ Response ده سيفه عندك وماتطلبوش مني تاني لمدة دقيقتين".
// Program.cs — تسجيل الـ Middleware app.UseResponseCaching(); // في الـ Endpoint [HttpGet("report")] // Duration = 120 ثانية = دقيقتين // VaryByHeader — يعمل كاش مختلف لو المتصفح مختلف [ResponseCache( Duration = 120, Location = ResponseCacheLocation.Any, VaryByHeader = "User-Agent" )] public IActionResult GetReport() { return Ok(new { Data = "Big Financial Report..." }); }
عيب كبير: السيرفر ملوش أي تحكم عليها بعد ما تطلع. لو الداتا اتغيرت على السيرفر، العميل هيفضل يشوف القديم لحد ما الوقت يخلص. مفيش طريقة تقوله "اعمل Refresh دلوقتي!"
بتكاش الـ Response بالكامل بس على السيرفر عندك مش عند العميل! ميزتها المرعبة إن السيرفر ليه تحكم كامل — تقدر تعمل Tagging: تعلم كذا Response بتاج معين، وأول ما الداتا تتغير تمسحهم كلهم في ثانية واحدة!
// Program.cs builder.Services.AddOutputCache(); var app = builder.Build(); app.UseOutputCache(); // لازم قبل الـ Routing
// Endpoint اللي هنكاش Response بتاعه [HttpGet("all-products")] [OutputCache(Duration = 600)] // كاش لمدة 10 دقايق public IActionResult GetProducts(HttpContext context) { // 🏷️ بنعلم الكاش ده بتاج — ده اللي بيخلينا نمسحه بعدين context.Features .Get<IOutputCacheFeature>()? .Context.Tags .Add("products-tag"); return Ok(_db.Products.ToList()); }
// Endpoint الإضافة — بعد ما نضيف منتج نمسح الكاش القديم [HttpPost("add-product")] public async Task<IActionResult> AddProduct( Product product, IOutputCacheStore cacheStore) { _db.Products.Add(product); await _db.SaveChangesAsync(); // 🗑️ امسح كل الـ Output Caches اللي عليها "products-tag" // في ثانية واحدة — العميل هيشوف المنتج الجديد فوراً await cacheStore.EvictByTagAsync("products-tag", default); return Ok(); }
| النوع | مكان التخزين | الاستخدام الأمثل | التحكم في Invalidation | الـ Overhead |
|---|---|---|---|---|
| IMemoryCache | RAM السيرفر | سيرفر واحد — بيانات صغيرة | ✅ TTL + Manual Remove | لا شيء — أسرع |
| IDistributedCache | Redis (خارجي) | كذا سيرفر — Microservices | ✅ TTL + Manual Remove | Serialization + Network |
| HybridCache | RAM + Redis (L1+L2) | الأفضل في الحالتين | ✅ TTL + Tags | L2 فقط لو Cache Miss |
| Response Cache | Browser / Proxy | بيانات ثابتة لا تتغير | ❌ مفيش — TTL فقط | مفيش — مابيجيش للسيرفر |
| Output Cache | Server Memory | Responses تحتاج تحكم | ✅ Tags + Instant Eviction | شوية Memory على السيرفر |