…
/
۱. طول عمر سرویسها در DI
Dependency Injection· در نقشه راه
سؤال: این کد را در code review میبینی. سرویس ReportCache به صورت Singleton ثبت شده است. سرویس AppDbContext با متد AddDbContext ثبت شده است.
builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(cs));
builder.Services.AddSingleton<ReportCache>();
public class ReportCache(AppDbContext db)
{
public Task<Report> LoadAsync(CancellationToken ct)
=> db.Reports.FirstAsync(ct);
}
این سرویس در همه request ها استفاده میشود. فرق Singleton، Scoped و Transient چیست؟ نظرت درباره این کد چیست؟ آیا در Production مشکلی دارد؟
جواب کوتاه: سرویس Singleton تا آخر عمر برنامه زنده است. پس DbContext داخل آن هم تا آخر عمر برنامه میماند. در نتیجه همه request ها همزمان از همین یک DbContext استفاده میکنند. اما DbContext برای این کار ساخته نشده است.
سرنخ (اگر کاندید گفت مشکلی نیست): در محیط Development، برنامه هنگام اجرا این خطا را میدهد: «Cannot consume scoped service from singleton». در Production این خطا نمیآید. ولی بعد از چند ساعت، گاهی این خطا در لاگ دیده میشود: «A second operation was started on this context instance before a previous operation completed». کاربرها هم گاهی داده قدیمی میبینند. حالا چرا؟
اول: سه نوع طول عمر
| نوع | چند نمونه ساخته میشود؟ | مثال مناسب |
|---|---|---|
| Singleton | فقط یک نمونه برای کل برنامه | تنظیمات، کش در حافظه |
| Scoped | یک نمونه برای هر request | DbContext |
| Transient | هر بار که خواسته شود، یک نمونه تازه | سرویس سبک و بدون state |
چرا این اتفاق میافتد؟ (قدم به قدم)
- سیستم DI فقط یک بار ReportCache را میسازد. همان لحظه یک AppDbContext هم میسازد و به آن میدهد.
- سرویس ReportCache هیچ وقت از بین نمیرود. پس آن AppDbContext هم هیچ وقت dispose نمیشود. به این اشتباه Captive Dependency (وابستگی زندانی) میگویند.
- حالا همه request ها یک ReportCache مشترک دارند، پس یک AppDbContext مشترک هم دارند.
- وقتی دو request در یک لحظه کوئری بزنند، DbContext خطای «second operation» میدهد. دلیلش این است که DbContext thread-safe نیست.
- هر رکوردی که DbContext میخواند، در حافظه خودش (change tracker) میماند. اگر سرویس دیگری آن رکورد را در دیتابیس عوض کند، این DbContext باز نسخه قدیمی را برمیگرداند. پس کاربر داده قدیمی میبیند. حافظه هم کمکم زیاد میشود.
- چرا فقط Development خطا میدهد؟ چون ASP.NET Core چک ValidateScopes را به طور پیشفرض فقط در Development روشن میکند.
قانون ساده: هر سرویس فقط میتواند به سرویسی وابسته باشد که عمرش مساوی یا بیشتر از خودش است.
راه حل: در سرویس Singleton، هر بار که لازم است یک DbContext کوتاهعمر میسازیم و بعد dispose میکنیم:
public class ReportCache(IDbContextFactory<AppDbContext> dbFactory)
{
public async Task<Report> LoadAsync(CancellationToken ct)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Reports.AsNoTracking().FirstAsync(ct);
}
}
راه دیگر این است که با IServiceScopeFactory یک scope جدا بسازیم. این روش در BackgroundService رایج است.
سؤال پیگیری: HttpClient را چطور استفاده میکنی؟ چرا ساختن یک HttpClient تازه در هر request بد است؟
جواب پیگیری:
- هر HttpClient تازه، connection های TCP خودش را باز میکند. بعد از Dispose هم سیستمعامل آن socket را چند دقیقه در حالت TIME_WAIT نگه میدارد.
- در بار زیاد، port های آزاد تمام میشوند و خطای SocketException میگیریم. به این Socket Exhaustion میگویند.
- اشتباه برعکس هم داریم: یک HttpClient استاتیک برای همیشه. در این حالت port تمام نمیشود، ولی برنامه تغییر DNS را هیچ وقت نمیبیند.
- راه درست اول: IHttpClientFactory. این ابزار connection ها را pool میکند و هر چند دقیقه آنها را تازه میکند.
- راه درست دوم: یک HttpClient مشترک با SocketsHttpHandler و تنظیم PooledConnectionLifetime.
نشانه خطر: فقط تعریف سه lifetime را از حفظ میگوید، ولی نمیتواند توضیح دهد ترکیب اشتباه آنها چه مشکلی میسازد.
۲. کار با async/await و Thread Pool
async/await و Thread Pool· در نقشه راه
سؤال: در یک API با ASP.NET Core، یک جای کد اینطور نوشته شده است:
var price = _httpClient.GetStringAsync(url).Result;
این کد را در code review میبینی. این endpoint در روز کمپین چند برابر ترافیک عادی را میگیرد. نظرت درباره این کد چیست؟ آیا مشکلی دارد؟ اصلاً async/await چه کاری میکند؟ آیا کد را سریعتر میکند؟
جواب کوتاه: کد async یک request را سریعتر نمیکند. کارش این است که وقتی منتظر I/O هستیم (شبکه، دیتابیس، فایل)، thread را آزاد کند. اما خاصیت Result همین thread را بلاک میکند. در بار زیاد، همه thread های thread pool منتظر میمانند. پس request های جدید در صف گیر میکنند. به این Thread Pool Starvation میگویند.
سرنخ (اگر کاندید گفت مشکلی نیست): با ترافیک کم همه چیز خوب است. در روز کمپین، زمان پاسخ از ۵۰ میلیثانیه به بیشتر از ۱۰ ثانیه میرسد. ولی CPU سرور فقط ۱۵٪ است. تعداد thread های process هم آهسته و مدام زیاد میشود. حالا چرا؟
یک مثال ساده: thread ها مثل گارسونهای رستوران هستند.
- در کد sync (یا با Result): گارسون سفارش را به آشپزخانه میدهد و همانجا میایستد تا غذا آماده شود. با ۸ گارسون، فقط ۸ میز همزمان سرویس میگیرند.
- در کد async: گارسون سفارش را میدهد و میرود سفارش میز بعدی را میگیرد. وقتی غذا آماده شد، هر گارسونی که آزاد است آن را میبرد.
چرا این اتفاق میافتد؟ (قدم به قدم)
- هر request روی یک thread از thread pool اجرا میشود.
- با await: وقتی به درخواست شبکه میرسیم، متد موقتاً برمیگردد و thread به pool برمیگردد. وقتی جواب شبکه رسید، ادامه متد روی یک thread آزاد اجرا میشود. کامپایلر برای این کار متد را به یک state machine تبدیل میکند.
- با Result: thread تا رسیدن جواب بلاک میماند و هیچ کار دیگری نمیکند.
- فرض کن pool در شروع ۸ thread دارد (معمولاً به تعداد هستههای CPU). هر درخواست بیرونی هم ۱ ثانیه طول میکشد. پس با Result حداکثر ۸ request در ثانیه جواب میگیرند. بقیه در صف میمانند.
- سیستم thread pool میبیند صف بزرگ شده و thread جدید اضافه میکند. ولی این کار را آهسته انجام میدهد (حدود یکی دو thread در ثانیه). برای همین تعداد thread ها آهسته زیاد میشود.
- این thread ها فقط منتظرند و کار محاسباتی نمیکنند. پس CPU پایین میماند. «CPU کم ولی سیستم کند» نشانه همین مشکل است.
در برنامههایی که SynchronizationContext دارند (مثل WinForms و ASP.NET قدیمی)، استفاده از Result حتی ممکن است deadlock بدهد.
راه حل: کد را از اول تا آخر async کنیم (async all the way):
public async Task<decimal> GetPriceAsync(string url, CancellationToken ct)
{
var text = await _httpClient.GetStringAsync(url, ct);
return decimal.Parse(text);
}
نکته: برای کار سنگین CPU (مثلاً یک محاسبه طولانی)، async کمکی نمیکند. چون آنجا thread واقعاً مشغول کار است، نه منتظر.
سؤال پیگیری: اگر بخواهیم ۱۰۰ درخواست HTTP بفرستیم، ولی حداکثر ۱۰ تا همزمان باشند، چه میکنی؟
جواب پیگیری: یک راه، متد Parallel.ForEachAsync با سقف ۱۰ است:
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct },
async (url, token) => await CallAsync(url, token));
راه دیگر، استفاده از SemaphoreSlim با ظرفیت ۱۰ است.
چرا اجرای همه ۱۰۰ درخواست با Task.WhenAll خوب نیست؟ چون هر ۱۰۰ درخواست با هم میروند. ممکن است سرویس مقابل overload شود، یا به rate limit آن بخوریم.
نشانه خطر: میگوید «async یعنی کد روی یک thread جدید اجرا میشود» یا «async کد را سریعتر میکند».
۳. مشکل N+1 در EF Core
Entity Framework Core· در نقشه راه
سؤال: یک endpoint داریم که لیست ۵۰ سفارش آخر را همراه با نام مشتری هر سفارش برمیگرداند. در پروژه Lazy Loading روشن است و کد این است:
var orders = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Take(50)
.ToListAsync();
return orders.Select(o => new OrderDto(o.Id, o.Total, o.Customer.Name));
این کد را در code review میبینی. نظرت چیست؟ آیا مشکلی دارد؟ این کد چند کوئری به دیتابیس میفرستد؟
جواب کوتاه: به این مشکل N+1 میگویند: ۱ کوئری برای لیست سفارشها، و بعد N کوئری (یکی برای هر سفارش) برای مشتری. علتش Lazy Loading است. یعنی EF Core مشتریِ هر سفارش را همان لحظهای میخواند که کد برای اولین بار به آن دست میزند، و این کار را با یک کوئری جدا انجام میدهد.
سرنخ (اگر کاندید گفت مشکلی نیست): جدولها کوچکاند، ولی جواب این endpoint حدود ۲ ثانیه طول میکشد. لاگ SQL در EF Core را روشن میکنیم (یا از SQL Server Profiler یا یک ابزار APM استفاده میکنیم). میبینیم برای یک بار صدا زدن این endpoint، ۵۱ کوئری به دیتابیس میرود: یک کوئری برای جدول سفارشها، و ۵۰ کوئری جدا برای جدول مشتریها (هر بار برای یک مشتری). خروجی API درست است و همان ۵۰ سفارش را برمیگرداند. پس مشکل تعداد رکوردها نیست، تعداد کوئریهاست. حالا چرا؟
تعریف Lazy Loading:
- وقتی Lazy Loading روشن است، EF Core در کوئری اول فقط ستونهای جدول سفارشها را میخواند. مشتریِ سفارش خالی میماند.
- هر وقت کد به مشتریِ یک سفارش برسد، EF Core میبیند هنوز بارگذاری نشده. پس همان لحظه یک کوئری برای همان یک مشتری میفرستد.
- بدون Lazy Loading، مشتریِ سفارش فقط null میشد و خطای NullReferenceException میگرفتیم. برای همین بعضی تیمها Lazy Loading را روشن میکنند و مشکل N+1 را نمیبینند.
چرا این اتفاق میافتد؟ (قدم به قدم)
- متد ToListAsync یک کوئری میزند: ۵۰ سفارش، بدون مشتری.
- بعد Select روی لیستِ داخل حافظه اجرا میشود. برای هر سفارش، نام مشتری را میخواند.
- هر بار، Lazy Loading یک کوئری جدا میفرستد. پس ۵۰ سفارش یعنی ۵۰ کوئری.
- هر کوئری، حتی اگر ۵ میلیثانیه باشد، یک رفت و برگشت شبکه دارد. ۵۰ رفت و برگشت پشت سر هم، endpoint را کند میکند. با لیست ۱۰۰۰تایی بدتر هم میشود.
همین مشکل بدون Lazy Loading هم پیش میآید، اگر برنامهنویس خودش داخل یک حلقه foreach کوئری بزند.
راه حل:
- با Include، مشتری با یک JOIN در همان کوئری اول میآید. پس ۵۱ کوئری میشود ۱ کوئری.
var orders = await db.Orders.Include(o => o.Customer)
.OrderByDescending(o => o.CreatedAt).Take(50).ToListAsync();
- راه بهتر Projection است: یعنی Select را قبل از ToListAsync بنویسیم. در این حالت EF Core خودش JOIN میسازد و فقط ستونهای لازم را میخواند. Lazy Loading و change tracking هم درگیر نمیشوند.
var result = await db.Orders
.OrderByDescending(o => o.CreatedAt).Take(50)
.Select(o => new OrderDto(o.Id, o.Total, o.Customer.Name))
.ToListAsync();
- اگر واقعاً خود entity لازم است و فقط میخوانیم، متد AsNoTracking را اضافه کنیم.
- خیلی از تیمها Lazy Loading را کلاً خاموش میکنند تا این مشکل پنهان نماند.
یک نکته مهم دیگر: تا وقتی کوئری از نوع IQueryable است، در دیتابیس (SQL) اجرا میشود. اگر وسط کار متد AsEnumerable یا ToList را بزنیم، فیلترها و Select های بعدی در حافظه اجرا میشوند. یعنی اول همه داده از دیتابیس میآید.
سؤال پیگیری: حالا فقط یک کوئری میرود، ولی هنوز کند است. بعد چه میکنی؟
جواب پیگیری:
- کوئری SQL واقعی را از لاگ برمیدارد.
- نقشه اجرای آن (execution plan) را نگاه میکند.
- مثلاً اگر برای مرتب کردن بر اساس تاریخ ساخت، کل جدول scan میشود، یک index روی ستون CreatedAt در جدول سفارشها میگذارد.
- قبل و بعد از تغییر، زمان را اندازه میگیرد.
نشانه خطر: هیچ وقت SQL ای که EF Core میسازد را نگاه نکرده است، یا نمیداند Lazy Loading کی کوئری میزند.
۴. کش کردن با Redis
Redis· در نقشه راهالگوهای Caching· در نقشه راه
سؤال: صفحه پروفایل کاربر در هر request از دیتابیس خوانده میشود و CPU دیتابیس بالا است. تصمیم گرفتیم پروفایل را در Redis کش کنیم. سه سؤال:
- خواندن و نوشتن کش را چطور طراحی میکنی؟
- وقتی کاربر پروفایلش را ویرایش میکند، چطور مطمئن میشوی داده قدیمی نمایش داده نشود؟
- اگر Redis از کار بیفتد، چه میشود؟
یک کلید دیگر هم داریم: «تنظیمات صفحه اول». این کلید در هر request صفحه اول خوانده میشود (حدود ۲۰۰۰ request در ثانیه) و TTL آن ۱۰ دقیقه است. این طراحی را چطور میبینی؟ در لحظه انقضای این کلید چه اتفاقی میافتد؟
جواب کوتاه:
- از الگوی Cache-Aside استفاده میکنیم و همیشه TTL میگذاریم.
- بعد از ویرایش، کلید کش را پاک میکنیم.
- اگر Redis از کار بیفتد، مستقیم از دیتابیس میخوانیم.
- اسم مشکل آخر Cache Stampede است: وقتی یک کلید پرطرفدار منقضی میشود، همه request ها همزمان به دیتابیس میروند.
سرنخ (اگر کاندید گفت مشکلی نیست): در مانیتورینگ میبینیم کلید «تنظیمات صفحه اول» هر ۱۰ دقیقه منقضی میشود. دقیقاً همان لحظه، CPU دیتابیس چند ثانیه به ۱۰۰٪ میرسد. حالا چرا؟
بخش ۱: الگوی Cache-Aside چطور کار میکند؟
- اول در Redis دنبال کلید میگردیم (مثلاً کلید پروفایل کاربر شماره ۴۲).
- اگر بود، همان را برمیگردانیم.
- اگر نبود، از دیتابیس میخوانیم. بعد آن را با TTL (مثلاً ۱۰ دقیقه) در Redis میگذاریم و برمیگردانیم.
چرا TTL لازم است؟ اگر جایی اشتباهی پیش بیاید و کش پاک نشود، TTL تضمین میکند داده قدیمی فقط چند دقیقه بماند، نه برای همیشه.
بخش ۲: وقتی داده عوض میشود
- اول در دیتابیس ذخیره میکنیم، بعد کلید کش را پاک میکنیم. request بعدی داده تازه را از دیتابیس میخواند و دوباره کش میکند.
- چرا پاک کردن و نه آپدیت کش؟ فرض کن دو ویرایش همزمان داریم:
- ممکن است دیتابیس با ترتیب A بعد B آپدیت شود.
- ولی کش با ترتیب B بعد A آپدیت شود.
- حالا کش تا پایان TTL داده اشتباه دارد.
- پاک کردن کش این مشکل را ندارد.
بخش ۳: اگر Redis از کار بیفتد
- کش باید «اختیاری» باشد. یعنی خطای Redis را میگیریم و مستقیم از دیتابیس میخوانیم.
- برای Redis یک timeout کوتاه میگذاریم. وگرنه هر request چند ثانیه منتظر Redis مرده میماند و کل سایت کند میشود.
- حواسمان باشد که دیتابیس باید بار بدون کش را هم تحمل کند، یا برای این حالت برنامه داشته باشیم.
بخش ۴: چرا CPU دیتابیس در لحظه انقضا بالا میرود؟ (Cache Stampede)
- تا وقتی کلید در کش هست، مثلاً ۲۰۰۰ request در ثانیه از Redis جواب میگیرند.
- لحظهای که کلید منقضی میشود، همه این request ها کش را خالی میبینند.
- پس همه با هم همان کوئری را به دیتابیس میفرستند، تا وقتی یکی از آنها دوباره کش را پر کند.
راهها:
- فقط یک request داده را بسازد و بقیه منتظر نتیجه همان بمانند (یک قفل برای هر کلید). در .NET، کتابخانه HybridCache با متد GetOrCreateAsync این کار را داخل هر سرور انجام میدهد.
- کمی قبل از انقضا، کش را در پسزمینه تازه کنیم.
- اگر کلیدهای زیادی با هم منقضی میشوند، به TTL کمی عدد تصادفی اضافه کنیم (jitter). اینطور همه در یک لحظه منقضی نمیشوند.
چه چیزی را کش نکنیم؟ دادهای که باید همیشه دقیق باشد (مثل موجودی حساب)، یا دادهای که خیلی زود عوض میشود.
سؤال پیگیری: کش محلی (In-Memory) و کش Redis چه فرقی دارند؟ اگر به جای Redis از کش محلی استفاده کنیم و ۵ سرور داشته باشیم، چه مشکلی پیش میآید؟
جواب پیگیری:
- کش محلی در حافظه همان سرور است. خیلی سریع است، چون شبکه ندارد. ولی هر سرور نسخه جدای خودش را دارد.
- مشکل: کاربر پروفایلش را ویرایش میکند. فقط سروری که این request را گرفته، کش خودش را پاک میکند. ۴ سرور دیگر تا پایان TTL داده قدیمی نشان میدهند. پس کاربر با هر refresh ممکن است داده قدیم یا جدید ببیند.
- راه اول: TTL کوتاه برای کش محلی.
- راه دوم: فرستادن پیام «این کلید را پاک کن» به همه سرورها (مثلاً با Redis Pub/Sub).
- راه سوم: ترکیب دو سطح، یعنی کش محلی جلوی Redis.
نشانه خطر: به پاک کردن کش بعد از تغییر داده و به از کار افتادن Redis هیچ فکری نکرده است.
۵. پیامرسانی با Kafka، پیام گمشده و پیام تکراری
Kafka· در نقشه راهIdempotency· در نقشه راهOutbox و Inbox· در نقشه راه
سؤال: بعد از ثبت سفارش، پیام «سفارش ثبت شد» (OrderCreated) را به Kafka میفرستیم. سرویس Notification این پیام را میخواند و به مشتری SMS میفرستد. کد ثبت سفارش این است:
db.Orders.Add(order);
await db.SaveChangesAsync();
await producer.ProduceAsync("orders", new Message<string, string> { Key = order.Id, Value = json });
سرویسها روی Kubernetes با چند pod اجرا میشوند و هر هفته چند بار deploy داریم. این کد و این طراحی را در code review میبینی. نظرت چیست؟ آیا مشتری همیشه دقیقاً یک SMS میگیرد؟ چرا؟
جواب کوتاه:
- مشکل ۱ (پیام گم میشود): نوشتن در دیتابیس و نوشتن در Kafka دو کار جدا هستند و تراکنش مشترک ندارند. راه حلش Outbox Pattern است.
- مشکل ۲ (پیام تکراری): Kafka پیام را «حداقل یک بار» تحویل میدهد، نه «دقیقاً یک بار». راه حلش این است که مصرفکننده Idempotent باشد.
سرنخ (اگر کاندید گفت مشکلی نیست): دو شکایت از مشتریها داریم. اول، بعضی مشتریها سفارش ثبت کردهاند ولی هیچ SMS ای نگرفتهاند. سفارش در دیتابیس هست، ولی پیامش در topic نیست. دوم، بعضی مشتریها برای یک سفارش دو SMS گرفتهاند. حالا چرا؟
مشکل ۱: چرا پیام گم میشود؟ (Dual Write)
- ذخیره در دیتابیس موفق میشود و سفارش ثبت میشود.
- دقیقاً بعد از آن، pod ریاستارت میشود (مثلاً به خاطر deploy)، یا Kafka چند ثانیه در دسترس نیست.
- فرستادن پیام به Kafka اجرا نمیشود یا خطا میدهد. سفارش هست، پیام نیست، و هیچکس هم متوجه نمیشود.
- اگر ترتیب را عوض کنیم (اول Kafka، بعد دیتابیس)، مشکل برعکس میشود: پیامِ سفارشی میرود که در دیتابیس نیست.
راه حل: Outbox Pattern
- در همان تراکنش دیتابیس، هم سفارش را ذخیره میکنیم و هم یک ردیف در جدول Outbox (متن پیام). دیتابیس تضمین میکند یا هر دو ذخیره میشوند، یا هیچکدام.
- یک BackgroundService ردیفهای ارسالنشده Outbox را میخواند و به Kafka میفرستد. بعد روی آنها علامت «ارسال شد» میزند.
- اگر Kafka چند دقیقه هم قطع باشد، پیامها در Outbox میمانند و بعداً ارسال میشوند. پس هیچ پیامی گم نمیشود.
نکته: اگر worker پیام را بفرستد و قبل از علامت زدن crash کند، دفعه بعد دوباره همان پیام را میفرستد. پس Outbox هم پیام تکراری میسازد. این ما را به مشکل ۲ میرساند.
راه دیگر به جای Outbox، ابزار CDC است (مثل Debezium) که تغییرات دیتابیس را میخواند.
مشکل ۲: چرا دو SMS فرستاده میشود؟
- سرویس Notification پیام را میخواند و SMS را میفرستد.
- قبل از اینکه به Kafka بگوید «این پیام تمام شد» (یعنی commit کردن offset)، pod میمیرد یا rebalance اتفاق میافتد.
- پس Kafka فکر میکند پیام پردازش نشده و آن را دوباره میدهد. SMS دوم فرستاده میشود.
- این رفتار اشکال Kafka نیست. تحویل در Kafka از نوع at-least-once است. پس باید همیشه انتظار پیام تکراری را داشته باشیم.
راه حل: مصرفکننده Idempotent
منظور از Idempotent این است: اگر یک پیام دو بار پردازش شود، نتیجه مثل همان یک بار باشد.
- هر پیام یک شناسه یکتا دارد (مثلاً MessageId یا OrderId).
- قبل از فرستادن SMS، این شناسه را در جدول ProcessedMessages ثبت میکنیم. این جدول یک unique constraint روی شناسه دارد.
- اگر ثبت با خطای unique شکست خورد، یعنی پیام قبلاً پردازش شده است. پس SMS را نمیفرستیم.
- مقدار offset را بعد از پردازش موفق commit میکنیم، نه قبل از آن. چون اگر قبل از پردازش commit کنیم و بعد crash کنیم، پیام گم میشود.
سؤال پیگیری: اگر ترتیب پیامهای یک سفارش مهم باشد (مثلاً «ثبت» قبل از «لغو»)، Kafka چطور ترتیب را حفظ میکند؟
جواب پیگیری: کافکا ترتیب را فقط داخل یک partition تضمین میکند. اگر Key پیام را شناسه سفارش بگذاریم، همه پیامهای یک سفارش به یک partition میروند. پس به همان ترتیب هم خوانده میشوند.
نشانه خطر: میگوید «Kafka خودش exactly-once است و پیام تکراری نداریم»، یا راه حلش برای مشکل ۱ فقط «try/catch و retry» است.
۶. رفع مشکل کندی در Production
Distributed Tracing· در نقشه راهLogging ساختاریافته· در نقشه راه
سؤال: ساعت ۱۰ صبح نسخه جدید API را deploy کردیم. از همان موقع، داشبورد مانیتورینگ نشان میدهد زمان پاسخ API از حدود ۱۰۰ میلیثانیه به ۳ ثانیه رسیده است. اما CPU سرورها فقط ۲۰٪ است، حافظه عادی است، و در لاگ هم خطای خاصی نیست. قدم به قدم چه میکنی؟
جواب کوتاه: اول کاربر را نجات میدهیم (rollback). بعد با داده، و بدون حدس، علت را پیدا میکنیم. سرنخ اصلی این است: «CPU کم ولی کند» یعنی برنامه مشغول محاسبه نیست، بلکه منتظر چیزی است.
قدمها و دلیل هر کدام:
- برگرداندن نسخه (Rollback). مشکل دقیقاً با deploy شروع شده است. پس سریعترین راه برای کاربر، برگشت به نسخه قبل است. پیدا کردن علت بعداً هم ممکن است.
- دیدن اینکه چه چیزی عوض شده. تفاوت (diff) نسخه جدید را نگاه کن: کوئری جدید، فراخوانی سرویس جدید، کتابخانه جدید، یا تغییر تنظیمات.
- پیدا کردن جایی که زمان صرف میشود، با tracing. ابزار distributed tracing (مثل OpenTelemetry) نشان میدهد از این ۳ ثانیه، چقدر در دیتابیس است، چقدر در سرویس دیگر، و چقدر داخل خود برنامه.
- دیدن اینکه برنامه منتظر چه چیزی است. چهار کاندید اصلی داریم:
- گرسنگی thread pool (مثل سؤال ۲): در ابزار dotnet-counters، طول صف thread pool بالا است و تعداد thread ها آهسته زیاد میشود.
- کوئری کند یا قفل در دیتابیس: در خود دیتابیس، لیست کوئریهای در حال اجرا و قفلها را نگاه کن.
- سرویس بیرونی کند: در tracing میبینیم که فراخوانی HTTP به آن سرویس بیشتر وقت را گرفته است.
- تمام شدن connection pool دیتابیس یا HttpClient: request ها منتظر یک connection آزاد میمانند. مثلاً وقتی کد جدید connection را dispose نمیکند.
- گرفتن dump، اگر هنوز علت معلوم نیست. با ابزار dotnet-dump از process یک dump بگیر. بعد ببین همه thread ها روی کدام خط کد منتظرند. اگر صدها thread روی Result یا روی یک lock منتظرند، علت پیدا شده است.
- بعد از پیدا کردن علت: اصلاح، تست بار، deploy دوباره، و یک گزارش کوتاه (postmortem). هدف این است که دفعه بعد زودتر بفهمیم (مثلاً با هشدار روی latency).
سؤال پیگیری: از کجا میفهمی مشکل از GC (جمعآوری حافظه) است؟
جواب پیگیری: در ابزار dotnet-counters دو عدد را نگاه میکند:
- درصد زمان GC. اگر خیلی بالا باشد، برنامه مدام برای GC مکث میکند.
- تعداد GC نسل ۲. این گرانترین نوع GC است.
اگر حافظه بعد از هر GC هم مدام بیشتر شود، احتمالاً نشت حافظه داریم (سؤال ۳۵). البته در این سناریو CPU کم است، پس احتمال GC کمتر است. چون GC سنگین معمولاً CPU را بالا میبرد.
نشانه خطر: اولین جوابش «سرور بیشتر اضافه میکنیم» است، یا بدون داده و با حدس شروع به تغییر کد میکند.
۷. انتخاب بین Monolith و Microservices
سبکهای معماری· در نقشه راهMicroservices· در نقشه راه
سؤال: یک تیم ۴ نفره میخواهد یک فروشگاه آنلاین جدید بسازد. یکی از اعضای تیم پیشنهاد میدهد از اول ۱۰ میکروسرویس جدا بسازند (کاربر، محصول، انبار، سفارش، پرداخت، ارسال و …). هر کدام دیتابیس خودش را دارد. نظرت چیست؟ کی سیستم را به میکروسرویس تقسیم میکنی و کی نه؟
جواب کوتاه: برای تیم کوچک و محصول جدید، معمولاً Modular Monolith بهتر است. دلیلش ساده است:
- میکروسرویس یک مشکل سازمانی را حل میکند: چند تیم که میخواهند مستقل کار کنند.
- در عوض، هزینه فنی زیادی دارد.
- تیم ۴ نفره این مشکل سازمانی را ندارد. پس فقط هزینه را میپردازد و سودی نمیبرد.
هزینه میکروسرویس با یک مثال واقعی: ثبت سفارش باید موجودی انبار را هم کم کند.
- در Monolith: هر دو کار در یک تراکنش دیتابیس انجام میشود. یا هر دو موفق میشوند، یا هیچکدام. فقط چند خط کد است.
- در میکروسرویس: سفارش و انبار دو سرویس و دو دیتابیس جدا هستند. تراکنش مشترک نداریم. اگر سفارش ثبت شود و بعد صدا زدن سرویس انبار خطا بدهد، داده ناهماهنگ میشود. پس باید پیامرسانی، Outbox، Saga و عملیات جبرانی بسازیم (سؤال ۵ و ۲۵).
هزینههای دیگر میکروسرویس:
- هر صدا زدن از شبکه میگذرد. پس کندتر است و ممکن است شکست بخورد.
- برای پیدا کردن یک باگ باید لاگ ۱۰ سرویس را دید. پس tracing لازم است.
- به جای یک pipeline و deploy، ۱۰ تا داریم.
- کار DevOps خیلی بیشتر میشود.
منظور از Modular Monolith چیست؟
- یک برنامه و یک deploy داریم. ولی کد به ماژولهای جدا تقسیم شده است (مثلاً هر ماژول یک project).
- هر ماژول جدولهای خودش را دارد. ماژول دیگر حق ندارد مستقیم به این جدولها دست بزند. فقط از راه یک interface عمومی با آن حرف میزند.
- مزیت اول: امروز سیستم ساده است.
- مزیت دوم: اگر روزی یک ماژول واقعاً لازم شد جدا شود، مرزش از قبل روشن است. پس جدا کردنش آسان است.
کی یک سرویس را جدا کنیم؟ فقط وقتی یک دلیل واقعی داریم:
- چند تیم جدا داریم که نمیخواهند برای deploy منتظر هم بمانند.
- یک بخش بار خیلی متفاوتی دارد و باید جدا scale شود. مثلاً جستجوی محصول ۱۰۰ برابر بقیه بار دارد.
- یک بخش نیاز فنی متفاوتی دارد. مثلاً تکنولوژی دیگری لازم دارد، یا باید حتی وقتی بقیه خراباند کار کند.
مرز سرویسها از کجا میآید؟ از Bounded Context در DDD، نه از جدولهای دیتابیس. مثال:
- در بخش کاتالوگ، «محصول» یعنی نام، عکس و توضیحات.
- در بخش انبار، «محصول» یعنی تعداد و قفسه.
- پس اینها دو مدل جدا با دو مالک جدا هستند.
اشتباه رایج: دیتابیس مشترک بین سرویسها.
- فرض کن دو سرویس از یک جدول استفاده میکنند.
- اگر یک ستون عوض شود، هر دو خراب میشوند.
- پس باید هر دو را با هم deploy کنیم.
- نتیجه: همه هزینههای میکروسرویس را داریم و هیچ مزیتش را نداریم. به این Distributed Monolith میگویند.
سؤال پیگیری: وقتی دو سرویس با هم حرف میزنند، کی ارتباط sync (مثل HTTP یا gRPC) و کی ارتباط async (پیام با Kafka یا RabbitMQ) را انتخاب میکنی؟
جواب پیگیری:
- ارتباط sync وقتی است که جواب را همین الان لازم داریم. مثال: صفحه پرداخت باید قیمت نهایی را همین الان بداند.
- ارتباط async وقتی است که کار میتواند چند ثانیه بعد انجام شود. مثال: فرستادن ایمیل بعد از سفارش. اگر سرویس ایمیل چند دقیقه قطع باشد، ثبت سفارش نباید خراب شود.
- چرا زنجیره طولانی sync بد است؟
- فرض کن هر سرویس ۹۹.۹٪ وقت سالم است.
- یک request از ۵ سرویس پشت سر هم میگذرد.
- احتمالها در هم ضرب میشوند. پس احتمال موفقیت کل تقریباً ۹۹.۵٪ میشود.
- هرچه زنجیره بلندتر باشد، سیستم شکنندهتر است.
نشانه خطر: میکروسرویس را همیشه بهتر میداند و نمیتواند هیچ هزینه مشخصی برایش نام ببرد.
۸. طراحی سیستم: فروش ویژه (Flash Sale)
طراحی سیستم· در نقشه راهRedis· در نقشه راه
سؤال: یک فروشگاه آنلاین ساعت ۱۲ ظهر ۱۰۰۰ عدد گوشی با تخفیف میفروشد. در دقیقه اول حدود ۲۰۰ هزار کاربر وارد میشوند و بارها روی دکمه خرید میزنند. سیستم ثبت سفارش را طراحی کن. سه شرط داریم:
- بیشتر از ۱۰۰۰ عدد فروخته نشود.
- سیستم نخوابد.
- هر کاربر فقط یک عدد بخرد.
به کاندید بگویید روی وایتبرد بکشد. اگر قبل از طراحی سؤال بپرسد (مثلاً «پرداخت هم در همین جریان است؟»)، این خودش امتیاز مثبت است.
جواب کوتاه: سه ایده اصلی داریم:
- بیشتر کاربرها (۱۹۹ هزار نفر) به هر حال چیزی نمیخرند. پس باید درخواست آنها را ارزان و زود رد کنیم.
- مهمترین بخش، کم کردن موجودی با یک عملیات اتمی است. اینطور دو نفر آخرین گوشی را با هم نمیخرند.
- کارهای سنگین (ساخت سفارش، فاکتور، ایمیل) با صف و بعداً انجام میشوند.
اول مشکل اصلی را ببینیم (فروش اضافه). این کد اشتباه است:
var stock = await db.Products.Where(p => p.Id == id).Select(p => p.Stock).FirstAsync();
if (stock > 0)
{
await db.Database.ExecuteSqlAsync($"UPDATE Products SET Stock = Stock - 1 WHERE Id = {id}");
}
چرا اشتباه است؟ قدم به قدم:
- فرض کن فقط یک گوشی مانده.
- دو request در یک لحظه میرسند.
- هر دو موجودی را میخوانند و عدد ۱ را میبینند.
- هر دو شرط را درست میبینند و هر دو موجودی را کم میکنند.
- موجودی ۱- میشود و یک گوشی اضافه فروخته میشود.
به این Race Condition میگویند. بین «خواندن» و «نوشتن» فاصله هست و یک request دیگر وسط این فاصله میآید.
طراحی خوب (قدم به قدم):
- نیازها را روشن کن. پرداخت در همین جریان است؟ کاربر چقدر وقت برای پرداخت دارد؟
- بار را قبل از رسیدن به سرور کم کن. صفحه محصول و عکسها روی CDN باشند. برای هر کاربر و هر IP محدودیت تعداد درخواست (Rate Limiting) بگذار. چرا؟ چون بیشتر درخواستها refresh و کلیک تکراری هستند و نباید به دیتابیس برسند.
- موجودی را اتمی کم کن (مهمترین بخش). دو راه داریم:
- راه اول با Redis: موجودی در Redis نگه داشته میشود و با دستور DECR کم میشود. Redis دستورها را یکییکی اجرا میکند. پس دو دستور DECR هرگز وسط هم اجرا نمیشوند. اگر عدد منفی شد، جواب میدهیم «تمام شد».
- راه دوم با دیتابیس: چک کردن موجودی و کم کردن آن در یک دستور UPDATE انجام میشود. شرط «موجودی بزرگتر از صفر» داخل همان دستور است (کد پایین). دیتابیس این یک دستور را اتمی اجرا میکند. اگر تعداد ردیفهای تغییرکرده صفر بود، یعنی موجودی تمام شده.
UPDATE Products SET Stock = Stock - 1
WHERE Id = @id AND Stock > 0;
- یک خرید برای هر کاربر. یک unique constraint روی ترکیب کاربر و فروش ویژه بگذار. یا در Redis یک کلید برای هر کاربر بساز که فقط اگر وجود نداشت ساخته شود (دستور SET با گزینه NX). نکته مهم: چک کاربر و کم کردن موجودی باید با هم انجام شوند (با یک Lua script در Redis یا یک تراکنش دیتابیس). وگرنه ممکن است موجودی کم شود، ولی خرید به خاطر تکراری بودن کاربر رد شود. در این حالت یک گوشی گم میشود.
- رزرو را از کار سنگین جدا کن. بعد از رزرو موفق، فقط یک پیام به صف (Kafka یا RabbitMQ) بفرست و به کاربر بگو «رزرو شد». سرویسهای دیگر سفارش، فاکتور و ایمیل را با سرعت خودشان میسازند. چرا؟ رزرو در Redis چند میلیثانیه طول میکشد، ولی ساخت سفارش کامل کندتر است. صف اوج بار را صاف میکند.
- پرداخت با مهلت. رزرو مثلاً ۱۰ دقیقه معتبر است. اگر پرداخت نشد، گوشی به موجودی برمیگردد.
- درخواست تکراری را مدیریت کن. کاربر ممکن است دو بار کلیک کند. کلاینت یک Idempotency Key میفرستد. سرور برای کلید تکراری همان جواب قبلی را برمیگرداند (سؤال ۲۱).
- مانیتورینگ و تست. قبل از روز فروش تست بار انجام بده. یک داشبورد برای موجودی و خطاها داشته باش. یک کلید هم برای خاموش کردن سریع فروش بگذار (feature flag).
سؤال پیگیری ۱: اگر Redis وسط فروش ریاستارت شود، موجودی چه میشود؟
جواب: اگر Redis بدون persistence باشد، عدد موجودی گم میشود. دو راه داریم:
- منبع اصلی حقیقت دیتابیس باشد. هر رزرو در دیتابیس هم ثبت شود. اینطور میشود موجودی را دوباره حساب کرد (۱۰۰۰ منهای تعداد رزروها).
- یا Redis را با persistence و replica راه بیندازیم.
سؤال پیگیری ۲: اگر ترتیب «اول آمده، اول خریده» باید دقیق باشد، چه میکنی؟
جواب: یک صف ورودی میسازیم (virtual waiting room). هر کاربر شماره نوبت میگیرد و به ترتیب به صفحه خرید راه داده میشود.
نشانه خطر: موجودی را با دو دستور جدا چک میکند (اول خواندن، بعد کم کردن)، یا تنها جوابش «سرور و pod بیشتر» است.
۹. تغییر همزمان یک رکورد: Lost Update
Transaction و Isolation Level· در نقشه راه
سؤال: در پنل پشتیبانی، چند کارمند با هم کار میکنند. صفحه ویرایش سفارش هنگام ذخیره، کل اطلاعات سفارش را با یک درخواست PUT میفرستد. سرور هم همه فیلدها را در دیتابیس مینویسد. فرض کن دو کارمند همزمان صفحه یک سفارش را باز میکنند. اولی آدرس را عوض میکند و ذخیره میکند. دومی چند ثانیه بعد وضعیت را به «ارسال شد» عوض میکند و ذخیره میکند. این طراحی را چطور میبینی؟ آخر کار چه دادهای در دیتابیس میماند؟ آیا مشکلی دارد؟
جواب کوتاه: فرم کارمند دوم هنوز آدرس قدیمی را داشت. درخواست PUT همه فیلدها را میفرستد. پس آدرس قدیمی روی آدرس جدید نوشته شد. راه حل Optimistic Concurrency است: هر رکورد یک شماره نسخه دارد. اگر نسخه عوض شده باشد، ذخیره رد میشود.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production این را دیدهایم: کارمند اول آدرس را عوض کرده و پیام «ذخیره شد» گرفته است. ولی بعد از ذخیره کارمند دوم، آدرس جدید از بین رفته است. بسته به آدرس قدیمی ارسال شده است. هیچ خطایی هم در لاگ نیست.
چه اتفاقی افتاد؟ (به ترتیب زمان)
| زمان | کارمند A | کارمند B | داده در دیتابیس |
|---|---|---|---|
| ۱ | فرم را باز میکند | آدرس: تهران، وضعیت: جدید | |
| ۲ | فرم را باز میکند (آدرس تهران) | آدرس: تهران، وضعیت: جدید | |
| ۳ | آدرس را تبریز میکند و ذخیره | آدرس: تبریز، وضعیت: جدید | |
| ۴ | وضعیت را «ارسال» میکند و ذخیره (فرم هنوز آدرس تهران دارد) | آدرس: تهران، وضعیت: ارسال |
آخرین نوشتن برنده شد (last write wins). تغییر A بیصدا گم شد.
چرا «تراکنش میگذاریم» کمکی نمیکند؟
- اینها دو request جدا هستند.
- بین باز کردن فرم و ذخیره، چند دقیقه فاصله است.
- هر ذخیره به تنهایی موفق و درست است.
- مشکل این است که B با داده کهنه ذخیره میکند.
راه حل: Optimistic Concurrency با EF Core
- یک ستون نسخه به جدول اضافه میکنیم. با هر تغییر ردیف، SQL Server مقدار آن را خودکار عوض میکند:
public class Order
{
public int Id { get; set; }
public string Address { get; set; } = "";
[Timestamp] public byte[] RowVersion { get; set; } = [];
}
- فرم هنگام باز شدن، مقدار نسخه (RowVersion) را هم میگیرد. هنگام ذخیره آن را پس میفرستد.
- هنگام ذخیره، EF Core یک شرط به دستور UPDATE اضافه میکند: «فقط اگر نسخه هنوز همان نسخه قدیمی است».
- در مثال بالا، ذخیره A نسخه را عوض کرد. پس شرط برای B درست نیست. هیچ ردیفی آپدیت نمیشود و EF Core خطای DbUpdateConcurrencyException میدهد.
- در این حالت API پاسخ 409 Conflict برمیگرداند. فرم به B میگوید: «این سفارش را شخص دیگری تغییر داده، لطفاً دوباره باز کنید.» اینکه بعد چه کنیم (بارگذاری دوباره یا ترکیب تغییرها) تصمیم business است.
راههای دیگر و محدودیتشان:
- قفل بدبینانه (Pessimistic Lock): وقتی فرم باز است، رکورد را قفل کنیم. برای فرمی که کاربر چند دقیقه باز نگه میدارد مناسب نیست. مثلاً اگر کاربر مرورگر را ببندد، قفل چه میشود؟
- فرستادن فقط فیلدهای تغییرکرده (PATCH): در این مثال مشکل را حل میکند. ولی اگر هر دو نفر یک فیلد را عوض کنند، هنوز یکی گم میشود.
سؤال پیگیری: در یک REST API، سرور و کلاینت با چه روش استانداردی میفهمند دادهای که کلاینت دارد کهنه است؟
جواب پیگیری: با هدر ETag. مراحل:
- سرور در پاسخ GET، نسخه را در هدر ETag میفرستد.
- کلاینت هنگام PUT، همان مقدار را در هدر If-Match پس میفرستد.
- اگر نسخه عوض شده باشد، سرور پاسخ 412 Precondition Failed برمیگرداند.
نشانه خطر: میگوید «تراکنش میگذاریم» یا «lock میگذاریم» و متوجه نیست که اینها دو request جدا با فاصله زمانی هستند.
۱۰. صفحهبندی روی جدول بزرگ
سؤال: جدول سفارشها ۵۰ میلیون رکورد دارد. API لیست سفارشها با روش OFFSET و FETCH صفحهبندی میکند (در EF Core همان Skip و Take). هر صفحه ۲۰ رکورد دارد و جدیدترین سفارشها اول هستند. در هر ثانیه چند سفارش جدید ثبت میشود. کد این است:
var page = await db.Orders
.OrderByDescending(o => o.CreatedAt)
.Skip((pageNumber - 1) * 20)
.Take(20)
.ToListAsync(ct);
این روش صفحهبندی را چطور میبینی؟ با این حجم داده و این تعداد سفارش جدید، در Production چه اتفاقی میافتد؟
جواب کوتاه: برای OFFSET، دیتابیس باید همه رکوردهای قبل از آن صفحه را بخواند و دور بریزد. تکرار هم به این دلیل است که بین دو درخواست، سفارش جدید اضافه شده و همه رکوردها یک خانه جابجا شدهاند. راه حل Keyset Pagination (یا Cursor Pagination) است.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production دو چیز دیدهایم. اول، صفحههای اول سریعاند، ولی صفحه ۵۰۰۰۰ چند ثانیه طول میکشد. دوم، کاربرها میگویند وقتی صفحه بعد را باز میکنند، گاهی آخرین سفارش صفحه قبل دوباره اول صفحه جدید دیده میشود.
مشکل ۱: چرا صفحههای آخر کند هستند؟
- صفحه ۵۰۰۰۰ یعنی «۹۹۹۹۸۰ ردیف را رد کن، بعد ۲۰ ردیف بده».
- دیتابیس نمیتواند مستقیم به ردیف ۹۹۹۹۸۰ بپرد. باید از اول index شروع کند.
- حدود یک میلیون ردیف را میشمارد و دور میریزد. بعد ۲۰ ردیف برمیگرداند.
- پس هرچه شماره صفحه بزرگتر باشد، کار بیشتر و کندتر است.
مشکل ۲: چرا رکورد تکراری دیده میشود؟
- کاربر صفحه ۱ را میبیند: سفارشهای ۱ تا ۲۰.
- همان موقع یک سفارش جدید ثبت میشود و بالای لیست مینشیند. حالا همه سفارشها یک خانه پایین رفتهاند.
- کاربر صفحه ۲ را میخواهد (۲۰ ردیف را رد کن). ردیف ۲۱ الان همان سفارش ۲۰ قبلی است. پس دوباره دیده میشود.
- برعکس، اگر رکوردی حذف شود، یک رکورد اصلاً دیده نمیشود.
راه حل: Keyset Pagination
به جای «صفحه چندم؟» میگوییم «بعد از آخرین رکوردی که دیدم، ۲۰ تای بعدی را بده»:
var page = await db.Orders
.Where(o => o.CreatedAt < lastCreatedAt
|| (o.CreatedAt == lastCreatedAt && o.Id < lastId))
.OrderByDescending(o => o.CreatedAt).ThenByDescending(o => o.Id)
.Take(20)
.ToListAsync(ct);
چرا این روش بهتر است؟
- با یک index روی زمان ثبت و شناسه سفارش، دیتابیس مستقیم به جای درست در index میپرد (seek). فقط ۲۰ ردیف میخواند. پس سرعت صفحه ۱ و صفحه ۵۰۰۰۰ یکی است.
- سفارش جدید بالای لیست، نقطه شروع ما را جابجا نمیکند. پس تکرار پیش نمیآید.
- چرا شناسه سفارش هم لازم است؟ چون دو سفارش ممکن است زمان ثبت یکسان داشته باشند. بدون شناسه، ترتیب یکتا نیست و باز هم ممکن است رکوردی جا بیفتد یا تکرار شود.
- به کلاینت یک «cursor» میدهیم. مثلاً همان دو مقدار (زمان و شناسه) به شکل یک رشته base64. کلاینت آن را در درخواست بعدی پس میفرستد.
نکته دیگر: شمردن کل ردیفها (برای نمایش «صفحه ۱ از ۲.۵ میلیون») هم روی جدول بزرگ گران است. معمولاً یک عدد تقریبی کافی است.
سؤال پیگیری: با روش تو، کاربر میتواند مستقیم به صفحه ۴۷ برود؟ این را چطور به Product Owner توضیح میدهی؟
جواب پیگیری: نه. روش Keyset فقط «صفحه بعد» و «صفحه قبل» دارد. برای scroll بیپایان و API عالی است. به Product Owner میگوییم: «کسی صفحه ۴۷ را به خاطر شمارهاش نمیخواهد. دنبال سفارش خاصی است. بهتر است فیلتر تاریخ و جستجو بدهیم.» اگر شماره صفحه واقعاً لازم است، OFFSET را فقط برای چند صفحه اول نگه داریم (مثلاً حداکثر ۱۰۰ صفحه).
نشانه خطر: فقط میگوید «index میگذاریم» و نمیداند چرا OFFSET با index هم کند است.
۱۱. خاموش شدن امن در Kubernetes
Kubernetes· در نقشه راهBackground Service· در نقشه راه
سؤال: سرویس ما در Kubernetes اجرا میشود. هم به request های HTTP جواب میدهد، هم از Kafka پیام میخواند. ترافیک زیاد است و چند بار در روز با rolling update دیپلوی میکنیم. برای خاموش شدن برنامه هیچ تنظیم خاصی نداریم. همه چیز روی تنظیمات پیشفرض Kubernetes و ASP.NET Core است. هنگام deploy، وقتی یک pod قدیمی خاموش میشود، چه اتفاقی برای request ها و پیامهای در حال پردازش میافتد؟ خاموش شدن امن (graceful shutdown) را چطور طراحی میکنی؟
جواب کوتاه: وقتی Kubernetes یک pod را خاموش میکند، برنامه باید کارهای نیمهکاره را تمام کند و بعد بسته شود. مشکل اصلی این است:
- در یک لحظه به برنامه میگوید «خاموش شو».
- همزمان به load balancer میگوید «دیگر به این pod ترافیک نده».
- ولی دومی چند ثانیه دیرتر اعمال میشود.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production این را دیدهایم: هر بار که deploy میکنیم، در چند ثانیه اول تعدادی از کاربرها خطای 502 میگیرند. بعضی پیامهای Kafka هم دو بار پردازش میشوند.
اول ببینیم Kubernetes یک pod را چطور خاموش میکند:
- به process سیگنال SIGTERM میفرستد. معنیاش این است: «لطفاً کارهایت را تمام کن و بسته شو.»
- همزمان، pod را از لیست Endpoints سرویس بیرون میکند. این تغییر چند ثانیه طول میکشد تا به kube-proxy و ingress برسد.
- یک مهلت دارد به نام termination grace period (پیشفرض ۳۰ ثانیه). اگر بعد از این مهلت برنامه هنوز بسته نشده باشد، SIGKILL میفرستد و process فوراً کشته میشود.
چرا خطای 502 میبینیم؟
- مرحله ۱ و ۲ همزمان شروع میشوند.
- برنامه با SIGTERM شروع به بسته شدن میکند.
- ولی load balancer هنوز چند ثانیه request جدید به این pod میفرستد.
- این request ها به برنامهای میرسند که دیگر request قبول نمیکند.
چرا پیام تکراری میبینیم؟ پیامی که وسط پردازش بوده تمام نشده، یا offset آن commit نشده، و process کشته شده. پس pod دیگری همان پیام را دوباره میگیرد.
راه حل (قدم به قدم):
- چند ثانیه صبر قبل از بسته شدن. یک preStop hook میگذاریم. Kubernetes اول این را اجرا میکند، بعد SIGTERM میفرستد. در این چند ثانیه load balancer فرصت دارد pod را از لیست بیرون کند:
lifecycle:
preStop:
sleep:
seconds: 5
- تمام کردن request های جاری. با SIGTERM، برنامه ASP.NET Core دیگر request جدید نمیگیرد و منتظر request های در حال اجرا میماند. حداکثر این صبر با تنظیم ShutdownTimeout در HostOptions مشخص میشود.
- حساب زمانها. زمان preStop بهعلاوه ShutdownTimeout باید کمتر از termination grace period باشد. مثلاً ۵ + ۳۰ = ۳۵، پس grace period را ۴۵ ثانیه میگذاریم. وگرنه SIGKILL وسط کار میرسد.
- کارهای پسزمینه. در BackgroundService، توکن توقف (stoppingToken) را به همه کارها پاس میدهیم. اینطور با SIGTERM دیگر کار جدیدی شروع نمیشود.
- مصرفکننده Kafka. اول پیام جاری تمام شود. بعد offset آن commit شود. بعد consumer با متد Close بسته شود. اینطور Kafka سریع partition ها را به pod دیگر میدهد.
حتی با همه اینها، pod گاهی ناگهانی میمیرد (مثلاً OOM یا خرابی node). پس پردازش پیام باید idempotent باشد (سؤال ۵).
سؤال پیگیری: فرق readiness probe و liveness probe چیست؟ اگر liveness probe دیتابیس را هم چک کند، چه مشکلی پیش میآید؟
جواب پیگیری:
- پروب readiness میپرسد: «آیا الان میتوانم ترافیک بگیرم؟» اگر جواب منفی باشد، فقط ترافیک به این pod نمیرود.
- پروب liveness میپرسد: «آیا برنامه زنده است یا گیر کرده؟» اگر جواب منفی باشد، Kubernetes این pod را ریاستارت میکند.
- حالا فرض کن liveness دیتابیس را چک کند و دیتابیس چند ثانیه کند شود. در این حالت همه pod ها با هم ریاستارت میشوند. یک مشکل کوچک دیتابیس به خاموشی کل سرویس تبدیل میشود.
- پس پروب liveness باید فقط خود process را چک کند.
نشانه خطر: نمیداند SIGTERM چیست، یا فکر میکند Kubernetes خودش صبر میکند تا همه کارها تمام شود.
۱۲. کد پرفشار (Hot Path) و حافظه
Performance و Span· در نقشه راهحافظه و Garbage Collector· در نقشه راه
سؤال: یک سرویس پیامهای قیمت را به شکل متن میگیرد و parse میکند. هر پیام سه بخش دارد که با ویرگول جدا شدهاند: نماد، قیمت و حجم (مثلاً AAPL و 123.45 و 100). این کد در هر ثانیه ۵۰ هزار بار اجرا میشود:
var parts = line.Split(',');
var quote = new Quote(parts[0], decimal.Parse(parts[1]), int.Parse(parts[2]));
این کد را در code review میبینی. نظرت چیست؟ با این بار در Production چه اتفاقی میافتد؟ آیا چیزی را در آن عوض میکنی؟
جواب کوتاه: قدم به قدم:
- هر بار اجرای این کد چند شیء جدید روی heap میسازد (یک آرایه و سه string).
- ۵۰ هزار بار در ثانیه یعنی صدها هزار شیء در ثانیه که فوراً زباله میشوند.
- سیستم GC باید مدام آنها را جمع کند. بعضی از GC ها thread ها را متوقف میکنند.
- راه حل: کم کردن allocation با ابزارهایی مثل Span.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production این را دیدهایم: زمان پردازش بیشتر وقتها خوب است. ولی هر چند ثانیه یک بار، به مدت چندصد میلیثانیه همه چیز میایستد. ابزار dotnet-counters هم نشان میدهد تعداد GC خیلی زیاد است.
چرا allocation زیاد باعث ایستادن میشود؟ (قدم به قدم)
- هر بار ساختن شیء جدید (و هر بار صدا زدن متد Split یا Substring) یک شیء روی heap میسازد.
- شیء جدید در نسل ۰ (Gen0) قرار میگیرد. نسل ۰ کوچک است و با این سرعت خیلی زود پر میشود.
- وقتی پر شد، GC اجرا میشود. GC نسل ۰ سریع است. ولی اگر خیلی زیاد اجرا شود، جمع زمانش قابل توجه میشود.
- اشیایی که هنگام GC هنوز زندهاند به نسل ۱ و ۲ میروند. وقتی نسل ۲ پر شود، یک GC کامل و گران اجرا میشود. این GC ممکن است چندصد میلیثانیه طول بکشد. همان «ایستادنهایی» که دیدیم.
- پس هرچه زباله کمتر بسازیم، GC کمتر اجرا میشود.
راه حل:
- اول اندازه بگیر. با ابزار dotnet-trace یا PerfView پیدا کن بیشترین allocation کجاست. با BenchmarkDotNet و ویژگی MemoryDiagnoser، قبل و بعد را مقایسه کن.
- بدون ساختن string و آرایه parse کن. برای این کار از ReadOnlySpan استفاده کن. یک Span فقط یک «پنجره» روی همان string اصلی است و چیزی کپی نمیکند:
ReadOnlySpan<char> s = line;
int i = s.IndexOf(',');
var symbol = s[..i];
s = s[(i + 1)..];
int j = s.IndexOf(',');
var price = decimal.Parse(s[..j], CultureInfo.InvariantCulture);
var volume = int.Parse(s[(j + 1)..]);
- منابع رایج دیگر allocation را هم نگاه کن:
- چسباندن string در حلقه.
- استفاده از LINQ و lambda در کد پرفشار.
- تبدیل struct به object (boxing).
- ساختن List بدون ظرفیت اولیه.
- بافرها را دوباره استفاده کن. با ArrayPool یک آرایه قرض بگیر (Rent) و بعد از کار پس بده (Return). این کار مخصوصاً برای آرایههای بزرگتر از حدود ۸۵ کیلوبایت مهم است. چون این آرایهها مستقیم به Large Object Heap میروند و جمع کردنشان گران است.
- تنظیمات GC را آخر بررسی کن. تنظیماتی مثل Server GC را بعد از اصلاح کد نگاه کن، نه قبل از آن.
سؤال پیگیری: کی ValueTask را به جای Task استفاده میکنی؟ با ValueTask چه کارهایی نباید کرد؟
جواب پیگیری:
- نوع Task یک class است. معمولاً با هر بار صدا زدن یک متد async، یک شیء روی heap ساخته میشود.
- گاهی متد بیشتر وقتها بدون انتظار تمام میشود (مثلاً ۹۹٪ وقتها از کش جواب میدهد) و در کد پرفشار است. در این حالت ValueTask این allocation را حذف میکند.
- محدودیتهای ValueTask:
- فقط یک بار await شود.
- از دو جا همزمان await نشود.
- قبل از تمام شدن کار، نتیجهاش (Result) خوانده نشود.
- اگر هر کدام از اینها لازم شد، اول آن را با متد AsTask به Task تبدیل کن.
- پیشفرض همچنان Task است، چون سادهتر و امنتر است.
نشانه خطر: بدون اندازهگیری شروع به «بهینهسازی» میکند، یا نمیتواند توضیح دهد چرا allocation زیاد باعث کندی میشود.
۱۳. اجرای تکراری یک Job روی چند Pod
Background Service· در نقشه راهRedis· در نقشه راه
سؤال: یک BackgroundService هر شب ساعت ۲ یک گزارش مالی میسازد و برای مشتریها ایمیل میکند. برای تحمل بار بیشتر، میخواهیم تعداد pod های این سرویس را از ۱ به ۳ برسانیم. کد Job را تغییر نمیدهیم. آیا این تغییر مشکلی ایجاد میکند؟ چرا؟
جواب کوتاه: هر pod یک نسخه کامل از برنامه را اجرا میکند. پس هر pod یک BackgroundService جدا دارد. ۳ pod یعنی ۳ زمانبند. هر کدام ساعت ۲ کار را شروع میکند. راه حل: یک مکانیزم مشترک بین pod ها که فقط به یکی اجازه اجرا بدهد.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production این را دیدهایم: از فردای روزی که pod ها را ۳ تا کردیم، هر مشتری هر شب ۳ ایمیل یکسان میگیرد.
راهها، از ساده به پیچیده:
- جدا کردن Job از سرویس. یک CronJob در Kubernetes بساز. این CronJob هر شب یک pod کوتاهعمر اجرا میکند. تنظیم concurrencyPolicy آن را روی Forbid بگذار تا دو اجرا با هم شروع نشوند. این سادهترین راه است، چون دیگر چند نسخه نداریم.
- ثبت اجرا در دیتابیس با unique constraint. یک جدول برای اجراها داریم. روی «نام Job و تاریخ» یک unique constraint میگذاریم. هر pod قبل از شروع، یک ردیف برای امروز در این جدول اضافه میکند:
INSERT INTO JobRuns (JobName, RunDate) VALUES ('daily-report', '2026-10-04');
-- UNIQUE (JobName, RunDate)
- فقط اولین نوشتن موفق میشود. دو pod دیگر خطای unique میگیرند و کار را رها میکنند. این راه ساده و مطمئن است، چون خود دیتابیس تضمین میدهد.
- قفل دیتابیس. در SQL Server قابلیت sp_getapplock و در PostgreSQL قابلیت advisory lock این کار را میکنند. این قفل به connection وصل است. اگر pod بمیرد، connection بسته میشود و قفل خودکار آزاد میشود.
- قفل Redis با دستور SET و گزینه NX (فقط اگر کلید نبود) و یک زمان انقضا. یا یک ابزار آماده مثل Hangfire یا Quartz.NET در حالت cluster.
- در همه حالتها، خود ارسال ایمیل هم بهتر است idempotent باشد. یعنی برای هر مشتری و هر تاریخ ثبت کنیم «ارسال شد». اگر Job وسط کار بیفتد و دوباره اجرا شود، به کسی دو بار ایمیل نمیرود.
سؤال پیگیری: فرض کن با Redis قفل گرفتی و TTL قفل ۳۰ ثانیه است. pod اول وسط کار ۴۰ ثانیه متوقف میشود (مثلاً به خاطر یک GC طولانی یا مشکل شبکه). چه اتفاقی میافتد؟
جواب پیگیری:
| زمان | pod A | pod B |
|---|---|---|
| ۰ | قفل را تا ثانیه ۳۰ میگیرد و کار را شروع میکند | منتظر است |
| ۵ | متوقف میشود (توقف GC) | |
| ۳۰ | هنوز متوقف است؛ قفل منقضی میشود | |
| ۳۱ | قفل را میگیرد و کار را شروع میکند | |
| ۴۵ | بیدار میشود و فکر میکند هنوز قفل دارد؛ ادامه میدهد | همزمان کار میکند |
- حالا دو pod همزمان کار میکنند.
- بیشتر کردن TTL فقط احتمال را کم میکند. مشکل را حل نمیکند.
- تمدید قفل در حین کار هم کمک میکند. ولی خود تمدید هم ممکن است به خاطر همین توقف انجام نشود.
- راه حل درست Fencing Token است. قدم به قدم:
- هر بار که کسی قفل میگیرد، یک عدد افزایشی هم میگیرد. مثلاً A عدد ۳۳ و B عدد ۳۴.
- هر نوشتن در دیتابیس این عدد را همراه خود دارد.
- دیتابیس بزرگترین عددی را که دیده نگه میدارد. نوشتنی را که عددش کمتر است رد میکند.
- پس بعد از شروع B (عدد ۳۴)، نوشتنهای A (عدد ۳۳) رد میشوند.
- اگر کاندید بحث Redlock و نقد Martin Kleppmann را بداند، نشانه عمق خیلی خوبی است.
نشانه خطر: فقط میگوید «با Redis قفل میگیریم» و هیچ وقت به منقضی شدن قفل وسط کار فکر نکرده است.
۱۴. طوفان Retry
Resilience با Polly· در نقشه راه
سؤال: سرویس A سرویس B را صدا میزند و B سرویس C را. در هر دو لایه (A به B و B به C) با Polly این تنظیم شده: ۳ بار retry و timeout دو ثانیه برای هر تلاش. زمان جواب عادی C حدود ۲۰۰ میلیثانیه است. این تنظیم را در code review میبینی. نظرت چیست؟ آیا مشکلی دارد؟ اگر C کند شود و زمان جوابش به حدود ۲ ثانیه برسد، چه اتفاقی میافتد؟ تنظیم درست چیست؟
جواب کوتاه: تعداد retry ها در لایهها در هم ضرب میشود. یک درخواست کاربر میتواند تا ۱۶ درخواست به C بفرستد. وقتی C کند است، این درخواستهای اضافه آن را کندتر میکنند. یک چرخه بد شروع میشود.
سرنخ (اگر کاندید گفت مشکلی نیست): یک روز C کمی کند شد و زمان جوابش از ۲۰۰ میلیثانیه به حدود ۲ ثانیه رسید. در مانیتورینگ دیدیم تعداد درخواستهای ورودی به C چند برابر شد، در حالی که تعداد کاربرها همان بود. بعد از چند دقیقه C کاملاً از کار افتاد. بعد A و B هم دیگر جواب ندادند، حتی برای کارهایی که به C ربطی ندارند. حالا چرا؟
چرا این اتفاق میافتد؟ (قدم به قدم)
- ضرب شدن retry ها. سرویس A تا ۴ بار B را صدا میزند (۱ بار اصلی و ۳ retry). در هر کدام از این ۴ بار، B تا ۴ بار C را صدا میزند. یعنی ۴ × ۴ = ۱۶ درخواست به C برای یک کلیک کاربر.
- زمان timeout درست لب مرز است. سرویس C حدود ۲ ثانیه جواب میدهد و timeout هم ۲ ثانیه است. پس بیشتر تلاشها timeout میشوند و retry شروع میشود. نکته مهم: C هنوز دارد درخواست قبلی را پردازش میکند. یعنی کار تکراری انجام میدهد.
- چرخه بد. بار بیشتر روی C، یعنی C کندتر. C کندتر، یعنی timeout بیشتر. timeout بیشتر، یعنی retry بیشتر. و retry بیشتر، یعنی باز هم بار بیشتر روی C. این ادامه دارد تا C کاملاً بیفتد.
- خرابی آبشاری. همه thread ها و connection های A و B منتظر C میمانند. پس A و B به بقیه کاربرها هم جواب نمیدهند. حتی برای کارهایی که به C ربطی ندارند.
تنظیم درست:
-
فقط یک لایه retry کند، نه همه لایهها.
-
فاصله بین retry ها کمکم بیشتر شود. به این exponential backoff میگویند: ۲۰۰ میلیثانیه، بعد ۴۰۰، بعد ۸۰۰. کمی عدد تصادفی (jitter) هم اضافه کن، تا هزاران کلاینت در یک لحظه retry نکنند.
-
فقط برای خطای موقت retry کن: timeout و کدهای 503 و 429. برای خطایی مثل 400 retry نکن، چون با تکرار درست نمیشود.
-
بودجه زمانی درست. timeout لایه بالا باید از کل زمان لایه پایین (همراه با retry هایش) بیشتر باشد. وگرنه A تسلیم میشود، ولی B هنوز دارد برای همان درخواست کار میکند.
-
استفاده از Circuit Breaker. مثل فیوز برق است:
- اگر درصد زیادی از درخواستها به C شکست بخورد، مدار باز میشود.
- مثلاً تا ۳۰ ثانیه اصلاً C را صدا نمیزنیم و فوراً خطا برمیگردانیم.
- در این مدت C فرصت بهبود پیدا میکند.
- بعد یک درخواست آزمایشی میفرستیم. اگر موفق بود، دوباره درخواستها را به C میفرستیم.
-
استفاده از Bulkhead، یعنی محدود کردن تعداد درخواست همزمان به C. اینطوری یک وابستگی کند همه thread ها و connection های B را نمیگیرد.
-
در .NET، کتابخانه Microsoft.Extensions.Http.Resilience (که بر پایه Polly است) بیشتر اینها را با تنظیمات خوب پیشفرض دارد:
builder.Services.AddHttpClient<ServiceCClient>()
.AddStandardResilienceHandler();
سؤال پیگیری: وقتی Circuit Breaker باز است، به کاربر چه جوابی میدهی؟
جواب پیگیری: بستگی به نوع کار دارد. به این کار fallback میگویند:
- برای خواندن داده (مثلاً لیست قیمتها): آخرین داده کششده را نشان میدهیم و میگوییم «ممکن است کمی قدیمی باشد».
- برای بخشهای غیر اصلی (مثلاً پیشنهاد محصول): آن بخش را موقتاً نشان نمیدهیم، ولی بقیه صفحه کار میکند.
- برای نوشتن و کارهای مهم (مثلاً ثبت سفارش): فوراً خطای 503 همراه با هدر Retry-After برمیگردانیم. وانمود نمیکنیم که کار انجام شده.
- نکته اصلی: خطای سریع بهتر از انتظار طولانی است، چون thread ها و connection ها آزاد میمانند.
نشانه خطر: راه حلش «تعداد retry را بیشتر میکنیم» یا «timeout را بزرگ میکنیم» است.
۱۵. تکرار درخواست در سیستم پرداخت
Idempotency· در نقشه راهResilience با Polly· در نقشه راه
سؤال: سرویس پرداخت ما برای هر خرید، درگاه بانک (PSP) را صدا میزند. timeout این تماس ۱۰ ثانیه است. اگر جواب نیاید، با Polly همان درخواست را دوباره میفرستیم. اگر بعد از همه تلاشها جواب نیاید، سفارش را «ناموفق» ثبت میکنیم. این طراحی را در code review میبینی. نظرت چیست؟ آیا مشکلی دارد؟ در یک روز شلوغ که بانک کند جواب میدهد، چه اتفاقی میافتد؟ تماس دوباره با درگاه پرداخت را چطور طراحی میکنی؟
جواب کوتاه: timeout یعنی «جواب نگرفتم»، نه «پرداخت انجام نشد». شاید بانک پول را کم کرده و فقط جوابش به ما نرسیده. پس retry کور یعنی پرداخت دوباره. راه درست: یک شناسه یکتا برای هر پرداخت، استعلام وضعیت قبل از تلاش دوباره، یک وضعیت «نامعلوم» در دیتابیس، و یک کار تطبیق (reconciliation) با گزارش بانک.
سرنخ (اگر کاندید گفت مشکلی نیست): چند روز بعد از یک روز شلوغ که بانک کند بود، پشتیبانی دو نوع شکایت گزارش میکند. چند مشتری میگویند دو بار از حسابشان پول کم شده، ولی فقط یک سفارش دارند. چند مشتری دیگر میگویند پول کم شده، ولی سفارششان در سیستم ما «ناموفق» است. در گزارش تراکنشهای بانک هم میبینیم که برای بعضی خریدها دو تراکنش موفق هست. حالا چرا؟
چرا پول دو بار کم شد؟ (قدم به قدم)
- ما درخواست پرداخت را به بانک میفرستیم.
- بانک پول را کم میکند، ولی چون شلوغ است، جوابش بعد از ۱۰ ثانیه میرسد.
- ما بعد از ۱۰ ثانیه timeout میگیریم و فکر میکنیم پرداخت انجام نشده.
- Polly همان درخواست را دوباره میفرستد. بانک آن را یک پرداخت جدید میبیند و دوباره پول کم میکند.
- نتیجه: دو تراکنش موفق در بانک، ولی یک سفارش در سیستم ما.
چرا پول کم شد ولی سفارش «ناموفق» است؟
- بانک پول را کم کرده، ولی همه تلاشهای ما timeout شدهاند.
- کد ما بعد از آخرین تلاش، سفارش را «ناموفق» ثبت کرده است.
- یعنی ما جواب «نمیدانم» را با جواب «نه» یکی گرفتیم.
طراحی درست:
- شناسه یکتا برای هر پرداخت. قبل از تماس با بانک، یک شناسه پرداخت میسازیم و در دیتابیس ذخیره میکنیم. در همه تلاشها همین شناسه را به بانک میفرستیم. بیشتر درگاهها اگر این شناسه تکراری باشد، پرداخت جدید نمیسازند و همان نتیجه قبلی را برمیگردانند.
- سه وضعیت، نه دو وضعیت. پرداخت فقط «موفق» و «ناموفق» نیست. یک وضعیت «نامعلوم» هم لازم است. timeout یعنی «نامعلوم»، نه «ناموفق».
- اول استعلام، بعد تلاش دوباره. بعد از timeout، درخواست پرداخت را دوباره نمیفرستیم. اول با همان شناسه از بانک میپرسیم: «وضعیت این پرداخت چیست؟»
- اگر بانک گفت موفق بوده، سفارش را موفق ثبت میکنیم.
- اگر گفت این پرداخت را ندیده، فقط آن وقت دوباره میفرستیم.
- اگر بانک هنوز جواب نمیدهد، پرداخت در وضعیت «نامعلوم» میماند.
- کار تطبیق (reconciliation). یک background job هر چند دقیقه پرداختهای «نامعلوم» را از بانک استعلام میکند. هر روز هم گزارش تراکنشهای بانک را با دیتابیس ما مقایسه میکند. اگر پول کم شده ولی سفارش نداریم، یا سفارش را کامل میکند یا پول را برمیگرداند (refund).
- retry کم و با فاصله. برای پرداخت، تعداد تلاش کم باشد و فاصلهها کمکم بیشتر شود (exponential backoff). در چند لایه با هم retry نکنیم (مثل سؤال ۱۴).
- پیام درست به کاربر. در حالت «نامعلوم» به کاربر نمیگوییم «پرداخت ناموفق بود». میگوییم «پرداخت در حال بررسی است» تا خودش دوباره پرداخت نکند.
یک نکته مهم: شناسه یکتا را باید قبل از تماس با بانک در دیتابیس ذخیره کنیم. اگر سرویس ما وسط کار restart شود، بعد از بالا آمدن میداند کدام پرداختها شروع شدهاند و باید استعلام شوند.
سؤال پیگیری: اگر درگاه بانک اصلاً شناسه یکتا و استعلام وضعیت را پشتیبانی نکند، چه میکنی؟
جواب پیگیری:
- در این حالت retry خودکار پرداخت را خاموش میکنیم. خطر پرداخت دوباره از خطر یک خرید ناموفق بیشتر است.
- پرداخت بعد از timeout در وضعیت «نامعلوم» میماند.
- فقط کار تطبیق روزانه با گزارش بانک وضعیت نهایی را مشخص میکند.
- اگر پول دو بار کم شده بود، با refund خودکار برمیگردانیم.
- این ریسک را هم به تیم محصول میگوییم، چون روی تجربه کاربر اثر دارد.
نشانه خطر: میگوید «timeout را بیشتر میکنیم» یا «retry را بیشتر میکنیم»، یا بعد از timeout پرداخت را «ناموفق» ثبت میکند.
۱۶. دیتابیس با Read Replica
SQL و Index· در نقشه راهConsistency و CAP· در نقشه راه
سؤال: برای کم کردن بار دیتابیس، یک Read Replica اضافه کردیم. همه نوشتنها به دیتابیس اصلی (Primary) میروند و همه خواندنها به Replica. این طراحی را چطور میبینی؟ آیا برای همه بخشهای سیستم درست کار میکند؟ مثلاً کاربر پروفایلش را ویرایش میکند و ذخیره میزند. بعد صفحه پروفایل دوباره بارگذاری میشود. چه اتفاقی میافتد؟ اگر مشکلی هست، چه راهحلهایی داری و هر کدام چه هزینهای دارد؟
جواب کوتاه: تغییرات Primary با کمی تأخیر به Replica میرسند. به این تأخیر Replication Lag میگویند. کاربر بلافاصله بعد از ذخیره، از Replica میخواند. یعنی قبل از اینکه تغییر به آنجا برسد. ولی کاربر انتظار دارد تغییر خودش را فوراً ببیند. به این نیاز Read-Your-Writes میگویند.
سرنخ (اگر کاندید گفت مشکلی نیست): کاربرها شکایت میکنند: «پروفایلم را ویرایش کردم، ذخیره زدم و پیام موفقیت دیدم. ولی صفحه هنوز اطلاعات قبلی را نشان میدهد.» با چند بار refresh درست میشود. در ساعتهای پربار این شکایت بیشتر است. حالا چرا؟
چه اتفاقی میافتد؟ (به ترتیب زمان)
- در زمان ۰، کاربر ذخیره میزند. تغییر در Primary ثبت میشود و API پیام «موفق» برمیگرداند.
- در زمان ۵۰ میلیثانیه، صفحه پروفایل دوباره بارگذاری میشود و از Replica میخواند.
- هنوز تغییر به Replica نرسیده است. چون replication معمولاً async است و از چند میلیثانیه تا چند ثانیه طول میکشد (در بار زیاد بیشتر). پس داده قدیمی برمیگردد.
- چند ثانیه بعد، تغییر به Replica میرسد و refresh بعدی درست است.
راهحلها و هزینه هر کدام:
| راه حل | چطور کار میکند | هزینه |
|---|---|---|
| نمایش از جواب ذخیره | صفحه بعد از ذخیره، داده را از جواب همان درخواست ذخیره نشان میدهد و دوباره نمیخواند | تقریباً هیچ؛ ولی فقط همان صفحه را حل میکند |
| خواندن از Primary برای چند ثانیه | تا مثلاً ۱۰ ثانیه بعد از هر نوشتن، خواندنهای همان کاربر از Primary انجام میشود (با یک فلگ زماندار در cookie یا کش) | کم و ساده؛ رایجترین راه |
| صفحههای «داده خودم» از Primary | صفحههایی که کاربر در آنها داده خودش را ویرایش میکند، همیشه از Primary میخوانند | بخشی از بار روی Primary میماند |
| منتظر ماندن برای Replica | بعد از نوشتن، موقعیت لاگ را نگه میداریم (مثل LSN در PostgreSQL). فقط از Replica ای میخوانیم که به این موقعیت رسیده | دقیق، ولی پیچیده |
| replication همزمان (sync) | سرور Primary منتظر میماند تا Replica تأیید کند | نوشتن کند میشود؛ اگر Replica بخوابد، نوشتن هم گیر میکند |
- همچنین باید مقدار replication lag را مانیتور کنیم و برایش هشدار بگذاریم.
سؤال پیگیری: کدام بخشهای سیستم اصلاً نباید از Replica بخوانند؟
جواب پیگیری: هر جایی که با داده خواندهشده تصمیم میگیریم و بعد مینویسیم.
- مثال: چک موجودی حساب قبل از برداشت. اگر از Replica بخوانیم، شاید برداشت چند ثانیه قبل را نبینیم. پس ممکن است بیشتر از موجودی اجازه برداشت بدهیم.
- موارد دیگر: مجوزها و تغییر رمز، ساخت شمارههای یکتا، و هر خواندنی که داخل یک تراکنش نوشتن است.
نشانه خطر: مفهوم replication lag را نمیشناسد، یا میگوید «مشکل از کش مرورگر است».
۱۷. تغییر ساختار دیتابیس بدون Downtime
Entity Framework Core· در نقشه راهCI/CD· در نقشه راه
سؤال: جدول مشتریها یک ستون «نام کامل» (FullName) دارد. میخواهیم آن را به دو ستون «نام» (FirstName) و «نام خانوادگی» (LastName) تبدیل کنیم. سرویس ۴ pod دارد و deploy به صورت rolling انجام میشود. یعنی pod ها یکییکی عوض میشوند و چند دقیقه نسخه قدیم و جدید کد با هم اجرا میشوند. یک برنامهنویس یک migration نوشته که سه کار را با هم میکند: دو ستون جدید را میسازد، داده را کپی میکند و ستون نام کامل را حذف میکند. این migration را در code review میبینی. آیا این migration هنگام deploy مشکلی ایجاد میکند؟ چرا؟ اگر مشکلی هست، قدم به قدم چطور انجامش میدهی؟
جواب کوتاه: در زمان rolling deploy، کد قدیم و جدید با هم کار میکنند. پس دیتابیس در هر لحظه باید با هر دو نسخه کد سازگار باشد. راه حل الگوی Expand and Contract است:
- اول چیز جدید را اضافه کن.
- چند مرحله هر دو را نگه دار.
- در آخر چیز قدیمی را حذف کن.
سرنخ (اگر کاندید گفت مشکلی نیست): در یک deploy قبلی همین کار را کردیم. وسط deploy، بخشی از درخواستها خطای 500 گرفتند. در لاگ pod های قدیمی خطای «ستون FullName وجود ندارد» دیده شد. بعد نسخه جدید یک باگ داشت و خواستیم rollback کنیم. ولی نسخه قدیم هم همان خطا را داد و کار نکرد. حالا چرا؟
چرا migration یکباره خطا میدهد؟
- وقتی migration اجرا میشود، ستون نام کامل حذف میشود.
- هنوز ۳ pod کد قدیم را دارند و ستون نام کامل را میخوانند. دیتابیس خطای «ستون وجود ندارد» میدهد و کاربر خطای 500 میگیرد.
- اگر برعکس، اول کد را deploy کنیم و بعد migration را، pod های جدید دنبال ستونهای نام و نام خانوادگی میگردند. ولی این ستونها هنوز وجود ندارند.
- اگر نسخه جدید باگ داشته باشد، rollback هم ممکن نیست. چون کد قدیم به ستون نام کامل نیاز دارد و آن ستون حذف شده است.
راه حل: Expand and Contract در ۶ قدم
| قدم | تغییر | کد از کجا میخواند؟ | کد کجا مینویسد؟ |
|---|---|---|---|
| ۱ | ستونهای جدید را nullable اضافه کن (کد عوض نمیشود) | FullName | FullName |
| ۲ | کد نسخه ۱ را deploy کن | FullName | هر دو |
| ۳ | رکوردهای قدیمی را در دستههای کوچک پر کن (backfill) | FullName | هر دو |
| ۴ | کد نسخه ۲ را deploy کن | ستونهای جدید | هر دو |
| ۵ | کد نسخه ۳ را deploy کن | ستونهای جدید | فقط ستونهای جدید |
| ۶ | بعد از چند روز و اطمینان، ستون FullName را حذف کن | ستونهای جدید | فقط ستونهای جدید |
- چرا این روش امن است؟ چون در هر قدم، نسخه قبلی کد هنوز با دیتابیس کار میکند. پس rolling deploy بدون خطاست. و برگشت یک قدم به عقب (rollback) همیشه ممکن است.
چند نکته مهم:
- پر کردن داده (backfill) در دستههای کوچک، مثلاً هر بار ۱۰۰۰ ردیف. یک UPDATE بزرگ روی میلیونها ردیف، جدول را مدت طولانی قفل میکند و لاگ تراکنش را پر میکند.
- اجرای migration جدا از بالا آمدن برنامه، مثلاً با یک Job در pipeline. اگر هر ۴ pod هنگام شروع، متد Migrate را صدا بزنند، ممکن است همزمان اجرا شوند و به مشکل بخورند.
- مراقب ALTER های سنگین باش. بعضی تغییرات (مثل عوض کردن نوع ستون) روی جدول بزرگ، کل جدول را بازنویسی و قفل میکنند.
سؤال پیگیری: بعد از قدم ۴ باگی پیدا شد و باید به نسخه ۱ برگردیم. آیا مشکلی پیش میآید؟ اگر بعد از قدم ۶ باشد چه؟
جواب پیگیری:
- بعد از قدم ۴ مشکلی نیست. نسخه ۱ از ستون نام کامل میخواند. این ستون هنوز هست و هنوز پر میشود.
- بعد از قدم ۶، ستون قدیمی حذف شده است. پس برگشت به نسخه ۱ ممکن نیست.
- برای همین قدم ۶ آخرین قدم است و با فاصله زمانی و جدا انجام میشود.
نشانه خطر: یک migration با rename یا drop مینویسد و میگوید «سریع deploy میکنیم، چند ثانیه خطا مهم نیست».
۱۸. خروجی گرفتن از داده زیاد
Entity Framework Core· در نقشه راهPerformance و Span· در نقشه راه
سؤال: کاربر در پنل مدیریت روی «دانلود Excel» کلیک میکند تا ۲ میلیون رکورد تراکنش را بگیرد. endpoint فعلی سه کار میکند: همه رکوردها را یکجا با متد ToListAsync میخواند، فایل Excel را در حافظه میسازد و آن را در پاسخ برمیگرداند. سقف حافظه هر pod یک گیگابایت است. این کد را در code review میبینی. نظرت چیست؟ در Production با این حجم داده چه اتفاقی میافتد؟ اگر لازم است، این قابلیت را چطور دوباره طراحی میکنی؟
جواب کوتاه: دو مشکل جدا داریم:
- همه داده یکجا در حافظه است.
- یک کار چنددقیقهای داخل یک درخواست HTTP انجام میشود.
- راه حل: کار را به پسزمینه ببر، داده را تکهتکه بخوان و بنویس، و فایل آماده را بعداً به کاربر بده.
سرنخ (اگر کاندید گفت مشکلی نیست): کاربر بعد از حدود ۱۰۰ ثانیه خطای timeout میبیند. گاهی هم pod با وضعیت OOMKilled (کمبود حافظه) ریاستارت میشود. در همان لحظه درخواستهای کاربرهای دیگر هم قطع میشوند. حالا چرا؟
چرا OOM میگیریم؟
- متد ToListAsync هر ۲ میلیون رکورد را به شکل شیء C# در حافظه میسازد. اگر هر شیء با رشتههایش حدود ۵۰۰ بایت باشد، یعنی حدود ۱ گیگابایت. change tracker هم برای هر شیء اطلاعات اضافه نگه میدارد.
- بعد فایل Excel هم کامل در حافظه ساخته میشود.
- اگر سقف حافظه pod مثلاً ۱ گیگابایت باشد، Kubernetes آن را میکشد. همه درخواستهای دیگری که روی همان pod بودند هم از بین میروند.
چرا timeout میگیریم؟ بین مرورگر و برنامه چند لایه هست: load balancer، ingress و reverse proxy. هر کدام برای یک درخواست یک حداکثر زمان دارند. کاری که چند دقیقه طول میکشد اصلاً نباید داخل یک درخواست باشد.
طراحی درست (قدم به قدم):
- اول، endpoint فقط یک «Job خروجی» ثبت میکند. بعد فوراً کد 202 (Accepted) را همراه با یک شناسه Job برمیگرداند.
- یک worker در پسزمینه (یک BackgroundService یا مصرفکننده صف) این Job را اجرا میکند.
- داده را تکهتکه میخواند، نه یکجا. با متدهای AsNoTracking و AsAsyncEnumerable، هر رکورد خوانده، نوشته و از حافظه رها میشود:
await foreach (var row in db.Transactions.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
{
writer.WriteRow(row);
}
- فایل هم به صورت stream نوشته میشود (مثلاً با OpenXmlWriter). اگر کسبوکار قبول کند، فرمت CSV خیلی سادهتر و سبکتر است.
- فایل در یک storage (مثل S3 یا MinIO) ذخیره میشود، نه در حافظه یا دیسک pod.
- برای اینکه بار روی دیتابیس اصلی نرود، از Read Replica میخوانیم.
نکته خوب (اگر کاندید بگوید امتیاز مثبت است): هر sheet در Excel حداکثر ۱٬۰۴۸٬۵۷۶ ردیف دارد. پس ۲ میلیون ردیف اصلاً در یک sheet جا نمیشود.
سؤال پیگیری: کاربر چطور میفهمد فایلش آماده شده؟ لینک دانلود را چطور امن میکنی؟
جواب پیگیری:
- برای خبر دادن: صفحه هر چند ثانیه وضعیت Job را از سرور میپرسد (polling). یا سرور با SignalR، ایمیل یا notification خبر میدهد.
- برای امنیت: لینک یک Pre-signed URL با عمر کوتاه است (مثلاً ۱۵ دقیقه). این لینک فقط بعد از این چک ساخته میشود که همین کاربر صاحب این Job است.
- فایلها بعد از چند روز خودکار پاک میشوند، چون داده مالی حساس است.
نشانه خطر: جوابش «timeout را بیشتر میکنیم» یا «حافظه pod را زیاد میکنیم» است.
۱۹. محدود کردن درخواست (Rate Limiting) روی چند سرور
Rate Limiting· در نقشه راهRedis· در نقشه راه
سؤال: یک API عمومی داریم. طبق قرارداد، هر مشتری حداکثر ۱۰۰ درخواست در دقیقه مجاز است. این محدودیت را با middleware داخلی ASP.NET Core (همان AddRateLimiter) گذاشتیم. سرویس در Production روی ۱۰ pod اجرا میشود و یک load balancer درخواستها را بین آنها پخش میکند. این طراحی را چطور میبینی؟ آیا در Production همان حد ۱۰۰ درخواست در دقیقه رعایت میشود؟ اگر نه، چطور درستش میکنی؟
جواب کوتاه: middleware داخلی شمارنده را در حافظه همان pod نگه میدارد. پس:
- هر pod جداگانه تا ۱۰۰ میشمارد و از pod های دیگر خبر ندارد.
- سرویس load balancer هم درخواستها را بین ۱۰ pod پخش میکند.
- پس حد واقعی میشود ۱۰ × ۱۰۰ = ۱۰۰۰.
- راه حل: شمارنده باید در یک جای مشترک باشد.
سرنخ (اگر کاندید گفت مشکلی نیست): در تست روی لپتاپ همه چیز درست کار میکرد. ولی در مانیتورینگ Production میبینیم بعضی مشتریها حدود ۱۰۰۰ درخواست در دقیقه میفرستند. این مشتریها هیچ خطای 429 نمیگیرند. حالا چرا؟
راهها:
- شمارنده مشترک در Redis. سادهترین نسخه، Fixed Window است:
- کلید از شناسه مشتری و دقیقه فعلی ساخته میشود. مثلاً «مشتری ۴۲ در ساعت ۱۰:۳۰».
- با دستور INCR یکی به شمارنده اضافه میکنیم. بار اول، با دستور EXPIRE زمان انقضای ۶۰ ثانیه میگذاریم. بهتر است هر دو در یک Lua script باشند تا اتمی اجرا شوند.
- اگر عدد از ۱۰۰ بیشتر شد، کد 429 برمیگردانیم.
- محدودیت در لبه سیستم. یک API Gateway یا Ingress جلوی همه pod ها است. همینجا در یک نقطه میشمارد.
- راه تقریبی. سهم هر pod میشود ۱۰۰ ÷ ۱۰ = ۱۰. این راه ساده است، ولی دو مشکل دارد: با عوض شدن تعداد pod ها باید عوض شود. و اگر load balancer بار را مساوی پخش نکند، دقیق نیست.
فرق الگوریتمها (یک نکته مهم):
- در Fixed Window، مشتری میتواند ۱۰۰ درخواست در ثانیه ۵۹ دقیقه اول بفرستد و ۱۰۰ درخواست در ثانیه صفر دقیقه بعد. یعنی ۲۰۰ درخواست در دو ثانیه.
- الگوریتمهای Sliding Window و Token Bucket این مشکل را ندارند.
- در Token Bucket، هر مشتری یک سطل ژتون دارد. سطل با سرعت ثابت پر میشود. هر درخواست یک ژتون برمیدارد. اگر ژتون نباشد، درخواست رد میشود.
جزئیات درست:
- جواب درست کد 429 (Too Many Requests) با هدر Retry-After است. با این هدر، کلاینت میداند کی دوباره تلاش کند.
- کلید محدودیت باید API Key یا شناسه مشتری باشد، نه IP. چون چند مشتری پشت یک NAT یک IP مشترک دارند. و یک مشتری هم ممکن است چند IP داشته باشد.
سؤال پیگیری: اگر Redis که شمارندهها را نگه میدارد از دسترس خارج شود، درخواستها را قبول میکنی یا رد؟ چرا؟
جواب پیگیری: بستگی دارد محدودیت برای چه گذاشته شده.
- اگر هدف محافظت از سیستم است، معمولاً درخواست را قبول میکنیم (fail-open). همراه با یک محدودیت ساده محلی به عنوان پشتیبان. چون نمیخواهیم از کار افتادن Redis کل API را بخواباند.
- اگر محدودیت مالی یا امنیتی است، شاید رد کردن (fail-closed) درست باشد. مثلاً وقتی هر درخواست برای ما هزینه دارد، یا محدودیت جلوی حدس زدن رمز را میگیرد.
- کاندید خوب میگوید «بستگی دارد» و دلیلش را توضیح میدهد.
نشانه خطر: نمیداند که limiter داخلی در هر pod جدا میشمارد.
۲۰. باطل کردن دسترسی با JWT
Authentication و Authorization· در نقشه راه
سؤال: سیستم ما برای احراز هویت از JWT استفاده میکند. عمر هر توکن ۱ ساعت است. مدیر امنیت، حساب یک کاربر متخلف را در پنل مدیریت غیرفعال میکند (فیلد فعال بودن کاربر در دیتابیس false میشود). به نظرت بعد از این کار، دسترسی این کاربر به API چه میشود؟ آیا این طراحی مشکلی دارد؟ اگر بله، چه راههایی داری و هر کدام چه هزینهای دارد؟
جواب کوتاه: سرور برای چک کردن JWT اصلاً به دیتابیس نگاه نمیکند. فقط امضا و تاریخ انقضای خود توکن را چک میکند. پس غیرفعال کردن کاربر در دیتابیس روی توکنی که قبلاً صادر شده اثری ندارد. این توکن تا زمان انقضا کار میکند.
سرنخ (اگر کاندید گفت مشکلی نیست): در مانیتورینگ API میبینیم این کاربر بعد از غیرفعال شدن هنوز API را صدا میزند. جوابها هم موفق است. این وضعیت تا حدود یک ساعت بعد ادامه دارد. حالا چرا؟
روش کار JWT چیست؟
- هنگام ورود، سرور یک توکن میسازد. اطلاعات کاربر (شناسه و نقشها) و زمان انقضا داخل توکن است. سرور توکن را با کلید خودش امضا میکند.
- در هر درخواست، سرور فقط امضا و انقضا را چک میکند. به هیچ دیتابیسی وصل نمیشود. برای همین سریع است. به این حالت stateless میگویند.
- همین مزیت، اینجا مشکل است. توکن تا زمان انقضا معتبر است، هر اتفاقی که در دیتابیس بیفتد.
راهها و هزینه هر کدام:
| راه | چطور کار میکند | هزینه |
|---|---|---|
| عمر کوتاه + Refresh Token | توکن دسترسی فقط ۵ تا ۱۵ دقیقه معتبر است. برای توکن جدید، کلاینت Refresh Token میفرستد. سرور همان لحظه وضعیت کاربر را در دیتابیس چک میکند | ساده است، ولی هنوز چند دقیقه فاصله هست |
| لیست سیاه (denylist) | شناسه توکن (فیلد jti) یا شناسه کاربر را تا زمان انقضا در Redis نگه میداریم. در هر درخواست آن را چک میکنیم | یک چک سریع Redis در هر درخواست |
| نسخه امنیتی (security stamp) | یک عدد نسخه داخل توکن است. با غیرفعال کردن کاربر، این عدد در دیتابیس عوض میشود. توکنهای قدیمی رد میشوند | باید در هر درخواست چک شود (با یک کش کوتاه) |
| توکن مرجع (Reference Token) | توکن فقط یک شناسه است. سرور هر بار از Identity Server میپرسد | دقیق است، ولی بار و تأخیر بیشتری دارد |
انتخاب معمول ترکیب راه اول و دوم است. بیشتر وقتها عمر کوتاه کافی است. برای مواقع اضطراری (مثل کاربر متخلف) از لیست سیاه استفاده میکنیم.
سؤال پیگیری: Refresh Token را کجا نگه میداری؟ اگر دزدیده شود، چطور میفهمی؟
جواب پیگیری:
- در سرور، آن را به صورت hash در دیتابیس نگه میداریم (مثل رمز عبور). اگر دیتابیس لو برود، خود توکنها لو نمیروند.
- در مرورگر، آن را داخل cookie میگذاریم، با تنظیمهای HttpOnly و Secure و SameSite. نه در localStorage. دلیلش ساده است:
- اگر سایت آسیبپذیری XSS داشته باشد، کد JavaScript مهاجم اجرا میشود.
- این کد میتواند localStorage را بخواند.
- ولی cookie با HttpOnly را نمیتواند بخواند.
- برای پیدا کردن دزدی از چرخش Refresh Token (Rotation) استفاده میکنیم:
- هر بار که Refresh Token استفاده میشود، یک Refresh Token جدید داده میشود و قبلی باطل میشود.
- اگر مهاجم توکن را دزدیده باشد، زود یا دیر یکی از دو طرف (کاربر یا مهاجم) یک توکن باطلشده را میفرستد.
- سرور با دیدن توکن باطلشده میفهمد دزدی شده است. پس همه توکنهای آن session را باطل میکند.
- کاربر واقعی فقط یک بار دیگر login میکند.
نشانه خطر: فکر میکند logout یا پاک کردن توکن در مرورگر، توکن را در سرور هم باطل میکند.
۲۱. دو درخواست همزمان با یک Idempotency Key
سؤال: برای API پرداخت، Idempotency Key پیاده کردهایم. روش فعلی این است: (۱) در دیتابیس میگردیم ببینیم این کلید نتیجهای دارد یا نه. (۲) اگر داشت، همان را برمیگردانیم. (۳) اگر نداشت، پرداخت را انجام میدهیم و نتیجه را با کلید ذخیره میکنیم. سرویس روی چند pod اجرا میشود. این طراحی را در code review میبینی. نظرت چیست؟ اگر دو درخواست با یک کلید تقریباً در یک لحظه برسند، چه اتفاقی میافتد؟
جواب کوتاه: الگوی «اول چک کن، بعد کار کن، آخر ذخیره کن» race condition دارد. هر دو درخواست قبل از اینکه دیگری چیزی ذخیره کند، چک میکنند. راه درست این است: اول کلید را ثبت کن (با unique constraint)، بعد کار کن.
سرنخ (اگر کاندید گفت مشکلی نیست): اپ موبایل به خاطر یک باگ، گاهی دو درخواست با یک کلید را تقریباً در یک لحظه میفرستد. در این موارد، از حساب مشتری دو بار پول کم میشود. حالا چرا؟
چه اتفاقی میافتد؟ (به ترتیب زمان)
| زمان | درخواست ۱ | درخواست ۲ |
|---|---|---|
| ۰ میلیثانیه | کلید را چک میکند: نیست | |
| ۲ میلیثانیه | کلید را چک میکند: نیست | |
| ۵ میلیثانیه | پرداخت را شروع میکند | پرداخت را شروع میکند |
| ۸۰۰ میلیثانیه | نتیجه را ذخیره میکند | نتیجه را ذخیره میکند |
هر دو در لحظه چک چیزی پیدا نکردند. چون نتیجه تازه بعد از ۸۰۰ میلیثانیه ذخیره میشود. پس هر دو پرداخت را انجام دادند.
راه حل: اول کلید را ثبت کن
- جدول کلیدها یک unique constraint روی دو ستون شناسه کاربر و کلید دارد.
- درخواست اول یک ردیف با وضعیت «در حال انجام» درج میکند. بعد پرداخت را انجام میدهد.
- درخواست دوم هم میخواهد همان کلید را درج کند. دیتابیس به آن خطای unique میدهد. دیتابیس تضمین میکند فقط یکی موفق شود، حتی اگر هر دو در یک میلیثانیه و روی دو pod مختلف باشند.
- درخواست دوم پاسخ ۴۰۹ (Conflict) برمیگرداند، یعنی «در حال انجام است، بعداً دوباره بپرس». یا کمی صبر میکند و نتیجه درخواست اول را برمیگرداند.
- بعد از پایان پرداخت، وضعیت «تمام شد» و نتیجه (کد وضعیت و بدنه پاسخ) ذخیره میشود.
db.IdempotencyKeys.Add(new IdempotencyKey(userId, key, Status.InProgress, requestHash));
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
return Results.Conflict("Request is already being processed.");
}
// فقط یک درخواست به اینجا میرسد
دو نکته دیگر:
- یک hash از بدنه درخواست را هم ذخیره کن. اگر همان کلید با مبلغ متفاوت آمد، خطا بده (مثلاً کد ۴۲۲). این نشانه باگ در کلاینت است.
- کلیدها را بعد از مدتی (مثلاً ۲۴ ساعت) پاک کن.
سؤال پیگیری: اگر درخواست اول وسط کار crash کند و کلید برای همیشه در وضعیت «در حال انجام» بماند، چه میشود؟
جواب پیگیری: کاربر دیگر نمیتواند با این کلید پرداخت کند. راه حل:
- زمان شروع را هم ذخیره کن. اگر یک کلید بیشتر از حد (مثلاً ۵ دقیقه) «در حال انجام» ماند، یعنی کار وسط راه مانده است.
- ولی قبل از تکرار پرداخت، باید بفهمیم پرداخت اول واقعاً انجام شده یا نه. پس از درگاه پرداخت با همان شناسه میپرسیم. به این کار reconciliation میگویند.
- اگر پرداخت انجام شده بود، نتیجه را ذخیره کن و برگردان. اگر نه، دوباره انجامش بده.
نشانه خطر: پیشنهاد میدهد از دستور lock در C# استفاده کنیم. این دستور فقط داخل یک process کار میکند. دو درخواست روی دو pod مختلف را متوقف نمیکند.
۲۲. ترتیب رویدادها از چند سرور
Kafka· در نقشه راهConsistency و CAP· در نقشه راه
سؤال: سه سرویس روی سه سرور مختلف رویدادهای یک سفارش را میسازند: «سفارش ساخته شد»، «پرداخت انجام شد»، «ارسال شد». هر سرویس با ساعت سرور خودش یک timestamp روی رویداد میگذارد. همه سرورها با NTP همگام میشوند. یک سرویس گزارشگیری این رویدادها را بر اساس timestamp مرتب میکند و بعد محاسبات گزارش را انجام میدهد. این طراحی را چطور میبینی؟ آیا این ترتیب همیشه قابل اعتماد است؟
جواب کوتاه: ساعت سرورها دقیقاً با هم یکی نیست. به این اختلاف Clock Skew میگویند. وقتی دو رویداد با فاصله چند میلیثانیه روی دو سرور مختلف اتفاق میافتند، timestamp آنها قابل اعتماد نیست. برای تعیین ترتیب باید از چیزی غیر از ساعت استفاده کرد.
سرنخ (اگر کاندید گفت مشکلی نیست): گاهی در خروجی گزارش، «پرداخت انجام شد» قبل از «سفارش ساخته شد» دیده میشود. در این موارد، محاسبات گزارش اشتباه میشود. حالا چرا؟
یک مثال عددی:
- ساعت سرور سفارش ۱۰۰ میلیثانیه جلو است. ساعت سرور پرداخت دقیق است.
- سفارش در زمان واقعی «ساعت ۱۰، میلیثانیه ۰» ساخته میشود. ولی timestamp آن «ساعت ۱۰، میلیثانیه ۱۰۰» ثبت میشود.
- پرداخت ۵۰ میلیثانیه بعد انجام میشود، یعنی «ساعت ۱۰، میلیثانیه ۵۰». همین را هم به عنوان timestamp میگیرد.
- حالا اگر بر اساس timestamp مرتب کنیم، پرداخت قبل از سفارش میآید.
حتی با NTP، ساعتها چند میلیثانیه (گاهی بیشتر) با هم فرق دارند. ساعت یک سرور ممکن است هنگام همگامسازی حتی به عقب بپرد.
راهها:
- شماره نسخه از یک منبع واحد: سفارش در دیتابیس خودش یک شماره نسخه دارد (۱، ۲، ۳ و …). هر رویداد شماره نسخه سفارش را همراه دارد. این عدد فقط در یک جا ساخته میشود، پس ترتیبش درست است.
- ترتیب در Kafka: همه رویدادهای یک سفارش را با کلید شناسه سفارش به یک topic بفرست. همه در یک partition قرار میگیرند. شماره offset ترتیب واقعی رسیدن را نشان میدهد.
- رابطه علت و معلول صریح: رویداد «پرداخت» شناسه رویدادی را که باعثش شده (سفارش) همراه دارد. به این شناسه causation id میگویند. گزارش میداند این رویداد بعد از آن یکی است، با هر timestamp که داشته باشد.
- ساعت منطقی (Lamport Clock یا Vector Clock): کافی است کاندید بداند چنین راهی هست.
قانون: timestamp برای نمایش و تحلیل تقریبی خوب است. ولی برای تصمیم درباره ترتیب دقیق بین سرورها مناسب نیست.
سؤال پیگیری: زمان را در دیتابیس چطور ذخیره میکنی؟ اگر یک Job باید برای مشتریهای چند کشور «ساعت ۰۰:۰۰ به وقت محلی هر مشتری» اجرا شود، چه نکتهای هست؟
جواب پیگیری:
- زمان را به UTC ذخیره کن. نوع DateTimeOffset بهتر از DateTime است. چون اختلاف ساعت (offset) همراه زمان است و ابهامی نمیماند.
- برای «نیمهشب محلی»، منطقه زمانی هر مشتری را با شناسه استاندارد IANA نگه دار (مثلاً منطقه مادرید). بعد با کلاس TimeZoneInfo حساب کن، نه با یک offset ثابت. دلیلش این است:
- با ساعت تابستانی، offset عوض میشود.
- گاهی یک ساعت دو بار تکرار میشود.
- گاهی یک ساعت اصلاً وجود ندارد.
- برای اینکه کد قابل تست باشد، به جای خواندن مستقیم زمان فعلی سیستم، از کلاس TimeProvider استفاده کن.
نشانه خطر: میگوید «همه سرورها را با NTP همگام میکنیم و مشکل حل است».
۲۳. پارتیشن داغ در Kafka
سؤال: یک topic با ۱۲ partition داریم. یک سرویس با ۱۲ pod (در یک consumer group) آن را میخواند. کلید (key) پیامها شناسه مشتری است. یک مشتری بزرگ حدود ۷۰٪ کل پیامها را میسازد. این طراحی را چطور میبینی؟ در Production با این بار چه اتفاقی میافتد؟ اگر بار بیشتر شود، آیا بیشتر کردن partition ها و pod ها کافی است؟
جواب کوتاه: کافکا همه پیامهای یک key را به یک partition میفرستد. هر partition هم فقط به یک consumer داده میشود. پس ۷۰٪ کار روی یک pod است. partition بیشتر کمکی نمیکند، چون کلید این مشتری هنوز یکی است. باید کلید را عوض کنیم یا بار این مشتری را جدا پخش کنیم.
سرنخ (اگر کاندید گفت مشکلی نیست): در مانیتورینگ میبینیم یک pod چند ساعت عقب افتاده است (consumer lag بالاست). یازده pod دیگر تقریباً بیکارند. تیم تعداد partition ها و pod ها را به ۲۴ رساند، ولی هیچ فرقی نکرد. حالا چرا؟
چرا این اتفاق میافتد؟ (قدم به قدم)
- کافکا برای انتخاب partition یک فرمول ساده دارد: hash کلید را میگیرد و باقیمانده تقسیم آن بر تعداد partition ها را حساب میکند. پس یک مشتری همیشه به یک partition میرود. این کار عمدی است، تا ترتیب پیامهای هر مشتری حفظ شود.
- در یک consumer group، هر partition در هر لحظه فقط به یک consumer (یک pod) داده میشود.
- پس همه پیامهای مشتری بزرگ، یعنی ۷۰٪ کل کار، فقط به یک pod میرسد. به این مشکل Hot Partition میگویند.
- با ۲۴ partition هم مشتری بزرگ هنوز یک key است. پس باز هم فقط یک partition و یک pod دارد.
راهها:
- کلید ریزتر: شاید ترتیب فقط داخل یک سفارش لازم باشد، نه برای کل مشتری. در این حالت، کلید را شناسه سفارش بگذار. سفارشهای مشتری بزرگ بین همه partition ها پخش میشوند. این بهترین و سادهترین راه است.
- روش Salting: کلید را شناسه مشتری بهعلاوه یک عدد بین ۰ تا N بگذار (مثلاً «مشتری ۴۲، شماره ۳»). بار این مشتری روی N تا partition پخش میشود. ولی ترتیب کلی پیامهای این مشتری از بین میرود.
- یک topic جدا برای مشتریهای بزرگ، با consumer های خودش.
- پردازش موازی داخل همان pod: pod پیامها را بر اساس شناسه سفارش بین چند worker داخلی پخش میکند. این راه سختتر است، چون commit کردن offset پیچیده میشود. فقط تا پیامی میشود commit کرد که همه پیامهای قبلیاش تمام شده باشند.
- سریعتر کردن پردازش هر پیام: مثلاً نوشتن دستهای (batch) در دیتابیس، به جای نوشتن یکییکی.
برای مانیتورینگ، lag را برای هر partition جدا نگاه کن، نه فقط مجموع کل group. مجموع کل این مشکل را پنهان میکند.
سؤال پیگیری: آیا راهحل تو ترتیب پیامهای این مشتری را خراب میکند؟ این مهم است یا نه؟
جواب پیگیری:
- با Salting، بله. ترتیب کلی از بین میرود.
- با کلید شناسه سفارش، ترتیب پیامهای هر سفارش حفظ میشود. ولی ترتیب بین سفارشهای مختلف یک مشتری حفظ نمیشود.
- کاندید خوب اول میپرسد: «ترتیب واقعاً کجا لازم است؟» مثلاً برای مانده حساب مشتری شاید ترتیب کلی مهم باشد. ولی برای ارسال مهم نیست.
نشانه خطر: تنها راهحلش «partition و consumer بیشتر» است.
۲۴. دادهای که در سرویس دیگر است
Microservices· در نقشه راهDomain-Driven Design· در نقشه راه
سؤال: صفحه «لیست سفارشها» ۵۰ سفارش نشان میدهد. برای هر سفارش، نام مشتری و نام محصول هم لازم است. سفارش در سرویس Order، مشتری در سرویس Customer و محصول در سرویس Catalog است. هر کدام دیتابیس خودش را دارد. الان سرویس Order برای هر سفارش، یک بار سرویس Customer و یک بار سرویس Catalog را با HTTP صدا میزند. این طراحی را چطور میبینی؟ آیا مشکلی دارد؟ اگر بخواهی بهترش کنی، چه راههایی داری و کدام را انتخاب میکنی؟
جواب کوتاه: این همان مشکل N+1 (سؤال ۳) است، این بار با فراخوانی HTTP به جای کوئری. ۵۰ سفارش ضرب در ۲ فراخوانی میشود ۱۰۰ فراخوانی شبکه، پشت سر هم. اگر هر کدام حدود ۴۰ میلیثانیه باشد، جمعش ۴ ثانیه است. سادهترین بهبود: به جای ۱۰۰ فراخوانی، ۲ فراخوانی دستهای و موازی.
سرنخ (اگر کاندید گفت مشکلی نیست): بارگذاری این صفحه حدود ۴ ثانیه طول میکشد. هر فراخوانی HTTP به سرویس دیگر حدود ۴۰ میلیثانیه طول میکشد.
راهها، از ساده به پیچیده:
۱. فراخوانی دستهای و موازی (API Composition):
- شناسههای مشتریها را جمع میکنیم و تکراریها را حذف میکنیم. همین کار را برای محصولها هم میکنیم.
- همه مشتریها را با یک درخواست میگیریم. همه محصولها را هم با یک درخواست.
- این دو درخواست را همزمان میفرستیم، نه پشت سر هم.
var customerIds = orders.Select(o => o.CustomerId).Distinct();
var productIds = orders.Select(o => o.ProductId).Distinct();
var customersTask = customerClient.GetByIdsAsync(customerIds, ct);
var productsTask = catalogClient.GetByIdsAsync(productIds, ct);
await Task.WhenAll(customersTask, productsTask);
از ۱۰۰ فراخوانی پشت سر هم به ۲ فراخوانی همزمان میرسیم. زمان از ۴ ثانیه به حدود ۵۰ میلیثانیه میرسد. یک کش کوتاه هم کمک میکند. این معمولاً اولین قدم است.
۲. کپی داده در لحظه ثبت (Snapshot): هنگام ساخت سفارش، نام مشتری و نام و قیمت محصول در خود سفارش ذخیره میشود. دیگر برای نمایش لیست به هیچ سرویسی نیاز نیست. برای فاکتور و اسناد مالی، این اغلب درستترین کار است. چون قیمت زمان خرید مهم است.
۳. کپی محلی با رویداد:
- سرویس Customer با هر تغییر نام، یک رویداد «نام مشتری عوض شد» منتشر میکند.
- سرویس Order یک جدول کوچک دارد: شناسه مشتری و نام.
- سرویس Order با این رویدادها جدولش را بهروز میکند.
خواندن سریع است و به سرویس Customer وابسته نیست. ولی داده چند ثانیه تأخیر دارد (eventual consistency) و کد بیشتری میخواهد.
۴. مدل خواندن جدا (Read Model در CQRS): یک دیتابیس یا ایندکس مخصوص همین صفحه داریم (مثلاً Elasticsearch). این ایندکس از رویدادهای هر سه سرویس پر میشود. برای جستجو و فیلتر پیچیده خوب است. ولی پرهزینهترین راه است.
کدام را انتخاب کنیم؟
- معمولاً اول راه ۱.
- راه ۲ برای دادهای که باید «تاریخی» بماند.
- راه ۳ یا ۴ وقتی بار زیاد است یا جستجوی پیچیده لازم است.
- اگر این سه داده تقریباً همیشه با هم لازماند، شاید مرز سرویسها اشتباه کشیده شده است. کاندیدی که این را بگوید، عمق خوبی دارد.
سؤال پیگیری: اگر مشتری نامش را عوض کند، سفارشهای قدیمیاش باید نام جدید را نشان دهند یا نام قدیمی را؟ این تصمیم با کیست؟
جواب پیگیری: این یک تصمیم business است، نه فنی. باید از Product Owner پرسید. معمولاً در فاکتور، نام و قیمت زمان خرید باید بماند (راه ۲). برای نمایش در پنل، شاید نام جدید بهتر باشد. نکته اصلی این است که کاندید خودش تصمیم نگیرد.
نشانه خطر: پیشنهاد میدهد سرویس Order مستقیم به دیتابیس سرویس Customer وصل شود، یا بین دیتابیسها JOIN بزند.
۲۵. شکست در جبران کار، Saga
سؤال: ثبت سفارش در سیستم میکروسرویسی ما یک Saga سه مرحلهای است: (۱) سرویس انبار کالا را رزرو میکند، (۲) سرویس پرداخت پول را از کارت مشتری کم میکند، (۳) سرویس ارسال درخواست پیک را ثبت میکند. یک روز مرحله ۳ خطا میدهد (مثلاً آدرس خارج از محدوده است). پس Saga باید پول را به مشتری برگرداند (refund) و رزرو انبار را آزاد کند. اما سرویس پرداخت هم الان قطع است و refund شکست میخورد. چه باید بشود؟ Saga را چطور طراحی میکنی که هیچ سفارشی در وضعیت نامعلوم گم نشود و پول هیچ مشتری گم نشود؟
جواب کوتاه: جبران کار (compensation) هم ممکن است شکست بخورد. پس باید برایش برنامه داشته باشیم:
- وضعیت Saga در دیتابیس ذخیره میشود.
- جبران با retry آنقدر تکرار میشود تا موفق شود.
- اگر باز هم نشد، کار به بررسی دستی میرود.
از همه بهتر این است که ترتیب مراحل را طوری طراحی کنیم که کمتر به refund نیاز باشد.
اول: Saga چیست؟
- در میکروسرویس، تراکنش مشترک بین سرویسها نداریم.
- یک Saga چند مرحله پشت سر هم است. هر مرحله در سرویس خودش یک تراکنش محلی است.
- هر مرحله یک «کار جبرانی» دارد. مثلاً کار جبرانیِ «رزرو» میشود «آزاد کردن رزرو».
- اگر مرحلهای شکست بخورد، کارهای جبرانی مراحل قبلی به ترتیب برعکس اجرا میشوند.
وقتی refund شکست میخورد، چه باید بشود؟
- وضعیت Saga در دیتابیس است، نه فقط در حافظه. مثلاً سفارش در وضعیت «منتظر برگشت پول» است. اگر سرویس ریاستارت شود، میداند کار از کجا مانده است.
- تکرار با فاصله بیشترشونده: refund بعد از ۱ دقیقه، ۵ دقیقه، ۳۰ دقیقه و … دوباره امتحان میشود، حتی اگر ساعتها طول بکشد.
- جبران باید idempotent باشد: هر refund یک شناسه یکتا دارد. شاید یک تلاش موفق شده ولی جوابش گم شده باشد. با این شناسه، تلاش بعدی دو بار پول برنمیگرداند.
- بررسی دستی: اگر بعد از یک حد مشخص (مثلاً ۲۴ ساعت) هنوز موفق نشد، سفارش به وضعیت «نیاز به بررسی» میرود. پیام به Dead Letter Queue میرود و به تیم مالی هشدار داده میشود.
- مهلت (timeout) برای هر مرحله: Saga هایی که مدت زیادی در یک مرحله ماندهاند، پیدا و گزارش میشوند.
- وضعیت واقعی به مشتری نشان داده میشود: «سفارش لغو شد، وجه در حال برگشت است».
- مقایسه شبانه (reconciliation) بین سیستم ما و درگاه پرداخت، به عنوان آخرین خط امنیت.
طراحی بهتر ترتیب مراحل (نکته طلایی):
- در مرحله ۲ پول را برنمیداریم. فقط آن را نگه میداریم (Authorize).
- بعد از موفقیت مرحله ۳، پول را برمیداریم (Capture).
- اگر ارسال شکست بخورد، فقط پول نگهداشتهشده آزاد میشود. این خیلی سادهتر از refund است. معمولاً بعد از چند روز خودکار هم آزاد میشود.
قانون کلی: مرحلهای که برگرداندنش سختتر است، آخر از همه باشد.
سؤال پیگیری: Saga را با Orchestration میسازی یا Choreography؟ فرقشان چیست؟
جواب پیگیری:
- روش Orchestration: یک سرویس مرکزی (orchestrator) مثل مدیر پروژه است. به هر سرویس میگوید چه کاری کند و وضعیت را نگه میدارد. دیدن وضعیت، مدیریت timeout و جبران سادهتر است. برای Saga های چندمرحلهای مثل این، معمولاً بهتر است.
- روش Choreography: مدیر مرکزی نداریم. هر سرویس به رویداد قبلی واکنش نشان میدهد. مثلاً انبار با «سفارش ثبت شد» رزرو میکند و پرداخت با «رزرو شد» پول میگیرد. وابستگی کمتر است. ولی فهمیدن اینکه «این سفارش الان کجای کار است؟» سخت میشود.
- در داتنت، ابزارهایی مثل MassTransit (با State Machine Saga) این الگو را آماده دارند.
نشانه خطر: پیشنهاد تراکنش توزیعشده (2PC) بین سرویسها را میدهد، یا برای شکست خود جبران هیچ برنامهای ندارد.
۲۶. مقیاسپذیری در Kubernetes: افقی و عمودی
سؤال: یک API با .NET در Kubernetes داریم. تنظیمات هر pod این است:
- مقدار CPU request برابر 500m (نیم هسته) است.
- مقدار CPU limit برابر ۱ هسته است.
- مقدار Memory request و Memory limit هر دو 512Mi است.
یک HPA هم داریم. وقتی مصرف CPU به ۷۰٪ برسد، pod اضافه میکند. در ساعت شلوغی، در مانیتورینگ این سه چیز را میبینیم:
- زمان پاسخ بالا میرود. ولی HPA هیچ pod جدیدی نمیسازد، چون CPU حدود ۴۰٪ است.
- بعضی pod ها با وضعیت OOMKilled ریاستارت میشوند.
- گاهی pod های جدید ساخته میشوند، ولی چند دقیقه در وضعیت Pending میمانند.
فرق scale افقی و عمودی چیست؟ request و limit دقیقاً چه کار میکنند؟ هر کدام از این سه اتفاق را چطور توضیح میدهی و چه تغییری میدهی؟
جواب کوتاه: افقی یعنی pod بیشتر. عمودی یعنی منابع بیشتر برای هر pod. مقدار request رزرو است و مقدار limit سقف مصرف است. سه اتفاق به این دلیلها است:
- اتفاق ۱: گلوگاه CPU نیست. پس HPA که به CPU نگاه میکند، کاری نمیکند.
- اتفاق ۲: مصرف حافظه از limit گذشته است.
- اتفاق ۳: هیچ node ای به اندازه request جای خالی ندارد.
اول: افقی و عمودی
| افقی (HPA) | عمودی (VPA) | |
|---|---|---|
| چه میکند | تعداد pod را بیشتر میکند | CPU و حافظه هر pod را بیشتر میکند |
| شرط | برنامه باید stateless باشد (session در حافظه یا فایل محلی مشکل میسازد) | معمولاً pod باید ریاستارت شود |
| محدودیت | تقریباً بیسقف (تا ظرفیت cluster) | سقف دارد: اندازه یک node |
نکته: HPA و VPA را روی یک معیار (مثلاً هر دو روی CPU) با هم استفاده نکن. با هم تداخل دارند.
دوم: request و limit
- مقدار request چیزی است که Kubernetes برای pod رزرو میکند. pod فقط روی node ای مینشیند که این مقدار را آزاد داشته باشد.
- نکته مهم: درصد CPU در HPA نسبت به request حساب میشود، نه نسبت به limit.
- مقدار limit سقف مصرف است. رفتار CPU و حافظه در این سقف فرق دارد:
- وقتی CPU به limit برسد: throttling رخ میدهد. pod کشته نمیشود، فقط کند میشود.
- وقتی حافظه از limit بگذرد: OOMKilled رخ میدهد. pod کشته میشود.
توضیح هر سه اتفاق
اتفاق ۱ (کندی با CPU ۴۰٪):
- وقتی CPU کم است ولی برنامه کند است، یعنی برنامه منتظر چیزی است. مثلاً دیتابیس، یک سرویس دیگر، یا thread pool (مثل سؤال ۶).
- این انتظار را HPA نمیبیند، چون فقط به CPU نگاه میکند.
- اگر گلوگاه دیتابیس باشد، pod بیشتر فقط بار دیتابیس را بیشتر میکند.
- علت دیگر میتواند throttling باشد. میانگین CPU پایین است، ولی در لحظههای کوتاه pod به limit میرسد و متوقف میشود.
- برای دیدن این حالت، متریک throttling را در Prometheus چک کن (متریک cfs throttled periods برای container).
- راه حل: اول علت واقعی را پیدا کن. اگر بار با تعداد request ها بالا میرود، HPA را روی معیار بهتری بگذار: تعداد request در ثانیه، latency، یا طول صف (مثلاً با KEDA).
اتفاق ۲ (OOMKilled):
- در .NET، GC سقف حافظه container را میبیند. به طور پیشفرض حدود ۷۵٪ آن را برای heap استفاده میکند.
- بقیه حافظه برای حافظه native، stack ها و چیزهای دیگر است.
- اول نشت حافظه را بررسی کن (سؤال ۳۵).
- اگر نشتی نیست و برنامه واقعاً حافظه بیشتری لازم دارد، limit را بر اساس مصرف واقعی بالا ببر.
اتفاق ۳ (Pending):
- وضعیت Pending یعنی scheduler هیچ node ای پیدا نکرده که این مقدار request را آزاد داشته باشد.
- یک راه: Cluster Autoscaler یک node جدید اضافه کند. این کار چند دقیقه طول میکشد.
- راه دیگر: مقدار request واقعبینانه باشد. request خیلی بزرگ ظرفیت را هدر میدهد. request خیلی کوچک باعث میشود pod های زیادی روی یک node جمع شوند.
چند نکته مخصوص .NET و تنظیم رایج
- در .NET، تعداد پردازندهها (ProcessorCount) از CPU limit گرفته میشود. با limit برابر ۱، thread pool و GC فکر میکنند فقط یک هسته وجود دارد.
- تنظیم رایج: Memory request برابر Memory limit باشد تا رفتار قابل پیشبینی باشد. CPU request هم بر اساس مصرف واقعی باشد.
- بعضی تیمها CPU limit را بالا میگذارند یا اصلاً نمیگذارند تا throttling نشود. این بحثبرانگیز است. کاندید خوب هزینه و فایدهاش را میگوید.
- سه کلاس QoS داریم: Guaranteed (وقتی request با limit برابر است)، Burstable و BestEffort. وقتی node کمبود حافظه دارد، اول pod های BestEffort و بعد Burstable بیرون انداخته میشوند.
سؤال پیگیری: کی scale عمودی را به افقی ترجیح میدهی؟ اگر پیک ترافیک هر روز ساعت ۹ صبح است و HPA دیر واکنش نشان میدهد، چه میکنی؟
جواب پیگیری:
- عمودی را برای چیزهایی انتخاب کن که نمیشود چند نسخه از آنها داشت. مثلاً دیتابیس، سرویس stateful، یا برنامه قدیمی که state را در حافظه نگه میدارد.
- برای پیک قابل پیشبینی: کمی قبل از ساعت ۹، حداقل تعداد pod را با زمانبندی بالا ببر (مثلاً با cron scaler در KEDA).
- زمان بالا آمدن pod را کم کن: image کوچکتر، ReadyToRun، و گرم کردن (warm-up) قبل از اینکه readiness مثبت شود.
- دلیل: HPA ذاتاً با تأخیر واکنش نشان میدهد.
نشانه خطر: throttling و OOM را با هم اشتباه میکند. یا فکر میکند درصد HPA نسبت به limit است. یا تنها جوابش «pod بیشتر» است.
۲۷. رابطه partition های Kafka و pod ها
Kafka· در نقشه راهKubernetes· در نقشه راه
سؤال: یک topic با ۶ partition داریم. یک سرویس مصرفکننده با ۴ pod آن را میخواند. همه pod ها در یک consumer group هستند. مانیتورینگ نشان میدهد دو pod مشغولتر از بقیه هستند. رابطه partition و pod چیست؟ بار بین این ۴ pod چطور تقسیم میشود؟ اگر HPA تعداد pod ها را به ۱۰ برساند چه میشود؟ اگر فقط ۱ pod بماند چه؟
جواب کوتاه: قانون اصلی این است:
- در یک consumer group، هر partition در هر لحظه فقط به یک consumer داده میشود.
- ولی یک consumer میتواند چند partition داشته باشد.
- پس بیشترین تعداد pod مفید برابر تعداد partition ها است.
سه حالت
| حالت | چه اتفاقی میافتد |
|---|---|
| ۶ partition و ۴ pod | دو pod هر کدام ۲ partition میگیرند و دو pod هر کدام ۱ partition. بار نامساوی است. برای همین دو pod مشغولترند. |
| ۶ partition و ۱۰ pod | شش pod هر کدام یک partition میگیرند و چهار pod بیکارند. فقط وقتی یک pod بمیرد، یکی از بیکارها جایش را میگیرد. سرعت بیشتر نمیشود. |
| ۶ partition و ۱ pod | همان یک pod هر ۶ partition را میگیرد. کار میکند، فقط کندتر است. |
نتیجههای عملی
- حداکثر تعداد pod در HPA (تنظیم maxReplicas) نباید از تعداد partition ها بیشتر باشد. pod اضافه فقط منبع هدر میدهد.
- برای تقسیم مساوی، تعداد partition را عددی بگذار که بر تعدادهای معمول pod بخشپذیر باشد. مثلاً ۱۲ partition با ۲، ۳، ۴، ۶ یا ۱۲ pod مساوی تقسیم میشود.
- برای scale کردن consumer ها، معیار درست consumer lag است، یعنی تعداد پیامهای خواندهنشده. CPU معیار درستی نیست. با KEDA میشود بر اساس lag مقیاس داد.
- بعداً میشود partition اضافه کرد، ولی نمیشود کم کرد.
- اضافه کردن partition، جای بعضی key ها را عوض میکند. پس ترتیب پیامهای آن key ها در همان لحظه ممکن است به هم بریزد.
- پس از اول کمی جا برای رشد بگذار.
مفهوم rebalance
- هر بار که تعداد pod ها عوض شود (scale، deploy یا crash)، Kafka partition ها را دوباره بین pod ها تقسیم میکند. به این rebalance میگویند.
- در این مدت مصرف پیام کند یا متوقف میشود.
- برای کم کردن اثرش، استراتژی CooperativeSticky را استفاده کن. با آن فقط partition های لازم جابجا میشوند، نه همه.
- برای ریاستارتهای کوتاه، static membership را فعال کن. یعنی هر pod یک شناسه ثابت در group دارد.
یک نکته .NET: در کتابخانه Confluent.Kafka، یک consumer را نمیشود همزمان از چند thread استفاده کرد. اگر در یک pod چند consumer بسازیم، هر کدام یک عضو جدای group حساب میشود و partition جدا میگیرد.
سؤال پیگیری: وسط پردازش یک پیام، rebalance رخ میدهد و partition از این pod گرفته میشود. چه مشکلی پیش میآید و چطور مدیریتش میکنی؟
جواب پیگیری:
- مشکل: پیامی که پردازش شده ولی offset آن هنوز commit نشده، به pod دیگری داده میشود. پس دو بار پردازش میشود.
- راه حل اول: پردازش idempotent باشد (سؤال ۵).
- راه حل دوم: Kafka قبل از گرفتن partition یک handler را صدا میزند (در Confluent.Kafka با SetPartitionsRevokedHandler). در این handler کار جاری را تمام کن و offset را commit کن.
نشانه خطر: فکر میکند دو pod در یک group میتوانند یک partition را با هم بخوانند و بار را نصف کنند. یا فکر میکند pod بیشتر همیشه یعنی سرعت بیشتر.
۲۸. استفاده از Task یا Thread
async/await و Thread Pool· در نقشه راه
سؤال: دو حالت را در نظر بگیر:
- حالت الف: یک API تعداد request خیلی زیادی دارد (مثلاً ۱۰ هزار request همزمان). هر request چند کار I/O انجام میدهد (دیتابیس، HTTP). بهتر است از Task (یعنی async/await) استفاده کنیم یا برای هر request یک Thread بسازیم؟ چرا؟
- حالت ب: یک سرویس ۱۰ کار پسزمینه دارد که همیشه در حال اجرا هستند. مثلاً گوش دادن به یک socket یا خواندن یک stream قیمت. برای اینها ۱۰ Task بهتر است یا ۱۰ Thread همیشه فعال؟
جواب کوتاه:
- برای حالت الف همیشه async/await. چون وقت انتظار برای I/O هیچ thread ای اشغال نمیشود.
- برای حالت ب جواب بستگی دارد که کد داخل حلقه async است یا blocking.
- اگر کد async است: Task معمولی.
- اگر کد blocking است: thread اختصاصی، نه thread pool.
فرق Thread و Task به زبان ساده
- یک Thread منبع سیستمعامل است. هر thread حافظه stack جدا دارد (معمولاً حدود ۱ مگابایت رزرو). ساختنش هزینه دارد. جابجایی بین thread ها (context switch) هم وقت CPU میگیرد.
- یک Task یک «کار» است، نه یک thread. Task ها روی thread های thread pool اجرا میشوند. وقتی یک Task منتظر I/O است (await)، هیچ thread ای اشغال نمیکند.
حالت الف: چرا async/await؟
- اگر برای هر request یک thread بسازیم، ۱۰ هزار request یعنی ۱۰ هزار thread.
- این یعنی حدود ۱۰ گیگابایت فضای stack. CPU هم بیشتر وقتش را صرف جابجایی بین thread ها میکند.
- بیشتر این thread ها فقط منتظر دیتابیس هستند و کاری نمیکنند.
- با async/await، وقتی request منتظر دیتابیس است، thread آزاد میشود و request دیگری را جلو میبرد.
- پس با چند ده thread میشود هزاران request همزمان را جواب داد.
- خود ASP.NET Core از thread pool استفاده میکند. کار ما فقط این است که کد را async بنویسیم و با خواندن Result آن را بلاک نکنیم (سؤال ۲).
حالت ب: بستگی دارد کد async است یا blocking
اگر کد async است (مثل متدهای ReceiveAsync در socket، ReadAsync در stream، یا کتابخانه System.IO.Pipelines):
- ده Task معمولی (مثلاً ۱۰ BackgroundService) بهترین انتخاب است.
- وقتی داده نیامده، هیچ thread ای اشغال نیست. پس این ۱۰ Task تقریباً هزینهای ندارند.
اگر کد blocking است (کتابخانهای که فقط متد sync دارد، متد Read بلاککننده، یا حلقه بیپایان محاسبه سنگین):
- نباید آن را به صورت Task معمولی روی thread pool اجرا کرد. دلیلش در چند قدم:
- این ۱۰ کار، ۱۰ thread از pool را برای همیشه اشغال میکنند.
- اگر pool مثلاً ۸ thread اولیه داشته باشد، برای request های API چیزی نمیماند.
- از طرف دیگر، thread pool هر thread جدید را آهسته اضافه میکند.
- نتیجه: کل برنامه کند میشود. به این thread pool starvation میگویند.
- راه درست: برای هر کار یک thread اختصاصی بساز. دو راه داریم:
// راه ۱: یک Thread پسزمینه
var t = new Thread(Listen) { IsBackground = true };
t.Start();
// راه ۲: یک thread جدا، خارج از pool
Task.Factory.StartNew(Listen, TaskCreationOptions.LongRunning);
اگر کار CPU سنگین است:
- تعداد thread فعال بیشتر از تعداد هستهها فایده ندارد. ده thread سنگین روی ۲ هسته فقط context switch بیشتر میسازد.
- الگوی بهتر سه بخش دارد: یک بخش که داده را میخواند، یک صف در حافظه (System.Threading.Channels)، و تعدادی worker به اندازه تعداد هستهها.
در همه حالتها: برای خاموش شدن امن، CancellationToken لازم است (سؤال ۱۱).
سؤال پیگیری: چرا این کد یک اشتباه رایج است؟
Task.Factory.StartNew(async () => await ListenAsync(ct), TaskCreationOptions.LongRunning);
جواب پیگیری:
- گزینه LongRunning یک thread اختصاصی میسازد. ولی کد فقط تا اولین await روی آن thread اجرا میشود.
- بعد از اولین await، ادامه کد روی thread pool میرود. thread اختصاصی بیفایده تمام میشود.
- پس LongRunning فقط برای کد blocking معنی دارد.
- مشکل دوم: StartNew با یک lambda از نوع async، یک Task تودرتو برمیگرداند (یک Task که داخلش Task دیگری است).
- اگر آن را با متد Unwrap باز نکنیم، await فقط تا اولین await داخلی صبر میکند. خطاهای بعدی هم گم میشوند.
- برای کد async کافی است متد را مستقیم صدا بزنیم، یا از متد Run در کلاس Task استفاده کنیم. این متد خودش Task تودرتو را باز میکند.
نشانه خطر: میگوید «Task سریعتر از Thread است» یا «Task یعنی همیشه یک thread جدید». یا برای هر request یک thread پیشنهاد میدهد.
۲۹. مهاجرت به Redis Cluster
سؤال: از یک Redis تکی به Redis Cluster با ۳ node اصلی میرویم. در سیستم فعلی این دو مورد را داریم:
- یک Lua script داریم که موجودی چند محصول را با هم و به صورت اتمی کم میکند (کلیدهای موجودی محصول ۱، ۲ و ۳). روی Redis تکی درست کار میکند.
- یک کلید خیلی پرطرفدار داریم: تنظیمات صفحه اول که همه request ها میخوانند.
بعد از رفتن به Cluster، این دو مورد چطور رفتار میکنند؟ آیا مشکلی پیش میآید؟ اگر بله، علت چیست و چطور حلش میکنی؟
جواب کوتاه: در Cluster هر کلید روی یک node مشخص است.
- مشکل ۱: کلیدهای این script روی node های مختلف هستند. پس Redis نمیتواند آن را اتمی اجرا کند.
- مشکل ۲: همه درخواستهای یک کلید به همان یک node میروند. Cluster بار یک کلید را پخش نمیکند.
سرنخ (اگر کاندید گفت مشکلی نیست): بعد از مهاجرت، Lua script خطای CROSSSLOT برمیگرداند. متن خطا میگوید کلیدهای این درخواست در یک slot نیستند. در مانیتورینگ هم میبینیم CPU یکی از node ها به ۱۰۰٪ رسیده، ولی دو node دیگر تقریباً بیکارند.
روش پخش کلیدها در Redis Cluster
- فضای کلیدها به ۱۶۳۸۴ بخش تقسیم شده است. به هر بخش hash slot میگویند. هر node مالک تعدادی از slot ها است.
- برای هر کلید، Redis یک hash از نام کلید میگیرد (با CRC16) و باقیمانده آن بر ۱۶۳۸۴ را حساب میکند. این عدد slot کلید است.
- پس کلید موجودی محصول ۱ و کلید موجودی محصول ۲ معمولاً slot های مختلف و node های مختلف دارند.
- دستورهای چندکلیدی (مثل MGET، تراکنش MULTI و Lua script) فقط روی یک node اجرا میشوند.
- اگر کلیدها در یک slot نباشند، Redis خطای CROSSSLOT میدهد.
راه حل مشکل ۱: Hash Tag
اگر بخشی از نام کلید داخل آکولاد باشد، فقط همان بخش برای محاسبه slot استفاده میشود:
stock:{sale42}:p1
stock:{sale42}:p2
stock:{sale42}:p3
- حالا هر سه کلید در یک slot هستند و script دوباره کار میکند.
- مراقب باش: hash tag همه آن کلیدها را روی یک node جمع میکند. اگر زیاد از آن استفاده شود، خودش یک node داغ میسازد.
- راه دیگر: داده را طوری طراحی کن که عملیات اتمی فقط روی یک کلید باشد. مثلاً یک Hash با چند فیلد، یک فیلد برای هر محصول.
مشکل ۲: کلید داغ (Hot Key)
یک کلید همیشه روی یک node است. اگر همه request ها همان کلید را بخوانند، همه به همان node میروند. اضافه کردن node هم کمکی نمیکند. سه راه داریم:
- کش محلی کوتاهمدت در هر pod (مثلاً ۵ ثانیه): بیشتر خواندنها اصلاً به Redis نمیرسند. برای دادهای که کم عوض میشود، این سادهترین و بهترین راه است.
- چند نسخه از کلید: مثلاً ۸ کلید تنظیمات با شماره ۱ تا ۸ و مقدار یکسان بسازیم. هر request یکی را تصادفی میخواند. پس بار بین node ها پخش میشود.
- خواندن از replica: در StackExchange.Redis با پرچم PreferReplica. باید کمی تأخیر در داده را قبول کنیم.
برای پیدا کردن کلید داغ: ابزار redis-cli با گزینه hotkeys (به شرط سیاست حذف LFU)، یا مقایسه متریکهای هر node.
سؤال پیگیری: «Big Key» چیست؟ چرا حذف یک Hash با یک میلیون فیلد با دستور DEL خطرناک است؟
جواب پیگیری:
- در Redis، دستورها روی یک thread اصلی، یکی پس از دیگری اجرا میشوند.
- کار روی یک کلید خیلی بزرگ (مثل DEL، HGETALL یا SMEMBERS) ممکن است چند ثانیه طول بکشد.
- در این مدت همه کلاینتها منتظر میمانند.
- راه حل: کلیدها را کوچک نگه دار.
- برای حذف، به جای DEL از UNLINK استفاده کن. UNLINK در پسزمینه حذف میکند.
- برای خواندن، به جای خواندن کامل از HSCAN و SSCAN استفاده کن تا داده تکهتکه خوانده شود.
نشانه خطر: فکر میکند Cluster بار هر کلید را خودش بین node ها پخش میکند.
۳۰. Deadlock در دیتابیس
Transaction و Isolation Level· در نقشه راه
سؤال: یک سرویس انتقال وجه داریم. هر انتقال در یک تراکنش دو کار میکند:
- موجودی حساب مبدأ را کم میکند.
- موجودی حساب مقصد را زیاد میکند.
در ساعات شلوغ، تعداد زیادی انتقال همزمان بین حسابها انجام میشود. این طراحی را چطور میبینی؟ زیر این بار همزمان چه اتفاقی میافتد؟ آیا مشکلی دارد؟
جواب کوتاه: هر تراکنش یکی از دو حساب را قفل کرده و منتظر حساب دیگر است. هیچکدام نمیتواند جلو برود. به این deadlock میگویند. راه حل اصلی: همه تراکنشها قفلها را با ترتیب ثابت بگیرند.
سرنخ (اگر کاندید گفت مشکلی نیست): در ساعات شلوغ، API برای بعضی انتقالها این خطا را برمیگرداند: «Transaction was deadlocked on lock resources with another process and has been chosen as the deadlock victim». بررسی نشان میدهد این خطا معمولاً وقتی رخ میدهد که همزمان یک انتقال از حساب A به B و یک انتقال از B به A انجام میشود.
چه اتفاقی میافتد؟ (به ترتیب زمان)
| زمان | تراکنش ۱ (A به B) | تراکنش ۲ (B به A) |
|---|---|---|
| ۱ | حساب A را آپدیت میکند و قفل A را میگیرد | حساب B را آپدیت میکند و قفل B را میگیرد |
| ۲ | میخواهد B را آپدیت کند، پس منتظر قفل B میماند | میخواهد A را آپدیت کند، پس منتظر قفل A میماند |
| ۳ | هر دو برای همیشه منتظر هماند. دیتابیس این را تشخیص میدهد و یکی را قربانی (victim) میکند |
راه حل
- ترتیب ثابت قفلها: همیشه اول حسابی را آپدیت کن که Id کوچکتری دارد، چه مبدأ باشد چه مقصد. حالا هر دو تراکنش اول سراغ A میروند. دومی فقط صبر میکند تا اولی تمام شود. بنبستی پیش نمیآید.
- تراکنش کوتاه: کار کند (فراخوانی HTTP، محاسبه سنگین) نباید داخل تراکنش باشد. هرچه قفل کوتاهتر نگه داشته شود، احتمال برخورد کمتر است.
- ایندکس درست: بدون index، دیتابیس برای پیدا کردن ردیف، ردیفهای بیشتری را میخواند و قفل میکند.
- تکرار (retry): deadlock هیچ وقت کاملاً صفر نمیشود. برای خطای deadlock کل تراکنش را چند بار تکرار کن. شماره این خطا در SQL Server برابر ۱۲۰۵ و در PostgreSQL کد 40P01 است. در EF Core، execution strategy این کار را انجام میدهد (مثلاً با گزینه EnableRetryOnFailure).
- پیدا کردن علت دقیق: در SQL Server از deadlock graph استفاده کن (با Extended Events و session پیشفرض system_health). در PostgreSQL لاگ خود دیتابیس این را ثبت میکند. این گزارش نشان میدهد کدام دو کوئری و کدام قفلها درگیر بودند.
سؤال پیگیری: Isolation Level ها را میشناسی؟ Read Committed Snapshot (RCSI) چه مشکلی را حل میکند و چه مشکلی هنوز میماند؟
جواب پیگیری:
- در RCSI، خواندن از نسخه قبلی و commit شده داده انجام میشود (row versioning). پس خواندن منتظر قفل نوشتن نمیماند.
- نتیجه: خواندن و نوشتن همدیگر را بلاک نمیکنند. بیشتر deadlock های بین خواندن و نوشتن از بین میروند.
- ولی دو نوشتن روی یک ردیف هنوز همدیگر را بلاک میکنند. پس deadlock این سؤال با RCSI حل نمیشود.
- مشکلی که میماند: Write Skew. دو تراکنش داده قدیمی را میخوانند. هر کدام بر اساس آن تصمیم میگیرد و مینویسد.
- مثال: موجودی ۱۰۰ است و دو برداشت ۸۰ تومانی همزمان میآید. هر کدام موجودی ۱۰۰ را میبیند و قبول میکند.
- راه حل اول: آپدیت شرطی و اتمی. یعنی کم کردن موجودی و چک کردن کافی بودن آن در یک دستور:
UPDATE Accounts
SET Balance = Balance - @x
WHERE Id = @id AND Balance >= @x;
- اگر هیچ ردیفی آپدیت نشد، یعنی موجودی کافی نبوده است.
- راه حل دوم: قفل صریح هنگام خواندن. در SQL Server با UPDLOCK و در PostgreSQL با FOR UPDATE.
نشانه خطر: راه حلش «NOLOCK میگذاریم» است. با NOLOCK دیتابیس داده نیمهکاره و commit نشده برمیگرداند (dirty read). حتی ممکن است ردیف تکراری یا گمشده برگرداند. برای انتقال وجه این خطرناک است.
۳۱. لغو درخواست توسط کاربر
async/await و Thread Pool· در نقشه راهMiddleware و Pipeline· در نقشه راه
سؤال: یک گزارش سنگین داریم. کوئری آن حدود ۲ دقیقه طول میکشد. کاربرها معمولاً صبر نمیکنند. صفحه را میبندند یا چند بار Refresh میزنند. این endpoint را در code review میبینی:
app.MapGet("/reports/sales", async (AppDbContext db) =>
await db.Sales.Where(...).GroupBy(...).ToListAsync());
نظرت چیست؟ آیا مشکلی دارد؟ وقتی کاربر صفحه را میبندد، چه اتفاقی برای کوئری میافتد؟
جواب کوتاه: وقتی کاربر صفحه را میبندد، ASP.NET Core متوجه میشود. ولی فقط یک «سیگنال» میفرستد. اسم این سیگنال CancellationToken است. اگر کد این token را به کوئری نداده باشد، کسی به دیتابیس نمیگوید «متوقف شو». پس کوئری تا آخر اجرا میشود. راه حل: token را در همه لایهها پاس بده.
سرنخ (اگر کاندید گفت مشکلی نیست): مدیر دیتابیس (DBA) در ابزار مانیتورینگ میبیند که در ساعات شلوغ، دهها کوئری همین گزارش همزمان در حال اجرا هستند. بیشتر کاربرهای این کوئریها رفتهاند. دیتابیس برای بقیه کاربرها کند شده است.
چه اتفاقی میافتد؟ (قدم به قدم)
- کاربر گزارش را میخواهد. کوئری ۲ دقیقهای شروع میشود.
- بعد از ۱۰ ثانیه، کاربر Refresh میزند. اتصال قبلی قطع میشود. یک درخواست جدید شروع میشود.
- فریمورک ASP.NET Core میفهمد اتصال اول قطع شده. پس token مربوط به آن درخواست را لغو میکند (همان RequestAborted).
- اما کدِ کوئری هیچ tokenی نگرفته است. پس کوئری اول تا آخر اجرا میشود. بعد نتیجهاش دور ریخته میشود.
- حالا برای یک کاربر، دو کوئری سنگین در حال اجراست. با چند کاربر بیصبر، دهها کوئری بیفایده داریم.
راه حل:
- توکن لغو (CancellationToken) را از اول تا آخر پاس بده. در controller یا minimal API کافی است یک پارامتر از این نوع بگیری. ASP.NET Core خودش آن را پر میکند:
app.MapGet("/reports/sales", async (AppDbContext db, CancellationToken ct) =>
await db.Sales.Where(...).GroupBy(...).ToListAsync(ct));
- وقتی token لغو شود، EF Core و ADO.NET دستور لغو را به دیتابیس میفرستند. پس کوئری در خود دیتابیس هم متوقف میشود. همین token را به HttpClient و بقیه متدهای async هم بده.
- محافظتهای دیگر:
- برای کوئری یک timeout بگذار (تنظیم CommandTimeout). اینطوری هیچ کوئری بیپایان نمیماند.
- اگر همین گزارش برای همین کاربر در حال اجراست، دوباره اجرایش نکن.
- برای گزارش خیلی سنگین، آن را کار پسزمینه کن یا نتیجه را کش کن (سؤال ۱۸).
- خطای OperationCanceledException که به خاطر بستن صفحه است، طبیعی است. نباید به عنوان error لاگ شود. نباید هشدار بسازد.
سؤال پیگیری: کجا نباید به لغو درخواست توجه کرد و باید کار را تا آخر انجام داد؟
جواب پیگیری: وقتی وسط یک کار چندمرحلهای هستیم و نیمهکاره ماندنش بد است. مثال: پول از درگاه کم شده و فقط ذخیره نتیجه در دیتابیس مانده. کاربر رفته است. ولی اگر اینجا لغو کنیم، پول کم شده و سفارشی ثبت نشده. برای این قدمها token خالی (CancellationToken.None) میدهیم. یا کار را به صف یا Outbox میسپاریم. کاندید خوب این دو را از هم جدا میکند: «کاربر دیگر جواب نمیخواهد» و «کار باید کامل شود».
نشانه خطر: هیچ وقت CancellationToken پاس نمیدهد. یا فکر میکند بستن مرورگر خودبهخود کوئری دیتابیس را متوقف میکند.
۳۲. تغییر ساختار پیامها (Schema Evolution)
سؤال: رویداد OrderCreated را ۵ سرویس مختلف از Kafka میخوانند. هر سرویس تیم خودش و زمان deploy خودش را دارد. الان پیام این شکلی است:
{ "orderId": "o-1", "amount": 250000 }
تیم Order به خاطر چندارزی شدن، میخواهد فیلد amount را به این شکل تبدیل کند:
{ "orderId": "o-1", "amount": { "value": 250000, "currency": "IRR" } }
اگر همین فردا این تغییر را deploy کنند، چه میشود؟ این تغییر را چطور انجام میدهی که هیچ سرویسی خراب نشود؟
جواب کوتاه: مصرفکنندههایی که هنوز منتظر یک عدد هستند، پیام جدید را نمیفهمند و گیر میکنند. پیام یک قرارداد (contract) است. تغییر نوع فیلد، حذف فیلد یا تغییر نام فیلد یک breaking change است. راه درست: فیلد جدید را کنار فیلد قدیم اضافه کن. فیلد قدیم را آخر از همه حذف کن.
چرا خراب میشود؟ (قدم به قدم)
- سرویس Notification هنوز کد قدیم را دارد. این سرویس فیلد amount را یک عدد اعشاری (decimal) میداند.
- پیام جدید میرسد. حالا amount یک object است. پس تبدیل پیام (deserialize) با خطای JsonException شکست میخورد.
- مصرفکننده پیام را دوباره امتحان میکند (retry) و باز شکست میخورد. در partition ترتیب مهم است. پس پیامهای بعدی هم پشت این پیام گیر میکنند. یا پیام به Dead Letter میرود و SMS به مشتری نمیرسد.
- مشکل برعکس هم هست. اگر یک سرویس زودتر به کد جدید برود، پیامهای قدیمی را نمیفهمد. این پیامها هنوز در topic هستند.
- هماهنگ کردن deploy پنج تیم در یک لحظه، عملاً ممکن نیست.
راه درست (شبیه Expand and Contract در سؤال ۱۷):
- یک فیلد جدید با نام جدید اضافه میشود. فیلد قدیم هم هنوز پر میشود:
{ "orderId": "o-1", "amount": 250000, "money": { "value": 250000, "currency": "IRR" } }
- مصرفکنندهها یکییکی، هر وقت خواستند، به خواندن فیلد money میروند.
- وقتی همه رفتند و پیامهای قدیمی هم دیگر لازم نیستند، فیلد amount حذف میشود.
قوانین ساده:
- اضافه کردن یک فیلد اختیاری امن است.
- حذف، تغییر نام یا تغییر نوع فیلد، breaking change است. یا با روش بالا انجامش بده، یا یک نسخه جدید از رویداد بساز (مثلاً OrderCreated نسخه ۲) و مدتی هر دو نسخه را منتشر کن.
- سمت مصرفکننده از الگوی Tolerant Reader استفاده کن. یعنی فیلدهای ناشناخته را نادیده بگیر و فقط فیلدهای لازم را بخوان.
- از ابزار Schema Registry با Avro یا Protobuf استفاده کن. با این ابزار، schema ناسازگار همان لحظه ثبت رد میشود، نه در Production.
سؤال پیگیری: یک سرویس جدید میخواهد همه رویدادهای یک سال گذشته را از اول بخواند (replay). چه مشکلی پیش میآید؟
جواب پیگیری: پیامهای یک سال، چند نسخه مختلف از schema دارند. پس دو راه داریم:
- سرویس جدید همه نسخهها را بفهمد.
- یا یک لایه تبدیل (upcaster) هر نسخه قدیمی را به نسخه جدید تبدیل کند.
برای همین، هر پیام باید شماره نسخهاش را همراه داشته باشد (در header یا با schema id). نکته دیگر: مدت نگهداری داده در Kafka (retention) باید کافی باشد. اگر داده یک سال نگه داشته نشده باشد، replay ممکن نیست.
نشانه خطر: میگوید «به همه تیمها خبر میدهیم و همه با هم deploy میکنیم».
۳۳. بنبست در Grain های Orleans
Microsoft Orleans· در نقشه راه
این سؤال برای کاندیدی است که با Orleans یا یک Actor Model دیگر کار کرده. اگر کار نکرده، فقط مفهوم «actor تکنخی» را با این مثال بپرسید.
سؤال: در Orleans دو grain داریم: grain حساب (AccountGrain) و grain ریسک (RiskGrain). grain حساب وقتی یک برداشت را پردازش میکند، grain ریسک را صدا میزند تا ریسک را چک کند. grain ریسک برای این چک، موجودی فعلی را لازم دارد. پس دوباره متد GetBalance را روی grain حساب صدا میزند. این طراحی را چطور میبینی؟ وقتی یک برداشت میآید، قدم به قدم چه اتفاقی میافتد؟ آیا مشکلی دارد؟
جواب کوتاه: هر grain به طور پیشفرض تکنخی است. یعنی تا درخواست فعلیاش تمام نشود، درخواست بعدی را شروع نمیکند. اینجا grain حساب منتظر grain ریسک است و grain ریسک منتظر grain حساب. هیچکدام جلو نمیروند. بهترین راه حل: این چرخه را در طراحی حذف کن.
سرنخ (اگر کاندید گفت مشکلی نیست): هیچ برداشتی تمام نمیشود. بعد از ۳۰ ثانیه همه برداشتها با خطای TimeoutException شکست میخورند.
چه اتفاقی میافتد؟ (قدم به قدم)
- ابتدا grain حساب درخواست «برداشت» را شروع میکند.
- وسط کار، منتظر جواب چک ریسک میماند (با await). نکته مهم: از نظر Orleans، درخواست «برداشت» هنوز تمام نشده است. پس درخواست دیگری به grain حساب داده نمیشود. به این حالت non-reentrant میگویند.
- بعد grain ریسک درخواست GetBalance را به grain حساب میفرستد. این درخواست در صف، پشت درخواست «برداشت» میماند.
- حالا یک چرخه داریم:
- «برداشت» منتظر ریسک است.
- ریسک منتظر GetBalance است.
- و GetBalance منتظر تمام شدن «برداشت» است.
- این یعنی بنبست. بعد از timeout پیشفرض (۳۰ ثانیه)، درخواست شکست میخورد.
چرا Orleans تکنخی است؟ چون به همین دلیل داخل grain به lock نیاز نداریم. state هیچ وقت با دو درخواست همزمان خراب نمیشود. این مزیت اصلی Actor Model است.
راهها، از بهتر به بدتر:
- حذف چرخه در طراحی: در این راه، grain حساب موجودی فعلی را همراه درخواست به grain ریسک میفرستد. دیگر تماس برگشتی لازم نیست:
var ok = await riskGrain.Check(amount, currentBalance);
- اجازه ورود دوباره، فقط برای همین زنجیره: در نسخههای جدید Orleans، متد AllowCallChainReentrancy این کار را میکند. درخواستی که از همان زنجیره برگشته، اجازه اجرا میگیرد.
- علامت زدن متدهای فقطخواندنی: مثلاً متد GetBalance را با attribute های ReadOnly یا AlwaysInterleave علامت بزن. اینطوری وسط کارهای دیگر هم اجرا میشود.
- کل grain را Reentrant کردن: سادهترین راه است، ولی خطرناکترین هم هست. امنیت تکنخی از بین میرود. بین دو await، یک درخواست دیگر میتواند state را عوض کند. مثال: دو برداشت هر دو موجودی ۱۰۰ را میبینند و هر دو قبول میشوند.
قانون کلی: چرخه بین grain ها را کم کن. کارهایی که جواب لازم ندارند را با پیام یکطرفه (attribute به نام OneWay) یا با stream انجام بده.
سؤال پیگیری: یک grain سراسری داریم، مثلاً شمارنده کل سفارشهای امروز. همه سفارشها آن را صدا میزنند. با اضافه کردن silo هم سریعتر نمیشود. چرا و چه میکنی؟
جواب پیگیری: هر grain فقط روی یک silo زنده است و تکنخی کار میکند. پس همه درخواستها پشت سر هم پردازش میشوند. silo بیشتر به یک grain تنها کمکی نمیکند. راهها:
- شمارنده را به چند grain تقسیم کن. مثلاً ۱۶ شمارنده جزئی (بر اساس hash شناسه سفارش) و یک grain که هر چند ثانیه آنها را جمع میکند.
- تغییرات را در هر silo جمع کن و دستهای بفرست، به جای یک تماس برای هر سفارش.
- برای کارهای بدون state از StatelessWorker استفاده کن. این نوع grain چند نسخه همزمان دارد.
نشانه خطر: فوراً Reentrant کردن grain را پیشنهاد میدهد و نمیداند با این کار چه محافظتی را از دست میدهد.
۳۴. پیگیری یک درخواست در چند سرویس (Observability)
Distributed Tracing· در نقشه راه
سؤال: یک سفارش این مسیر را طی میکند: API، بعد پیام Kafka، بعد Worker، بعد فراخوانی HTTP به سرویس پرداخت. یک مشتری زنگ میزند و میگوید «سفارش من ۲ ساعت است در وضعیت منتظر پرداخت مانده». لاگها در ۴ سرویس جدا هستند. هر سرویس چند نسخه (pod) دارد. هر کدام هزاران خط لاگ در دقیقه مینویسد. الان پیدا کردن جای گیر کردن این سفارش چند ساعت طول میکشد. سیستم را چطور طراحی میکنی که بشود مسیر این یک سفارش را در چند دقیقه پیدا کرد؟
جواب کوتاه: با Distributed Tracing. هر درخواست یک شناسه به نام Trace Id میگیرد. این شناسه در همه سرویسها همراه درخواست میرود، حتی از داخل Kafka. با همین یک شناسه، کل مسیر و زمان هر بخش را در یک صفحه میبینیم. کنار آن، لاگهای ساختیافته را در یک جای مرکزی جمع میکنیم.
چرا الان سخت است؟
- لاگها پراکندهاند. هیچ شناسه مشترکی آنها را به هم وصل نمیکند.
- باید حدس بزنیم کدام pod این سفارش را پردازش کرده. بعد باید لاگها را با ساعت با هم تطبیق دهیم.
طراحی درست:
- ردیابی توزیعشده با OpenTelemetry: هر درخواست یک Trace Id دارد. هر بخش کار (مثلاً یک کوئری یا یک فراخوانی HTTP) یک Span است. در .NET، خود ASP.NET Core و HttpClient هدر استاندارد traceparent را میفرستند و میخوانند. این کار با کلاس Activity انجام میشود.
- منتقل کردن Trace Id از Kafka (نکته مهم): خود Kafka این کار را خودکار نمیکند. پس دو کار لازم است:
- هنگام تولید پیام، traceparent را در header پیام میگذاریم.
- هنگام مصرف پیام، از همان header یک Activity جدید میسازیم. یا از یک کتابخانه instrumentation آماده استفاده میکنیم.
- اگر این کار را نکنیم، trace در Kafka قطع میشود.
- لاگ ساختیافته (Structured Logging): لاگ باید به شکل فیلد ذخیره شود، نه فقط متن:
logger.LogInformation("Payment requested for {OrderId} amount {Amount}", order.Id, order.Amount);
با این روش، شناسه سفارش و Trace Id در ابزار مرکزی (Elasticsearch، Loki یا Seq) قابل جستجو هستند.
- شناسه کسبوکار در trace: شناسه سفارش را هم روی span ها بگذار. پشتیبانی Trace Id را نمیداند، ولی شماره سفارش را میداند. پس با شماره سفارش به trace میرسیم و با trace به همه لاگها.
- هشدار قبل از شکایت مشتری: یک متریک بساز که تعداد سفارشهای مانده در هر وضعیت را نشان دهد. وقتی سفارشی بیشتر از حد (مثلاً ۱۰ دقیقه) در یک وضعیت ماند، هشدار بده.
نکته امنیتی: لاگ نباید داده حساس داشته باشد، مثل رمز، شماره کارت یا توکن.
سؤال پیگیری: ذخیره trace برای همه درخواستها خیلی گران است، چون حجم داده زیاد است. چه میکنی؟
جواب پیگیری: از نمونهبرداری (Sampling) استفاده میکنیم. دو روش داریم:
- روش head-based: از اول درخواست تصمیم میگیریم. مثلاً فقط ۱۰٪ درخواستها ذخیره شوند. ساده است. ولی ممکن است trace یک درخواست خطادار که لازم داریم، جزو آن ۹۰٪ حذفشده باشد.
- روش tail-based: بعد از تمام شدن trace تصمیم میگیریم (مثلاً در OpenTelemetry Collector). همه trace های خطادار و کند نگه داشته میشوند. از trace های عادی فقط درصد کمی نگه داشته میشود.
نشانه خطر: راه حلش این است: «در لاگ همه سرویسها با شماره سفارش جستجو میکنیم». و درباره منتقل کردن Trace Id از Kafka فکری نکرده است.
۳۵. نشت حافظه در .NET
حافظه و Garbage Collector· در نقشه راهBackground Service· در نقشه راه
سؤال: در داشبورد مانیتورینگ میبینیم حافظه یک سرویس در طول چند روز آهسته و مدام بالا میرود. آخر کار، pod با وضعیت OOMKilled ریاستارت میشود. بعد از ریاستارت، همین چرخه دوباره تکرار میشود. یک همکار میگوید: «.NET که GC دارد، پس نشت حافظه ممکن نیست. فقط limit حافظه را بیشتر کنیم.» نظرت چیست؟ قدم به قدم چطور علت را پیدا میکنی؟
جواب کوتاه: سیستم GC فقط اشیایی را پاک میکند که دیگر هیچ چیزی به آنها اشاره نمیکند. گاهی یک مرجع (reference) فراموششده هنوز به شیء اشاره میکند، مثلاً از یک شیء static. آن شیء هیچ وقت پاک نمیشود. پس نشت حافظه در .NET کاملاً ممکن است. بیشتر کردن limit فقط OOM را عقب میاندازد.
یک مثال واقعی از نشت:
// PriceFeed is registered as Singleton
public class OrderHandler // Scoped: one instance per request
{
public OrderHandler(PriceFeed feed)
{
feed.PriceChanged += OnPriceChanged; // never unsubscribed
}
private void OnPriceChanged(object? s, PriceEventArgs e) { /* ... */ }
}
چرا این نشت است؟ (قدم به قدم)
- هر event به همه مشترکهایش یک مرجع نگه میدارد. پس سرویس PriceFeed به هر OrderHandler اشاره میکند.
- سرویس PriceFeed از نوع Singleton است. یعنی تا آخر عمر برنامه زنده است.
- پس هر OrderHandler که در هر درخواست ساخته میشود، هم برای همیشه زنده میماند.
- بعد از میلیونها درخواست، میلیونها handler در حافظه داریم.
مثال واقعی دوم: یک DbContext برای همیشه در BackgroundService
// Wrong: one scope and one DbContext for the whole life of the service
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
while (!ct.IsCancellationRequested)
{
var items = await db.Outbox.Where(x => !x.Sent).Take(100).ToListAsync(ct);
foreach (var item in items) item.Sent = true;
await db.SaveChangesAsync(ct);
await Task.Delay(1000, ct);
}
}
چرا این نشت است؟ (قدم به قدم)
- سرویس BackgroundService یک بار شروع میشود و تا آخر عمر برنامه زنده است. پس آن scope و آن DbContext هم تا آخر زنده میمانند.
- هر DbContext یک Change Tracker دارد. هر entity که با آن خوانده یا ذخیره میشود، در Change Tracker میماند. حتی بعد از SaveChanges هم بیرون نمیرود.
- حلقه هر ثانیه ۱۰۰ ردیف جدید میخواند. پس بعد از یک روز، میلیونها entity در Change Tracker داریم.
- سیستم GC نمیتواند اینها را پاک کند، چون DbContext هنوز به همه آنها اشاره میکند.
- حافظه آهسته بالا میرود تا pod با OOMKilled ریاستارت شود.
- سرعت هم کم میشود. متد SaveChanges قبل از ذخیره، کار DetectChanges را انجام میدهد. یعنی همه entity های داخل Change Tracker را یکییکی چک میکند. هر چه تعداد بیشتر شود، هر دور حلقه کندتر میشود.
// Right: a new scope (and a new DbContext) for each batch
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var items = await db.Outbox.Where(x => !x.Sent).Take(100).ToListAsync(ct);
foreach (var item in items) item.Sent = true;
await db.SaveChangesAsync(ct);
} // scope and DbContext are disposed here
await Task.Delay(1000, ct);
}
}
- با این کار، هر دور حلقه یک DbContext تازه و خالی دارد. در پایان دور، DbContext dispose میشود و GC همه entity ها را پاک میکند.
- برای کوئریهایی که فقط میخوانند و چیزی را تغییر نمیدهند، متد AsNoTracking را بزن. آن وقت entity اصلاً وارد Change Tracker نمیشود.
- اگر به هر دلیلی باید همان DbContext بماند، بعد از هر دسته متد ChangeTracker.Clear را صدا بزن. ولی راه اول (scope جدید) تمیزتر است.
- به جای scope، میشود از IDbContextFactory هم استفاده کرد و در هر دور یک DbContext تازه ساخت.
علتهای رایج دیگر:
-
یک DbContext که برای همیشه زنده میماند (مثلاً در BackgroundService یا داخل یک Singleton). همه entity ها در Change Tracker آن جمع میشوند.
-
کش یا Dictionary استاتیک که فقط به آن اضافه میشود و هیچ وقت پاک نمیشود. یا IMemoryCache بدون زمان انقضا و بدون محدودیت اندازه (SizeLimit).
-
تایمرهایی (Timer) که dispose نمیشوند.
-
سرویس disposable از نوع Transient که از container اصلی گرفته میشود، نه از یک scope. در این حالت container آن را تا آخر عمر برنامه نگه میدارد تا در پایان dispose کند.
-
منابع unmanaged، مثل stream و connection، که dispose نمیشوند.
روش پیدا کردن علت (قدم به قدم):
- با ابزار dotnet-counters ببین کدام بالا میرود: حافظه GC Heap (اشیای .NET) یا فقط حافظه کل process. اگر GC Heap ثابت است ولی حافظه کل بالا میرود، احتمالاً نشت native داریم.
- دو snapshot از حافظه با فاصله زمانی بگیر (مثلاً یک ساعت). برای این کار از ابزار dotnet-gcdump یا dotnet-dump استفاده کن.
- دو snapshot را با هم مقایسه کن (در Visual Studio، PerfView یا dotnet-dump). سؤال این است: تعداد کدام نوع شیء مدام زیاد میشود؟ مثلاً میبینی ۲ میلیون OrderHandler در حافظه است.
- برای یکی از آن اشیا، GC Root را پیدا کن (با دستور gcroot). این دستور زنجیره مرجعها را نشان میدهد. یعنی میگوید چه چیزی شیء را زنده نگه داشته. اینجا جواب، event داخل PriceFeed است.
- کد را اصلاح کن و دوباره اندازه بگیر. در این مثال، در متد Dispose از event جدا شو (unsubscribe). یا طراحی را عوض کن تا روی Singleton از event استفاده نشود.
سؤال پیگیری: فرض کن نشتی نداریم. ولی حافظه سرویس بعد از یک پیک بار بالا میماند و پایین نمیآید. این طبیعی است؟ از کجا میفهمی نشت است یا نه؟
جواب پیگیری: بله، ممکن است طبیعی باشد. دلیلش:
- همیشه GC حافظه آزادشده را فوراً به سیستمعامل پس نمیدهد. آن را برای استفاده بعدی نگه میدارد.
- حالت Server GC هم به طور پیشفرض حافظه بیشتری مصرف میکند.
معیار درست: حافظه را بعد از هر GC کامل (Gen2) نگاه کن. اگر هر بار به یک سطح ثابت برمیگردد، نشتی نیست. اگر این سطح هر بار بالاتر میرود، نشت داریم. اگر مصرف حافظه از سرعت مهمتر است، میشود GC را تنظیم کرد. مثلاً با تنظیم GCConserveMemory، یا با Workstation GC در pod های کوچک.
نشانه خطر: میگوید «در .NET نشت حافظه نداریم چون GC داریم». یا تنها راه حلش «limit حافظه را بیشتر میکنیم» است.
۳۶. Aggregate و حفظ قانونهای دامنه
Domain-Driven Design· در نقشه راه
سؤال: در سیستم سفارش، یک قانون کسبوکار داریم: جمع مبلغ هر سفارش نباید از سقف اعتبار مشتری بیشتر شود. کلاس Order این قانون را در متد AddLine چک میکند. حالا یک endpoint جدید برای «تغییر تعداد یک قلم سفارش» نوشته شده است:
public async Task ChangeQuantity(int lineId, int newQty)
{
var line = await _db.OrderLines.FindAsync(lineId);
line.Quantity = newQty;
await _db.SaveChangesAsync();
}
این کد را در code review میبینی. تستها هم سبز هستند. نظرت چیست؟ آیا مشکلی دارد؟ اگر خودت مینوشتی، چطور مینوشتی؟
جواب کوتاه: این کد از کنار ریشه Aggregate رد میشود. قانون سقف اعتبار در Order است، ولی کد مستقیم OrderLine را عوض میکند. پس قانون هیچ وقت چک نمیشود. در DDD، فقط ریشه Aggregate (Aggregate Root) از بیرون عوض میشود. ریشه همه قانونهای داخل مرز خودش را حفظ میکند. کل Aggregate با هم load و با هم ذخیره میشود.
سرنخ (اگر کاندید گفت مشکلی نیست): چند هفته بعد، تیم مالی گزارش میدهد که چند سفارش، مبلغی بالاتر از سقف اعتبار مشتری دارند. در لاگ endpoint ثبت سفارش هیچ خطایی نیست. همه این سفارشها بعد از ثبت، از صفحه «ویرایش تعداد» تغییر کردهاند. حالا چرا؟
چرا این کد خطرناک است؟ (قدم به قدم)
- قانون سقف اعتبار به کل سفارش مربوط است، نه به یک قلم. برای چک کردنش باید همه قلمها را ببینیم.
- کلاس OrderLine از قلمهای دیگر خبر ندارد. پس نمیتواند این قانون را چک کند.
- کد مستقیم از DbSet قلمها یک قلم را میگیرد و تعدادش را عوض میکند. متد AddLine صدا زده نمیشود.
- پس قانون دور زده میشود. هر برنامهنویس جدید هم میتواند همین کار را در جای دیگری تکرار کند.
- تستها سبز هستند، چون تستها فقط مسیر AddLine را تست کردهاند.
راه حل: همه تغییرها از ریشه رد شوند
public class Order
{
private readonly List<OrderLine> _lines = new();
public IReadOnlyList<OrderLine> Lines => _lines;
public void ChangeQuantity(int lineId, int qty, Money creditLimit)
{
var line = _lines.Single(l => l.Id == lineId);
var newTotal = Total() - line.Total() + line.Price * qty;
if (newTotal > creditLimit)
throw new DomainException("Credit limit exceeded");
line.SetQuantity(qty); // internal setter
}
}
var order = await _db.Orders.Include(o => o.Lines)
.SingleAsync(o => o.Id == orderId);
order.ChangeQuantity(lineId, newQty, customer.CreditLimit);
await _db.SaveChangesAsync();
چند قانون مهم برای Aggregate:
- فقط ریشه از بیرون در دسترس است. برای OrderLine نه repository جدا میسازیم و نه DbSet عمومی. لیست قلمها هم فقطخواندنی بیرون میآید.
- مرز سازگاری همان مرز تراکنش است. هر چیزی داخل Aggregate باید در یک تراکنش با هم درست بماند. یک تراکنش فقط یک Aggregate را عوض میکند.
- کوچک نگهش دار. یک Aggregate بزرگ یعنی load سنگین و تداخل زیاد بین کاربرها (قفل یا خطای همزمانی). فقط چیزهایی را داخلش بگذار که یک قانون واقعی آنها را به هم وصل میکند.
- به Aggregate های دیگر با Id اشاره کن. سفارش فقط شناسه مشتری را نگه میدارد، نه خود شیء مشتری. اینطوری مرزها قاطی نمیشوند.
- همزمانی را هم ببین. اگر دو کاربر همزمان یک سفارش را ویرایش کنند، با یک ستون نسخه (RowVersion) در ریشه، EF Core تغییر دوم را رد میکند.
سؤال پیگیری: وقتی سفارش تأیید میشود، باید موجودی انبار هم کم شود. Order و Inventory دو Aggregate جدا هستند. هر دو را در یک تراکنش عوض میکنی یا نه؟
جواب پیگیری:
- قانون پایه: یک تراکنش، یک Aggregate. پس معمولاً از سازگاری نهایی (Eventual Consistency) استفاده میکنیم.
- سفارش هنگام تأیید یک Domain Event به اسم «سفارش تأیید شد» ثبت میکند.
- این رویداد با الگوی Outbox در همان تراکنش سفارش ذخیره میشود. پس گم نمیشود.
- یک handler جدا رویداد را میگیرد و موجودی را کم میکند. اگر موجودی کافی نبود، یک رویداد جبرانی میفرستد و سفارش لغو یا معلق میشود.
- چه زمانی یک تراکنش مشترک قابل قبول است؟ وقتی هر دو در یک سرویس و یک دیتابیس هستند، بار کم است و کسبوکار حتی یک لحظه ناسازگاری را قبول نمیکند. ولی باید آگاهانه باشد. این کار تداخل و قفل را بیشتر میکند.
- سؤال خوب از کسبوکار: «اگر موجودی چند ثانیه بعد کم شود، چه ضرری دارد؟» اغلب جواب «هیچ» است.
نشانه خطر: برای هر جدول یک repository میسازد و میگوید «قانونها را در لایه service چک میکنیم». یا یک Aggregate خیلی بزرگ میسازد که مشتری، همه سفارشها و انبار را با هم دارد.
۳۷. مدل دامنه کمخون (Anemic) یا غنی (Rich)
Domain-Driven Design· در نقشه راه
سؤال: در پروژه سفارش، کلاس Order و سرویس OrderService اینطور نوشته شدهاند:
public class Order
{
public int Id { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public DateTime? ConfirmedAt { get; set; }
}
public class OrderService
{
public async Task Confirm(int id)
{
var order = await _db.Orders.FindAsync(id);
if (order.Status != "Draft") throw new Exception("Invalid");
if (order.Total <= 0) throw new Exception("Empty order");
order.Status = "Confirmed";
order.ConfirmedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
// Cancel, Ship, Refund ... the same style, 900 lines
}
این ساختار در کل پروژه تکرار شده است. نظرت درباره این طراحی چیست؟ اگر پروژه تو بود، چیزی را عوض میکردی؟
جواب کوتاه: این یک مدل کمخون (Anemic Domain Model) است. کلاس Order فقط داده دارد و همه قانونها در سرویس است. چون همه setter ها عمومی هستند، هر کدی میتواند وضعیت را بدون چک قانون عوض کند. در مدل غنی، قانونها داخل خود entity هستند. مثلاً متدهای Confirm و Cancel. setter ها خصوصی هستند. مقدارهای مهم هم Value Object هستند، مثل Money. ولی برای یک CRUD ساده، مدل کمخون کاملاً قابل قبول است.
سرنخ (اگر کاندید گفت مشکلی نیست): در Production چند سفارش داریم که وضعیتشان «تأیید شده» است ولی تاریخ تأیید ندارند. یک job شبانه هم سفارشهای لغو شده را دوباره «ارسال شده» کرده است. در یک ماه، سه باگ از همین نوع گزارش شده است. هر بار در یک جای جدید کد. حالا چرا؟
چرا این طراحی در دامنه پیچیده مشکل میسازد؟ (قدم به قدم)
- قانون «فقط سفارش پیشنویس تأیید میشود» فقط در یک متد سرویس است.
- همه setter ها عمومی هستند. پس یک job، یک handler یا یک کد تست میتواند مستقیم وضعیت را عوض کند.
- هر کس که این کار را بکند، قانون را دور زده است. مثلاً وضعیت را عوض میکند ولی تاریخ تأیید را فراموش میکند.
- قانونها در چند سرویس تکرار میشوند و کمکم با هم فرق پیدا میکنند.
- وضعیت یک رشته متنی است. یک غلط تایپی مثل «confirmed» با حرف کوچک هم کامپایل میشود.
- سرویس ۹۰۰ خطی میشود. برای فهمیدن رفتار سفارش باید کل آن را خواند.
راه حل: رفتار را به entity ببر
public class Order
{
public int Id { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public Money Total { get; private set; }
public DateTime? ConfirmedAt { get; private set; }
public void Confirm(DateTime now)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only draft orders can be confirmed");
if (Total.Amount <= 0)
throw new DomainException("Order is empty");
Status = OrderStatus.Confirmed;
ConfirmedAt = now;
}
}
public record Money(decimal Amount, string Currency)
{
public static Money operator +(Money a, Money b) =>
a.Currency == b.Currency ? a with { Amount = a.Amount + b.Amount }
: throw new DomainException("Currency mismatch");
}
- حالا سرویس فقط هماهنگ میکند: سفارش را load میکند، متد Confirm را صدا میزند و ذخیره میکند.
- قانون فقط در یک جاست. هیچ کدی نمیتواند آن را دور بزند.
- تست قانون ساده است. یک شیء Order میسازی و Confirm را صدا میزنی. دیتابیس و mock لازم نیست.
- با EF Core، setter خصوصی و فیلد خصوصی مشکلی ندارد. برای Value Object هم از Owned Type یا Complex Property استفاده میکنیم.
چه زمانی مدل کمخون خوب است؟
- وقتی برنامه فقط CRUD است و قانون واقعی ندارد. مثلاً یک پنل مدیریت برای جدول شهرها.
- وقتی کلاس فقط برای انتقال داده است، مثل DTO یا مدل خواندنی (read model).
- اینجا ساختن مدل غنی فقط پیچیدگی اضافه است (YAGNI).
سؤال پیگیری: فرق Value Object و Entity چیست؟ آدرس مشتری را کدام میگیری؟
جواب پیگیری:
- موجودیت (Entity) هویت دارد. دو سفارش با مقدارهای یکسان، اگر شناسهشان فرق کند، دو سفارش جدا هستند. در طول زمان هم عوض میشود ولی همان سفارش میماند.
- شیء مقداری (Value Object) هویت ندارد. با مقدارش شناخته میشود. دو مبلغ «۱۰۰ تومان» کاملاً برابرند.
- شیء مقداری تغییرناپذیر (immutable) است. برای تغییر، یک شیء جدید میسازیم.
- شیء مقداری هنگام ساخت، خودش را چک میکند. مثلاً Email نامعتبر اصلاً ساخته نمیشود. پس در بقیه کد لازم نیست دوباره چک کنیم.
- آدرس مشتری معمولاً Value Object است. اگر آدرس عوض شود، یک آدرس جدید جایگزین میشود. ولی اگر کسبوکار آدرسها را جدا مدیریت کند (مثلاً دفترچه آدرس با شناسه)، آن وقت Entity است.
- در C#، نوع record برای Value Object خیلی مناسب است. چون برابری بر اساس مقدار را خودش میسازد.
نشانه خطر: میگوید «entity فقط باید داده باشد و منطق همیشه در service است». یا برعکس، اصرار دارد حتی برای یک CRUD ساده، مدل غنی و Value Object بسازد.
۳۸. Bounded Context و زبان مشترک
Domain-Driven Design· در نقشه راه
سؤال: در شرکت سه تیم داریم: فروش، پشتیبانی و مالی. هر سه از یک کلاس مشترک Customer و یک جدول مشترک مشتریها استفاده میکنند. این کلاس در یک پروژه Shared است و همه سرویسها به آن reference دارند:
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
public string SalesStage { get; set; } // Sales
public decimal? DiscountPercent { get; set; } // Sales
public int OpenTickets { get; set; } // Support
public string SlaLevel { get; set; } // Support
public string TaxCode { get; set; } // Billing
public decimal CreditLimit { get; set; } // Billing
// ... 60 more fields
}
هر تیم برای نیاز خودش فیلد اضافه میکند. این طراحی را چطور میبینی؟ با رشد شرکت و تیمها چه اتفاقی میافتد؟
جواب کوتاه: این مدل همه چیز را برای همه میخواهد. کلمه «مشتری» در هر تیم معنای متفاوتی دارد. پس یک کلاس مشترک همه تیمها را به هم گره میزند. راه حل DDD تقسیم سیستم به Bounded Context است. هر context مدل خودش از مشتری را دارد، با زبان خودش (Ubiquitous Language). contextها فقط با شناسه مشتری و با رویدادها به هم وصل میشوند. یک نقشه contextها (Context Map) رابطه آنها را نشان میدهد.
سرنخ (اگر کاندید گفت مشکلی نیست): تیم مالی نوع یک فیلد را عوض کرد. سرویس پشتیبانی بعد از deploy از کار افتاد. حالا برای هر تغییر کوچک، سه تیم باید جلسه بگذارند و با هم deploy کنند. کلمه «مشتری فعال» در فروش یعنی «در ۳ ماه اخیر خرید کرده»، ولی در مالی یعنی «بدهی باز دارد». گزارشهای دو تیم با هم نمیخوانند. حالا چرا؟
چرا این طراحی کمکم مشکل میسازد؟ (قدم به قدم)
- هر تیم فقط بخش کوچکی از فیلدها را لازم دارد. ولی همه به کل کلاس وابستهاند.
- وقتی یک تیم فیلدی را عوض میکند، کد تیمهای دیگر هم میشکند. پس تیمها نمیتوانند جدا deploy کنند.
- یک کلمه در هر تیم معنای دیگری دارد. ولی در کد فقط یک کلاس و یک فیلد داریم. پس معنا قاطی میشود و باگ منطقی میسازد.
- کلاس مدام بزرگتر میشود. هیچ کس جرأت نمیکند فیلدی را حذف کند، چون نمیداند چه کسی از آن استفاده میکند.
- جدول مشترک یعنی قفل و کندی مشترک. کوئری سنگین یک تیم، تیم دیگر را کند میکند.
راه حل: هر context مدل خودش را دارد
// Sales context
public class Customer { public Guid Id; public string Name; public SalesStage Stage; }
// Support context
public class Requester { public Guid CustomerId; public SlaLevel Sla; }
// Billing context
public class Account { public Guid CustomerId; public TaxCode TaxCode; public Money CreditLimit; }
- هر context مدل و دیتابیس (یا حداقل schema) خودش را دارد. فقط فیلدهایی که خودش لازم دارد. حتی اسم کلاس میتواند فرق کند. در پشتیبانی به آن «درخواستدهنده» میگویند و در مالی «حساب».
- ارتباط با شناسه. همه contextها یک شناسه مشترک مشتری دارند. ولی دادههای همدیگر را مستقیم نمیخوانند.
- ارتباط با رویداد. وقتی فروش مشتری جدید میسازد، رویداد «مشتری ثبت شد» را منتشر میکند (مثلاً با Kafka). مالی و پشتیبانی آن را میگیرند و مدل خودشان را میسازند.
- لایه ضد فساد (Anti-Corruption Layer). وقتی یک context باید به یک سیستم قدیمی یا مدل دیگر وصل شود، یک لایه ترجمه میگذاریم. این لایه مدل بیرونی را به مدل خودمان تبدیل میکند. پس تغییر مدل بیرونی فقط همین لایه را عوض میکند، نه کل کد ما را.
- نقشه contextها. رابطه هر دو context را مینویسیم. چه کسی تأمینکننده است و چه کسی مصرفکننده؟ چه قراردادی بین آنهاست؟ مثلاً قرارداد رویدادها نسخه دارد و بدون هماهنگی نمیشکند.
- کار را تدریجی کن. لازم نیست همه چیز یکباره عوض شود. اول یک context را جدا کن (مثلاً مالی)، با رویدادها پر کن، و بعد بقیه را.
هزینه این راه حل را هم بگو:
- داده تکراری میشود و سازگاری نهایی (Eventual Consistency) داریم. چند ثانیه طول میکشد تا مشتری جدید در مالی دیده شود.
- گزارشهای کلی به یک مدل جدا نیاز دارند، مثلاً یک data warehouse.
- برای یک تیم کوچک با یک محصول ساده، این تقسیم ممکن است زیادی باشد.
سؤال پیگیری: مرز contextها را از کجا پیدا میکنی؟
جواب پیگیری:
- از تفاوت زبان. وقتی یک کلمه در دو تیم دو معنا دارد، یا دو کلمه برای یک چیز هست، احتمالاً به مرز رسیدهای.
- با Event Storming. همه افراد کسبوکار و فنی جمع میشوند و رویدادهای دامنه را روی دیوار میچینند. مثلاً «سفارش ثبت شد» و «فاکتور صادر شد». جاهایی که رویدادها گروه میشوند و زبان عوض میشود، مرز context است.
- از ساختار تیمها. طبق قانون Conway، سیستم شبیه ساختار ارتباطی تیمها میشود. بهتر است هر context مال یک تیم باشد.
- از قانونها و دادهها. چیزهایی که با هم و با یک قانون عوض میشوند، در یک context میمانند.
- از سرعت تغییر. بخشی که هر هفته عوض میشود را از بخش ثابت جدا کن.
نشانه خطر: میگوید «یک مدل مشترک بهتر است، چون تکرار نداریم (DRY)». یا برعکس، فوراً برای هر جدول یک میکروسرویس پیشنهاد میدهد، بدون این که به زبان و مرز کسبوکار فکر کند.
۳۹. الگوی Mediator و کتابخانه MediatR
CQRS و Mediator· در نقشه راهDesign Patterns· در نقشه راه
سؤال: در یک API با ASP.NET Core، سازنده (constructor) کنترلر سفارشها این شکل را دارد:
public OrdersController(
IOrderService orders, ICustomerService customers,
IInventoryService inventory, IPaymentService payments,
IDiscountService discounts, INotificationService notifications,
IAuditService audit, IMapper mapper, ILogger<OrdersController> logger)
یکی از همتیمیها پیشنهاد میدهد همه کنترلرها را به MediatR ببریم. یعنی هر کار یک Command یا Query باشد و یک Handler جدا داشته باشد. کنترلر هم فقط یک IMediator بگیرد. نظرت درباره این پیشنهاد چیست؟ الگوی Mediator به ما چه میدهد و چه مشکلی را حل میکند؟ کی ارزشش را دارد و کی ندارد؟
جواب کوتاه: الگوی Mediator فرستنده درخواست را از کسی که آن را انجام میدهد جدا میکند. کنترلر فقط یک پیام میفرستد و نمیداند چه کسی آن را اجرا میکند. هر use case یک Handler کوچک دارد. کارهای مشترک (validation، لاگ، تراکنش) هم یک جا در pipeline نوشته میشوند. ولی هزینه هم دارد: لایه اضافه، کلاسهای زیاد، و پیدا کردن سختتر کد. مهمتر اینکه ۹ وابستگی در کنترلر نشانه یک مشکل طراحی است. MediatR این مشکل را حل نمیکند، فقط آن را پنهان میکند.
سرنخ (اگر کاندید گفت مشکلی نیست): در یک پروژه دیگر همین کار را کردیم. بعد از چند ماه، برنامهنویسهای جدید میگفتند پیدا کردن کدی که اجرا میشود سخت است. با «رفتن به تعریف» فقط به متد Send میرسیدند. بعضی Handler ها هم خودشان Handler دیگری را با Send صدا میزدند و زنجیره طولانی شده بود. حالا چرا؟
مشکل اصلی کجاست؟ (قدم به قدم)
- کنترلر با ۹ وابستگی یعنی این کلاس کارهای زیادی را میشناسد. این خلاف اصل تکمسئولیتی (SRP) است.
- هر action فقط به دو یا سه سرویس نیاز دارد. ولی برای ساختن کنترلر، همه ۹ سرویس ساخته میشوند.
- تست کنترلر هم سخت است. برای هر تست باید ۹ وابستگی را mock کنی.
- با MediatR، کنترلر فقط یک IMediator میگیرد. هر Handler فقط وابستگیهای خودش را دارد. پس کلاسها کوچک میشوند.
- ولی اگر منطق کسبوکار درست تقسیم نشده باشد، همان شلوغی به Handler ها منتقل میشود. پس MediatR جای طراحی خوب را نمیگیرد.
مزیتهای Mediator:
- جدا شدن فرستنده از گیرنده: کنترلر فقط پیام را میفرستد. کنترلر نازک میشود.
- یک Handler برای هر use case: شبیه CQRS است. کلاسها کوچک، با یک کار مشخص، و جدا از هم هستند.
- رفتارهای pipeline: کارهای مشترک مثل validation، لاگ، اندازهگیری زمان و تراکنش یک بار نوشته میشوند و روی همه درخواستها اجرا میشوند.
- تست راحتتر: هر Handler را جدا و با وابستگیهای کم تست میکنی.
هزینهها و ریسکها:
- لایه اضافه (indirection): با «رفتن به تعریف» در IDE به Handler نمیرسی. باید دنبال کلاس Handler بگردی.
- کلاسهای زیاد: برای هر کار ساده یک Command، یک Handler و گاهی یک Validator لازم است.
- پنهان کردن مشکل: وابستگیها پشت IMediator پنهان میشوند. دیگر از روی سازنده نمیفهمی کلاس به چه چیزهایی وابسته است.
- زنجیره Send: اگر Handler ها همدیگر را با Send صدا بزنند، جریان کد گم میشود.
- برای CRUD ساده لازم نیست: در یک سرویس کوچک، چند سرویس ساده کافی است (YAGNI).
- لایسنس: کتابخانه MediatR از نسخه ۱۳ (سال ۲۰۲۵) لایسنس تجاری دارد. برای شرکتهای بزرگتر پولی است و فقط برای شرکتهای کوچک نسخه رایگان Community دارد. این را باید قبل از انتخاب بررسی کرد.
کی ارزشش را دارد؟
- وقتی پروژه بزرگ است و use case های زیادی دارد.
- وقتی کارهای مشترک زیادی داریم که باید روی همه درخواستها اجرا شوند.
- وقتی تیم با CQRS و Vertical Slice کار میکند.
کی ارزشش را ندارد؟
- وقتی سرویس کوچک است یا بیشتر CRUD ساده دارد.
- وقتی هدف فقط کم کردن پارامترهای سازنده است. در این حالت بهتر است کنترلر را به چند کنترلر کوچکتر بشکنیم، یا سرویسها را درستتر تقسیم کنیم.
راههای سادهتر: برای مشکل این کنترلر، این کارها هم جواب میدهند:
- کنترلر را بر اساس کار بشکن (مثلاً کنترلر پرداخت سفارش جدا شود).
- سرویسهایی که همیشه با هم استفاده میشوند را پشت یک سرویس کسبوکار جمع کن.
- در Minimal API، هر endpoint فقط وابستگیهای خودش را میگیرد.
- اگر الگوی Mediator را میخواهیم ولی کتابخانه را نه، یک dispatcher ساده داخلی در چند ده خط نوشته میشود.
سؤال پیگیری: فرض کن MediatR را انتخاب کردیم. میخواهیم همه Command ها قبل از اجرا validate شوند. چطور این کار را بدون تکرار کد در هر Handler انجام میدهی؟
جواب پیگیری: با یک رفتار pipeline (Pipeline Behavior). این کلاس دور همه Handler ها اجرا میشود. همه Validator های آن درخواست را از DI میگیرد و اجرا میکند. اگر خطا بود، Handler اصلاً اجرا نمیشود:
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
foreach (var validator in validators)
await validator.ValidateAndThrowAsync(request, ct);
return await next(ct);
}
}
ثبت آن در DI:
builder.Services.AddMediatR(cfg =>
{
cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
- با همین روش، لاگ و تراکنش هم به صورت Behavior جدا اضافه میشوند.
- ترتیب ثبت Behavior ها مهم است. اولین Behavior ثبتشده، بیرونیترین لایه است.
نشانه خطر: میگوید «MediatR همیشه لازم است و معماری را تمیز میکند» و هیچ هزینهای برایش نمیبیند. یا نمیبیند که ۹ وابستگی در کنترلر خودش نشانه یک مشکل طراحی است.
۴۰. الگوی Strategy به جای switch بزرگ
سؤال: در سرویس پرداخت این کد را داریم:
public class PaymentService(BankAClient bankA, BankBClient bankB, WalletClient wallet)
{
public async Task<PaymentResult> PayAsync(Order order, string provider, CancellationToken ct)
{
switch (provider)
{
case "bank-a":
var token = await bankA.GetTokenAsync(ct);
return await bankA.PayAsync(token, order.Amount, ct);
case "bank-b":
return await bankB.PayAsync(order.Id, order.Amount * 10, ct);
case "wallet":
return await wallet.ChargeAsync(order.CustomerId, order.Amount, ct);
default:
throw new NotSupportedException(provider);
}
}
}
تیم محصول گفته در ماههای آینده چند درگاه پرداخت دیگر هم اضافه میشود. این کد را در code review میبینی. نظرت چیست؟ آیا مشکلی دارد؟ اگر بخواهی آن را عوض کنی، چه طراحیای پیشنهاد میدهی؟
جواب کوتاه: این کد امروز کار میکند. ولی با هر درگاه جدید، باید همین کلاس را باز کنیم و عوض کنیم. این خلاف اصل باز-بسته (Open/Closed) است. راه بهتر الگوی Strategy است: یک اینترفیس مشترک برای همه درگاهها، یک کلاس برای هر درگاه، و انتخاب درگاه با DI. ولی اگر فقط ۲ یا ۳ حالت ثابت داریم، همین switch کافی است (YAGNI).
سرنخ (اگر کاندید گفت مشکلی نیست): در یک سال، این کلاس از ۳۰ خط به ۶۰۰ خط رسید و ۱۵ وابستگی گرفت. یک بار یک تغییر کوچک برای بانک B، پرداخت کیف پول را خراب کرد. دو برنامهنویس که همزمان دو درگاه جدید اضافه میکردند، هر بار روی همین فایل conflict داشتند. برای تست یک درگاه هم باید همه client ها را mock میکردیم. حالا چرا؟
چرا این کد در طول زمان مشکل میسازد؟ (قدم به قدم)
- هر درگاه جدید یعنی یک case جدید و یک وابستگی جدید در سازنده. کلاس مدام بزرگتر میشود.
- همه درگاهها در یک فایل هستند. پس تغییر در یکی ممکن است دیگری را خراب کند.
- هر تغییر یعنی تست دوباره کل کلاس، نه فقط درگاه جدید.
- برای تست یک درگاه، باید همه client ها را بسازی یا mock کنی.
- چند نفر که همزمان روی درگاههای مختلف کار میکنند، روی یک فایل conflict میگیرند.
- معمولاً همین switch در جاهای دیگر هم تکرار میشود (برگشت پول، استعلام وضعیت). پس اضافه کردن درگاه یعنی تغییر چند جای کد.
راه حل: Strategy با DI
اول یک اینترفیس مشترک:
public interface IPaymentProvider
{
string Key { get; }
Task<PaymentResult> PayAsync(Order order, CancellationToken ct);
}
بعد یک کلاس برای هر درگاه:
public sealed class BankAProvider(BankAClient client) : IPaymentProvider
{
public string Key => "bank-a";
public async Task<PaymentResult> PayAsync(Order order, CancellationToken ct)
{
var token = await client.GetTokenAsync(ct);
return await client.PayAsync(token, order.Amount, ct);
}
}
و ثبت همه در DI:
builder.Services.AddScoped<IPaymentProvider, BankAProvider>();
builder.Services.AddScoped<IPaymentProvider, BankBProvider>();
builder.Services.AddScoped<IPaymentProvider, WalletProvider>();
- حالا برای درگاه جدید فقط یک کلاس جدید مینویسی و یک خط ثبت اضافه میکنی. کد قبلی دست نمیخورد.
- هر درگاه فقط وابستگی خودش را دارد و جدا تست میشود.
- تفاوتهای هر درگاه (مثلاً تبدیل تومان به ریال برای بانک B) داخل کلاس خودش میماند.
کی همان switch بهتر است؟
- وقتی فقط ۲ یا ۳ حالت داریم و قرار نیست زیاد شوند.
- وقتی هر case فقط یکی دو خط است.
- در این حالت، ساختن اینترفیس و چند کلاس فقط پیچیدگی اضافه است. اصل YAGNI میگوید تا لازم نشده، نساز.
سؤال پیگیری: درگاه را کاربر در زمان اجرا انتخاب میکند. چطور درگاه درست را پیدا میکنی، بدون اینکه دوباره یک switch بنویسی؟
جواب پیگیری: سه راه رایج:
- دیکشنری: همه IPaymentProvider ها را از DI بگیر و بر اساس Key در یک دیکشنری بگذار:
public sealed class PaymentService(IEnumerable<IPaymentProvider> providers)
{
private readonly Dictionary<string, IPaymentProvider> _map =
providers.ToDictionary(p => p.Key);
public Task<PaymentResult> PayAsync(Order order, string key, CancellationToken ct) =>
_map.TryGetValue(key, out var provider)
? provider.PayAsync(order, ct)
: throw new NotSupportedException(key);
}
- سرویسهای کلیددار (Keyed Services): از .NET 8 به بعد، DI خودش ثبت با کلید را دارد:
builder.Services.AddKeyedScoped<IPaymentProvider, BankAProvider>("bank-a");
var provider = serviceProvider.GetRequiredKeyedService<IPaymentProvider>(key);
- کلاس Factory: یک کلاس کوچک که کلید را میگیرد و درگاه را برمیگرداند. داخلش از یکی از دو روش بالا استفاده میکند. بقیه کد فقط با این Factory کار میکند.
- نکته: وقتی کلید از ورودی کاربر میآید، حتماً حالت «کلید ناشناخته» را درست مدیریت کن و خطای واضح برگردان.
نشانه خطر: هیچ مشکلی در رشد switch نمیبیند. یا برعکس، برای هر ۲ حالت ساده هم اینترفیس و Factory و چند لایه میسازد و نمیتواند بگوید کی switch کافی است.
۴۱. الگوی Decorator و Repository روی EF Core
Design Patterns· در نقشه راهEntity Framework Core· در نقشه راه
سؤال: یک IProductRepository داریم که با EF Core پیادهسازی شده است. حالا میخواهیم دور آن cache (با Redis) و لاگ اضافه کنیم. یکی از همتیمیها این کار را اینطور انجام داده است:
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct)
{
_logger.LogInformation("Getting product {Id}", id);
var cached = await _cache.GetStringAsync($"product:{id}", ct);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _db.Products.FindAsync([id], ct);
await _cache.SetStringAsync($"product:{id}", JsonSerializer.Serialize(product), ct);
return product;
}
همین الگو در همه متدهای این repository تکرار شده است. این کد را در code review میبینی. نظرت چیست؟ اگر خودت بودی، cache و لاگ را چطور اضافه میکردی؟ اصلاً آیا این repository روی EF Core لازم است؟
جواب کوتاه: این کد سه کار را در یک کلاس قاطی کرده است: دسترسی به داده، cache و لاگ. راه بهتر الگوی Decorator است. یک کلاس جدید که همان اینترفیس را پیادهسازی میکند، repository اصلی را در خود نگه میدارد و فقط cache را دور آن اضافه میکند. کلاس لاگ هم جدا است. این کلاسها در DI روی هم قرار میگیرند و کد اصلی دست نمیخورد. درباره repository هم باید گفت DbContext خودش Unit of Work و Repository است. پس یک repository عمومی روی آن اغلب فقط یک لایه اضافه است.
سرنخ (اگر کاندید گفت مشکلی نیست): یک بار Redis قطع شد و همه متدهای خواندن محصول خطا دادند، حتی اگر دیتابیس سالم بود. وقتی خواستیم کلید cache را عوض کنیم، باید ۱۲ متد را یکییکی عوض میکردیم و یکی جا ماند. تست واحد repository هم بدون Redis اجرا نمیشد. یک جا هم محصولی که وجود نداشت، به شکل مقدار خالی در cache ذخیره شده بود. حالا چرا؟
مشکل این کد کجاست؟ (قدم به قدم)
- هر متد سه مسئولیت دارد. این خلاف اصل تکمسئولیتی (SRP) است.
- منطق cache در همه متدها تکرار شده است. پس هر تغییر در cache (کلید، زمان انقضا، مدیریت خطا) یعنی تغییر همه متدها.
- اگر بخواهیم cache را جایی خاموش کنیم (مثلاً در یک job داخلی)، راه سادهای نیست.
- تست منطق دسترسی به داده بدون cache ممکن نیست.
- یک باگ کوچک هم دارد: وقتی محصول پیدا نشود، مقدار خالی در cache میرود. زمان انقضا هم مشخص نشده است.
راه حل: Decorator
کلاس cache همان اینترفیس را پیادهسازی میکند و repository اصلی را میگیرد:
public sealed class CachedProductRepository(
IProductRepository inner, HybridCache cache) : IProductRepository
{
public async Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
await cache.GetOrCreateAsync($"product:{id}",
async token => await inner.GetByIdAsync(id, token),
cancellationToken: ct);
// بقیه متدها: خواندن از cache، یا فقط صدا زدن inner و پاک کردن cache
}
- حالا repository اصلی فقط با EF Core کار میکند. کلاس cache فقط cache را میداند.
- کلاس لاگ هم به همین شکل یک Decorator جدا است.
- کلاس HybridCache در .NET 9 به بعد آمده است. جلوی هجوم همزمان درخواستها برای یک کلید (cache stampede) را هم میگیرد.
ثبت در DI:
با کتابخانه Scrutor ساده است:
builder.Services.AddScoped<IProductRepository, EfProductRepository>();
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
builder.Services.Decorate<IProductRepository, LoggingProductRepository>();
بدون کتابخانه هم میشود دستی ثبت کرد:
builder.Services.AddScoped<EfProductRepository>();
builder.Services.AddScoped<IProductRepository>(sp =>
new LoggingProductRepository(
new CachedProductRepository(
sp.GetRequiredService<EfProductRepository>(),
sp.GetRequiredService<HybridCache>()),
sp.GetRequiredService<ILogger<LoggingProductRepository>>()));
- در Scrutor، آخرین Decorate بیرونیترین لایه است. پس ترتیب اجرا این است: لاگ، بعد cache، بعد EF Core.
آیا Repository روی EF Core لازم است؟
- کلاس DbContext خودش یک Unit of Work است. تغییرات را جمع میکند و با SaveChanges یکجا ذخیره میکند.
- هر DbSet هم خودش شبیه یک Repository است.
- پس یک repository عمومی (با متدهای Add، Update، GetAll برای همه جدولها) اغلب فقط یک لایه اضافه است. گاهی هم امکانات EF Core مثل Include یا projection را پنهان میکند.
- ولی یک repository مخصوص یک aggregate (مثل همین repository محصول) جاهایی مفید است:
- وقتی میخواهیم کوئریهای تکراری یک جا باشند.
- وقتی در DDD، دسترسی فقط از طریق aggregate root باشد.
- وقتی میخواهیم یک نقطه برای Decorator داشته باشیم (مثل همین cache).
- خلاصه: repository عمومی روی EF Core نه، repository مخصوص و با هدف مشخص، بله.
سؤال پیگیری: ترتیب Decorator ها مهم است؟ اگر لاگ بیرون cache باشد، یا داخل آن، چه فرقی دارد؟
جواب پیگیری: بله، مهم است. هر Decorator فقط درخواستهایی را میبیند که به لایه او میرسند.
- لاگ بیرون، cache داخل: همه درخواستها لاگ میشوند، چه از cache جواب بگیرند چه از دیتابیس. پس زمان واقعیای که کاربر میبیند را اندازه میگیریم.
- لاگ داخل، cache بیرون: وقتی جواب در cache باشد، درخواست اصلاً به لاگ نمیرسد. پس فقط درخواستهایی لاگ میشوند که به دیتابیس رفتهاند. این برای دیدن بار دیتابیس خوب است.
- مثال دیگر: اگر یک Decorator برای retry داشته باشیم، باید داخل cache باشد. اگر بیرون باشد، با هر retry دوباره سراغ cache میرود که بیفایده است.
- قاعده کلی: اول تصمیم بگیر هر لایه باید چه چیزی را ببیند، بعد ترتیب را بچین.
نشانه خطر: cache را داخل همه متدها کپی میکند و مشکلی در آن نمیبیند. یا روی EF Core یک repository عمومی و یک Unit of Work جدا میسازد و نمیداند DbContext خودش همین کار را میکند.
در این مرورگر ذخیره نشد.
لینک نتیجه برای مصاحبهشونده
این لینک خصوصی است. هر کس آن را داشته باشد، نتیجه را میبیند (بدون یادداشتهای خصوصی).