معماری Microservices
سیستم را به چند سرویس کوچک تقسیم کن که هر کدام یک بخش کسبوکار را دارد، دیتابیس خودش را دارد و جدا منتشر میشود. این کار به تیمها استقلال میدهد، ولی هزینه فنی زیادی دارد. پس فقط وقتی دلیل واقعی داری از آن استفاده کن.
نویسنده: bezzad
مشکل: یک برنامه بزرگ برای چند تیم
یک فروشگاه اینترنتی داریم. اول کار، یک تیم ۴ نفره یک برنامه (Monolith) ساخته است. سفارش، انبار، پرداخت و ارسال همه در یک پروژه و یک دیتابیس هستند.
دو سال بعد، شرکت بزرگ شده است. حالا ۵ تیم روی همان برنامه کار میکنند. مشکلها شروع میشوند:
- انتشار کند میشود. تیم پرداخت یک تغییر کوچک دارد، ولی باید منتظر بماند تا کار تیم انبار هم آماده شود. چون همه با هم منتشر میشوند.
- یک باگ همه را خراب میکند. یک نشت حافظه در بخش گزارش، کل فروشگاه را پایین میآورد.
- بار یکسان نیست. جستجوی محصول صد برابر ثبت سفارش بار دارد. ولی برای بیشتر کردن جستجو، باید کل برنامه را چند برابر کنیم.
ایده Microservices این است: هر بخش کسبوکار یک برنامه جدا باشد. هر تیم سرویس خودش را دارد، دیتابیس خودش را دارد و هر وقت خواست منتشرش میکند.
سه شکل ساختن سیستم
بین «یک برنامه درهم» و «دهها سرویس جدا» یک راه میانه هم هست: Modular Monolith.
- برنامه یکپارچه (Monolith). یک برنامه و یک دیتابیس. اگر مرزها روشن نباشد، همه کدها به هم وابسته میشوند.
- برنامه یکپارچه ماژولار (Modular Monolith). هنوز یک برنامه است و یک بار منتشر میشود. ولی هر ماژول جدولهای خودش را دارد. ماژول دیگر فقط از راه یک interface عمومی با آن حرف میزند.
- سرویسهای جدا (Microservices). هر ماژول یک برنامه جدا با دیتابیس جدا است. تماسها از شبکه میگذرند.
هزینه Microservices
در یک برنامه، ثبت سفارش و کم کردن موجودی در یک تراکنش است. یا هر دو انجام میشوند، یا هیچکدام. ولی در Microservices این دو کار در دو سرویس و دو دیتابیس هستند. پس:
- تراکنش مشترک نداریم. اگر سفارش ثبت شد و صدا زدن انبار خطا داد، داده ناهماهنگ میشود. برای حلش به Outbox، Saga و کار جبرانی نیاز داریم.
- شبکه قابل اعتماد نیست. هر صدا زدن ممکن است کند شود، خطا بدهد یا دو بار برسد. پس Retry، Timeout، Circuit Breaker و Idempotency لازم است.
- پیدا کردن باگ سخت است. یک درخواست از چند سرویس میگذرد. بدون Distributed Tracing و لاگ ساختیافته، نمیدانی کجا خراب شد.
- کار عملیاتی چند برابر میشود. به جای یک خط انتشار (Pipeline)، ده تا داری. هر کدام مانیتورینگ، هشدار و نسخهبندی خودش را میخواهد.
- داده بلافاصله یکی نیست. وقتی سرویسها با پیام هماهنگ میشوند، چند ثانیه طول میکشد تا همه یک چیز را ببینند (Eventual Consistency).
مرز سرویس از کجا میآید؟
بزرگترین اشتباه این است که برای هر جدول یک سرویس بسازیم: سرویس مشتری، سرویس محصول، سرویس قیمت. نتیجه این است که برای هر کار ساده، چند سرویس باید همدیگر را صدا بزنند.
مرز درست از بخشهای کسبوکار میآید. در زبان DDD به هر بخش Bounded Context میگوییم. برای پیدا کردن مرز، این سؤالها را بپرس:
- کدام تیم مالک این قانون است؟ قانون «حداکثر تخفیف» مال تیم فروش است. پس در سرویس فروش میماند.
- این کلمه همه جا یک معنا دارد؟ «محصول» در فروش یعنی نام، عکس و قیمت. در انبار یعنی تعداد و قفسه. پس اینها دو مدل جدا هستند.
- کدام دادهها با هم عوض میشوند؟ دادههایی که همیشه با هم و در یک تراکنش عوض میشوند، باید در یک سرویس باشند.
هر سرویس، دیتابیس خودش
قانون اصلی این است: هیچ سرویسی مستقیم به دیتابیس سرویس دیگر دست نمیزند. نه برای خواندن، نه برای نوشتن.
چرا دیتابیس مشترک بد است؟ قدم به قدم:
- سرویس سفارش و سرویس انبار از یک جدول استفاده میکنند.
- تیم انبار اسم یک ستون را عوض میکند.
- سرویس سفارش خراب میشود، چون هنوز اسم قدیمی را میخواند.
- از این به بعد، دو تیم باید هر تغییر را با هم هماهنگ و با هم منتشر کنند.
به این حالت Distributed Monolith میگویند: همه سختیهای شبکه را داری، ولی استقلال نداری.
پس اگر سرویس سفارش به داده انبار نیاز دارد، دو راه دارد:
- صدا زدن API. وقتی جواب را همین الان لازم داری. ولی مراقب باش: برای یک لیست ۵۰ سفارشی، سرویس را ۵۰ بار پشت سر هم صدا نزن. همه شناسهها را جمع کن و با یک درخواست دستهای بگیر.
- نگه داشتن یک کپی محلی. سرویس انبار با هر تغییر یک رویداد منتشر میکند. سرویس سفارش یک جدول کوچک از همان دادهای که لازم دارد نگه میدارد. داده چند ثانیه تأخیر دارد، ولی خواندنش سریع است و به انبار وابسته نیست.
ارتباط همزمان یا با پیام؟
سرویسها دو راه برای حرف زدن دارند:
همزمان (HTTP یا gRPC)
- وقتی جواب را همین الان لازم داری.
- مثال: صفحه پرداخت باید قیمت نهایی را همین حالا بداند.
- اگر سرویس مقصد قطع باشد، درخواست تو هم شکست میخورد.
با پیام (Kafka یا RabbitMQ)
- وقتی کار میتواند چند ثانیه بعد انجام شود.
- مثال: فرستادن پیامک بعد از ثبت سفارش.
- اگر سرویس مقصد قطع باشد، پیام در صف میماند و بعداً پردازش میشود.
چرا زنجیره طولانی همزمان خطرناک است؟
- فرض کن هر سرویس ۹۹.۹ درصد وقتها سالم است.
- یک درخواست از ۵ سرویس پشت سر هم میگذرد.
- احتمالها در هم ضرب میشوند. پس احتمال موفقیت کل حدود ۹۹.۵ درصد میشود.
- یعنی خرابی کل سیستم حدود ۵ برابر یک سرویس است. هر چه زنجیره بلندتر باشد، بدتر است.
کد
شروع درست: Modular Monolith
هر ماژول یک قرارداد عمومی دارد. بقیه کد فقط این قرارداد را میبیند، نه جدولها و کلاسهای داخلی را.
// Inventory module: the only public surface.
public interface IInventoryModule
{
Task<bool> ReserveAsync(Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct);
}
// Everything else in the module is internal.
internal sealed class InventoryModule(InventoryDbContext db) : IInventoryModule
{
public async Task<bool> ReserveAsync(
Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct)
{
// Uses only the inventory tables (its own schema).
// ...
await db.SaveChangesAsync(ct);
return true;
}
}
اگر روزی ماژول انبار واقعاً لازم شد جدا شود، فقط پیادهسازی این interface عوض میشود. کد سفارش تغییر نمیکند.
بعد از جدا کردن: صدا زدن با محافظ
وقتی انبار یک سرویس جدا شد، همان قرارداد با HTTP پیاده میشود. چون شبکه قابل اعتماد نیست، یک لایه Resilience هم اضافه میکنیم:
builder.Services
.AddHttpClient<IInventoryModule, InventoryHttpClient>(client =>
client.BaseAddress = new Uri("https://inventory"))
.AddStandardResilienceHandler(); // timeout, retry, circuit breaker
internal sealed class InventoryHttpClient(HttpClient http) : IInventoryModule
{
public async Task<bool> ReserveAsync(
Guid orderId, IReadOnlyList<OrderLine> lines, CancellationToken ct)
{
var response = await http.PostAsJsonAsync(
$"reservations/{orderId}", lines, ct);
return response.IsSuccessStatusCode;
}
}
کارهای غیرفوری: رویداد
برای فرستادن پیامک، سفارش منتظر سرویس اطلاعرسانی نمیماند. فقط یک رویداد منتشر میکند:
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total);
این رویداد باید همراه با ذخیره سفارش و با الگوی Outbox فرستاده شود. وگرنه ممکن است سفارش ذخیره شود ولی رویداد هیچ وقت نرود.
قانونهای مهم
- با Modular Monolith شروع کن. مرزها را در کد روشن کن. جدا کردن را تا وقتی دلیل واقعی داری عقب بینداز.
- فقط با دلیل واقعی جدا کن. دلیلهای خوب: چند تیم که نمیخواهند منتظر هم بمانند، بخشی که بار خیلی متفاوت دارد، یا بخشی که نیاز فنی جدا دارد.
- مرز را از کسبوکار بگیر، نه از جدولها.
- دیتابیس مشترک ممنوع. هر سرویس مالک داده خودش است.
- هر جا میشود، پیام به جای زنجیره همزمان. زنجیره همزمان را کوتاه نگه دار.
- از روز اول ابزار دیدن داشته باش. شناسه ردیابی (Trace Id) در همه سرویسها، لاگ ساختیافته و مانیتورینگ.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| ده سرویس برای یک تیم کوچک | همه وقت تیم صرف زیرساخت میشود، نه محصول. | شروع با Modular Monolith. |
| یک سرویس برای هر جدول | برای هر کار ساده چند سرویس صدا زده میشود. | مرز بر اساس بخش کسبوکار. |
| دیتابیس مشترک | تیمها باید با هم منتشر کنند (Distributed Monolith). | دیتابیس جدا و ارتباط با API یا رویداد. |
| زنجیره طولانی صدا زدن همزمان | کند شدن یک سرویس، همه را کند میکند. | پیام برای کارهای غیرفوری، کپی محلی داده. |
| تراکنش بین سرویسها با فرض «همیشه موفق است» | داده ناهماهنگ میشود و کسی نمیفهمد. | الگوی Outbox و Saga. |
| بدون Tracing و لاگ ساختیافته | پیدا کردن یک باگ چند روز طول میکشد. | شناسه ردیابی مشترک از روز اول. |
چه وقت Microservices؟
مناسب
- چند تیم مستقل روی یک محصول بزرگ کار میکنند.
- بخشهایی با بار خیلی متفاوت داریم که باید جدا بزرگ شوند.
- مرز بخشهای کسبوکار روشن و پایدار است.
- تیم تجربه و ابزار لازم برای سیستم توزیعشده را دارد.
نامناسب
- تیم کوچک و محصول جدید که هنوز دامنهاش را خوب نمیشناسد.
- مرزها هنوز معلوم نیستند و زیاد عوض میشوند.
- دلیل اصلی «مد روز است» یا «شرکتهای بزرگ این کار را میکنند».
- کسبوکار هیچ ناهماهنگی موقتی را قبول نمیکند.
خلاصه در شش خط
- معماری Microservices یعنی هر بخش کسبوکار یک سرویس جدا با دیتابیس جدا.
- سود اصلیاش استقلال تیمها است، نه سرعت یا تمیزی کد.
- هزینهاش بالاست: شبکه، نبود تراکنش مشترک، ردیابی و کار عملیاتی.
- با Modular Monolith شروع کن و فقط با دلیل واقعی جدا کن.
- مرز را از بخشهای کسبوکار بگیر و دیتابیس را هیچ وقت شریک نکن.
- برای کارهای غیرفوری از پیام استفاده کن و زنجیره همزمان را کوتاه نگه دار.