Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~47 دقیقه مطالعه~41 min read
تابآوری: Resilience4j و الگوهای مقاومتResilience: Resilience4j & Fault-Tolerance Patterns
از صفر تا سنیور با تابآوری: چرا فراخوانیهای شبکهای شکست میخورند و چطور با Resilience4j و الگوهایی مثل circuit breaker، retry با backoff و jitter، rate limiter، bulkhead، time limiter و fallback جلوی فروپاشیِ زنجیرهای را میگیری.A zero-to-senior tour of resilience: why remote calls fail and how to stop a cascading failure using Resilience4j and patterns like circuit breaker, retry with backoff and jitter, rate limiter, bulkhead, time limiter, and fallbacks.
سلام. بیا با یک حقیقتِ تلخ شروع کنیم که هر کسی یک سیستمِ واقعی را در تولید (production) دیده باشد با گوشت و پوستش لمس کرده: در یک سیستم توزیعشده، شکست یک استثنا نیست، یک قطعیت است. روی لپتاپ خودت که همهچیز در یک پروسه است، فراخوانیِ یک متد یا کار میکند یا کد باگ دارد. اما وقتی سرویس تو از پشت شبکه به سرویسِ دیگری زنگ میزند، بین این دو یک دنیای پر از خطر هست: کابل، سوییچ، DNS، صف، دیتابیسِ کُند، سرویسی که تازه ریاستارت شده. این فصل دربارهی این است که چطور بهجای اینکه آرزو کنیم شبکه سالم بماند، بپذیریم که خراب میشود و از قبل برایش نقشه بکشیم.
در این فصل قرار نیست فقط اسمِ چند تا الگو را حفظ کنی؛ قرار است بفهمی هر الگو چه دردی را درمان میکند، چرا وجود دارد، و در Resilience4j — کتابخانهی استانداردِ امروزِ جاواست — چطور آن را با کدِ واقعی پیاده کنی و در مصاحبهی سنیور دربارهاش حرف بزنی.
با هم این مسیر را قدمبهقدم میرویم:
- چرا شبکه خراب میشود — «هشت دروغِ محاسباتِ توزیعشده» و مفهومِ فروپاشیِ زنجیرهای.
- تابآوری یعنی چه — واژههای پایه: fault، latency، blast radius، idempotent.
- Resilience4j را بشناس — چرا جایگزینِ Hystrixِ بازنشسته شد و ماژولهایش چیستند.
- Circuit Breaker — سه حالتِ closed/open/half-open، پنجرهی لغزان و پیکربندی.
- Retry — تلاش دوباره با backoff نمایی و jitter (و چرا jitter حیاتی است).
- Rate Limiter، Bulkhead، Time Limiter — سه سپرِ دیگر برای سه دردِ متفاوت.
- Fallback و ترکیبِ decoratorها — و اینکه ترتیبشان چرا مهم است.
- Idempotency — چرا بدون آن retry خطرناک است.
- تست کردنِ تابآوری، دامها، پرسشهای مصاحبه و جمعبندی.
بخش صفر — چند کلمه که پیش از شروع باید حسشان کنی
قبل از هر کد، چند اصطلاح هست که در کل فصل برمیگردند. بگذار همین حالا با تشبیه در ذهنت جا بیندازمشان تا بعداً سرگردان نشوی.
- خطا/نقص (fault): یک اتفاقِ بد در سیستم — مثلاً سرویسِ پرداخت جواب نمیدهد. نقص همیشه پیش میآید؛ هنر ما این است که نقص را به failure (شکستِ کاملِ سیستم) تبدیل نکنیم.
- تابآوری (resilience): توانایی سیستم برای ادامهی کار در حضورِ نقص. سیستمِ تابآور کامل نمیماند، اما بهجای فروپاشی، «آبرومندانه تنزل میکند» (graceful degradation) — مثلاً بهجای قیمتِ زنده، قیمتِ کششده را نشان میدهد.
- تأخیر (latency): مدتزمانی که طول میکشد تا یک فراخوانی جواب برگردد. دشمنِ اصلیِ ما همیشه «خطا» نیست؛ خیلی وقتها کُندی خطرناکتر از خطاست، چون منابع را قفل میکند و کسی هم فریاد نمیزند.
- شعاعِ انفجار (blast radius): وقتی یک قطعه خراب میشود، خرابی تا کجا پخش میشود. کلِ فلسفهی این فصل «کوچک نگهداشتنِ شعاعِ انفجار» است — اینکه یک سرویسِ خرابْ کلِ سیستم را با خودش پایین نکشد.
- خنثی به تکرار (idempotent): عملیاتی که اگر چند بار اجرا شود، اثرش با یک بار فرقی نکند. این کلمه را نگه دار؛ ستونِ فقراتِ retry همین است.
تصور کن آشپزِ اصلی (سرویسِ تو) برای هر سفارش باید از سه بخشِ دیگر کمک بگیرد: انبار، گریل و شیرینیپزی. اگر بخشِ گریل کُند شود، آشپزِ اصلی همانجا کنارِ پنجره منتظر میماند و ملاقه در دست خشکش میزند. حالا سفارشهای بعدی هم پشتش صف میکشند، چون تنها آشپز مشغولِ انتظار است. خیلی زود کلِ آشپزخانه قفل میشود — نه چون همه خراباند، بلکه چون یک بخشِ کُند همه را گروگان گرفته. الگوهای این فصل دقیقاً همان قواعدی هستند که یک آشپزخانهی حرفهای دارد: «بیش از اینقدر منتظرِ گریل نمان»، «اگر گریل سه بار پشتهم خراب داد، فعلاً بیخیالش شو»، «برای هر بخش فقط اینقدر آشپز بگذار».
چرا فراخوانیهای شبکهای شکست میخورند؟
قبل از اینکه سراغِ درمان برویم، باید بیماری را خوب بشناسیم. برنامهنویسهای تازهکار ناخودآگاه فرض میکنند شبکه مثل فراخوانیِ یک متدِ محلی است: سریع، مطمئن و بیهزینه. این فرضها آنقدر رایج و آنقدر غلطاند که بهشان میگویند «هشت دروغِ محاسباتِ توزیعشده» (Fallacies of Distributed Computing) — فهرستی که مهندسانِ Sun Microsystems در دههی ۹۰ جمع کردند:
- شبکه قابلاعتماد است.
- تأخیر صفر است.
- پهنایباند بینهایت است.
- شبکه امن است.
- توپولوژی عوض نمیشود.
- یک مدیر (administrator) هست.
- هزینهی انتقال صفر است.
- شبکه همگن (homogeneous) است.
هر باگِ بزرگِ توزیعشدهای که تا حالا دیدهام، ریشهاش یکی از این هشت دروغ بوده. کدی که «روی لپتاپ کار میکند» ناخودآگاه فرض کرده تأخیر صفر است و شبکه هرگز قطع نمیشود. تابآوری یعنی این هشت فرض را از ذهنت پاک کنی و کد را طوری بنویسی که انتظارِ خرابی داشته باشد.
حالا سؤال کلیدی: چرا یک فراخوانیِ شبکهایِ خراب میتواند کلِ سیستم را پایین بکشد؟ جواب در یک زنجیره است.
فروپاشیِ زنجیرهای (cascading failure)
فرض کن سرویسِ A به سرویسِ B زنگ میزند و B هم به دیتابیس. حالا دیتابیس کُند میشود و هر کوئری بهجای ۱۰ میلیثانیه، ۳۰ ثانیه طول میکشد. چه اتفاقی میافتد؟
- هر thread در B که منتظرِ دیتابیس است، ۳۰ ثانیه «قفل» میماند.
- درخواستهای تازه سرازیر میشوند، اما thread آزادی نیست؛ استخرِ thread پُر میشود.
- حالا B هم به همه دیر جواب میدهد یا اصلاً جواب نمیدهد.
- threadهای A که منتظرِ B هستند هم قفل میشوند و A هم میافتد.
- بههمین ترتیب خرابیِ یک دیتابیس، مثل دومینو، تا کاربرِ نهایی بالا میآید.
شهودِ اولیه میگوید خطرناکترین حالت این است که سرویسِ B خطای ۵۰۰ برگرداند. اما در واقع، یک خطای سریع بهترین اتفاق است: thread فوراً آزاد میشود و میتوانی تصمیم بگیری. حالتِ واقعاً کُشنده «کُندیِ خاموش» است: سرویسی که نه خطا میدهد و نه جواب، فقط threadها را یکییکی میبلعد تا هیچ thread آزادی نماند. به این میگویند resource exhaustion (تحلیلرفتنِ منابع). بیشترِ الگوهای این فصل در اصل برای مهارِ همین کُندیاند، نه صرفاً خطا.
خلاصه: در یک سیستمِ توزیعشده تو نمیتوانی جلوی خرابیِ وابستگیها را بگیری؛ فقط میتوانی تصمیم بگیری که خرابیِ آنها چطور روی تو اثر بگذارد. این دقیقاً تعریفِ تابآوری است.
Resilience4j را بشناس
Resilience4j یک کتابخانهی سبکِ تابآوری برای جاواست که از الهامِ Hystrixِ Netflix، اما با فلسفهای مدرنتر ساخته شد. طراحیاش بر پایهی برنامهنویسیِ تابعی (functional programming) است: تو یک تابع (مثلاً یک فراخوانیِ شبکهای) داری و Resilience4j آن را در یک لایهی محافظ میپیچد (decorate میکند) و یک تابعِ جدید و مقاوم به تو پس میدهد.
تابعِ اصلیِ تو مثل خودِ گوشی است — کارش را بلد است اما شکننده. Resilience4j هر لایهی محافظ (circuit breaker، retry، …) را مثل یک کاور یا محافظِ صفحه رویش میکشد. نکتهی زیبا این است که خودِ گوشی هیچ تغییری نمیکند؛ فقط دورش را میپوشانی. به همین دلیل میتوانی چند کاور را روی هم بگذاری و هر وقت خواستی برداری — کدِ منطقِ کسبوکار پاک میماند و «نگرانیِ تابآوری» از آن جدا میشود.
چرا Hystrix بازنشسته شد و Resilience4j جایش را گرفت؟
Hystrix سالها استانداردِ صنعت بود، اما Netflix در سال ۲۰۱۸ اعلام کرد آن را به حالتِ نگهداری (maintenance mode) میبرد؛ یعنی دیگر ویژگیای اضافه نمیشود و توسعهی فعال متوقف است. امروز پروژههای جدید نباید سراغش بروند. بیایید تفاوتها را در یک جدول ببینیم:
| ویژگی | Hystrix (بازنشسته) | Resilience4j (امروزی) |
|---|---|---|
| وضعیت | حالتِ نگهداری از ۲۰۱۸ | فعال و در حال توسعه |
| وابستگیها | سنگین (Archaius، RxJava، …) | فقط Vavr؛ سبک و ماژولار |
| مدل برنامهنویسی | ارثبری از HystrixCommand |
تابعی — تابع را decorate میکنی |
| ماژولبندی | یکپارچه (همهچیز با هم) | هر الگو یک ماژولِ جدا |
| جدا کردن (isolation) | thread pool بهصورت پیشفرض | سمافور یا thread pool، به انتخابِ تو |
| نسخهی جاوا | جاوای قدیمی | جاوا ۸ به بالا (خط ۲.x نیازمندِ جاوا ۱۷) |
| Spring Cloud Circuit Breaker | حذف شده | پیادهسازیِ پیشفرض |
اگر در مصاحبه پرسیدند «چرا Resilience4j و نه Hystrix؟» جوابِ درجهیک این است: «Hystrix از ۲۰۱۸ در حالتِ نگهداری است و دیگر توسعه نمیشود؛ ضمناً معماریاش سنگین و مبتنی بر ارثبریِ command بود، در حالی که Resilience4j سبک، ماژولار و تابعی است و بهصورت پیشفرض هر الگو را جداگانه میدهد تا فقط چیزی را که نیاز داری برداری.» همین یک جمله نشان میدهد که تاریخِ اکوسیستم را میشناسی.
ماژولها و راهاندازی
Resilience4j هستهی خالص دارد بهعلاوهی ادغامها. ماژولهای اصلی:
resilience4j-circuitbreaker— قطعکنندهی مدارresilience4j-retry— تلاشِ دوبارهresilience4j-ratelimiter— محدودکنندهی نرخresilience4j-bulkhead— دیوارهی جداکنندهresilience4j-timelimiter— محدودکنندهی زمانresilience4j-spring-boot3— ادغام با Spring Boot 3 (اَنوتیشنها + پیکربندی درapplication.yml)resilience4j-micrometer— سنجهها (metrics) برای مانیتورینگ
برای یک پروژهی Spring Boot 3 معمولاً همین یک وابستگی کافی است:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.4.0</version>
</dependency>
<!-- برای @CircuitBreaker/@Retry روی متدها به AOP هم نیاز است -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
Resilience4j را میتوانی به دو شکل بهکار ببری. یکی تابعی/برنامهای: مستقیم با کد یک تابع را decorate میکنی (CircuitBreaker.decorateSupplier(...))؛ این راه شفاف است و به هیچ فریمورکی وابسته نیست. دوم اعلامی/اَنوتیشنی: در Spring فقط @CircuitBreaker را روی متد میگذاری و پیکربندی را در application.yml مینویسی. در این فصل هر دو را نشان میدهم، چون فهمِ راهِ تابعی کمکت میکند بفهمی اَنوتیشنها زیرِ پوست چه میکنند.
Circuit Breaker — قطعکنندهی مدار
مهمترین الگوی این فصل همین است. اسمش از فیوزِ برق میآید و تشبیهش کارِ همه را راه میاندازد.
در خانهات یک فیوز هست. اگر اتصالِ کوتاه رخ دهد و جریانِ خطرناک راه بیفتد، فیوز خودش را عمداً میسوزاند و مدار را قطع میکند تا کلِ خانه آتش نگیرد. بعد که مشکل را حل کردی، فیوز را دوباره وصل میکنی. circuit breakerِ نرمافزاری هم دقیقاً همین است: اگر ببیند فراخوانی به یک سرویس مدام خطا میدهد، عمداً جلوی فراخوانیهای بعدی را میگیرد و فوراً خطا برمیگرداند، بهجای اینکه threadهایت را قربانیِ سرویسِ خرابی کند که میداند جواب نمیدهد. کمی بعد یک فراخوانیِ آزمایشی میفرستد تا ببیند اوضاع بهتر شده یا نه.
سه حالتِ اصلی: بسته، باز، نیمهباز
قلبِ circuit breaker یک ماشینِ حالت (state machine) با سه حالت است:
- بسته (CLOSED): حالتِ عادی. همهی فراخوانیها عبور میکنند، اما breaker در سکوت نتیجهی هر کدام (موفق/ناموفق) را در یک «پنجرهی لغزان» ثبت میکند.
- باز (OPEN): اگر نرخِ خطا از یک آستانه گذشت، مدار «باز» میشود و مثل فیوزِ سوخته، دیگر هیچ فراخوانیای عبور نمیکند؛ همه فوراً با
CallNotPermittedExceptionرد میشوند. این «شکستِ سریع» (fail-fast) است که threadهایت را نجات میدهد. - نیمهباز (HALF_OPEN): بعد از یک مدتِ انتظار، breaker چند فراخوانیِ آزمایشیِ محدود را اجازه میدهد. اگر آنها موفق بودند، به CLOSED برمیگردد؛ اگر باز هم خطا دادند، به OPEN برمیگردد و دوباره صبر میکند.
Resilience4j سه حالتِ ویژهی دیگر هم دارد که کمتر استفاده میشوند: DISABLED (همیشه عبور)، FORCED_OPEN (همیشه رد) و METRICS_ONLY (فقط ثبت، بدون قطع).
نرخِ خطا از آستانه گذشت
CLOSED ─────────────────────────▶ OPEN
▲ │
│ فراخوانیِ آزمایشی موفق شد │ مدتِ انتظار تمام شد
│ ▼
└──────────── HALF_OPEN ◀─────────┘
فراخوانیِ آزمایشی خطا داد ──▶ OPEN
پنجرهی لغزان (sliding window): مغزِ تصمیمگیری
breaker چطور «نرخِ خطا» را میسنجد؟ با یک پنجرهی لغزان که دو نوع دارد:
- مبتنی بر تعداد (COUNT_BASED): آخرین N فراخوانی را نگه میدارد. مثلاً «در ۱۰۰ فراخوانیِ آخر چند درصد خطا بود؟»
- مبتنی بر زمان (TIME_BASED): فراخوانیهای N ثانیهی اخیر را نگه میدارد. مثلاً «در ۶۰ ثانیهی گذشته چند درصد خطا بود؟»
یک اشتباهِ رایج: نرخِ خطا را روی ۵۰٪ میگذاری اما فراموش میکنی که با یک فراخوانی هم میشود به ۱۰۰٪ خطا رسید! minimumNumberOfCalls میگوید «تا وقتی حداقل این تعداد فراخوانی نداشتهام، اصلاً نرخِ خطا را حساب نکن و مدار را باز نکن.» بدونِ این، breakerِ تو با اولین خطای اتفاقیِ صبحگاهی باز میشود. مقدارش را متناسب با ترافیک واقعی تنظیم کن.
پیکربندی و کدِ تابعی
بیایید یک breaker را دستی بسازیم تا همهی کلیدها را ببینیم:
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(10) // ۱۰ فراخوانیِ آخر را بسنج
.minimumNumberOfCalls(5) // تا ۵ فراخوانی نداشتهای، تصمیم نگیر
.failureRateThreshold(50f) // اگر ۵۰٪ خطا شد، باز کن
.slowCallRateThreshold(80f) // اگر ۸۰٪ کُند بودند هم باز کن
.slowCallDurationThreshold(Duration.ofSeconds(2)) // «کُند» یعنی بیش از ۲ ثانیه
.waitDurationInOpenState(Duration.ofSeconds(10)) // ۱۰ ثانیه در حالتِ باز بمان
.permittedNumberOfCallsInHalfOpenState(3) // ۳ فراخوانیِ آزمایشی بده
.automaticTransitionFromOpenToHalfOpenEnabled(true)
.recordExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessException.class) // این خطا را «شکست» حساب نکن
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker breaker = registry.circuitBreaker("paymentService");
// حالا تابع را میپیچیم:
Supplier<String> decorated =
CircuitBreaker.decorateSupplier(breaker, () -> paymentClient.charge(order));
// و اجرا با یک fallback امن:
String result = Try.ofSupplier(decorated)
.recover(throwable -> "payment-unavailable") // اگر شکست خورد، این را برگردان
.get();
دقت کن که Resilience4j دو در برای بازکردنِ مدار دارد: نرخِ خطا و نرخِ فراخوانیِ کُند. این تفکیک طلاست، چون همانطور که گفتیم دشمنِ اصلی کُندی است نه خطا. slowCallDurationThreshold میگوید «هر فراخوانیای که بیش از ۲ ثانیه طول بکشد، حتی اگر موفق باشد، یک زنگِ خطر است.» با این تنظیم، breaker میتواند قبل از اینکه سرویس کاملاً بمیرد و شروع به خطادادن کند، فقط بر اساسِ کُندشدنش مدار را باز کند. این یعنی جلوگیریِ پیشگیرانه.
همان کار با اَنوتیشن در Spring Boot
در Spring بهجای کدِ بالا، فقط پیکربندی را در application.yml مینویسی:
resilience4j:
circuitbreaker:
instances:
paymentService:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 50
slow-call-rate-threshold: 80
slow-call-duration-threshold: 2s
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
@Service
public class PaymentService {
@CircuitBreaker(name = "paymentService", fallbackMethod = "chargeFallback")
public String charge(Order order) {
return paymentClient.charge(order); // فراخوانیِ شبکهای واقعی
}
// امضای متدِ fallback = امضای اصلی + یک پارامترِ Throwable در آخر
public String chargeFallback(Order order, Throwable t) {
log.warn("payment circuit open, using fallback: {}", t.toString());
return "payment-deferred";
}
}
یک دامِ کلاسیک: متدِ fallback باید همان امضای متدِ اصلی را داشته باشد، بهعلاوهی یک پارامترِ Throwable (یا نوعِ خاصترِ استثنا) در انتها. اگر نوع یا ترتیبِ پارامترها فرق کند، Resilience4j در زمانِ اجرا fallback را پیدا نمیکند و بهجای پیامِ خوبت، یک NoSuchMethodException میگیری. میتوانی چند fallback با نوعهای مختلفِ استثنا بنویسی؛ Resilience4j دقیقترین تطبیق را انتخاب میکند.
رصدکردنِ رویدادها
breaker یک ناشرِ رویداد (event publisher) دارد که برای دیباگ و مانیتورینگ عالی است:
breaker.getEventPublisher()
.onStateTransition(e -> log.info("circuit {} -> {}",
e.getStateTransition().getFromState(),
e.getStateTransition().getToState()))
.onCallNotPermitted(e -> log.warn("call blocked, circuit is OPEN"))
.onError(e -> log.debug("recorded failure: {}", e.getThrowable().toString()));
Retry — تلاشِ دوباره (اما با احتیاط)
بعضی خطاها گذرا (transient) هستند: یک قطعیِ لحظهایِ شبکه، یک timeout اتفاقی، یک ریاستارتِ کوتاه. برای اینها منطقی است که بهجای تسلیمِ فوری، چند بار دیگر تلاش کنیم. اما retryِ سادهلوحانه از خودِ خرابی خطرناکتر است.
اگر زنگِ در را زدی و کسی باز نکرد، منطقی است چند لحظه بعد دوباره بزنی — شاید طرف در آشپزخانه بود. اما اگر بلافاصله و بیوقفه زنگ را فشار بدهی و ول نکنی، دو فاجعه رخ میدهد: اول، اگر واقعاً کسی خانه نیست، فقط داری خودت را خسته میکنی؛ دوم و بدتر، اگر طرف مریض و کُند است، زنگِ بیامانِ تو حالش را بدتر میکند. retry هم دقیقاً همین است: باید فاصله بگذاری و بدانی کِی کوتاه بیایی.
backoff نمایی: هر بار بیشتر صبر کن
backoff یعنی بین تلاشها صبر کنی، و exponential backoff یعنی این صبر هر بار دو برابر شود: ۱ ثانیه، بعد ۲، بعد ۴، بعد ۸. منطقش این است: اگر سرویس بعد از یک تلاشِ فوری جواب نداد، احتمالاً واقعاً مشکل دارد و باید بیشتر بهش وقت بدهی.
چرا jitter (پراکندگیِ تصادفی) حیاتی است؟
حالا تصورِ یک فاجعهی ظریف: هزار سرویس همگی همزمان به یک دیتابیس زنگ میزنند و دیتابیس یک لحظه میافتد. همهی هزار تا همزمان خطا میگیرند، و همه با backoffِ یکسان دقیقاً ۱ ثانیه بعد با هم دوباره تلاش میکنند — یک موجِ عظیم که دیتابیسِ تازهبلندشده را دوباره میکوبد. به این میگویند thundering herd یا رگبارِ retry.
راهحل این است که به هر backoff یک مقدارِ تصادفیِ کوچک اضافه کنی تا تلاشهای همه پخش شود: یکی ۱.۰ ثانیه، یکی ۱.۳، یکی ۰.۸. به این پراکندگیِ عمدی میگویند jitter. در Resilience4j این کار با IntervalFunction.ofExponentialRandomBackoff(...) انجام میشود. قانونِ طلایی سنیوری: backoff بدونِ jitter یعنی رگبارِ همگام؛ همیشه jitter را روشن کن. این تفاوتِ کسی است که فقط retry را «شنیده» با کسی که آن را در تولید «بهکار برده».
RetryConfig config = RetryConfig.<String>custom()
.maxAttempts(4) // یک تلاشِ اصلی + ۳ تلاشِ دوباره
.intervalFunction(IntervalFunction
.ofExponentialRandomBackoff(
Duration.ofMillis(500), // شروع از ۵۰۰ms
2.0, // ضریبِ دو برابر شدن
0.5)) // jitter تا ۵۰٪
.retryExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessException.class)
// فقط وقتی نتیجه null بود دوباره تلاش کن (retry بر اساسِ نتیجه، نه فقط استثنا):
.retryOnResult(result -> result == null)
.build();
Retry retry = Retry.of("inventory", config);
Supplier<String> decorated = Retry.decorateSupplier(retry,
() -> inventoryClient.check(sku));
معادلش در application.yml:
resilience4j:
retry:
instances:
inventory:
max-attempts: 4
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true # همان jitter
randomized-wait-factor: 0.5
retry-exceptions:
- java.io.IOException
ignore-exceptions:
- com.example.BusinessException
این مهمترین جملهی این بخش است: هرگز یک عملیاتِ غیرِidempotent را کورکورانه retry نکن. اگر متدت «۱۰۰ تومان از حساب کم کن» است و اولین تلاش در واقع موفق شده اما جوابش در راه گم شده، تلاشِ دوم دوباره پول کم میکند و مشتری دوبار پول میدهد. retry فقط وقتی امن است که تکرارِ عملیات بیضرر باشد. به idempotency در بخشِ جداگانه میرسیم.
ترکیبِ retry و circuit breaker
این دو با هم بهتریناند اما ترتیب مهم است: معمولاً میخواهی circuit breaker داخلِ retry باشد، تا وقتی مدار باز شد، CallNotPermittedException سریع صادر شود؛ و آن استثنا را در ignoreExceptionsِ retry بگذاری تا retry بیخودی روی مدارِ باز تلاش نکند.
Rate Limiter — محدودکنندهی نرخ
circuit breaker از خودت در برابرِ سرویسِ خراب محافظت میکند. rate limiter برعکس است: از دیگری (یا از سهمیهات) در برابرِ خودت محافظت میکند، با محدودکردنِ تعدادِ فراخوانی در واحدِ زمان.
در ورودیِ بعضی اتوبانها یک چراغِ قرمزِ کوچک هست که هر چند ثانیه یک ماشین را رد میکند، تا اتوبان یکدفعه پُر و قفل نشود. rate limiter همین است: مثلاً «حداکثر ۱۰ فراخوانی در هر ثانیه»؛ فراخوانیِ یازدهم یا پشتِ چراغ صبر میکند تا نوبتش شود، یا اگر صبرش تمام شد رد میشود. این هم برای رعایتِ سهمیهی APIهای طرفِسوم (که اگر رد کنی جریمهات میکنند) لازم است، هم برای اینکه خودت یک سرویسِ ضعیف را زیرِ بار له نکنی.
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(10) // ۱۰ اجازه در هر دوره
.limitRefreshPeriod(Duration.ofSeconds(1)) // هر ۱ ثانیه سهمیه شارژ شود
.timeoutDuration(Duration.ofMillis(500)) // حداکثر ۵۰۰ms منتظرِ اجازه بمان
.build();
RateLimiter limiter = RateLimiter.of("reportApi", config);
Supplier<String> decorated = RateLimiter.decorateSupplier(limiter,
() -> reportClient.generate());
اگر در بازه، سهمیه تمام شود و در timeoutDuration هم جایی باز نشود، RequestNotPermitted صادر میشود.
هر دو «محدود میکنند»، اما بُعدِ متفاوت: rate limiter بر اساسِ زمان محدود میکند («X فراخوانی در هر ثانیه»)، در حالی که bulkhead بر اساسِ همزمانی محدود میکند («حداکثر X فراخوانیِ در حالِ اجرا بهطور همزمان»). اولی جلوی سرعت را میگیرد، دومی جلوی ازدحام را. در مصاحبه اگر این تمایز را روشن بگویی، امتیازِ خوبی میگیری.
Bulkhead — دیوارهی جداکننده
اسمِ bulkhead از دیوارههای ضدِآبِ کشتی میآید و همین تشبیه کلِ ایده را میرساند.
بدنهی یک کشتیِ بزرگ را با دیوارههایی به چند بخشِ جدا تقسیم میکنند. اگر یک بخش سوراخ شود و آب بگیرد، دیوارهها جلوی پخششدنِ آب به بقیهی بخشها را میگیرند و کشتی شناور میماند. تایتانیک غرق شد چون دیوارههایش تا بالا نمیرسید و آب سرریز کرد. در نرمافزار هم bulkhead یعنی برای هر وابستگی یک «بخشِ جدا» از منابع (thread یا اجازهی همزمانی) کنار بگذاری، تا اگر سرویسِ A همهی منابعش را قفل کرد، هنوز منابعی برای سرویسِ B باقی مانده باشد و کلِ کشتی نخوابد.
Resilience4j دو نوع bulkhead دارد:
- سمافوری (SemaphoreBulkhead): با یک شمارنده (سمافور) تعدادِ فراخوانیهای همزمان را محدود میکند. سبک و بدونِ thread اضافه؛ فراخوانی روی همان thread فراخواننده اجرا میشود.
- استخرِ thread (ThreadPoolBulkhead): یک استخرِ thread و یک صفِ جدا برای این وابستگی میسازد. فراخوانی روی thread دیگری اجرا میشود، پس جداسازیِ کاملتری میدهد و میتواند timeout را واقعاً لغو کند — اما سنگینتر است و فقط با فراخوانیِ ناهمگام (
CompletableFuture) کار میکند.
| جنبه | Semaphore Bulkhead | ThreadPool Bulkhead |
|---|---|---|
| مکانیزم | شمارندهی همزمانی | استخرِ thread + صف |
| thread اجرا | همان thread فراخواننده | thread جداگانهی استخر |
| سربار | بسیار کم | بیشتر (سوییچِ context) |
| لغوِ واقعیِ timeout | نه | بله |
| مناسبِ | بارِ بالا، همگام | جداسازیِ کامل، ناهمگام |
// نوعِ سمافوری
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(20) // حداکثر ۲۰ فراخوانیِ همزمان
.maxWaitDuration(Duration.ofMillis(100)) // ۱۰۰ms منتظرِ جای خالی بمان
.build();
Bulkhead bulkhead = Bulkhead.of("searchService", config);
Supplier<String> decorated = Bulkhead.decorateSupplier(bulkhead,
() -> searchClient.query(term));
اگر همهی جاها پُر باشند و در maxWaitDuration جایی باز نشود، BulkheadFullException صادر میشود.
درسِ بزرگی که Netflix از Hystrix گرفت این بود: اگر همهی وابستگیهایت از یک استخرِ thread مشترک استفاده کنند، یک سرویسِ کُند میتواند کلِ استخر را ببلعد و همهی سرویسهای دیگر — حتی سالمها — را هم بخواباند. bulkhead این را حل میکند: به هر وابستگی سهمیهی جدا بده تا خرابیِ یکی، شعاعِ انفجارش محدود بماند. این دقیقاً همان «کوچک نگهداشتنِ شعاعِ انفجار» است که در بخشِ صفر گفتیم.
Time Limiter — محدودکنندهی زمان
یادت هست که گفتیم دشمنِ اصلی کُندی است؟ time limiter مستقیماً همان را هدف میگیرد: به یک فراخوانی بیش از X ثانیه فرصت نده. اگر جوابی نیامد، فراخوانی را قطع کن و یک TimeoutException بده.
وقتی چیزی را در فر میگذاری، تایمر میزنی؛ سرِ مدتِ مشخص، چه پخته باشد چه نه، درش را باز میکنی. اگر تایمر نگذاری و آشپز حواسش پرت شود، ممکن است ساعتها منتظرِ چیزی بمانی که هرگز آماده نمیشود. time limiter همان تایمر است: سرِ مهلت، دستِ سرویسِ کُند را از روی thread تو کوتاه میکند.
time limiter بر خلافِ بقیه روی CompletableFuture (فراخوانیِ ناهمگام) کار میکند، چون برای لغوکردنِ واقعیِ یک کارِ در حالِ اجرا به یک thread جدا نیاز داری:
TimeLimiterConfig config = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(3)) // بیش از ۳ ثانیه صبر نکن
.cancelRunningFuture(true) // future را واقعاً لغو کن
.build();
TimeLimiter timeLimiter = TimeLimiter.of("mapsApi", config);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
Supplier<CompletableFuture<String>> futureSupplier =
() -> CompletableFuture.supplyAsync(() -> mapsClient.route(from, to));
Callable<String> decorated = TimeLimiter.decorateFutureSupplier(
timeLimiter, futureSupplier);
در Spring، @TimeLimiter فقط روی متدهایی کار میکند که CompletableFuture برمیگردانند:
@TimeLimiter(name = "mapsApi")
@CircuitBreaker(name = "mapsApi", fallbackMethod = "routeFallback")
public CompletableFuture<String> route(String from, String to) {
return CompletableFuture.supplyAsync(() -> mapsClient.route(from, to));
}
public CompletableFuture<String> routeFallback(String from, String to, Throwable t) {
return CompletableFuture.completedFuture("route-unavailable");
}
یک دامِ ظریف: خیلیها فراموش میکنند که کلاینتِ HTTP (مثلِ RestClient یا WebClient) خودش timeout دارد. اگر time limiter را روی ۳ ثانیه بگذاری اما connection-timeoutِ کلاینت ۳۰ ثانیه باشد، ممکن است رفتارها با هم قاطی شوند. قاعده: time limiter باید معمولاً کمی بزرگتر از read-timeoutِ کلاینت باشد تا سطحبندیِ مهلتها منطقی بماند، و هرگز روی بینهایت رهایشان نکن.
Fallback — نقشهی جایگزین
fallback یعنی «وقتی مسیرِ اصلی شکست خورد، بهجای پرتکردنِ خطا به کاربر، یک جوابِ جایگزینِ آبرومندانه بده.» این همان graceful degradation است.
تصور کن نانواییِ محله نانِ تازهاش تمام شده. یک نانوایِ بد در را میبندد و میگوید «هیچی ندارم» (خطای ۵۰۰). یک نانوایِ خوب میگوید «تازه ندارم، اما نانِ دیروز هست، میخواهی؟» (fallback). مشتری شاید بهترین چیز را نگیرد، اما دستخالی و عصبانی هم برنمیگردد. fallbackِ خوب همیشه چیزی مفید برمیگرداند: یک مقدارِ کششده، یک مقدارِ پیشفرضِ منطقی، یا یک لیستِ خالی — نه یک صفحهی خطا.
انواعِ fallbackِ خوب:
- مقدارِ کششده: آخرین مقدارِ موفق را برگردان (مثلاً قیمتِ ۵ دقیقه پیش).
- مقدارِ پیشفرض: یک پاسخِ محافظهکارانه و امن.
- تنزلِ کاربردی: بخشی از قابلیت را خاموش کن اما بقیه سرِ پا بماند (مثلاً «پیشنهادهای شخصی» را نشان نده اما محصول را نشان بده).
یک خطای رایج این است که در fallback خودت یک فراخوانیِ شبکهایِ دیگر بگذاری که آن هم میتواند شکست بخورد یا کُند باشد — این فقط زنجیرهی خرابی را طولانیتر میکند. fallback باید تا حدِ ممکن محلی، سریع و بیخطر باشد: یک مقدار از کش، یک ثابت، یا یک لیستِ خالی. اگر مجبوری در fallback کارِ ریسکی بکنی، آن را هم با یک لایهی محافظِ جدا بپوشان.
ترکیبِ decoratorها — و چرا ترتیب مهم است
قدرتِ واقعیِ Resilience4j وقتی آزاد میشود که چند محافظ را روی هم بگذاری. کلاسِ Decorators این کار را روان میکند:
Supplier<String> resilient = Decorators.ofSupplier(() -> paymentClient.charge(order))
.withBulkhead(bulkhead) // درونیترین
.withTimeLimiter(timeLimiter, scheduler)
.withRateLimiter(rateLimiter)
.withCircuitBreaker(circuitBreaker)
.withRetry(retry) // بیرونیترین
.withFallback(List.of(CallNotPermittedException.class),
t -> "payment-fallback")
.decorate();
ترتیبِ لایهها معنای متفاوتی میسازد. در Spring، ترتیبِ پیشفرضِ aspectها اینطور است (از بیرون به درون):
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( تابعِ اصلی ) ) ) ) )
این ترتیب عمدی است. اگر Retry بیرونیتر از CircuitBreaker باشد، معنیاش این است: «هر بار که retry دوباره تلاش میکند، از دلِ circuit breaker رد میشود.» پس اگر مدار باز شد، تلاشهای بعدیِ retry فوراً با CallNotPermittedException رد میشوند و بیهوده به سرویسِ خراب فشار نمیآورند. برعکسش (retry داخلِ breaker) بد است: retry تلاشهای خطادار را میبلعد و آمار به breaker نمیرسد، پس مدار دیر باز میشود. قاعدهی سنیوری: retry بیرون، breaker داخل. ترتیبِ aspectها را در Spring میتوانی با ویژگیهایی مثل retryAspectOrder تغییر بدهی، اما پیشفرض معمولاً درست است.
تا اینجا پنج سپر دیدیم؛ بگذار در یک نگاه کنارِ هم بگذارمشان:
| الگو | درد | مکانیزم |
|---|---|---|
| Circuit Breaker | سرویسِ مداوماً خراب | بعد از آستانهی خطا، fail-fast |
| Retry | خطای گذرا و لحظهای | تلاشِ دوباره با backoff+jitter |
| Rate Limiter | عبور از سهمیه/نرخ | محدودکردن بر اساسِ زمان |
| Bulkhead | تحلیلرفتنِ منابعِ مشترک | محدودکردنِ همزمانی |
| Time Limiter | فراخوانیِ کُند/بیجواب | قطع بعد از مهلت |
| Fallback | هر شکستی | پاسخِ جایگزینِ آبرومندانه |
Idempotency — چرا بدونِ آن retry بمب است
قول داده بودم به این برگردیم، چون بیفهمِ آن، retry بهجای درمان، بیماریزاست.
کلیدِ روشن/خاموشِ چراغ idempotent است در جهتِ «روشن»: هر چند بار هم که کلیدِ روشن را فشار بدهی، چراغ فقط روشن است؛ فشارِ دوم چیزی را عوض نمیکند. اما دکمهی «طبقهی ۵» در آسانسورِ قدیمی اینطور نیست — یا مثلاً یک دکمهی «یک قهوه بریز»: هر فشار یک قهوهی جدید! اگر جوابِ فشارِ اولت گم شد و دوباره فشار دادی، دو قهوه داری. عملیاتِ idempotent مثل کلیدِ چراغ است: تکرار بیضرر. عملیاتِ غیرِidempotent مثل دکمهی قهوه است: تکرار خطرناک.
مشکلِ بنیادی این است: وقتی یک فراخوانی timeout میخورد، تو نمیدانی که آیا سرور کار را انجام نداده، یا انجام داده اما جوابش گم شده. اگر عملیات idempotent باشد، فرقی نمیکند و با خیالِ راحت retry میکنی. اگر نباشد، retry ممکن است پول را دوبار کم کند یا دو سفارش ثبت کند.
راهحلِ استاندارد کلیدِ idempotency (idempotency key) است: کلاینت برای هر عملیاتِ منطقی یک شناسهی یکتا (مثلاً یک UUID) میسازد و در همهی retryها همان را میفرستد؛ سرور اگر آن کلید را قبلاً دیده باشد، بهجای اجرای دوباره، همان نتیجهی قبلی را برمیگرداند.
public PaymentResult charge(Order order) {
// یک کلیدِ پایدار برای این عملیاتِ منطقی؛ در همهی retryها ثابت میماند
String idempotencyKey = order.getId() + ":charge";
return httpClient.post("/charge")
.header("Idempotency-Key", idempotencyKey) // سرور با این کلید تکرار را میگیرد
.body(order)
.retrieve();
}
دقت کن که فرستادنِ کلید از سمتِ کلاینت کافی نیست؛ سرور هم باید آن کلید را ذخیره کند و در برابرِ درخواستِ تکراری، اجرای دوباره را رد کند و نتیجهی قبلی را بدهد. متدهای HTTP هم اینجا کمک میکنند: طبقِ استاندارد، GET، PUT و DELETE ذاتاً idempotentاند اما POST نیست — پس برای POSTها معمولاً به کلیدِ idempotency نیاز داری. این یکی از پرتکرارترین سؤالهای مصاحبهی طراحیِ سیستم است.
تستکردنِ تابآوری
نوشتنِ کدِ تابآور کافی نیست؛ باید ثابت کنی که واقعاً کار میکند. سه سطحِ تست:
۱) تستِ واحدِ خودِ ماشینِ حالت. میتوانی breaker را مجبور به گذارِ حالت کنی و رفتارش را چک کنی:
@Test
void breakerOpensAfterFailures() {
CircuitBreaker cb = CircuitBreaker.of("test",
CircuitBreakerConfig.custom()
.slidingWindowSize(4)
.minimumNumberOfCalls(4)
.failureRateThreshold(50f)
.build());
// ۴ فراخوانیِ خطادار را شبیهسازی کن
for (int i = 0; i < 4; i++) {
cb.onError(0, TimeUnit.MILLISECONDS, new IOException("boom"));
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
// حالا هر فراخوانی باید رد شود:
assertThatThrownBy(() -> cb.executeSupplier(() -> "x"))
.isInstanceOf(CallNotPermittedException.class);
}
۲) تستِ یکپارچه با سرورِ جعلی. با ابزاری مثل WireMock یک سرورِ HTTP جعلی بالا میآوری و عمداً آن را به تأخیر یا خطا وادار میکنی تا ببینی circuit breaker و retryِ واقعی درست واکنش میدهند:
// WireMock را وادار کن ۳ ثانیه تأخیر بدهد تا time limiter فعال شود
stubFor(get("/inventory")
.willReturn(aResponse()
.withFixedDelay(3000)
.withStatus(200)
.withBody("ok")));
// یا خطای متوالی برای بازکردنِ مدار:
stubFor(get("/inventory").willReturn(aResponse().withStatus(500)));
تستِ واحد و یکپارچه خوباند، اما بلوغِ واقعی وقتی است که در محیطی شبیهِ تولید عمداً خرابی تزریق کنی — تأخیر اضافه کنی، بستهها را دور بریزی، یک نمونه را بکُشی — و ببینی آیا سیستم واقعاً آبرومندانه تنزل میکند یا فرو میپاشد. به این کار مهندسیِ آشوب میگویند (که Netflix با Chaos Monkey مشهورش کرد). فلسفهاش این است: بهتر است خرابی را در ساعتِ کاری و کنترلشده کشف کنی تا ساعتِ ۳ بامداد و غافلگیرانه.
۳) رصد در تولید. با resilience4j-micrometer سنجهها را به Prometheus/Grafana بده تا حالتِ مدارها، نرخِ خطا و شمارِ retry را زنده ببینی. بدونِ رصد، نمیفهمی مدارت اصلاً کِی باز و بسته میشود.
دامها و بهترین شیوهها
- مقادیرِ پیشفرض را کورکورانه نپذیر. پیشفرضِ
slidingWindowSizeصد وminimumNumberOfCallsهم صد است؛ اگر ترافیکت کم باشد، مدار عملاً هرگز باز نمیشود. تنظیم را با ترافیکِ واقعی هماهنگ کن. - backoff بدونِ jitter نگذار — رگبارِ retry میسازی.
- retry روی عملیاتِ غیرِidempotent نگذار — یا اول idempotency را حل کن.
- fallback را سبک و بیخطر بگذار — نه یک فراخوانیِ شبکهایِ دیگر.
- ترتیبِ decoratorها را آگاهانه انتخاب کن — retry بیرون، breaker داخل.
- همهچیز را رصد کن — یک مدارِ باز که کسی نمیبیندش، همانقدر بد است که هیچ مداری نداشتن.
- breaker و timeout را جدا از هم نبین — کُندی را با slow-call threshold و time limiter بگیر، نه فقط با شمارشِ خطا.
ignoreExceptionsرا درست بگذار — خطاهای کسبوکار (مثل «موجودی کافی نیست») نباید مدار را باز کنند؛ آنها شکستِ زیرساخت نیستند.
پرسشهای مصاحبه
حالا وقتِ آن است که همهچیز را با پرسشهای واقعیِ سنیوری محک بزنی. اول خودت جواب بده، بعد پاسخ را باز کن.
circuit breaker جلوی «فروپاشیِ زنجیرهای» را با شکستِ سریع (fail-fast) میگیرد: بهجای اینکه threadها را قربانیِ سرویسی کنیم که میدانیم خراب است، فوراً خطا برمیگردانیم. سه حالت: CLOSED (عادی؛ فراخوانیها عبور میکنند و نتیجه ثبت میشود)، OPEN (نرخِ خطا از آستانه گذشته؛ همه فوراً با CallNotPermittedException رد میشوند)، و HALF_OPEN (بعد از مدتِ انتظار، چند فراخوانیِ آزمایشی اجازه دارند؛ موفق شوند → CLOSED، خطا بدهند → OPEN). ارزشِ اصلیاش این است که به سرویسِ خراب «فرصتِ نفسکشیدن» میدهد و threadهای تو را آزاد نگه میدارد.
چون خطای سریع بیضررترین حالت است: thread فوراً آزاد میشود و میتوانی تصمیم بگیری. اما یک فراخوانیِ کُندِ بیجواب thread را قفل میکند؛ درخواستها پشتش صف میکشند، استخرِ thread پُر میشود و کلِ سرویس میخوابد (resource exhaustion). به همین دلیل Resilience4j علاوه بر نرخِ خطا، نرخِ فراخوانیِ کُند (slowCallRateThreshold) را هم دارد تا بتوانی قبل از شروعِ خطاها، صرفاً بر اساسِ کُندی مدار را باز کنی.
backoff نمایی یعنی فاصلهی بینِ تلاشهای retry هر بار دو برابر شود (۵۰۰ms، ۱s، ۲s…) تا به سرویسِ گرفتار فشار نیاوریم. اما اگر همهی کلاینتها با backoffِ یکسان دقیقاً همزمان دوباره تلاش کنند، یک موجِ همزمان (thundering herd) سرویسِ تازهبلندشده را دوباره میکوبد. jitter یک مقدارِ تصادفیِ کوچک به هر backoff اضافه میکند تا تلاشها پخش شوند. در Resilience4j با IntervalFunction.ofExponentialRandomBackoff(...). قاعده: backoff همیشه باید jitter داشته باشد.
Semaphore Bulkhead با یک شمارنده تعدادِ فراخوانیهای همزمان را محدود میکند و روی همان thread فراخواننده اجرا میشود؛ سبک و کمسربار، مناسبِ بارِ بالا. ThreadPool Bulkhead یک استخرِ thread و صفِ جدا میسازد و فراخوانی روی thread دیگری اجرا میشود؛ جداسازیِ کاملتر میدهد و میتواند timeout را واقعاً لغو کند، اما سنگینتر است و فقط با فراخوانیِ ناهمگام کار میکند. اگر همزمانیِ بالا و سادگی میخواهی سمافور؛ اگر جداسازیِ سختِ منابع و لغوِ واقعی میخواهی thread pool.
Retry باید بیرونیتر از CircuitBreaker باشد (retry بیرون، breaker داخل). اینطور هر تلاشِ دوباره از دلِ breaker رد میشود، پس اگر مدار باز شود، تلاشهای بعدی فوراً با CallNotPermittedException رد میشوند و بیخودی به سرویسِ خراب فشار نمیآورند. اگر برعکس باشد (breaker بیرون، retry داخل)، retry خطاها را میبلعد و آمارِ کافی به breaker نمیرسد، پس مدار دیر باز میشود. در Spring ترتیبِ پیشفرضِ aspectها Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(fn))))) است که همین را رعایت میکند.
چون وقتی فراخوانی timeout میخورد نمیدانی کار انجام شده و جواب گم شده، یا اصلاً انجام نشده. اگر عملیات غیرِidempotent باشد (مثلِ «کسرِ پول»)، retry میتواند اثر را دوبار اعمال کند. راهحل، کلیدِ idempotency است: کلاینت یک شناسهی یکتا برای هر عملیاتِ منطقی میسازد و در همهی retryها همان را میفرستد؛ سرور اگر آن کلید را قبلاً دیده، بهجای اجرای دوباره همان نتیجهی قبلی را برمیگرداند. یادت باشد GET/PUT/DELETE ذاتاً idempotentاند ولی POST نه.
هر دو محدود میکنند اما بُعدِ متفاوت: rate limiter بر اساسِ زمان محدود میکند («حداکثر X فراخوانی در هر ثانیه») و برای رعایتِ سهمیهی API یا کنترلِ throughput خوب است. bulkhead بر اساسِ همزمانی محدود میکند («حداکثر X فراخوانیِ در حالِ اجرا بهطور همزمان») و برای جداسازیِ منابع و مهارِ resource exhaustion خوب است. اولی سرعت را میگیرد، دومی ازدحام را.
این پارامتر میگوید تا وقتی حداقل این تعداد فراخوانی در پنجره ثبت نشده، breaker اصلاً نرخِ خطا را حساب نکند و باز نشود. بدونِ آن، با نرخِ آستانهی ۵۰٪ ممکن است یک خطای اتفاقی مدار را باز کند (چون یک از یک میشود ۱۰۰٪). اگر خیلی بزرگ بگذاری، در سرویسِ کمترافیک مدار عملاً هرگز باز نمیشود و محافظت بیاثر میشود. باید متناسب با ترافیکِ واقعیِ همان instance تنظیم شود.
چون برای لغوکردنِ واقعیِ یک کارِ در حالِ اجرا (نه فقط رهاکردنِ انتظار) به یک thread جدا نیاز داری که کار را نظارت و در صورتِ عبور از مهلت، آن را قطع کند. یک فراخوانیِ همگام روی thread تو را نمیشود از بیرون «قطع» کرد بیآنکه ناهمگام باشد. به همین دلیل time limiter روی Supplier<CompletableFuture<...>> عمل میکند و با cancelRunningFuture(true) میتواند future را واقعاً لغو کند. در Spring، @TimeLimiter فقط روی متدهایی که CompletableFuture برمیگردانند اثر دارد.
چون همهی استثناها «شکستِ زیرساخت» نیستند. مثلاً «موجودی کافی نیست» یا «رمزِ عبور اشتباه است» خطاهای کسبوکارند و کاملاً طبیعی؛ اگر اینها را «شکست» بشماری، مدار بیجهت باز میشود و کاربرانِ سالم را هم قطع میکند. با ignoreExceptions میگویی اینها را در نرخِ خطا حساب نکن. متقابلاً recordExceptions فقط انواعِ مشخصی (مثلِ IOException, TimeoutException) را شکست بشمار. تفکیکِ درستِ این دو، نشانهی بلوغِ مهندسی است.
Netflix از ۲۰۱۸ Hystrix را به حالتِ نگهداری برد؛ توسعهی فعال ندارد. معماریاش سنگین بود (وابسته به RxJava/Archaius) و بر ارثبری از HystrixCommand متکی. Resilience4j سبک، ماژولار و تابعی است: تابع را decorate میکنی و فقط ماژولهایی را که نیاز داری برمیداری؛ فقط به Vavr وابسته است؛ و به انتخابِ تو bulkhead سمافوری یا thread pool میدهد. ضمناً Spring Cloud Circuit Breaker امروز بهصورت پیشفرض روی Resilience4j است.
لایهها را روی هم میگذارم: (۱) Time Limiter تا هیچ فراخوانی بیش از مهلت طول نکشد؛ (۲) Bulkhead تا فشارِ این وابستگی کلِ منابع را نبلعد؛ (۳) Circuit Breaker با آستانهی خطا و slow-call تا در خرابیِ مداوم fail-fast کنم؛ (۴) Retry با backoff+jitter در بیرونیترین لایه، فقط اگر عملیات idempotent باشد یا کلیدِ idempotency داشته باشم؛ (۵) Fallback سبک (مثلِ «پرداخت را به صف بینداز و بعداً پردازش کن»). و همه را با micrometer رصد میکنم و با WireMock و مهندسیِ آشوب تست میکنم. نکتهی کلیدی: پرداخت را idempotent میکنم تا retry دوبار پول کم نکند.
عملاً هیچ محافظتی نمیگیری. پیشفرضِ slidingWindowSize و minimumNumberOfCalls هر دو صد است؛ اگر سرویست در پنجرهی زمانی حتی به صد فراخوانی نرسد، breaker هرگز نرخِ خطا را حساب نمیکند و هرگز باز نمیشود — یعنی سرِ خرابی fail-fast رخ نمیدهد. درمان: slidingWindowType را TIME_BASED کن یا اندازهها را با ترافیکِ واقعی کوچک کن (مثلِ window ۱۰ و minimum ۵). این نشان میدهد که پیشفرضها را کورکورانه نپذیرفتهای.
سه سطح: (۱) تستِ واحد روی ماشینِ حالتِ breaker با فراخوانی مستقیمِ onError/onSuccess و بررسیِ گذارِ حالتها؛ (۲) تستِ یکپارچه با یک سرورِ جعلی مثلِ WireMock که عمداً تأخیر یا خطای ۵۰۰ میدهد تا retry و time limiter و breakerِ واقعی را زیرِ فشار ببینم؛ (۳) مهندسیِ آشوب در محیطِ شبیهِ تولید با تزریقِ عمدیِ خرابی (تأخیر، کشتنِ نمونه) و رصد با micrometer تا مطمئن شوم سیستم آبرومندانه تنزل میکند. بدونِ رصدِ زنده، اصلاً نمیفهمم مدارهایم کِی باز/بسته میشوند.
- در سیستمِ توزیعشده شکست قطعی است؛ هدفِ تابآوری این است که نقص را به فروپاشیِ زنجیرهای تبدیل نکنیم و شعاعِ انفجار را کوچک نگه داریم.
- دشمنِ اصلی معمولاً کُندی است نه خطا، چون منابع را قفل میکند (resource exhaustion).
- Resilience4j جایگزینِ سبک، ماژولار و تابعیِ Hystrixِ بازنشسته است و پیشفرضِ Spring Cloud Circuit Breaker است.
- Circuit Breaker: سه حالتِ CLOSED/OPEN/HALF_OPEN، پنجرهی لغزانِ count/time، و دو در برای بازکردن — نرخِ خطا و نرخِ فراخوانیِ کُند.
- Retry: فقط با backoff نمایی + jitter (تا thundering herd نسازی) و فقط روی عملیاتِ idempotent.
- Rate Limiter (محدودیتِ زمانی)، Bulkhead (محدودیتِ همزمانی: سمافوری یا thread pool)، Time Limiter (قطعِ فراخوانیِ کُند)، و Fallback (تنزلِ آبرومندانه) هر کدام یک دردِ متفاوت را میگیرند.
- ترتیبِ decoratorها مهم است: retry بیرون، breaker داخل.
- تابآوری را با تستِ واحد، WireMock و مهندسیِ آشوب ثابت کن و همیشه با micrometer رصد کن.
Let's start with a hard truth that anyone who has run a real system in production has felt in their bones: in a distributed system, failure is not an exception — it is a certainty. On your laptop, where everything lives in one process, a method call either works or the code has a bug. But the moment your service reaches across a network to another service, a whole world of danger sits between them: cables, switches, DNS, queues, a slow database, a service that just restarted. This chapter is about how, instead of wishing the network stays healthy, we accept that it will break and plan for it in advance.
You won't just memorize a list of pattern names here. You'll understand what pain each pattern cures, why it exists, and how to implement it with real code in Resilience4j — today's standard resilience library for Java — and how to talk about it in a senior interview.
Here's the path we'll walk together:
- Why the network breaks — the "Fallacies of Distributed Computing" and cascading failure.
- What resilience means — base vocabulary: fault, latency, blast radius, idempotent.
- Meet Resilience4j — why it replaced the retired Hystrix, and its modules.
- Circuit Breaker — the closed/open/half-open state machine, sliding windows, config.
- Retry — retrying with exponential backoff and jitter (and why jitter is vital).
- Rate Limiter, Bulkhead, Time Limiter — three more shields for three different pains.
- Fallbacks and combining decorators — and why their order matters.
- Idempotency — why retry is dangerous without it.
- Testing resilience, pitfalls, interview questions, and a summary.
Part 0 — a few words you must feel before we start
Before any code, a few terms recur throughout this chapter. Let me plant them with analogies now so you don't get lost later.
- Fault: a bad event inside the system — say, the payment service stops responding. Faults always happen; the art is not letting a fault become a failure (a total collapse of the system).
- Resilience: a system's ability to keep working in the presence of faults. A resilient system doesn't stay perfect, but instead of collapsing it degrades gracefully — e.g. showing a cached price instead of the live one.
- Latency: how long a call takes to return. Our enemy isn't always the "error"; very often slowness is more dangerous than errors, because it locks up resources while nobody is even shouting.
- Blast radius: when one piece breaks, how far the damage spreads. The whole philosophy of this chapter is "keep the blast radius small" — don't let one broken service drag the entire system down with it.
- Idempotent: an operation that, run multiple times, has the same effect as running once. Hold onto this word; it is the backbone of retry.
Picture the head chef (your service) needing help from three other stations for every order: the pantry, the grill, and the pastry counter. If the grill gets slow, the head chef stands frozen by the window, ladle in hand, waiting. Now the next orders pile up behind, because the only chef is busy waiting. Very quickly the whole kitchen locks up — not because everything is broken, but because one slow station took everyone hostage. The patterns in this chapter are exactly the rules a professional kitchen has: "don't wait on the grill longer than this," "if the grill fails three times in a row, skip it for now," "assign only this many cooks per station."
Why do remote calls fail?
Before we reach for cures, we must know the disease well. Junior developers unconsciously assume the network behaves like a local method call: fast, reliable, free. These assumptions are so common and so wrong that they have a name — the Fallacies of Distributed Computing, a list Sun Microsystems engineers compiled in the '90s:
- The network is reliable.
- Latency is zero.
- Bandwidth is infinite.
- The network is secure.
- Topology doesn't change.
- There is one administrator.
- Transport cost is zero.
- The network is homogeneous.
Every big distributed bug I've ever seen traces back to one of these eight fallacies. Code that "works on my laptop" has unconsciously assumed latency is zero and the network never drops. Resilience means erasing these eight assumptions from your mind and writing code that expects breakage.
Now the key question: why can one broken remote call drag down the entire system? The answer is a chain.
Cascading failure
Suppose service A calls service B, and B calls the database. Now the database slows down and each query, instead of 10 milliseconds, takes 30 seconds. What happens?
- Every thread in B waiting on the database is "locked" for 30 seconds.
- Fresh requests pour in, but there are no free threads; the thread pool fills.
- Now B also responds slowly to everyone, or not at all.
- A's threads waiting on B lock up too, and A goes down.
- Like dominoes, one database's slowness climbs all the way to the end user.
Your first instinct says the most dangerous case is service B returning a 500 error. But actually, a fast error is the best possible outcome: the thread is freed instantly and you can decide what to do. The truly lethal case is "silent slowness": a service that neither errors nor responds, just swallowing threads one by one until none are free. This is called resource exhaustion. Most patterns in this chapter exist to tame slowness, not merely errors.
Bottom line: in a distributed system you can't stop your dependencies from breaking; you can only decide how their breakage affects you. That is precisely the definition of resilience.
Meet Resilience4j
Resilience4j is a lightweight fault-tolerance library for Java, inspired by Netflix's Hystrix but built with a more modern philosophy. Its design rests on functional programming: you have a function (say, a remote call), and Resilience4j wraps (decorates) it in a protective layer and hands you back a new, resilient function.
Your core function is like the phone itself — it knows its job but is fragile. Resilience4j slips each protective layer (circuit breaker, retry, …) over it like a case or screen protector. The elegant part is that the phone itself doesn't change; you only wrap around it. That's why you can stack several cases and peel them off anytime — the business-logic code stays clean and the "resilience concern" is separated from it.
Why did Hystrix retire, and Resilience4j take its place?
Hystrix was the industry standard for years, but Netflix announced in 2018 that it was moving to maintenance mode — no new features, active development stopped. New projects should not adopt it. Let's see the differences in a table:
| Aspect | Hystrix (retired) | Resilience4j (modern) |
|---|---|---|
| Status | Maintenance mode since 2018 | Active and evolving |
| Dependencies | Heavy (Archaius, RxJava, …) | Only Vavr; lightweight, modular |
| Programming model | Extend HystrixCommand |
Functional — you decorate a function |
| Modularity | Monolithic (all-in-one) | Each pattern is a separate module |
| Isolation | Thread pool by default | Semaphore or thread pool, your choice |
| Java version | Older Java | Java 8+ (the 2.x line needs Java 17) |
| Spring Cloud Circuit Breaker | Removed | The default implementation |
If an interviewer asks "why Resilience4j and not Hystrix?", the top-tier answer is: "Hystrix has been in maintenance mode since 2018 and is no longer developed; its architecture was also heavy and built on command inheritance, whereas Resilience4j is lightweight, modular, and functional, giving each pattern as a separate module so you take only what you need." That single sentence shows you know the ecosystem's history.
Modules and setup
Resilience4j has a pure core plus integrations. The main modules:
resilience4j-circuitbreakerresilience4j-retryresilience4j-ratelimiterresilience4j-bulkheadresilience4j-timelimiterresilience4j-spring-boot3— Spring Boot 3 integration (annotations +application.ymlconfig)resilience4j-micrometer— metrics for monitoring
For a Spring Boot 3 project, usually this one dependency is enough:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.4.0</version>
</dependency>
<!-- @CircuitBreaker/@Retry on methods also need AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
You can use Resilience4j two ways. First, functional/programmatic: you decorate a function directly in code (CircuitBreaker.decorateSupplier(...)); this is transparent and framework-agnostic. Second, declarative/annotation-based: in Spring you just put @CircuitBreaker on a method and write the config in application.yml. I'll show both, because understanding the functional path helps you see what the annotations do under the hood.
Circuit Breaker
This is the most important pattern in the chapter. Its name comes from an electrical fuse, and that analogy makes everything click.
Your house has a fuse. If a short circuit occurs and a dangerous current flows, the fuse deliberately blows itself and cuts the circuit so the whole house doesn't catch fire. Once you've fixed the problem, you flip it back on. A software circuit breaker is exactly this: if it sees calls to a service failing repeatedly, it deliberately blocks further calls and returns errors instantly, instead of sacrificing your threads to a service it knows won't answer. A little later, it sends one trial call to check whether things have recovered.
The three main states: closed, open, half-open
The heart of a circuit breaker is a state machine with three states:
- CLOSED: normal mode. All calls pass through, but the breaker silently records each result (success/failure) in a "sliding window."
- OPEN: if the failure rate crosses a threshold, the circuit "opens" and, like a blown fuse, no call passes; everyone is rejected instantly with
CallNotPermittedException. This is the fail-fast that saves your threads. - HALF_OPEN: after a wait period, the breaker permits a limited number of trial calls. If they succeed, it returns to CLOSED; if they fail again, it goes back to OPEN and waits once more.
Resilience4j also has three special, less-used states: DISABLED (always pass), FORCED_OPEN (always reject), and METRICS_ONLY (record only, never break).
failure rate crossed the threshold
CLOSED ───────────────────────────────────▶ OPEN
▲ │
│ trial call succeeded │ wait duration elapsed
│ ▼
└───────────────── HALF_OPEN ◀──────────────┘
trial call failed ──▶ OPEN
The sliding window: the decision-making brain
How does the breaker measure "failure rate"? With a sliding window, which comes in two types:
- COUNT_BASED: keeps the last N calls. "Of the last 100 calls, what percent failed?"
- TIME_BASED: keeps calls from the last N seconds. "In the past 60 seconds, what percent failed?"
A common mistake: you set the failure rate to 50% but forget that with one call you can already hit 100% failure! minimumNumberOfCalls says "until I have at least this many recorded calls, don't compute the failure rate and don't open the circuit." Without it, your breaker trips on the first random morning glitch. Tune it to your real traffic.
Config and functional code
Let's build a breaker by hand so we see every knob:
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(SlidingWindowType.COUNT_BASED)
.slidingWindowSize(10) // measure the last 10 calls
.minimumNumberOfCalls(5) // don't decide until 5 calls
.failureRateThreshold(50f) // open if 50% fail
.slowCallRateThreshold(80f) // also open if 80% are slow
.slowCallDurationThreshold(Duration.ofSeconds(2)) // "slow" = over 2s
.waitDurationInOpenState(Duration.ofSeconds(10)) // stay OPEN for 10s
.permittedNumberOfCallsInHalfOpenState(3) // allow 3 trial calls
.automaticTransitionFromOpenToHalfOpenEnabled(true)
.recordExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessException.class) // don't count this as failure
.build();
CircuitBreakerRegistry registry = CircuitBreakerRegistry.of(config);
CircuitBreaker breaker = registry.circuitBreaker("paymentService");
// now wrap the function:
Supplier<String> decorated =
CircuitBreaker.decorateSupplier(breaker, () -> paymentClient.charge(order));
// and run it with a safe fallback:
String result = Try.ofSupplier(decorated)
.recover(throwable -> "payment-unavailable") // on failure, return this
.get();
Notice Resilience4j has two doors to open the circuit: failure rate and slow-call rate. This split is gold, because as we said the real enemy is slowness, not errors. slowCallDurationThreshold says "any call taking longer than 2 seconds, even if it succeeds, is a warning sign." With this, the breaker can open before the service dies completely and starts erroring, purely based on its slowness. That's preventive protection.
The same thing with an annotation in Spring Boot
In Spring, instead of the code above, you write the config in application.yml:
resilience4j:
circuitbreaker:
instances:
paymentService:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 50
slow-call-rate-threshold: 80
slow-call-duration-threshold: 2s
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
@Service
public class PaymentService {
@CircuitBreaker(name = "paymentService", fallbackMethod = "chargeFallback")
public String charge(Order order) {
return paymentClient.charge(order); // real remote call
}
// fallback signature = original signature + a trailing Throwable
public String chargeFallback(Order order, Throwable t) {
log.warn("payment circuit open, using fallback: {}", t.toString());
return "payment-deferred";
}
}
A classic trap: the fallback method must have the same signature as the original method, plus a trailing Throwable (or a more specific exception type) parameter. If the type or order of parameters differs, Resilience4j won't find the fallback at runtime and, instead of your nice message, you get a NoSuchMethodException. You may write several fallbacks with different exception types; Resilience4j picks the most specific match.
Observing events
The breaker has an event publisher, great for debugging and monitoring:
breaker.getEventPublisher()
.onStateTransition(e -> log.info("circuit {} -> {}",
e.getStateTransition().getFromState(),
e.getStateTransition().getToState()))
.onCallNotPermitted(e -> log.warn("call blocked, circuit is OPEN"))
.onError(e -> log.debug("recorded failure: {}", e.getThrowable().toString()));
Retry — trying again (but carefully)
Some errors are transient: a momentary network blip, a random timeout, a brief restart. For these it makes sense not to give up instantly but to try a few more times. But naive retry is more dangerous than the failure itself.
If you rang the bell and nobody answered, it's reasonable to ring again a moment later — maybe they were in the kitchen. But if you jam the bell down and hold it endlessly, two disasters strike: first, if nobody's home, you're just tiring yourself out; second and worse, if the person is sick and slow, your relentless ringing makes them worse. Retry is exactly this: you must space out attempts and know when to back off.
Exponential backoff: wait longer each time
Backoff means waiting between attempts, and exponential backoff means that wait doubles each time: 1 second, then 2, then 4, then 8. The logic: if the service didn't answer after an instant retry, it probably really has a problem and needs more breathing room.
Why jitter (random spread) is vital
Now picture a subtle catastrophe: a thousand services all call one database at the same time, and the database blips for a moment. All thousand get errors simultaneously, and all, with the same backoff, retry together exactly 1 second later — a giant wave that hammers the just-recovering database again. This is called a thundering herd, or a retry storm.
The fix is to add a small random amount to each backoff so everyone's attempts spread out: one at 1.0 seconds, one at 1.3, one at 0.8. This deliberate spread is called jitter. In Resilience4j it's done with IntervalFunction.ofExponentialRandomBackoff(...). The golden senior rule: backoff without jitter means a synchronized storm; always turn jitter on. This separates someone who merely "heard of" retry from someone who used it in production.
RetryConfig config = RetryConfig.<String>custom()
.maxAttempts(4) // one initial + 3 retries
.intervalFunction(IntervalFunction
.ofExponentialRandomBackoff(
Duration.ofMillis(500), // start at 500ms
2.0, // doubling multiplier
0.5)) // jitter up to 50%
.retryExceptions(IOException.class, TimeoutException.class)
.ignoreExceptions(BusinessException.class)
// retry only when the result is null (retry on result, not just exceptions):
.retryOnResult(result -> result == null)
.build();
Retry retry = Retry.of("inventory", config);
Supplier<String> decorated = Retry.decorateSupplier(retry,
() -> inventoryClient.check(sku));
The application.yml equivalent:
resilience4j:
retry:
instances:
inventory:
max-attempts: 4
wait-duration: 500ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
enable-randomized-wait: true # this is jitter
randomized-wait-factor: 0.5
retry-exceptions:
- java.io.IOException
ignore-exceptions:
- com.example.BusinessException
This is the most important sentence of this section: never blindly retry a non-idempotent operation. If your method is "deduct 100 from the account" and the first attempt actually succeeded but its response got lost on the way, the second attempt deducts again and the customer pays twice. Retry is only safe when repeating the operation is harmless. We'll get to idempotency in its own section.
Combining retry and circuit breaker
These two are best together, but order matters: usually you want the circuit breaker inside the retry, so when the circuit opens, CallNotPermittedException is thrown fast; and you put that exception in the retry's ignoreExceptions so retry doesn't pointlessly hammer an open circuit.
Rate Limiter
A circuit breaker protects you from a broken service. A rate limiter is the opposite: it protects someone else (or your quota) from you, by capping the number of calls per unit of time.
At some highway entrances there's a small red light that releases one car every few seconds, so the highway doesn't suddenly fill and jam. A rate limiter is exactly this: e.g. "at most 10 calls per second"; the eleventh call either waits behind the light for its turn, or, if its patience runs out, is rejected. You need this both to respect third-party API quotas (which fine you if you exceed them) and to avoid crushing a weak service under load yourself.
RateLimiterConfig config = RateLimiterConfig.custom()
.limitForPeriod(10) // 10 permits per period
.limitRefreshPeriod(Duration.ofSeconds(1)) // refill the quota every 1s
.timeoutDuration(Duration.ofMillis(500)) // wait at most 500ms for a permit
.build();
RateLimiter limiter = RateLimiter.of("reportApi", config);
Supplier<String> decorated = RateLimiter.decorateSupplier(limiter,
() -> reportClient.generate());
If the quota is exhausted during the period and no slot opens within timeoutDuration, RequestNotPermitted is thrown.
Both "limit," but on different axes: a rate limiter limits by time ("X calls per second"), whereas a bulkhead limits by concurrency ("at most X calls in-flight at once"). The first caps speed, the second caps crowding. In an interview, articulating this distinction clearly earns you good points.
Bulkhead
The name bulkhead comes from a ship's watertight partitions, and that analogy carries the whole idea.
A large ship's hull is divided by walls into separate compartments. If one compartment is punctured and floods, the walls stop the water from spreading to the rest, and the ship stays afloat. The Titanic sank because its bulkheads didn't reach high enough and water spilled over. In software, a bulkhead means reserving a "separate compartment" of resources (threads or concurrency permits) for each dependency, so if service A locks up all its resources, there are still resources left for service B and the whole ship doesn't go down.
Resilience4j has two kinds of bulkhead:
- SemaphoreBulkhead: uses a counter (semaphore) to cap concurrent calls. Lightweight, no extra threads; the call runs on the caller's own thread.
- ThreadPoolBulkhead: creates a dedicated thread pool and queue for this dependency. The call runs on another thread, giving fuller isolation and the ability to truly cancel on timeout — but it's heavier and works only with asynchronous calls (
CompletableFuture).
| Aspect | Semaphore Bulkhead | ThreadPool Bulkhead |
|---|---|---|
| Mechanism | concurrency counter | thread pool + queue |
| Execution thread | the caller's thread | a separate pool thread |
| Overhead | very low | higher (context switch) |
| True timeout cancellation | no | yes |
| Best for | high load, synchronous | strong isolation, async |
// semaphore type
BulkheadConfig config = BulkheadConfig.custom()
.maxConcurrentCalls(20) // at most 20 concurrent calls
.maxWaitDuration(Duration.ofMillis(100)) // wait 100ms for a free slot
.build();
Bulkhead bulkhead = Bulkhead.of("searchService", config);
Supplier<String> decorated = Bulkhead.decorateSupplier(bulkhead,
() -> searchClient.query(term));
If all slots are full and none frees within maxWaitDuration, BulkheadFullException is thrown.
The big lesson Netflix learned from Hystrix was: if all your dependencies share one thread pool, one slow service can swallow the whole pool and take down every other service — even the healthy ones. The bulkhead solves this: give each dependency its own quota so one's breakage has a bounded blast radius. This is exactly the "keep the blast radius small" we mentioned in part zero.
Time Limiter
Remember we said the real enemy is slowness? The time limiter targets it directly: don't give a call more than X seconds. If no answer arrives, cut the call and throw a TimeoutException.
When you put something in the oven, you set a timer; at the set time, cooked or not, you open the door. Without a timer, if the cook gets distracted, you might wait hours for something that's never ready. The time limiter is that timer: at the deadline, it pries the slow service's grip off your thread.
Unlike the others, the time limiter works on a CompletableFuture (an asynchronous call), because to truly cancel a running task you need a separate thread:
TimeLimiterConfig config = TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofSeconds(3)) // don't wait beyond 3s
.cancelRunningFuture(true) // actually cancel the future
.build();
TimeLimiter timeLimiter = TimeLimiter.of("mapsApi", config);
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
Supplier<CompletableFuture<String>> futureSupplier =
() -> CompletableFuture.supplyAsync(() -> mapsClient.route(from, to));
Callable<String> decorated = TimeLimiter.decorateFutureSupplier(
timeLimiter, futureSupplier);
In Spring, @TimeLimiter works only on methods that return a CompletableFuture:
@TimeLimiter(name = "mapsApi")
@CircuitBreaker(name = "mapsApi", fallbackMethod = "routeFallback")
public CompletableFuture<String> route(String from, String to) {
return CompletableFuture.supplyAsync(() -> mapsClient.route(from, to));
}
public CompletableFuture<String> routeFallback(String from, String to, Throwable t) {
return CompletableFuture.completedFuture("route-unavailable");
}
A subtle trap: many people forget that the HTTP client (like RestClient or WebClient) has its own timeout. If you set the time limiter to 3 seconds but the client's connection timeout is 30 seconds, behaviors can conflict. The rule: the time limiter should usually be slightly larger than the client's read timeout so the deadlines nest sensibly, and never leave any of them at infinity.
Fallback — the plan B
A fallback means "when the main path fails, instead of throwing an error at the user, give a graceful alternative answer." This is graceful degradation.
Imagine the neighborhood bakery has run out of fresh bread. A bad baker locks the door and says "I have nothing" (a 500 error). A good baker says "no fresh, but there's yesterday's bread, want it?" (a fallback). The customer may not get the best thing, but they don't leave empty-handed and furious. A good fallback always returns something useful: a cached value, a sensible default, or an empty list — not an error page.
Kinds of good fallbacks:
- Cached value: return the last successful value (e.g. a price from 5 minutes ago).
- Default value: a conservative, safe response.
- Functional degradation: turn off part of the capability but keep the rest alive (e.g. hide "personal recommendations" but still show the product).
A common mistake is to put another remote call inside your fallback, which itself can fail or be slow — this just lengthens the chain of failure. A fallback should be as local, fast, and harmless as possible: a value from cache, a constant, or an empty list. If you must do something risky in a fallback, wrap it in its own protective layer too.
Combining decorators — and why order matters
Resilience4j's real power is unleashed when you stack several protections. The Decorators class makes this fluent:
Supplier<String> resilient = Decorators.ofSupplier(() -> paymentClient.charge(order))
.withBulkhead(bulkhead) // innermost
.withTimeLimiter(timeLimiter, scheduler)
.withRateLimiter(rateLimiter)
.withCircuitBreaker(circuitBreaker)
.withRetry(retry) // outermost
.withFallback(List.of(CallNotPermittedException.class),
t -> "payment-fallback")
.decorate();
The order of layers gives different meanings. In Spring, the default aspect order is (outer to inner):
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( the function ) ) ) ) )
This order is deliberate. If Retry is outer to CircuitBreaker, it means: "every time retry tries again, it passes through the circuit breaker." So once the circuit opens, subsequent retries are rejected instantly with CallNotPermittedException and don't uselessly pound the broken service. The reverse (retry inside the breaker) is bad: retry swallows failing attempts, so the stats don't reach the breaker and the circuit opens too late. The senior rule: retry outside, breaker inside. In Spring you can change the aspect order via properties like retryAspectOrder, but the default is usually correct.
We've seen five shields; let me put them side by side at a glance:
| Pattern | Pain | Mechanism |
|---|---|---|
| Circuit Breaker | a persistently broken service | fail-fast past the error threshold |
| Retry | a transient, momentary error | try again with backoff+jitter |
| Rate Limiter | exceeding a quota/rate | limit by time |
| Bulkhead | shared-resource exhaustion | limit concurrency |
| Time Limiter | a slow/unanswered call | cut off after a deadline |
| Fallback | any failure | a graceful alternative response |
Idempotency — why retry is a bomb without it
I promised to come back to this, because without grasping it, retry causes disease instead of curing it.
A light's on/off switch is idempotent in the "on" direction: no matter how many times you press "on," the light is just on; the second press changes nothing. But a "pour one coffee" button is not — each press makes a new coffee! If your first press's response got lost and you pressed again, you have two coffees. An idempotent operation is like the light switch: repetition is harmless. A non-idempotent operation is like the coffee button: repetition is dangerous.
The fundamental problem: when a call times out, you don't know whether the server didn't do the work, or did it but the response got lost. If the operation is idempotent, it doesn't matter and you retry safely. If it isn't, retry might deduct money twice or place two orders.
The standard fix is an idempotency key: the client generates a unique id (e.g. a UUID) for each logical operation and sends the same one across all retries; if the server has seen that key before, it returns the previous result instead of executing again.
public PaymentResult charge(Order order) {
// a stable key for this logical operation; constant across all retries
String idempotencyKey = order.getId() + ":charge";
return httpClient.post("/charge")
.header("Idempotency-Key", idempotencyKey) // server dedupes with this key
.body(order)
.retrieve();
}
Note that sending the key from the client isn't enough; the server must also store that key and, on a repeated request, refuse to re-execute and return the previous result. HTTP methods help here too: by standard, GET, PUT, and DELETE are inherently idempotent but POST is not — so for POSTs you usually need an idempotency key. This is one of the most frequently asked system-design interview questions.
Testing resilience
Writing resilient code isn't enough; you must prove it works. Three levels of testing:
1) Unit-testing the state machine itself. You can force the breaker through state transitions and check its behavior:
@Test
void breakerOpensAfterFailures() {
CircuitBreaker cb = CircuitBreaker.of("test",
CircuitBreakerConfig.custom()
.slidingWindowSize(4)
.minimumNumberOfCalls(4)
.failureRateThreshold(50f)
.build());
// simulate 4 failing calls
for (int i = 0; i < 4; i++) {
cb.onError(0, TimeUnit.MILLISECONDS, new IOException("boom"));
}
assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN);
// now every call should be rejected:
assertThatThrownBy(() -> cb.executeSupplier(() -> "x"))
.isInstanceOf(CallNotPermittedException.class);
}
2) Integration testing with a fake server. With a tool like WireMock you spin up a fake HTTP server and deliberately make it delay or error, to see whether the real circuit breaker and retry react correctly:
// make WireMock delay 3 seconds so the time limiter fires
stubFor(get("/inventory")
.willReturn(aResponse()
.withFixedDelay(3000)
.withStatus(200)
.withBody("ok")));
// or repeated errors to open the circuit:
stubFor(get("/inventory").willReturn(aResponse().withStatus(500)));
Unit and integration tests are good, but real maturity is when you deliberately inject failure in a production-like environment — add latency, drop packets, kill an instance — and watch whether the system truly degrades gracefully or collapses. This is called chaos engineering (which Netflix made famous with Chaos Monkey). Its philosophy: it's better to discover breakage during business hours and under control than at 3 a.m. by surprise.
3) Observing in production. With resilience4j-micrometer, feed metrics to Prometheus/Grafana to watch circuit states, failure rates, and retry counts live. Without observability, you can't tell when your circuit even opens and closes.
Pitfalls and best practices
- Don't blindly accept defaults. The default
slidingWindowSizeis 100 andminimumNumberOfCallsis also 100; if your traffic is low, the circuit effectively never opens. Align config with real traffic. - Never use backoff without jitter — you'll create a retry storm.
- Don't retry non-idempotent operations — or solve idempotency first.
- Keep fallbacks light and harmless — not another remote call.
- Choose decorator order deliberately — retry outside, breaker inside.
- Observe everything — an open circuit nobody sees is as bad as having no circuit at all.
- Don't treat breaker and timeout as separate concerns — catch slowness with the slow-call threshold and the time limiter, not just failure counting.
- Set
ignoreExceptionscorrectly — business errors (like "insufficient balance") should not open the circuit; they aren't infrastructure failures.
Interview Questions
Now it's time to drill everything with real senior-interview questions. Answer each yourself first, then open the answer.
A circuit breaker prevents "cascading failure" via fail-fast: instead of sacrificing threads to a service we know is broken, we return errors instantly. Three states: CLOSED (normal; calls pass and results are recorded), OPEN (failure rate crossed the threshold; everyone is rejected instantly with CallNotPermittedException), and HALF_OPEN (after a wait, a few trial calls are allowed; succeed → CLOSED, fail → OPEN). Its core value is giving the broken service "room to breathe" while keeping your threads free.
Because a fast error is the most harmless case: the thread frees instantly and you can decide. But a slow, unanswered call locks the thread; requests pile up behind it, the thread pool fills, and the whole service goes down (resource exhaustion). That's why Resilience4j has, beyond failure rate, a slow-call rate (slowCallRateThreshold) so you can open the circuit before errors even start, purely based on slowness.
Exponential backoff means the gap between retry attempts doubles each time (500ms, 1s, 2s…) to avoid pressuring a struggling service. But if all clients retry with the same backoff at exactly the same moment, a synchronized wave (thundering herd) hammers the just-recovered service again. Jitter adds a small random amount to each backoff so attempts spread out. In Resilience4j: IntervalFunction.ofExponentialRandomBackoff(...). Rule: backoff must always have jitter.
A Semaphore Bulkhead caps concurrent calls with a counter and runs on the caller's own thread; lightweight, low overhead, good for high load. A ThreadPool Bulkhead creates a dedicated pool and queue and runs the call on another thread; it gives fuller isolation and can truly cancel on timeout, but it's heavier and works only with async calls. If you want high concurrency and simplicity, semaphore; if you want hard resource isolation and real cancellation, thread pool.
Retry should be outer to the circuit breaker (retry outside, breaker inside). This way each retry passes through the breaker, so once the circuit opens, subsequent attempts are rejected instantly with CallNotPermittedException and don't uselessly pound the broken service. The reverse (breaker outside, retry inside) is bad: retry swallows failures and not enough stats reach the breaker, so it opens too late. In Spring the default aspect order Retry(CircuitBreaker(RateLimiter(TimeLimiter(Bulkhead(fn))))) already respects this.
Because when a call times out you don't know if the work was done and the response lost, or never done at all. If the operation is non-idempotent (like "deduct money"), retry can apply the effect twice. The fix is an idempotency key: the client generates a unique id per logical operation and sends the same one across all retries; if the server has seen the key, it returns the previous result instead of re-executing. Remember GET/PUT/DELETE are inherently idempotent but POST is not.
Both limit, but on different axes: a rate limiter limits by time ("at most X calls per second"), good for respecting API quotas or controlling throughput. A bulkhead limits by concurrency ("at most X calls in-flight at once"), good for resource isolation and taming resource exhaustion. The first caps speed, the second caps crowding.
It says the breaker won't compute the failure rate or open until at least this many calls are recorded in the window. Without it, with a 50% threshold one random error could open the circuit (one out of one is 100%). If you set it too high, on a low-traffic service the circuit effectively never opens and protection is nullified. It must be tuned to that instance's real traffic.
Because to truly cancel a running task (not just abandon the wait) you need a separate thread that supervises the work and, if the deadline passes, interrupts it. A synchronous call on your own thread can't be "cut" from outside unless it's asynchronous. That's why the time limiter operates on Supplier<CompletableFuture<...>> and, with cancelRunningFuture(true), can actually cancel the future. In Spring, @TimeLimiter only affects methods returning a CompletableFuture.
Because not all exceptions are "infrastructure failures." For example "insufficient balance" or "wrong password" are business errors and perfectly normal; if you count these as "failures," the circuit opens needlessly and cuts off healthy users too. With ignoreExceptions you say don't count these toward the failure rate. Conversely recordExceptions counts only specific types (like IOException, TimeoutException) as failures. Correctly separating these two is a sign of engineering maturity.
Netflix moved Hystrix to maintenance mode in 2018; it has no active development. Its architecture was heavy (dependent on RxJava/Archaius) and relied on extending HystrixCommand. Resilience4j is lightweight, modular, and functional: you decorate a function and take only the modules you need; it depends only on Vavr; and it lets you choose a semaphore or thread-pool bulkhead. Also, Spring Cloud Circuit Breaker today defaults to Resilience4j.
I stack layers: (1) a Time Limiter so no call exceeds a deadline; (2) a Bulkhead so this dependency's pressure doesn't swallow all resources; (3) a Circuit Breaker with failure and slow-call thresholds to fail-fast under persistent breakage; (4) a Retry with backoff+jitter as the outermost layer, only if the operation is idempotent or I have an idempotency key; (5) a light Fallback (like "queue the payment and process later"). I observe all of it with micrometer and test with WireMock and chaos engineering. The key point: I make the payment idempotent so retry doesn't double-charge.
You effectively get no protection. The default slidingWindowSize and minimumNumberOfCalls are both 100; if your service doesn't even reach 100 calls in the window, the breaker never computes the failure rate and never opens — so fail-fast never happens on breakage. The fix: make slidingWindowType TIME_BASED, or shrink the sizes to match real traffic (like a window of 10 and minimum of 5). This shows you didn't blindly accept the defaults.
Three levels: (1) unit tests on the breaker's state machine by calling onError/onSuccess directly and checking transitions; (2) integration tests with a fake server like WireMock that deliberately delays or returns 500s, to see the real retry, time limiter, and breaker under pressure; (3) chaos engineering in a production-like environment with deliberate failure injection (latency, killing an instance) and observability via micrometer, to confirm the system degrades gracefully. Without live observability, I can't even tell when my circuits open and close.
- In a distributed system, failure is certain; resilience's goal is to stop a fault from becoming a cascading failure and to keep the blast radius small.
- The real enemy is usually slowness, not errors, because it locks up resources (resource exhaustion).
- Resilience4j is the lightweight, modular, functional replacement for the retired Hystrix, and is the default for Spring Cloud Circuit Breaker.
- Circuit Breaker: three states CLOSED/OPEN/HALF_OPEN, a count/time sliding window, and two doors to open — failure rate and slow-call rate.
- Retry: only with exponential backoff + jitter (to avoid a thundering herd) and only on idempotent operations.
- Rate Limiter (time limit), Bulkhead (concurrency limit: semaphore or thread pool), Time Limiter (cut off slow calls), and Fallback (graceful degradation) each catch a different pain.
- Decorator order matters: retry outside, breaker inside.
- Prove resilience with unit tests, WireMock, and chaos engineering, and always observe with micrometer.