پایداری در برابر خطا با Polly
در سیستم توزیعشده، سرویسهای دیگر گاهی کند میشوند یا خطا میدهند. با Timeout، تلاش دوباره با فاصله (Retry) و فیوز (Circuit Breaker) جلوی گسترش خرابی را بگیر. ولی اگر اینها را بد تنظیم کنی، خودشان سیستم را خراب میکنند.
نویسنده: bezzad
مشکل: یک سرویس کند، کل فروشگاه را میخواباند
در فروشگاه اینترنتی، سرویس سفارش برای هر سفارش، سرویس انبار را صدا میزند تا موجودی را چک کند. یک روز سرویس انبار کند میشود. جوابش به جای ۲۰۰ میلیثانیه، ۳۰ ثانیه طول میکشد. چه اتفاقی میافتد؟
- هر درخواست سفارش منتظر میماند. هر کدام یک Thread یا یک اتصال (Connection) را نگه میدارد.
- منابع تمام میشوند. چند صد کاربر همزمان، همه اتصالها را پر میکنند.
- سرویس سفارش هم جواب نمیدهد. حتی برای کارهایی که به انبار ربطی ندارند، مثل دیدن سفارشهای قبلی.
- خرابی پخش میشود. سرویسهایی که سفارش را صدا میزنند هم گیر میکنند.
به این خرابی آبشاری (Cascading Failure) میگویند. یک سرویس کند، بدتر از یک سرویس خاموش است. چون سرویس خاموش فوراً خطا میدهد، ولی سرویس کند منابع تو را نگه میدارد.
خطای موقت و خطای دائمی
قبل از هر چیز، باید خطا را بشناسیم:
خطای موقت (Transient)
- با تلاش دوباره شاید درست شود.
- مثال: قطعی کوتاه شبکه، کد ۵۰۳ (سرویس موقتاً در دسترس نیست)، کد ۴۲۹ (درخواست زیاد)، تمام شدن زمان انتظار.
خطای دائمی
- با تکرار هیچ وقت درست نمیشود.
- مثال: کد ۴۰۰ (درخواست غلط)، کد ۴۰۱ (اجازه نداری)، کد ۴۰۴ (پیدا نشد).
- تلاش دوباره فقط بار اضافه میسازد.
ابزار اول: Timeout
هیچ تماسی با شبکه نباید بدون سقف زمان باشد. کلاس HttpClient به طور پیشفرض ۱۰۰ ثانیه صبر میکند. برای یک صفحه وب، این خیلی زیاد است.
قانون ساده: Timeout را بر اساس زمان واقعی جواب سرویس تنظیم کن. مثلاً اگر انبار معمولاً در ۲۰۰ میلیثانیه جواب میدهد و در بدترین حالتهای عادی در ۸۰۰ میلیثانیه، یک سقف ۲ ثانیهای معقول است.
ابزار دوم: Retry، با احتیاط
تلاش دوباره برای خطای موقت مفید است. ولی Retry بد، از نبود Retry خطرناکتر است. سه دام اصلی:
دام اول: ضرب شدن تلاشها در لایهها
- سرویس سفارش تا ۴ بار سرویس قیمت را صدا میزند: یک بار اصلی و سه بار دوباره.
- در هر کدام، سرویس قیمت تا ۴ بار انبار را صدا میزند.
- یعنی ۴ ضرب در ۴، برابر ۱۶ درخواست به انبار برای یک کلیک.
- انبار که کند بود، حالا بار بیشتری میگیرد و کندتر میشود. به این طوفان Retry (Retry Storm) میگویند.
راه حل: فقط یک لایه تلاش دوباره کند. معمولاً لایهای که به کاربر نزدیکتر است، یا لایهای که کار را بهتر میشناسد.
دام دوم: همه با هم دوباره تلاش میکنند
اگر هزار کلاینت با هم خطا بگیرند و همه دقیقاً بعد از یک ثانیه دوباره تلاش کنند، یک موج هزارتایی دوباره به سرویس میخورد. راه حل:
- فاصله رو به افزایش (Exponential Backoff). مثلاً ۲۰۰، بعد ۴۰۰، بعد ۸۰۰ میلیثانیه. سرویس فرصت نفس کشیدن پیدا میکند.
- کمی عدد تصادفی (Jitter). هر کلاینت کمی زودتر یا دیرتر تلاش میکند. موجها پخش میشوند.
دام سوم: تکرار کاری که نباید تکرار شود
اگر درخواست «پرداخت» یا «رزرو کالا» به خاطر Timeout شکست خورد، شاید کار در سرور انجام شده و فقط جوابش نرسیده است. تلاش دوباره یعنی پرداخت دوباره.
پس فقط کارهایی را دوباره تلاش کن که Idempotent هستند. یعنی تکرارشان همان نتیجه را بدهد. برای POST، یا کلید تکرار (Idempotency Key) بفرست، یا Retry را خاموش کن.
ابزار سوم: Circuit Breaker (فیوز)
اگر انبار کاملاً خراب است، تلاش دوباره فایده ندارد. فقط منابع ما را هدر میدهد و به انبار فرصت بهبود نمیدهد. فیوز مثل فیوز برق خانه کار میکند:
- بسته (Closed). حالت عادی. درخواستها میروند. فیوز درصد خطا را در یک بازه زمانی میشمارد.
- باز (Open). درصد خطا از حد گذشت. تا مدتی مشخص، هیچ درخواستی به انبار نمیرود. فوراً خطا برمیگردد.
- نیمهباز (Half-Open). بعد از آن مدت، یک درخواست آزمایشی میرود. اگر موفق بود، فیوز بسته میشود. اگر نه، دوباره باز میشود.
چرا خطای فوری بهتر از انتظار است؟ چون Thread و اتصال فوراً آزاد میشوند. سرویس سفارش برای بقیه کارها سالم میماند.
مثال زنده
انبار را خراب کن و چند درخواست بفرست. ببین فیوز کی باز میشود و بعد از ۵ ثانیه چطور آزمایش میکند:
قانون این فیوز: اگر از ۴ درخواست آخر حداقل ۳ تا شکست بخورد، فیوز برای ۵ ثانیه باز میشود.
وقتی فیوز باز است، به کاربر چه بگوییم؟
به این تصمیم Fallback میگویند. جواب به نوع کار بستگی دارد:
- خواندن داده. آخرین داده کششده را نشان بده. مثلاً «موجودی ممکن است کمی قدیمی باشد».
- بخش غیر اصلی. آن بخش را موقتاً پنهان کن. مثلاً بخش «محصولات پیشنهادی». بقیه صفحه کار میکند.
- نوشتن و کار مهم. فوراً خطای ۵۰۳ برگردان. هیچ وقت وانمود نکن که سفارش ثبت شد. اگر کار میتواند بعداً انجام شود، آن را در صف بگذار و بگو «در حال پردازش».
ابزار چهارم: محدود کردن همزمانی (Bulkhead)
حتی با Timeout، یک وابستگی کند میتواند همه اتصالها را بگیرد. پس تعداد درخواست همزمان به هر وابستگی را محدود کن. مثل دیوارههای کشتی: اگر یک بخش آب گرفت، بقیه کشتی غرق نمیشود. در Polly این کار با یک Rate Limiter همزمانی انجام میشود.
کد
راه ساده: محافظ استاندارد برای HttpClient
کتابخانه Microsoft.Extensions.Http.Resilience بر پایه Polly ساخته شده است. با یک خط، پنج لایه محافظ با تنظیمات پیشفرض خوب به HttpClient اضافه میکند:
builder.Services
.AddHttpClient<InventoryClient>(client =>
client.BaseAddress = new Uri("https://inventory"))
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(10);
options.Retry.MaxRetryAttempts = 2;
options.Retry.Delay = TimeSpan.FromMilliseconds(200);
options.Retry.DisableForUnsafeHttpMethods(); // no retry for POST, PUT, PATCH, DELETE
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
});
چند نکته:
- تلاش دوباره به طور پیشفرض برای POST هم روشن است. متد DisableForUnsafeHttpMethods آن را برای روشهای غیر امن خاموش میکند.
- بودجه زمانی درست است. سه تلاش ۲ ثانیهای و دو فاصله کوتاه، کمتر از سقف کل ۱۰ ثانیه است.
- فاصلهها رو به افزایش است و عدد تصادفی دارد. این رفتار پیشفرض محافظ استاندارد است.
راه دلخواه: ساختن لایهها خودت
اگر تنظیمات بیشتری لازم داری، لایهها را خودت بچین:
builder.Services
.AddHttpClient<PriceClient>(client => client.BaseAddress = new Uri("https://price"))
.AddResilienceHandler("price", pipeline =>
{
pipeline.AddTimeout(TimeSpan.FromSeconds(5)); // total budget
pipeline.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(200)
});
pipeline.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, // open at 50% failures
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 20, // need enough samples first
BreakDuration = TimeSpan.FromSeconds(15)
});
pipeline.AddTimeout(TimeSpan.FromSeconds(1)); // each attempt
});
ترتیب مهم است. لایهای که اول اضافه میشود، بیرونیترین لایه است. پس Timeout اول، سقف کل کار است و Timeout آخر، سقف هر تلاش.
برای کارهای غیر HTTP
برای کارهای دیگر، مثلاً صدا زدن یک SDK، از خود Polly استفاده کن:
ResiliencePipeline pipeline = new ResiliencePipelineBuilder()
.AddRetry(new RetryStrategyOptions
{
MaxRetryAttempts = 3,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = new PredicateBuilder().Handle<TimeoutException>()
})
.AddTimeout(TimeSpan.FromSeconds(2))
.Build();
await pipeline.ExecuteAsync(async token => await search.ReindexAsync(productId, token), ct);
قانونهای مهم
- هر تماس شبکه یک Timeout داشته باشد. هم برای هر تلاش، هم برای کل کار.
- فقط خطای موقت را دوباره تلاش کن. خطای ۴۰۰ با تکرار درست نمیشود.
- فقط کار Idempotent را دوباره تلاش کن. وگرنه کلید تکرار بفرست.
- فقط یک لایه تلاش دوباره کند. تلاشها در لایهها ضرب میشوند.
- فاصله رو به افزایش با عدد تصادفی. نه فاصله ثابت.
- فیوز برای وابستگیهای مهم. خطای سریع بهتر از انتظار طولانی است.
- برای هر وابستگی، یک Fallback روشن. و هیچ وقت وانمود نکن که کار انجام شد.
- همه را مانیتور کن. تعداد تلاشهای دوباره و باز شدن فیوز، نشانههای زودهنگام مشکل هستند.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| تلاش دوباره در همه لایهها | طوفان Retry و خرابی سرویس آخر. | فقط یک لایه تلاش دوباره کند. |
| فاصله ثابت بدون عدد تصادفی | موجهای همزمان از همه کلاینتها. | فاصله رو به افزایش با Jitter. |
| تلاش دوباره برای POST پرداخت | پول دو بار کم میشود. | خاموش کردن Retry یا کلید تکرار. |
| بیشتر کردن Timeout وقتی سرویس کند است | منابع بیشتری منتظر میمانند. | سقف زمان واقعی و فیوز. |
| سقف زمان لایه بالا کمتر از لایه پایین | کار بیهوده در لایه پایین ادامه دارد. | بودجه زمانی از بالا به پایین. |
| تلاش دوباره برای خطای ۴۰۰ یا ۴۰۱ | بار اضافه، بدون هیچ شانس موفقیت. | فقط خطای موقت. |
کدام ابزار برای کدام مشکل؟
| مشکل | ابزار |
|---|---|
| سرویس گاهی برای یک لحظه خطا میدهد | تلاش دوباره با فاصله رو به افزایش |
| سرویس کند است و منابع ما را نگه میدارد | سقف زمان و محدود کردن همزمانی |
| سرویس برای مدتی کاملاً خراب است | فیوز و Fallback |
| کاربر نباید یک صفحه خالی ببیند | کش و Fallback |
خلاصه در شش خط
- یک سرویس کند میتواند با گرفتن منابع، کل سیستم را بخواباند.
- هر تماس شبکه به سقف زمان برای هر تلاش و برای کل کار نیاز دارد.
- تلاش دوباره فقط برای خطای موقت، فقط برای کار Idempotent و فقط در یک لایه.
- فاصلهها رو به افزایش با کمی عدد تصادفی باشد.
- فیوز وقتی خطا زیاد است تماس را قطع میکند تا سرویس خراب فرصت بهبود داشته باشد.
- در .NET، محافظ استاندارد بیشتر اینها را با یک خط اضافه میکند.