پیادهسازی الگوی Circuit Breaker در داتنت با Polly — از پایه تا پیادهسازی
درک بحران خطای آبشاری و ضرورت پایداری در معماریهای توزیعشده
در معماریهای میکروسرویس، وابستگی به سرویسهای خارجی یک واقعیت اجتنابناپذیر است اما هنگامی که یکی از این سرویسها با کندی یا قطعی مواجه میشود، کل سیستم شما در لبه پرتگاه قرار میگیرد. خطای آبشاری زمانی رخ میدهد که از کار افتادن یک پایگاه داده یا API جانبی، به سرعت تمام منابع سرور شما را میبلعد و برنامهای که تا چند لحظه پیش کاملاً سالم بود را زمینگیر میکند.
توسعهدهندگان تازهکار معمولاً برای حل این مشکل به سراغ بلوکهای try-catch بیپایان یا حلقههای تکرار تهاجمی میروند. این رویکرد سنتی نه تنها مشکل را حل نمیکند، بلکه در زمان قطعی سرویس خارجی، درخواستهای متوالی را روانه آن کرده و ناخواسته سیستم مقصد را دچار حمله انکار سرویس یا همان DDOS داخلی میکند.
Client ---> HTTP Request ---> Unresponsive Service ---> Thread Blocked ---> System Crash
برای درک بهتر فاجعه، فرض کنید یک کد ساده با استفاده از کلاس HttpClient استاندارد در داتنت نوشتهاید و زمان انتظار پیشفرض را روی 30 ثانیه تنظیم کردهاید.
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var response = await client.GetAsync("https://api.external-service.com/data");
مقدار 30 ثانیه برای Timeout در دنیای وب سرورهای پربازدید یک فاجعه مطلق است زیرا تردهای (Threads) ارزشمند درون Thread Pool را مسدود میکند. اگر در همین لحظه هزار کاربر همزمان درخواست بفرستند، تمام تردهای برنامه قفل میشوند و سرور دیگر قادر به پاسخگویی به هیچ درخواست دیگری نخواهد بود.
نتیجه این مسدودسازی منابع، نمایان شدن لاگهای هشداری مشابه نمونه زیر در کنسول برنامه شماست که نشاندهنده اشباع کامل منابع است:
System.Net.Http.HttpRequestException: The request was canceled due to the configured HttpClient.Timeout of 30 seconds elapsing.
at System.Net.Http.HttpConnection.SendAsync(...)
--- End of inner exception stack trace ---
System.Threading.ThreadPool: Warning: Thread pool starvation detected. Active threads: 500, Available threads: 0.
در مقایسه عملکردی، تفاوت رویکرد مسدودکننده سنتی با مدیریت اصولی سوکتها بسیار چشمگیر است:
| ویژگی سیستم | رویکرد مسدودکننده سنتی (Synchronous Blocking) | مدیریت نوین سوکت و مدیریت پایداری (Asynchronous Socket Handling) |
|---|---|---|
| مصرف ترد | قفل شدن ترد تا زمان دریافت پاسخ نهایی | آزاد کردن ترد بلافاصله پس از ارسال درخواست |
| واکنش به قطعی | مصرف حداکثری منابع و ایجاد صف انتظار طولانی | تشخیص سریع خطا (Fail-Fast) و بازگرداندن پاسخ |
| پایداری سیستم | آسیبپذیر در برابر خطای آبشاری | مقاوم و خودترمیمی در برابر قطعیهای زنجیرهای |
انتخاب مهندسی درست این است که به جای انتظار کورکورانه، اصل Fail-Fast یا شکست سریع را بپذیریم و به جای معطل نگه داشتن تردهای سیستم، از کتابخانههای تابآوری برای مدیریت اتصال استفاده کنیم.
نکته کاربردی: هرگز اجازه ندهید خطاهای سرویسهای خارجی به استخر اتصال پایگاه داده (Database Pool) شما سرایت کند؛ اگر یک API خارجی کند است، نباید اجازه دهید تردهای پایگاه داده شما نیز معطل آن بمانند.
در بخشهای بعدی خواهیم دید که چگونه با استفاده از الگوهای مدرن در داتنت، مرزهای پایداری سیستم خود را در برابر این خطاهای پیشبینینشده استحکام ببخشیم.
آشنایی با کتابخانه Polly و ساختار پایهای خطمشیها (Policies)
هنگامی که سرویسهای ما در محیط عملیاتی با خطاهای مکرر شبکه یا کندی ناگهانی پایگاه داده مواجه میشوند، نوشتن کدهای سفارشی برای مدیریت مجدد درخواستها و وضعیت سلامت سرویس، کدی پیچیده و پر از خطاهای منطقی تولید میکند. اگر خودمان بخواهیم با متغیرهای بولین ایزوله (Boolean Flags) وضعیت قطعی سرویس را ردگیری کنیم، به مرور با ماشینهای حالت ناپایداری روبهرو میشویم که در تراکنشهای همزمان (Thread-safe) دچار نقص میشوند.
راهکار یک توسعهدهنده جونیور این است که کلاسهای استاتیک سفارشی با متغیرهای volatile بنویسد تا وضعیت خطای سرور را ذخیره کند، اما این روش در مدیریت همزمانی تردهای متعدد شکست میخورد و منطق تابآوری را در سراسر لایه بیزینس پخش میکند. انتخاب مهندسی درست این است که از کتابخانه Polly استفاده کنیم؛ ابزاری که منطق تابآوری را به خطمشیهای (Policies) اعلامی، زنجیرهای و ایمن در برابر ترد تبدیل میکند.
مدیریت دستی وضعیت به دلیل خطاهای ناشی از دسترسی همزمان تردهای وبسایت به متغیرهای وضعیت، پایداری برنامه را به خطر میاندازد. استفاده از Polly به ما اجازه میدهد رفتار سیستم را به صورت ساختاریافته کنترل کنیم، بدون اینکه مجبور باشیم کدهای تکراری مدیریت خطا بنویسیم.
معماری Polly مانند یک میانافزار (Middleware) عمل میکند که درخواستهای خروجی را رهگیری کرده و بر اساس قوانین مشخصشده، نتیجه را مدیریت میکند.
درخواست (Request) -> [ پوششدهنده خطمشی Polly ] -> عملیات اصلی هدف (Target Operation) -> مدیریت پاسخ (Result Handling)
برای درک نحوه ساخت یک خطمشی پایه، به نمونه کد زیر نگاه کنید که خطای موقت شبکه را مدیریت میکند.
var basicRetryPolicy = Policy
.Handle<HttpRequestException>()
.WaitAndRetryAsync(3, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
در این کد، متد Handle مشخص میکند که خطمشی ما به دنبال چه نوع استثنایی باشد. مقدار عدد ۳ یعنی حداکثر سه بار تلاش مجدد انجام میشود تا از بمباران سرویس مقصد جلوگیری کنیم. فرمول نمایی Math.Pow(2, retryAttempt) فاصله زمانی بین تلاشها را به ترتیب ۲، ۴ و ۸ ثانیه افزایش میدهد تا سرور زیر فشار آسیب نبیند.
خطمشیهای Polly به صورت پیشفرض ایمن در برابر تردها (Thread-safe) هستند و بهترین الگو این است که آنها را به عنوان تکنمونه (Singleton) در سیستم تزریق وابستگی داتنت ثبت کنیم.
services.AddSingleton<IAsyncPolicy>(sp =>
Policy.Handle<HttpRequestException>().RetryAsync(2));
ثبت خطمشی به صورت Singleton مانع از هدر رفتن منابع حافظه میشود و ساختار شیگرایی تمیزی در لایه ثبت سرویسها ایجاد میکند. اگر این خطمشیها به صورت Transient ثبت شوند، در هر درخواست یک شیء جدید ساخته میشود که بار پردازشی اضافی به همراه دارد.
برای مقایسه دقیقتر، بیایید تفاوت پیادهسازی دستی با ساختار اعلامی Polly را بررسی کنیم:
| ویژگی | رویکرد دستی (کدگذاری سنتی) | رویکرد مهندسی با Polly |
|---|---|---|
| مدیریت همزمانی | نیازمند قفلگذاری دستی (Locks) | کاملاً Thread-safe به صورت پیشفرض |
| خوانایی کد | پیچیده، پراکنده و آلوده به منطق خطا | ساختاریافته، Fluent و خوانا |
| قابلیت ترکیب | نیازمند بازنویسی منطق برای ترکیب Retry و Circuit Breaker | قابلیت زنجیرهسازی آسان خطمشیها (Policy Wrapping) |
هنگامی که خطمشی تعریفشده ما وارد عمل میشود، گزارشهای رهگیری در کنسول به شکل زیر ثبت میشوند تا وضعیت اجرای عملیات شفاف باشد:
[12:05:01 INF] اجرای عملیات فراخوانی سرویس خارجی آغاز شد.
[12:05:01 WRR] خطای HttpRequestException رخ داد. تلاش مجدد شماره ۱ پس از ۲ ثانیه...
[12:05:03 INF] عملیات با موفقیت در تلاش دوم به پایان رسید.
این خروجی نشان میدهد که سیستم به جای شکست فوری در برابر اولین خطای شبکه، هوشمندانه مکث کرده و درخواست را مجدداً ارسال نموده است.
نکته کاربردی: هرگز خطمشیهای Polly را به صورت نمونههای محلی درون متدهای سرویس نسازید؛ همیشه آنها را از طریق کانتینر DI تزریق کنید تا وضعیت حافظه و عملکرد برنامه بهینه بماند.
در این بخش آموختیم که چگونه با استفاده از ساختار پایهای Polly، خطمشیهای قابل اعتماد بسازیم و منطق تابآوری را از کدهای بیزینس جدا کنیم.
پیادهسازی حالتهای سهگانه Circuit Breaker: بسته، باز و نیمهباز
وقتی یک سرویس خارجی دچار اختلال میشود، ارسال مداوم درخواستها به آن مانند کوبیدن بر درِ یک خانه خالی است که تنها باعث اتلاف منابع سرور خودتان و عمیقتر شدن بحران در سیستم مقصد میشود. راهکار یک توسعهدهنده جونیور این است که برای هر درخواست خطایابی دستی بنویسد یا با تایمرهای پیچیده تلاش کند وضعیت اتصال را ریست کند، اما این روشها در محیطهای همزمان (Concurrent) دچار شرایط مسابقه (Race Conditions) شده و عملاً سیستم را از پا درمیآورند. انتخاب مهندسی درست این است که از الگوی مدارشکن برای خودکارسازی ریاضیاتی وضعیتها بر اساس آستانه خطا استفاده کنیم تا سیستم بدون دخالت انسانی بین حالتهای مختلف جابهجا شود. هزینه این رویکرد پیچیدگی نسبی در مدیریت حافظه وضعیت است، اما در عوض از انتشار خطاهای آبشاری در کل معماری میکروسرویس جلوگیری میکند.
برای درک بهتر رفتار مدارشکن، بیایید نگاهی به چرخه حیات این الگو بیندازیم که چگونه درخواستها را هدایت میکند:
[ Closed State ] --(Failures > Threshold)---> [ Open State ]
^ |
| |
(Success) (Timeout Expires)
| v
+------------------------------------ [ Half-Open State ]
مدارشکن در دنیای واقعی مانند فیوز خودکار منزل عمل میکند که وقتی جریان برق بیش از حد مجاز شود، جریان را قطع میکند تا تجهیزات نسوزند. در کدنویسی داتنت، این الگو دارای سه وضعیت حیاتی است که هر کدام وظیفه مشخصی در مدیریت ترافیک شبکه بر عهده دارند. حالت بسته (Closed) یعنی همه چیز عادی است و درخواستها به سمت سرویس مقصد روانه میشوند. حالت باز (Open) زمانی فعال میشود که خطاهای سرویس از حد مجاز فراتر روند و در این حالت، مدار به سرعت درخواستها را بدون ارسال به شبکه پس میزند. حالت نیمهباز (Half-Open) یک فضای تست کنترلشده است که به اولین درخواست اجازه عبور میدهد تا صحت بازیابی سرویس خارجی را ارزیابی کند.
برای پیادهسازی این رفتار در کتابخانه پولی، از متد مشخصی برای مدیریت خطاها و تغییر وضعیت استفاده میکنیم. در قطعه کد زیر، یک خطمشی مدارشکن میسازیم که روی خطاهای هندلشده نظارت میکند و پس از رسیدن به آستانه مشخص، مدار را سفر (Trip) میدهد.
var breakerPolicy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30)
);
در این پیکربندی، مقدار عدد ۳ به عنوان آستانه خطا تعیین شده است چون سه شکست متوالی، یک الگوی قطعی از خرابی سرویس است نه یک نوسان لحظهای شبکه. همچنین زمان ۳۰ ثانیه برای وقفه انتخاب شده است تا به سرور مقصد فرصت کافی برای بازیابی و تخلیه صفهای پردازشی داده شود. اگر این عدد خیلی کم باشد، سرور زیر بار درخواستهای تستی له میشود و اگر خیلی زیاد باشد، کاربر مدت زیادی معطل میماند.
نکته کاربردی: وضعیت نیمهباز (Half-Open) درست مانند یک کاوشگر تکدرخواستی عمل میکند که با ارسال تنها یک درخواست تستی، صحت سلامت سرویس خارجی را راستیآزمایی میکند تا از بازگشت امن ترافیک مطمئن شویم.
هنگامی که این خطمشی در حال اجراست، کنسول برنامه لاگهایی تولید میکند که تغییر وضعیت مدار را به صورت لحظهای رصد و ثبت میکنند. خروجی واقعی اجرای این سناریو به شکل زیر است:
[12:00:01] State: Closed -> Request failed. Failure count: 1
[12:00:02] State: Closed -> Request failed. Failure count: 2
[12:00:03] State: Closed -> Request failed. Failure count: 3 -> Transition to Open!
[12:00:04] State: Open -> Request blocked instantly. Broken for 30s.
[12:00:34] State: Half-Open -> Test request sent. Awaiting response...
[12:00:35] State: Closed -> Test succeeded! Normal traffic restored.
لاگهای بالا نشان میدهند که چگونه سیستم پس از سه خطای متوالی به حالت باز رفته و بعد از گذشت تایمر وقفه، با یک درخواست تستی دوباره به حالت پایدار و بسته بازگشته است. تفاوت اصلی در استراتژیهای مدرن، انتخاب بین آستانه تعداد خطا (Failure Count Threshold) و آستانه نرخ خطا (Failure Rate Threshold) است. در روش اول که بالا دیدیم، سه خطای مطلق مدار را باز میکند، فارغ از اینکه در آن بازه زمانی هزار درخواست موفق داشتهایم یا فقط سه درخواست. اما در روش نرخ خطا، سیستم بررسی میکند که مثلاً اگر بیش از پنجاه درصد از کل درخواستها در یک بازه زمانی مشخص با شکست مواجه شدند، مدار قطع شود که برای سرویسهای پرتردد انتخاب بسیار هوشمندانهتری است.
در این بخش آموختیم که چگونه با تنظیم آستانههای دقیق و درک مکانیسم حالتهای سهگانه، مدارشکن را برای مهار خطاهای زنجیرهای پیکربندی کنیم.
مدیریت خطاهای خاص و استثنائات در Polly
تصور کنید سرویس پرداخت شما به دلیل یک خطای اعتبارسنجی ساده در درخواست کاربر (مثل ارسال کد ملی نامعتبر که با خطای ۴۰۰ پاسخ داده میشود) مدارشکن را فعال کند و دسترسی کل کاربران را به درگاه ببندد. این دقیقاً همان نقطهای است که یک پیادهسازی ناآماتور میتواند سیستم شما را به جای محافظت، فلج کند.
راهکار یک توسعهدهنده تازهکار این است که بدون تفکیک خطاها، هرگونه Exception را در سطح برنامه صید کند. این رویکرد خطاهای منطقی بیزینس یا خطاهای ساختاری پایگاه داده را با قطعی موقت شبکه یکی میداند و باعث پنهان شدن باگهای نرمافزاری میشود. انتخاب مهندسی صحیح این است که با فیلتر کردن صریح استثنائات شبکه، مدارشکن را فقط و فقط در برابر فروپاشی زیرساخت تحریک کنیم.
مکانیسم این تصمیم مهندسی به شکل زیر در جریان درخواست اتفاق میافتد:
Exception Thrown -> Is Transient? --(Yes)---> Count Toward Breaker
--(No)---> Bubble Up Exception
برای پیادهسازی این رویکرد جراحیشده، از متد Handle<T>() استفاده میکنیم. در اکوسیستم داتنت، ترکیب HttpRequestException و OperationCanceledException حدود ۹۵ درصد از خطاهای گذرا مانند قطعی موقت شبکه یا اتمام زمان انتظار را پوشش میدهد.
var pipeline = new ResiliencePipelineBuilder()
.AddCircuitBreaker(new CircuitBreakerStrategyOptions
{
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>()
.Handle<OperationCanceledException>()
})
.Build();
در قطعه کد بالا، اگر خطایی از جنس درخواست HTTP یا لغو عملیات رخ دهد، مدارشکن آن را به عنوان خطای زیرساختی ثبت میکند. اما اگر سرور مقصد با خطای ۴۰۴ پاسخ دهد، این خطا توسط فیلتر نادیده گرفته میشود و به عنوان خطای زیرساختی محاسبه نمیگردد.
نکته کاربردی: هرگز مدارشکن را روی خطاهای احراز هویت یا اعتبارسنجی سمت کاربر (Client-side) فعال نکنید. این خطاها نشانه نقص در دادههای ورودی هستند، نه خرابی سرور مقصد.
برای مدیریت سناریوهای مختلف، میتوانیم انواع استثنائات را با استفاده از عملگرهای ترکیبی گسترش دهیم. در ادامه چند تغییر مختلف را برای فیلتر کردن خطوها مشاهده میکنید:
- فقط خطاهای شبکه:
ShouldHandle = new PredicateBuilder().Handle<HttpRequestException>()
- ترکیب شبکه و تایماوت:
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>()
.Handle<TimeoutRejectedException>()
- فیلتر سفارشی بر اساس کد وضعیت:
ShouldHandle = new PredicateBuilder()
.Handle<HttpRequestException>(ex => ex.StatusCode >= System.Net.HttpStatusCode.InternalServerError)
در سناریوی آخر، خطای ۵۰۳ (سرور از دسترس خارج است) مدار را قطع میکند، در حالی که خطای ۴۰۴ (یافت نشد) به صورت عادی از مدار عبور میکند و باعث قطع شدن جریان سرویسدهی نمیشود. این سطح از تفکیک خطا به ما اجازه میدهد خطاهای زیرساختی را با دقت بالا ایزوله کنیم.
در این بخش آموختیم که چگونه با تفکیک هوشمندانه خطاهای شبکه از خطاهای منطقی، از فعال شدن بیمورد مدارشکن جلوگیری کنیم.
پیکربندی رویدادها و مدیریت رخدادهای تغییر وضعیت (Callbacks)
تصور کنید سرویس پرداخت شما به یک درگاه بانکی متصل است و ناگهان درگاه از دسترس خارج میشود. مدارشکن (Circuit Breaker) طبق تنظیمات ما فعال میشود و جلوی درخواستهای اضافه را میگیرد تا سرور مقصد نفس بکشد، اما تیم مهندسی ما هیچ ابزاری برای دیدن این اتفاق در لحظه ندارد. خطاهای خاموش در لایه زیرساخت باعث میشوند تا زمانی که مشتریان با عصبانیت با پشتیبانی تماس نگرفتهاند، تیم فنی هیچ ایدهای درباره قطعی ارتباط نداشته باشد.
یک توسعهدهنده جونیور برای حل این مشکل ممکن است هر چند دقیقه یکبار با نوشتن کدهای سفارشی و سرویسهای پسزمینه (Background Services)، وضعیت سلامت سرویس را به صورت دستی بررسی کند یا لاگهای معمولی ثبت کند. این روش نه تنها سربار شبکه را بالا میبرد، بلکه تغییر وضعیت دقیق مدار از حالت بسته به باز را در لحظه رصد نمیکند و تأخیر زیادی در آگاهی از بحران ایجاد میکند. انتخاب مهندسی درست این است که از مکانیزمهای داخلی پایش رویداد در کتابخانههای استاندارد استفاده کنیم تا سیستم به محض تغییر وضعیت، سیگنالهای هشدار را صادر کند.
برای پیادهسازی این قابلیت در پلتفرم داتنت، کتابخانه Polly ابزارهای قدرتمندی به نام رویدادهای برگشتی یا همان Callbacks در اختیار ما قرار میدهد. با استفاده از متدهایی مانند OnBreak و OnReset، میتوانیم به چرخه حیات مدارشکن قلاب شویم و هر زمان که وضعیت مدار تغییر کرد، کدهای دلخواه خود را اجرا کنیم. قیمت این شفافیت بالا، مدیریت دقیقتر حافظه و پردازش رویدادها است که در مقیاسهای بسیار بزرگ باید بهینهسازی شود، اما برای ۹۹ درصد سیستمها مزایای آن بینظیر است.
State Changes -> Trigger Callback -> Emit Metric / Log Warning -> Notify SRE Team
برای اینکه این مکانیزم به شکل استاندارد با زیرساخت لاگبرداری داتنت یکپارچه شود، از رابطهای کاربری Microsoft.Extensions.Logging استفاده میکنیم. در قطعه کد زیر، نحوه اتصال این رویدادها به سیاست مدارشکن را مشاهده میکنید:
var breakerPolicy = Policy
.Handle<HttpRequestException>()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30),
onBreak: (exception, timespan) =>
{
logger.LogWarning(exception, "مدارشکن به دلیل خطاهای متوالی باز شد. مدت زمان قطع: {Duration} ثانیه", timespan.TotalSeconds);
},
onReset: () =>
{
logger.LogInformation("مدارشکن به حالت عادی بازگشت و ترافیک برقرار شد.");
}
);
در این پیکربندی، مقدار عدد 3 به این معنا است که سیستم پس از سه خطای پیاپی صبر خود را از دست میدهد و مقدار 30 ثانیه یعنی مدار دقیقا نیم دقیقه قفل میماند تا سرور مقصد ریکاوری شود. کم کردن این زمان باعث تحمیل بار اضافی به سرور خراب میشود و زیاد کردن آن، کاربران را مدت بیشتری در صف انتظار نگه میدارد. اطلاعاتی که در متد onBreak دریافت میکنیم شامل نمونه استثنا و مدت زمان قطع است که به ما اجازه میدهد پنجره دقیق قطعی را محاسبه کنیم.
وقتی این کد در محیط عملیاتی اجرا میشود و مدار قطع میشود، خروجی ساختاریافتهی زیر در سیستم لاگبرداری ثبت خواهد شد:
{
"@t": "2024-03-20T10:15:30.1234567Z",
"@mt": "مدارشکن به دلیل خطاهای متوالی باز شد. مدت زمان قطع: {Duration} ثانیه",
"@l": "Warning",
"Exception": "System.Net.Http.HttpRequestException: No such host is known.",
"Duration": 30
}
این ساختار به سیستمهای مانیتورینگ اجازه میدهد تا به راحتی فیلد سطح هشدار و زمان دقیق وقوع رویداد را استخراج کنند. برای مقایسه، در نظر بگیرید که ثبت ساده لاگهای متنی با استفاده از شمارندههای پرومتئوس (Prometheus Counters) چه تفاوتی دارد؛ لاگها جزئیات خطای رخداده و ریشه مشکل را نشان میدهند، در حالی که شمارندهها فقط تعداد دفعات تغییر وضعیت را به صورت آماری برای رسم نمودارها مانیتور میکنند.
توسعه سیستم از مدیریت وضعیت ایزوله به سمت پایش سراسری، نیازمند استفاده ترکیبی از این ابزارها است. به عنوان یک سناریوی پیشرفتهتر، میتوانیم به جای فقط لاگ کردن، اقدامات کنترلی فوری انجام دهیم.
نکته کاربردی: از رویداد
OnBreakبرای پاکسازی فوری کشهای داخلی یا سوئیچ کردن سیستم به حالت پاسخدهی جایگزین (Fallback) استفاده کنید تا کاربران به جای دیدن صفحه خطا، دادههای کششده قدیمی را دریافت کنند.
در این بخش آموختیم که چگونه با استفاده از رویدادهای تغییر وضعیت در Polly، سیستم را نسبت به حوادث زیرساختی آگاه سازیم و پایش عملکرد را به شکل حرفهای پیادهسازی کنیم.
ترکیب Circuit Breaker با Retry Policy برای تابآوری حداکثری
تصور کنید یک خطای شبکه بسیار لحظهای و کوتاه رخ میدهد و سرور مقصد برای کسری از ثانیه پاسخ نمیدهد. اگر بدون تفکر، مدار محافظ (Circuit Breaker) را به تنهایی روی درخواستها فعال کنیم، این نوسان جزئی بلافاصله مدار را باز میکند و جلوی تمام درخواستهای بعدی را میگیرد. برای جلوگیری از این خطای زودهنگام، ما به مکانیزمی نیاز داریم که ابتدا خطاهای موقت را چندین بار مجدداً تلاش کند و تنها در صورت تداوم قطعی، اجازه دهد مدار محافظ وارد عمل شود.
رویکرد طبیعی یک توسعهدهنده جونیور این است که سیاستهای مختلف پولی را بدون در نظر گرفتن ترتیب لانهگذاری (Nesting) به صورت تصادفی کنار هم بچیند. این روش نامنظم باعث میشود که کنترل جریان درخواستها از دست برود و سیاستها خنثیکننده یکدیگر عمل کنند. انتخاب مهندسی صحیح این است که سیاست تلاش مجدد (Retry) را دقیقاً در دل سیاست مدار محافظ (Circuit Breaker) لانهگذاری کنیم تا نوسانات کوچک ابتدا توسط تلاش مجدد مدیریت شوند.
در این معماری لایهای، جریان درخواست به گونهای طراحی میشود که بیرونیترین لایه تصمیمگیرنده نهایی باشد.
درخواست کاربر -> [ لایه مدار محافظ (Circuit Breaker) ] -> [ لایه تلاش مجدد (Retry) ] -> سرویس هدف (Target API)
برای پیادهسازی این الگو از متد Policy.WrapAsync در پولی استفاده میکنیم. لایه بیرونی مدار محافظ است زیرا این مؤلفه باید وضعیت کلی سلامت سیستم را رصد کند و لایه درونی سیاست تلاش مجدد خواهد بود تا خطاهای گذرا را پیش از ثبت شدن به عنوان شکست قطعی ایزوله کند.
var retryPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.WaitAndRetryAsync(2, attempt => TimeSpan.FromMilliseconds(500 * attempt));
var circuitBreakerPolicy = HttpPolicyExtensions
.HandleTransientHttpError()
.CircuitBreakerAsync(
handledEventsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30)
);
var combinedPolicy = Policy.WrapAsync(circuitBreakerPolicy, retryPolicy);
در قطعه کد بالا، تعداد تلاش مجدد روی عدد ۲ با الگوی رشد نمایی تنظیم شده است تا به سرور هدف فرصت بازیابی داده شود. تعداد مجاز خطا برای باز شدن مدار روی عدد ۳ و زمان استراحت روی ۳۰ ثانیه تنظیم شده است که تعادل مناسبی میان حساسیت به خطا و پایداری سیستم ایجاد میکند. اگر تعداد تلاشها تمام شود و خطا همچنان باقی بماند، کنترل به مدار محافظ منتقل میشود تا تصمیم نهایی را اتخاذ کند.
نکته کاربردی: نقطه ورود اصلی همیشه توسط بیرونیترین سیاست تعیین میشود؛ بنابراین همیشه سیاست تلاش مجدد را در داخل سیاست مدار محافظ لانهگذاری کنید تا اندازهگیری خطاهای دائمی به درستی انجام شود.
خروجی واقعی اجرای این خط مشی ترکیبی در زمان بروز قطعی مداوم در سرور به شکل زیر ثبت میشود:
[00:01:10] Request started -> Retry attempt 1 failed. Waiting 500ms...
[00:01:11] Request started -> Retry attempt 2 failed. Waiting 1000ms...
[00:01:12] Circuit Breaker intercepted failure. State changed from Closed to Open for 30s.
[00:01:13] Request rejected instantly by Circuit Breaker. Throwing BrokenCircuitException.
در بررسی این خروجی مشاهده میکنیم که دو تلاش اول طبق تنظیمات انجام شده و پس از شکست، مدار محافظ در تلاش سوم وارد عمل شده و وضعیت خود را به حالت باز تغییر داده است. درخواست چهارم بلافاصله و بدون ارسال به شبکه رد میشود تا از اتلاف منابع جلوگیری شود.
در این بخش آموختیم که چگونه با استفاده از ترکیب لایهای سیاستها در پولی، تابآوری سیستم را به حداکثر برسانیم.
یکپارچهسازی Polly با IHttpClientFactory در ASP.NET Core
وقتی درخواستهای HTTP را به صورت دستی و با ساخت new HttpClient() در سراسر برنامه مدیریت میکنیم، با مشکلات جدی نشت حافظه (Memory Leak) و از دست رفتن وضعیت مدار محافظ روبهرو میشویم. راهکار یک توسعهدهنده جونیور این است که یک نمونه استاتیک از Policy را داخل سازنده سرویسها بسازد، اما این کار باعث میشود وضعیت مدار (باز یا بسته بودن) بین درخواستهای مختلف کاربران به اشتراک کشیده نشود و مدیریت چرخههای عمر (Lifetimes) از دست برود. انتخاب مهندسی این است که از متد الحاقی AddPolicyHandler در کنار IHttpClientFactory استفاده کنیم تا لایه تابآوری به صورت ایمن و تزریق شده به خط لوله HTTP متصل شود.
[ Dependency Injection ] -> [ HttpClient Registration ] -> [ Polly HttpExtension ] -> [ Resilient Outbound Call ]
برای پیادهسازی این الگو در سطح زیرساخت برنامههای داتنت، بسته Microsoft.Extensions.Http ابزارهای استاندارد را در اختیار ما قرار میدهد. در ادامه، نحوه پیکربندی این سیاستها را در فایل Program.cs مشاهده میکنید که چگونه یک کلاینت HTTP را با خط لوله تابآوری تجهیز میکند.
builder.Services.AddHttpClient<IPaymentService, PaymentService>()
.AddTransientHttpErrorPolicy(policy => policy.AddCircuitBreaker(
handledEventsAllowedBeforeBreaking: 3,
durationOfBreak: TimeSpan.FromSeconds(30)
));
در قطعه کد بالا، مقدار 3 برای تعداد خطاهای مجاز انتخاب شده است تا سیستم در برابر نوسانات لحظهای و بسیار کوتاه شبکه حساسیت نشان ندهد. مقدار 30 ثانیه برای زمان استراحت مدار مشخص میکند که پس از بروز خرابی، سرویسدهنده مقصد فرصت کافی برای بازیابی و پایداری مجدد را داشته باشد.
نکته کاربردی: همیشه از متد
AddTransientHttpErrorPolicyبه عنوان میانبری استاندارد برای مدیریت خطاهای موقت وبسایتها و خطاهای شبکه استفاده کنید تا کد تمیزتر و خواناتری داشته باشید.
هنگامی که برنامه را اجرا میکنیم و درخواستها به سمت سرویس خارجی روانه میشوند، لاگهای استارتاپ داتنت به وضوح ثبت شدن این سیاستها را تأیید میکنند. خروجی زیر نمونهای از ثبت موفقیتآمیز کلاینت و خط لوله تابآوری آن را نشان میدهد:
info: Microsoft.Extensions.Http.DefaultHttpClientFactory[0]
Policy configured for HttpClient 'IPaymentService' using 'AddTransientHttpErrorPolicy'.
Circuit Breaker: 3 failures allowed, 30s break duration.
در معماری برنامههای مدرن، میتوانیم بین کلاینتهای نامگذاری شده و کلاینتهای تایپشده یکی را انتخاب کنیم. بررسی تفاوتهای ساختاری این دو رویکرد در جدول زیر به ما کمک میکند تصمیم بهتری بگیریم:
| ویژگی | Named Clients | Typed Clients |
|---|---|---|
| تزریق وابستگی | نیاز به IHttpClientFactory دارد |
به صورت مستقیم در سازنده تزریق میشود |
| خوانایی کد | متوسط (نیاز به مشخص کردن نام کلاینت) | بالا (مبتنی بر اصول شیءگرایی) |
| مدیریت سیاستها | مناسب برای سناریوهای عمومی | عالی برای دامنههای مشخص کاری |
تنظیم دقیق طول عمر سرویسها در ظرف تزریق وابستگی (DI Container) اهمیت بسیار بالایی دارد تا از خطاهای مربوط به محصور شدن وابستگیها جلوگیری شود. کلاینتهای پیکربندی شده با Polly به صورت خودکار مدیریت چرخههای حیات سوکتها را بهینه میکنند تا سیستم در مقیاس بالا دچار مشکل اتمام منابع نشود.
در این بخش آموختیم که چگونه با بهرهگیری از زیرساخت IHttpClientFactory و متدهای الحاقی پولی، خطوط لوله تابآوری را به صورت استاندارد و ایمن در معماری ASP.NET Core پیادهسازی کنیم.
پیادهسازی یک سناریوی واقعی تولیدی (Production-Ready) با Fallback و مانیتورینگ
وقتی یک سرویس حیاتی در لایه زیرساخت با شکست مواجه میشود، نمایش خطای خام ۵۰۰ به کاربر نهایی نهتنها تجربهای ناخوشایند خلق میکند، بلکه ساختار فرانتاند را هم دچار فروپاشی میکند. یک توسعهدهنده جونیور معمولاً در چنین شرایطی سعی میکند با نوشتن بلوکهای try-catch سنگین در تمامی کنترلرها، خطاهای سرویس خارجی را بگیرد و یک پیام متنی ساده برگرداند؛ اما این رویکرد کد شما را به شدت کثیف کرده و در مقیاس بالا مدیریتش غیرممکن میشود. انتخاب مهندسی صحیح این است که خطوط لوله تابآوری را طوری ترکیب کنیم که در زمان قطعی سرویس، به جای تسلیم شدن در برابر خطا، دادههای کش شده یا مقادیر پیشفرض امن را به صورت ساختاریافته به کاربر تحویل دهیم.
معماری این فرآیند تابآوری به شکل زیر عمل میکند تا درخواست ورودی در صورت مسدود شدن مدار، به سمت لایه جایگزین هدایت شود:
Request ---> [ Circuit Breaker (Open) ] ---> [ Fallback Policy ] ---> Return Cached Data
برای پیادهسازی این سناریو در محیط عملیاتی، از ترکیب کتابخانه Polly و ابزار کشینگ استاندارد IMemoryCache استفاده میکنیم. در این الگو، اگر سرویس خارجی قطع شود، سیاست Fallback وارد عمل شده و آخرین داده معتبر را از حافظه موقت استخراج میکند.
کلاس سرویس زیر نحوه ادغام این سیاستها را به صورت یکپارچه نشان میدهد:
public class ResilientProductService : IProductService
{
const string CacheKey = "fallback_products_cache";
public async Task<IEnumerable<Product>> GetProductsAsync()
{
// اجرای منطق اصلی ارتباط با سرویس بیرونی
return await FetchExternalProductsAsync();
}
}
برای اینکه این سرویس در برابر قطعیهای ناگهانی مقاوم شود، سیاست Fallback را به همراه یک مقدار پیشفرض امن پیکربندی میکنیم. ارزش مقادیر انتخابی در اینجا حیاتی است؛ مثلاً استفاده از یک لیست خالی به همراه وضعیت مشخص، به کلاینت اجازه میدهد بدون کرش کردن رابط کاربری، پیام مناسب را به کاربر نشان دهد.
var fallbackPolicy = Policy<IEnumerable<Product>>
.Handle<Exception>()
.FallbackAsync(
fallbackValue: Enumerable.Empty<Product>(),
onFallbackAsync: async exception =>
{
// لاگ کردن خطای رخ داده برای بررسیهای مانیتورینگ
await Task.CompletedTask;
});
نکته کاربردی: همیشه یک هاردکد سفارشی مانند
X-Data-Source: Fallbackدر پاسخ HTTP سرویس قرار دهید تا لایه فرانتاند یا سیستمهای مانیتورینگ بدانند داده دریافتی از حافظه موقت تأمین شده است، نه از پایگاه داده اصلی.
اکنون ساختار پاسخ خروجی را بررسی میکنیم که به صورت یک ساختار JSON استاندارد با هدرهای مانیتورینگ وضعیت بازمیگردد:
{
"status": "Degraded",
"source": "FallbackCache",
"data": []
}
در مقایسه روشها، استفاده از مقادیر ثابت (Static Fallback) برای دادههای پایهای و ثابت مناسب است، در حالی که استفاده از کش توزیعشده (Distributed Cache Fallback) مانند Redis برای سیستمهای بزرگ و دستهبندیشده (Cluster) انتخاب حرفهایتری محسوب میشود. با این رویکرد لایهای، سیستم شما حتی در تاریکترین ساعات قطعی زیرساخت نیز پایداری خود را حفظ خواهد کرد.