Levelwise
فارسی
سیستم‌های توزیع‌شده

پایداری در برابر خطا با Polly

در سیستم توزیع‌شده، سرویس‌های دیگر گاهی کند می‌شوند یا خطا می‌دهند. با Timeout، تلاش دوباره با فاصله (Retry) و فیوز (Circuit Breaker) جلوی گسترش خرابی را بگیر. ولی اگر این‌ها را بد تنظیم کنی، خودشان سیستم را خراب می‌کنند.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۷ دقیقهمثال سرویس سفارش و سرویس انبارکد C# و Polly در .NET 10

نویسنده: bezzad

مشکل: یک سرویس کند، کل فروشگاه را می‌خواباند

در فروشگاه اینترنتی، سرویس سفارش برای هر سفارش، سرویس انبار را صدا می‌زند تا موجودی را چک کند. یک روز سرویس انبار کند می‌شود. جوابش به جای ۲۰۰ میلی‌ثانیه، ۳۰ ثانیه طول می‌کشد. چه اتفاقی می‌افتد؟

  1. هر درخواست سفارش منتظر می‌ماند. هر کدام یک Thread یا یک اتصال (Connection) را نگه می‌دارد.
  2. منابع تمام می‌شوند. چند صد کاربر همزمان، همه اتصال‌ها را پر می‌کنند.
  3. سرویس سفارش هم جواب نمی‌دهد. حتی برای کارهایی که به انبار ربطی ندارند، مثل دیدن سفارش‌های قبلی.
  4. خرابی پخش می‌شود. سرویس‌هایی که سفارش را صدا می‌زنند هم گیر می‌کنند.

به این خرابی آبشاری (Cascading Failure) می‌گویند. یک سرویس کند، بدتر از یک سرویس خاموش است. چون سرویس خاموش فوراً خطا می‌دهد، ولی سرویس کند منابع تو را نگه می‌دارد.

هدف Resilience: هدف این نیست که خطا پیش نیاید. هدف این است که خطای یک بخش، کوچک و محدود بماند و به بقیه سیستم سرایت نکند.

خطای موقت و خطای دائمی

قبل از هر چیز، باید خطا را بشناسیم:

خطای موقت (Transient)

  • با تلاش دوباره شاید درست شود.
  • مثال: قطعی کوتاه شبکه، کد ۵۰۳ (سرویس موقتاً در دسترس نیست)، کد ۴۲۹ (درخواست زیاد)، تمام شدن زمان انتظار.

خطای دائمی

  • با تکرار هیچ وقت درست نمی‌شود.
  • مثال: کد ۴۰۰ (درخواست غلط)، کد ۴۰۱ (اجازه نداری)، کد ۴۰۴ (پیدا نشد).
  • تلاش دوباره فقط بار اضافه می‌سازد.

ابزار اول: Timeout

هیچ تماسی با شبکه نباید بدون سقف زمان باشد. کلاس HttpClient به طور پیش‌فرض ۱۰۰ ثانیه صبر می‌کند. برای یک صفحه وب، این خیلی زیاد است.

قانون ساده: Timeout را بر اساس زمان واقعی جواب سرویس تنظیم کن. مثلاً اگر انبار معمولاً در ۲۰۰ میلی‌ثانیه جواب می‌دهد و در بدترین حالت‌های عادی در ۸۰۰ میلی‌ثانیه، یک سقف ۲ ثانیه‌ای معقول است.

بودجه زمانی: سقف زمان لایه بالا باید از کل زمان لایه پایین بیشتر باشد، همراه با همه تلاش‌های دوباره‌اش. وگرنه لایه بالا تسلیم می‌شود، ولی لایه پایین هنوز برای همان درخواست کار می‌کند و بار بیهوده می‌سازد.

ابزار دوم: Retry، با احتیاط

تلاش دوباره برای خطای موقت مفید است. ولی Retry بد، از نبود Retry خطرناک‌تر است. سه دام اصلی:

دام اول: ضرب شدن تلاش‌ها در لایه‌ها

کاربرسرویس سفارشسرویس قیمتسرویس انبار۱ کلیک۴ درخواست۱۶ درخواستیک بار اصلی و سه بار دوبارهچهار ضرب در چهارانبار کند است، پس درخواست‌های بیشتری می‌گیردبار بیشتر، کندی بیشتر، تلاش دوباره بیشتر: یک چرخه بد
سه بار تلاش دوباره در هر لایه، یک کلیک را به ۱۶ درخواست تبدیل می‌کند.
  1. سرویس سفارش تا ۴ بار سرویس قیمت را صدا می‌زند: یک بار اصلی و سه بار دوباره.
  2. در هر کدام، سرویس قیمت تا ۴ بار انبار را صدا می‌زند.
  3. یعنی ۴ ضرب در ۴، برابر ۱۶ درخواست به انبار برای یک کلیک.
  4. انبار که کند بود، حالا بار بیشتری می‌گیرد و کندتر می‌شود. به این طوفان Retry (Retry Storm) می‌گویند.

راه حل: فقط یک لایه تلاش دوباره کند. معمولاً لایه‌ای که به کاربر نزدیک‌تر است، یا لایه‌ای که کار را بهتر می‌شناسد.

دام دوم: همه با هم دوباره تلاش می‌کنند

فاصله ثابت و بدون عدد تصادفی: همه با همموج‌های بزرگ در یک لحظهفاصله رو به افزایش و عدد تصادفی: پخش در زمان200ms400ms800ms1600msزمان از راست به چپ
فاصله رو به افزایش با کمی عدد تصادفی، تلاش‌ها را در زمان پخش می‌کند.

اگر هزار کلاینت با هم خطا بگیرند و همه دقیقاً بعد از یک ثانیه دوباره تلاش کنند، یک موج هزارتایی دوباره به سرویس می‌خورد. راه حل:

  1. فاصله رو به افزایش (Exponential Backoff). مثلاً ۲۰۰، بعد ۴۰۰، بعد ۸۰۰ میلی‌ثانیه. سرویس فرصت نفس کشیدن پیدا می‌کند.
  2. کمی عدد تصادفی (Jitter). هر کلاینت کمی زودتر یا دیرتر تلاش می‌کند. موج‌ها پخش می‌شوند.

دام سوم: تکرار کاری که نباید تکرار شود

اگر درخواست «پرداخت» یا «رزرو کالا» به خاطر Timeout شکست خورد، شاید کار در سرور انجام شده و فقط جوابش نرسیده است. تلاش دوباره یعنی پرداخت دوباره.

پس فقط کارهایی را دوباره تلاش کن که Idempotent هستند. یعنی تکرارشان همان نتیجه را بدهد. برای POST، یا کلید تکرار (Idempotency Key) بفرست، یا Retry را خاموش کن.

ابزار سوم: Circuit Breaker (فیوز)

اگر انبار کاملاً خراب است، تلاش دوباره فایده ندارد. فقط منابع ما را هدر می‌دهد و به انبار فرصت بهبود نمی‌دهد. فیوز مثل فیوز برق خانه کار می‌کند:

Closedبسته: درخواست‌ها می‌روندOpenباز: فوراً خطا، بدون تماسHalf-Openنیمه‌باز: یک درخواست آزمایشیدرصد خطا از حد گذشتزمان قطع تمام شدآزمایش موفقآزمایش ناموفق
فیوز وقتی خطاها زیاد شد باز می‌شود، بعد از مدتی یک آزمایش می‌کند و اگر موفق بود دوباره بسته می‌شود.
  1. بسته (Closed). حالت عادی. درخواست‌ها می‌روند. فیوز درصد خطا را در یک بازه زمانی می‌شمارد.
  2. باز (Open). درصد خطا از حد گذشت. تا مدتی مشخص، هیچ درخواستی به انبار نمی‌رود. فوراً خطا برمی‌گردد.
  3. نیمه‌باز (Half-Open). بعد از آن مدت، یک درخواست آزمایشی می‌رود. اگر موفق بود، فیوز بسته می‌شود. اگر نه، دوباره باز می‌شود.

چرا خطای فوری بهتر از انتظار است؟ چون Thread و اتصال فوراً آزاد می‌شوند. سرویس سفارش برای بقیه کارها سالم می‌ماند.

مثال زنده

انبار را خراب کن و چند درخواست بفرست. ببین فیوز کی باز می‌شود و بعد از ۵ ثانیه چطور آزمایش می‌کند:

مثال زنده: فیوز جلوی سرویس انبار

قانون این فیوز: اگر از ۴ درخواست آخر حداقل ۳ تا شکست بخورد، فیوز برای ۵ ثانیه باز می‌شود.

بستهوضعیت فیوز
۰تماس با انبار
۰رد سریع بدون تماس
۰جواب موفق

    وقتی فیوز باز است، به کاربر چه بگوییم؟

    به این تصمیم Fallback می‌گویند. جواب به نوع کار بستگی دارد:

    1. خواندن داده. آخرین داده کش‌شده را نشان بده. مثلاً «موجودی ممکن است کمی قدیمی باشد».
    2. بخش غیر اصلی. آن بخش را موقتاً پنهان کن. مثلاً بخش «محصولات پیشنهادی». بقیه صفحه کار می‌کند.
    3. نوشتن و کار مهم. فوراً خطای ۵۰۳ برگردان. هیچ وقت وانمود نکن که سفارش ثبت شد. اگر کار می‌تواند بعداً انجام شود، آن را در صف بگذار و بگو «در حال پردازش».

    ابزار چهارم: محدود کردن همزمانی (Bulkhead)

    حتی با Timeout، یک وابستگی کند می‌تواند همه اتصال‌ها را بگیرد. پس تعداد درخواست همزمان به هر وابستگی را محدود کن. مثل دیواره‌های کشتی: اگر یک بخش آب گرفت، بقیه کشتی غرق نمی‌شود. در Polly این کار با یک Rate Limiter همزمانی انجام می‌شود.

    کد

    راه ساده: محافظ استاندارد برای HttpClient

    کتابخانه Microsoft.Extensions.Http.Resilience بر پایه Polly ساخته شده است. با یک خط، پنج لایه محافظ با تنظیمات پیش‌فرض خوب به HttpClient اضافه می‌کند:

    Rate Limiter۱. محدود کردن تعداد درخواست همزمانTotal Timeout۲. سقف زمان برای کل کار با همه تلاش‌هاRetry۳. تلاش دوباره با فاصلهCircuit Breaker۴. قطع موقت تماسAttempt Timeout۵. سقف هر تلاش
    ترتیب لایه‌ها در محافظ استاندارد، از بیرون به داخل.
    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);
        });

    چند نکته:

    1. تلاش دوباره به طور پیش‌فرض برای POST هم روشن است. متد DisableForUnsafeHttpMethods آن را برای روش‌های غیر امن خاموش می‌کند.
    2. بودجه زمانی درست است. سه تلاش ۲ ثانیه‌ای و دو فاصله کوتاه، کمتر از سقف کل ۱۰ ثانیه است.
    3. فاصله‌ها رو به افزایش است و عدد تصادفی دارد. این رفتار پیش‌فرض محافظ استاندارد است.

    راه دلخواه: ساختن لایه‌ها خودت

    اگر تنظیمات بیشتری لازم داری، لایه‌ها را خودت بچین:

    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);

    قانون‌های مهم

    1. هر تماس شبکه یک Timeout داشته باشد. هم برای هر تلاش، هم برای کل کار.
    2. فقط خطای موقت را دوباره تلاش کن. خطای ۴۰۰ با تکرار درست نمی‌شود.
    3. فقط کار Idempotent را دوباره تلاش کن. وگرنه کلید تکرار بفرست.
    4. فقط یک لایه تلاش دوباره کند. تلاش‌ها در لایه‌ها ضرب می‌شوند.
    5. فاصله رو به افزایش با عدد تصادفی. نه فاصله ثابت.
    6. فیوز برای وابستگی‌های مهم. خطای سریع بهتر از انتظار طولانی است.
    7. برای هر وابستگی، یک Fallback روشن. و هیچ وقت وانمود نکن که کار انجام شد.
    8. همه را مانیتور کن. تعداد تلاش‌های دوباره و باز شدن فیوز، نشانه‌های زودهنگام مشکل هستند.

    اشتباه‌های رایج

    اشتباه نتیجه راه درست
    تلاش دوباره در همه لایه‌ها طوفان Retry و خرابی سرویس آخر. فقط یک لایه تلاش دوباره کند.
    فاصله ثابت بدون عدد تصادفی موج‌های همزمان از همه کلاینت‌ها. فاصله رو به افزایش با Jitter.
    تلاش دوباره برای POST پرداخت پول دو بار کم می‌شود. خاموش کردن Retry یا کلید تکرار.
    بیشتر کردن Timeout وقتی سرویس کند است منابع بیشتری منتظر می‌مانند. سقف زمان واقعی و فیوز.
    سقف زمان لایه بالا کمتر از لایه پایین کار بیهوده در لایه پایین ادامه دارد. بودجه زمانی از بالا به پایین.
    تلاش دوباره برای خطای ۴۰۰ یا ۴۰۱ بار اضافه، بدون هیچ شانس موفقیت. فقط خطای موقت.

    کدام ابزار برای کدام مشکل؟

    مشکل ابزار
    سرویس گاهی برای یک لحظه خطا می‌دهد تلاش دوباره با فاصله رو به افزایش
    سرویس کند است و منابع ما را نگه می‌دارد سقف زمان و محدود کردن همزمانی
    سرویس برای مدتی کاملاً خراب است فیوز و Fallback
    کاربر نباید یک صفحه خالی ببیند کش و Fallback

    خلاصه در شش خط

    1. یک سرویس کند می‌تواند با گرفتن منابع، کل سیستم را بخواباند.
    2. هر تماس شبکه به سقف زمان برای هر تلاش و برای کل کار نیاز دارد.
    3. تلاش دوباره فقط برای خطای موقت، فقط برای کار Idempotent و فقط در یک لایه.
    4. فاصله‌ها رو به افزایش با کمی عدد تصادفی باشد.
    5. فیوز وقتی خطا زیاد است تماس را قطع می‌کند تا سرویس خراب فرصت بهبود داشته باشد.
    6. در .NET، محافظ استاندارد بیشتر این‌ها را با یک خط اضافه می‌کند.