وقتی برای اولین بار Redis را وارد معماری میکنیم، معمولاً مسئله خیلی ساده به نظر میرسد:
«دیتای پرتکرار را از Database بخوان، داخل Redis بگذار و Requestهای بعدی را از Cache جواب بده.»
و واقعاً هم همین کار باعث کاهش Load روی Database و کاهش Latency میشود.
اما یک سؤال مهم وجود دارد:
❓ وقتی Data در Database تغییر کرد، Redis از کجا بفهمد که مقدار قبلی دیگر معتبر نیست؟
فرض کنید:
Database
Product:123
Price = 100
Redis
Product:123
Price = 100
حالا قیمت محصول تغییر میکند:
Database
Product:123
Price = 150
اما Redis هنوز دارد:
Redis
Product:123
Price = 100
اگر Cache Invalidation درست انجام نشود، کاربر ممکن است همچنان قیمت
100 را دریافت کند؛ در حالی که Source of Truth مقدار 150 را دارد.اینجاست که مسئله واقعی Caching خودش را نشان میدهد:
🔥 Cache Invalidation
🔹 Cache-Aside Pattern
یکی از رایجترین الگوها برای Cache کردن داده، Cache-Aside است.
در Read:
Request
↓
Redis
↓
Cache Hit?
┌───────┴───────┐
Yes No
↓ ↓
Return Database
↓
Update Redis
↓
Return
یعنی Application خودش مسئول مدیریت Cache است.
اگر Data در Cache وجود داشته باشد، همان مقدار استفاده میشود.
اگر وجود نداشته باشد، Application سراغ Database میرود و بعد مقدار خواندهشده را در Cache قرار میدهد.
اما مشکل اصلی در Update اتفاق میافتد.
معمولاً داریم:
Update Database
↓
Invalidate Cache
مثلاً:
await db.Products.UpdateAsync(product);
await redis.RemoveAsync($"product:{product.Id}");
ءRequest بعدی دوباره Data را از Database میخواند و Cache را با مقدار جدید Populate میکند.
تا اینجا همهچیز خوب به نظر میرسد...
اما یک Race Condition میتواند تمام این طراحی را خراب کند. 👇
⚠️ ءRace Condition در Cache
فرض کنید دو Request تقریباً همزمان اتفاق میافتند. Request A میخواهد قیمت را
150 کند. Request B تقریباً همزمان Data قدیمی را از Database میخواند.ممکن است چیزی شبیه این اتفاق بیفتد:
Request A Request B
Update DB → 150
Read DB → 100
↓
Set Redis → 100
Delete Redis
حالا Database مقدار
150 دارد، اما بسته به ترتیب دقیق عملیات، Cache ممکن است دوباره با مقدار قدیمی Populate شود.این یکی از دلایلی است که طراحی Cache در سیستمهای Concurrent بسیار پیچیدهتر از یک
GET و SET ساده است.🔐 پس فقط DEL کردن Redis کافی نیست؟
نه همیشه.
بسته به Requirement سیستم، ممکن است به تکنیکهای دیگری نیاز داشته باشید:
🔹 Optimistic Locking
برای تشخیص اینکه Data در فاصله بین Read و Write تغییر کرده است.
🔹 Distributed Lock
در سناریوهایی که چند Instance نباید همزمان یک عملیات حساس را انجام دهند.
🔹 Versioning
برای اینکه بتوانیم تشخیص دهیم مقدار Cache مربوط به چه Versionی از Data است.
مثلاً:
Product
Version = 42
Price = 150
و Cache هم Version را نگه دارد.
اگر یک Update قدیمی با Version پایینتر بخواهد Cache را Update کند، میتوان آن را نادیده گرفت.
🔹 Event-driven Cache Invalidation
به جای اینکه هر قسمت Application خودش مسئول Invalidate کردن Cache باشد، تغییرات Data را به صورت Event منتشر کنیم:
Database Update
↓
Domain / Integration Event
↓
Cache Invalidation Handler
↓
Redis DEL
🔹 TTL
و در نهایت TTL میتواند یک لایه محافظتی دیگر باشد.
⏳ اما یک اشتباه رایج:
ءTTL بهتنهایی مشکل Consistency را حل نمیکند.
اگر:
TTL = 5 minutes
باشد، این یعنی Data قدیمی ممکن است تا ۵ دقیقه در Cache باقی بماند. TTL فقط میگوید:
«این Data بعد از این مدت دیگر معتبر نیست.»
اما نمیگوید:
«به محض تغییر Source of Truth، Cache هم فوراً معتبر بودنش را از دست بدهد.»
بنابراین اگر Requirement شما Strong Consistency است، نباید TTL را جایگزین Cache Invalidation بدانید.
⚖️ آیا همیشه Strong Consistency لازم داریم؟
اینجا باید از Business Requirement شروع کنیم، نه از تکنولوژی.
برای بعضی Dataها چند ثانیه اختلاف کاملاً قابل قبول است:
Product View Count
Analytics
Recommendation
Reporting Data
اگر View Count یک محصول برای چند ثانیه
10,521 و بعد 10,524 باشد، احتمالاً Business مشکلی ندارد.در اینجا Eventual Consistency میتواند انتخاب کاملاً منطقیای باشد.