Spring · اسپرینگ سنیورSenior ~45 دقیقه مطالعه~37 min read
Spring Cloud و اجزای میکروسرویسSpring Cloud & Microservices Building Blocks
در این درس یاد میگیری وقتی یک برنامهٔ یکپارچه را به میکروسرویس میشکنی چه مشکلاتی سر برمیآورند و چطور Spring Cloud با کشف سرویس، پیکربندی متمرکز، دروازهٔ API، توزیع بار، تابآوری و ردیابی توزیعشده تکتکشان را حل میکند.In this lesson you'll learn which problems reappear the moment you split a monolith into microservices, and how Spring Cloud solves each one — service discovery, centralized config, an API gateway, load balancing, resilience, and distributed tracing.
تصور کن یک رستوران کوچک داری که همهچیزش زیر یک سقف است: آشپز، صندوقدار، انبار، پیشخدمت. اگر بخواهی چیزی از انبار برداری، فقط چند قدم راه میروی. حالا همان رستوران را به یک زنجیرهٔ دهشعبهای در سطح شهر تبدیل کن. ناگهان کارهایی که «رایگان» و بدیهی بودند — «انبار کجاست؟»، «قیمت امروز چقدر است؟»، «اگر یک شعبه آتش گرفت چه؟» — به مسائل واقعی و پیچیده تبدیل میشوند.
شکستن یک مونولیت (monolith، یعنی برنامهای که همهچیزش در یک پردازهٔ واحد اجرا میشود) به میکروسرویسها (سرویسهای کوچک مستقل) دقیقاً همین اتفاق است. این درس دربارهٔ ابزارهایی است که Spring Cloud برای هر یک از این مسائل تازهمتولدشده به تو میدهد.
اول یک مدل ذهنی میسازیم: مونولیت چه چیزهایی را مجانی میداد و در میکروسرویس چه چیزی را از دست میدهی. بعد شش قطعهٔ سازندهٔ Spring Cloud را یکییکی و از صفر یاد میگیری: ۱) کشف سرویس (Eureka) — «سرویس کجاست؟» ۲) Config Server — «پیکربندی متمرکز و زندهٔ همهجا». ۳) API Gateway — «یک درِ ورودی واحد». ۴) توزیع بار سمت کلاینت (Spring Cloud LoadBalancer) — «بار را روی نمونهها پخش کن». ۵) تابآوری (Resilience4j) — «وقتی چیزی خراب شد، آبشاری نمیر». ۶) ردیابی توزیعشده (Micrometer Tracing + Zipkin) — «یک درخواست را در سراسر سرویسها دنبال کن». در پایان، یک بخش کامل سؤالات مصاحبهٔ سنیور با پاسخهای تشریحی داری.
مدل ذهنی: Spring Cloud واقعاً چه مشکلاتی را حل میکند؟
بیا اول احساسِ مسئله را بسازیم. در مونولیت، وقتی متد A میخواست متد B را صدا بزند، فقط یک فراخوانی درونپردازهای (in-process) بود — همان چند نانوثانیه، هرگز شکست نمیخورد، هرگز «نیمهکاره» نمیماند. JVM این تضمینها را رایگان به تو میداد. لحظهای که B را به یک سرویس جدا روی ماشین دیگر منتقل میکنی، همان فراخوانی حالا از روی شبکه میگذرد: میتواند کند شود، timeout بخورد، یا وسط کار قطع شود.
جدول زیر دقیقاً نشان میدهد هر «هدیهٔ رایگان» مونولیت در دنیای میکروسرویس به چه مسئلهای بدل میشود و کدام جزء Spring Cloud پاسخ آن است:
| مونولیت به شما میداد… | در میکروسرویس اکنون نیاز دارید… | جزء Spring Cloud |
|---|---|---|
| فراخوانی متد درونپردازهای | یافتن محل سرویس | کشف سرویس (service discovery / Eureka) |
یک application.properties |
پیکربندی برای N سرویس و N محیط، قابل تغییر در زمان اجرا | Config Server + @RefreshScope |
| یک درِ ورودی (سرولت) | یک ورودی واحد با احراز هویت، مسیریابی، محدودسازی نرخ | Spring Cloud Gateway |
| متد محلی هرگز «نیمهکاره خراب» نمیشد | فراخوانیهایی که timeout میشوند یا آبشاری خراب میشوند | Resilience4j |
| یک stack trace واحد | یک درخواست عبوری از ۶ سرویس | Micrometer Tracing + Zipkin |
| توزیع بار «زمانبند CPU» بود | پخش فراخوانی روی N نمونه | Spring Cloud LoadBalancer |
اشتباه رایج این است که فکر کنی Spring Cloud یک فریمورک واحد مثل Spring MVC است. نیست. آن را مثل یک «جعبهٔ هماهنگشدهٔ ابزار» تصور کن که یک تولیدکننده بستهبندی کرده تا مطمئن شوی همهٔ ابزارها با هم جور درمیآیند. این بسته را در دنیای Spring یک قطار انتشار (release train) یا BOM (مخفف Bill of Materials، یعنی «صورتحساب مواد» — فهرستی که نسخهٔ سازگار همهٔ اجزا را قفل میکند) مینامند.
Spring Cloud یک فریمورک واحد نیست؛ یک قطار انتشار (release train) یا BOM است که نسخهٔ چندین پروژهٔ مستقل را همتراز میکند. هر قطار به یک نسخهٔ مشخص از Spring Boot گره خورده است. تا میانهٔ ۲۰۲۵ قطارهای مرتبط اینها هستند (نامها اسم ایستگاههای متروی لندناند — یک شوخی داخلی تیم):
- 2024.0.x («Moorgate») → Spring Boot 3.4.x
- 2025.0.0 («Northfields») → Spring Boot 3.5.x
- 2025.1.x («Oakwood») → Spring Boot 4.0.x
نکتهٔ کلیدی: تو هرگز نباید نسخهٔ تکتک artifactهای spring-cloud-* را دستی بنویسی. فقط قطار را از طریق BOM قفل کن و بگذار خودش نسخهٔ درستِ همهٔ اجزا را انتخاب کند:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2024.0.1</version> <!-- متناظر با Boot 3.4.x شما -->
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
اگر با نسل قدیم Spring Cloud آشنایی داری، اسمهای زیر برایت آشناست — اما همگی در خط Boot 3 حذف یا EOL (مخفف End Of Life، یعنی «پایان عمر»؛ دیگر پشتیبانی و بهروزرسانی نمیشود) شدهاند: Netflix Ribbon، Hystrix، Zuul 1 و Spring Cloud Sleuth. جایگزینهایشان: Spring Cloud LoadBalancer، Resilience4j (از طریق Spring Cloud CircuitBreaker)، Spring Cloud Gateway و Micrometer Tracing. اگر مصاحبهگر Hystrix یا Ribbon را نام برد، پاسخ سنیورِ تو این است: «آنها مردهاند؛ این جایگزینشان است و چرا.»
۱. کشف سرویس (Eureka)
تشبیه: دفترچهٔ تلفنِ زنده
فرض کن میخواهی به دوستت زنگ بزنی، ولی او هر هفته شمارهاش عوض میشود. اگر شمارهٔ ثابتی روی کاغذ نوشته باشی، خیلی زود بیفایده میشود. راهحل چیست؟ یک دفترچهٔ تلفن مرکزی زنده که هر کس تازه شمارهاش عوض شد، فوراً خودش را در آن ثبت میکند؛ و تو بهجای حفظکردن شماره، هربار از دفترچه میپرسی «شمارهٔ الانِ فلانی چیست؟».
مشکل واقعی: نمونههای سرویس زودگذرند (ephemeral، یعنی موقتی و بیدوام) — مقیاسپذیری خودکار، ریاستارتها و زمانبندهای کانتینر مدام IP جدید میدهند. کدنویسی سخت http://10.0.3.14:8080 یک خیال است؛ آن IP فردا وجود ندارد.
مدل: یک رجیستری (registry، یعنی دفترچهٔ ثبت مرکزی) به نام Eureka Server داری. نمونهها هنگام راهاندازی خودشان را در آن ثبت (register) میکنند و مرتب به آن heartbeat (ضربان قلب — یک پیام کوچک «من هنوز زندهام») میفرستند. کلاینتها این رجیستری را کوئری (یا کش) میکنند تا نامی منطقی مانند order-service را به فهرست میزبانهای زندهٔ واقعی تبدیل کنند.
سرور Eureka
اول خودِ دفترچهٔ تلفن را میسازیم. فقط یک annotation کافی است:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}
# discovery-server application.yml
server:
port: 8761
eureka:
client:
register-with-eureka: false # سرور خودش را ثبت نمیکند
fetch-registry: false
server:
# در توسعه، self-preservation را خاموش کنید تا نمونههای مرده سریع evict شوند.
# در تولید روشن بگذارید — از eviction انبوه هنگام قطعی شبکه محافظت میکند.
enable-self-preservation: true
دقت کن که خود سرور با register-with-eureka: false میگوید «من دفترچهام، خودم را در خودم ثبت نمیکنم». پورت 8761 قرارداد شناختهشدهٔ Eureka است.
یک کلاینت که خودش را ثبت میکند
حالا یک سرویس واقعی که میخواهد در دفترچه دیده شود:
# order-service application.yml
spring:
application:
name: order-service # این کلید رجیستری است که کلاینتها جستجو میکنند
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true # ثبت IP نه hostname (در کانتینرها مهم)
lease-renewal-interval-in-seconds: 10 # آهنگ heartbeat
آن خط spring.application.name: order-service قلب ماجراست: همین نام کلیدی است که بقیه با آن تو را پیدا میکنند. جالب اینکه فقط افزودن وابستگی spring-cloud-starter-netflix-eureka-client اپلیکیشن را خودکار ثبت میکند؛ در نسخههای امروزی حتی به هیچ annotation نیازی نیست.
تصور کن اپراتور دفترچهٔ تلفن ناگهان میبیند نصف شهر دیگر «من زندهام» نمیفرستند. دو تفسیر ممکن است: یا نصف شهر واقعاً مرده (بعید)، یا کابلِ خودِ اپراتور قطع شده و پیامها نمیرسند (محتملتر). Eureka در این حالت وارد self-preservation میشود: فرض میکند خطا از شبکهٔ خودش است، پس دست از حذف کردن نمونهها میکشد تا مبادا اشتباهاً همه را پاک کند. در تولید نجاتبخش است؛ در توسعه دیوانهکننده، چون نمونههای «زامبی» (مرده ولی هنوز در فهرست) میبینی.
ظرافتهای سنیور
اینجا جایی است که پاسخهای سطح سنیور از سطح جونیور جدا میشود:
- Eureka از نوع AP است نه CP. این اصطلاح از قضیهٔ CAP میآید. CAP میگوید در یک سیستم توزیعشده وقتی شبکه پارتیشن میشود (بخشهایی از هم بریده میشوند)، فقط میتوانی دو تا از این سه را داشته باشی: Consistency (سازگاری — همه همیشه یک جواب میبینند)، Availability (دسترسپذیری — همیشه جواب میگیری)، Partition tolerance (تحمل پارتیشن). چون پارتیشن اجباری است، عملاً بین C و A انتخاب میکنی. Eureka دسترسپذیری را برمیگزیند: بهجای بلاکشدن، یک رجیستری شاید کهنه را سرو میکند. این انتخابی عمدی است — تو تحمل میکنی که نمونهای تازهمرده را فراخوانی کنی (قفل مدارت آن را مدیریت میکند) در ازای اینکه رجیستری هرگز نقطهٔ خرابیِ مسدودکننده نشود. Consul و ZooKeeper به CP متمایلاند (سازگاری را ترجیح میدهند).
- حالت self-preservation: اگر Eureka تعداد heartbeat موردانتظار را دریافت نکند (مثلاً پارتیشن شبکه)، دست از eviction میکشد با این فرض که شبکه خراب شده نه نمونهها. عالی در تولید، دیوانهکننده در توسعه.
- کش سمت کلاینت: کلاینتها رجیستری را میگیرند و کش میکنند (پیشفرض هر ۳۰ ثانیه تازهسازی). پس همیشه یک تأخیر انتشار (propagation delay) بین مرگ یک نمونه و اطلاع کلاینتها هست — دلیل دیگری بر اینکه الگوهای تابآوری اجباریاند نه اختیاری.
همین «۳۰ ثانیه کش» یعنی همیشه پنجرهای هست که یک نمونه مرده اما کلاینتها هنوز آن را زنده میپندارند و صدایش میزنند. پس discovery بهتنهایی هرگز کافی نیست؛ حتماً باید با timeout و circuit breaker (که در بخش ۵ میآید) جفت شود. این جمله را در ذهنت حک کن: discovery میگوید کجا، تابآوری تصمیم میگیرد وقتی آنجا خالی بود چه کنی.
تنظیمات علی: سرور Eureka ستون فقراتی است که به gateway و فراخوانیهای بینسرویسی اجازه میدهد lb://order-service را آدرسدهی کنند بهجای IP — همان رجیستریای که route locator گیتوی از آن میخواند.
۲. Config Server و پیکربندی متمرکز
تشبیه: تابلوی اعلانات مرکزیِ شرکت
فرض کن یک شرکت با ۵۰ کارمند داری و هر کارمند باید بداند «ساعت کاری امروز چند است؟». راه احمقانه: به هر کارمند یک برگهٔ کاغذ بدهی که خودش نگه دارد؛ برای تغییر ساعت باید ۵۰ برگه را عوض کنی. راه درست: یک تابلوی اعلانات مرکزی که همه از روی آن میخوانند؛ یکبار عوض میکنی، همه میبینند.
مشکل واقعی: N سرویس × M محیط = انفجاری از فایلهای .properties. علاوه بر این، میخواهی پیکربندی را بدون استقرار مجدد (redeploy) تغییر دهی و اسرار (secrets، مثل رمز پایگاهداده) را از ایمیجهای داکر بیرون نگه داری.
مدل: یک Config Server به یک مخزن Git (یا Vault) متصل میشود و نقش همان تابلوی اعلانات را بازی میکند. کلاینتها پیکربندی خود را هنگام راهاندازی بر اساس سه محور میگیرند: {application} (کدام سرویس)، {profile} (کدام محیط، مثلاً dev/prod)، {label} (کدام شاخهٔ Git).
سرور
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication { /* ... */ }
# config-server application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/acme/config-repo
default-label: main
search-paths: '{application}'
نکتهٔ زیبا: منبع حقیقت، یک مخزن Git است. یعنی هر تغییر پیکربندی، تاریخچه، بازبینی (review) و بازگردانی (rollback) دارد — درست مثل کد. چیدمان مخزن، خودِ resolution را هدایت میکند:
config-repo/
├── application.yml # پیشفرضهای مشترک همهٔ سرویسها
├── order-service.yml # order-service، همهٔ پروفایلها
├── order-service-prod.yml # order-service، پروفایل prod (override)
└── gateway.yml
فایل application.yml مثل «قوانین عمومی شرکت» است که برای همه صدق میکند؛ order-service-prod.yml مثل «استثنای مخصوص این بخش در این شرایط» که قوانین عمومی را override میکند.
کلاینت
از Spring Boot 2.4 به بعد از spring.config.import استفاده کن (فایل bootstrap.yml قدیمی به یک starter میراثی جدا نیاز دارد):
# order-service application.yml
spring:
application:
name: order-service
config:
import: "optional:configserver:http://localhost:8888"
ترتیب تقدم (precedence) مهم است: مقادیر Config Server بر application.yml محلی غلبه میکنند — که کل هدف است، تا تغییر مرکزی همیشه برنده شود. (به آن optional: در ابتدای رشته دقت کن؛ در بخش تلهها میبینی چرا حیاتی است.)
تازهسازی در زمان اجرا
اینجا یک ظرافت مهم است: Config Server بهتنهایی تغییرات را push نمیکند. تابلوی اعلانات را عوض کردهای، اما کارمندها هنوز نگاهش نکردهاند. باید به کلاینتها گفته شود «دوباره بخوانید». دو سازوکار:
@RefreshScope+/actuator/refresh— بینهایی (beanهایی) که با@RefreshScopeعلامت خوردهاند، پس از یک درخواست POST به/actuator/refreshنابود و بهصورت تنبل (lazy، یعنی در اولین دسترسی بعدی) با پیکربندی جدید بازسازی میشوند.- Spring Cloud Bus — یک
/actuator/busrefreshرا روی یک broker پیام (واسط پیامرسان مثل RabbitMQ/Kafka) پخش میکند تا همهٔ نمونهها را با یک فراخوانی تازه کنی، بهجای اینکه تکتک را بزنی.
@RestControllerAdvice
@RefreshScope // این بین هنگام refresh بازساخته میشود؛ بدون آن، @Value در راهاندازی منجمد است
public class FeatureFlags {
@Value("${features.new-checkout:false}")
private boolean newCheckoutEnabled;
public boolean newCheckout() { return newCheckoutEnabled; }
}
یک بینِ معمولی مثل کارمندی است که روز اول استخدام یک برگه اطلاعات گرفته و آن را حفظ کرده؛ هرچقدر تابلو را عوض کنی، او همان حفظیاتش را تکرار میکند. @RefreshScope این رفتار را عوض میکند: بهجای اینکه سعی کنی ذهن آن کارمند را تغییر دهی، هنگام refresh کلاً یک کارمند تازه استخدام میکنی که برگهٔ جدید را میخواند. برای همین یک @Value روی بینِ بدون @RefreshScope تا ابد منجمد میماند — آن کارمند هرگز عوض نمیشود.
تلهها
- یک فیلد خام
@Valueروی بینی که@RefreshScopeندارد برای همیشه منجمد میماند. اما بینهای@ConfigurationPropertiesبهصورت پیشفرض هنگام refresh دوباره bind میشوند (چون در رویداد تغییر environment شرکت میکنند). - refresh بدون Bus بین نمونهها اتمیک نیست — پنجرهای خواهی داشت که نمونهها با هم اختلاف دارند (بعضی پیکربندی نو، بعضی کهنه).
- هرگز اسرار را در Git ساده نگذار. از مقادیر رمزنگاریشده با
{cipher}(کلید متقارن/نامتقارن سمت سرور) یا بهتر، HashiCorp Vault بهعنوان backend استفاده کن.
۳. دروازهٔ API — Spring Cloud Gateway
تشبیه: میز پذیرشِ یک ساختمان اداری بزرگ
یک برج اداری با دهها شرکت را تصور کن. اگر هر بازدیدکننده مستقیم به هر طبقهای سرک بکشد هرجومرج میشود. بهجایش یک میز پذیرش (reception) در ورودی داری: کارت شناسایی چک میکند (احراز هویت)، تو را به طبقهٔ درست راهنمایی میکند (مسیریابی)، و اگر صف شلوغ شد جلوی ازدحام را میگیرد (محدودسازی نرخ). هر شرکت لازم نیست خودش نگهبان استخدام کند.
مشکل واقعی: به یک ورودی واحد نیاز داری که مسیریابی، احراز هویتِ مشترک، دستکاری هدر، محدودسازی نرخ و پایاندادن TLS (TLS termination، یعنی رمزگشایی HTTPS در همین نقطه) را انجام دهد — تا هر سرویس مجبور نباشد آن را دوباره پیاده کند.
Spring Cloud Gateway واکنشی (reactive) است (بر پایهٔ Spring WebFlux / Netty). واکنشی یعنی non-blocking (ناهمگام): بهجای اینکه هر درخواست یک نخ را تا آخر اشغال کند، تعداد کمی نخ روی یک event loop (حلقهٔ رویداد) هزاران درخواست را میچرخانند. برای همین با نخهای اندک، همزمانی بالا را اداره میکند. (یک نسخهٔ جدیدتر server-web-mvc برای پشتههای بلاککننده هم هست، اما نسخهٔ واکنشی متعارف است.)
انتزاعهای اصلی
سه واژه را باید بشناسی:
- Route (مسیر) = شناسه + URI مقصد + predicateها (شرطها: چه زمانی این route منطبق میشود؟) + filterها (دستکاری درخواست/پاسخ).
- Predicate (محمول/شرط): Path، Method، Header، Host، Query، After/Before/Between (بر اساس زمان)، Weight (برای canary، یعنی فرستادن مثلاً ۵٪ ترافیک به نسخهٔ جدید).
- Filter (فیلتر): AddRequestHeader، RewritePath، CircuitBreaker، RequestRateLimiter، Retry و
GlobalFilterهای سفارشی.
# gateway application.yml
spring:
cloud:
gateway:
discovery:
locator:
enabled: true # ساخت خودکار routeها از شناسههای Eureka
lower-case-service-id: true
routes:
- id: order-service
uri: lb://order-service # lb:// = resolution از طریق LoadBalancer + discovery
predicates:
- Path=/api/orders/**
filters:
- RewritePath=/api/orders/(?<segment>.*), /$\{segment}
- name: CircuitBreaker
args:
name: ordersCB
fallbackUri: forward:/fallback/orders
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # توکن بر ثانیه
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@userKeyResolver}"
آن lb://order-service ثمرهٔ ترکیب discovery + load balancing است: پیشوند lb:// (مخفف load-balanced) به گیتوی میگوید «این یک IP نیست، یک نام منطقی است؛ آن را از رجیستری به یک نمونهٔ زنده resolve کن و بار را پخش کن».
یک global filter سفارشی (احراز هویت / correlation id)
فیلترِ سراسری مثل نگهبانی است که هر بازدیدکننده حتماً از جلویش رد میشود:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !token.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete(); // اتصال کوتاه؛ chain را صدا نزنید
}
// غنیسازی درخواست بهسمت پاییندست
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("X-User-Id", extractUserId(token))
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
@Override public int getOrder() { return -100; } // قبل از فیلترهای مسیریابی اجرا شود
private String extractUserId(String token) { /* verify + parse JWT */ return "u123"; }
}
به آن setComplete() دقت کن: وقتی توکن نامعتبر است، پاسخ را همینجا میبندد و chain.filter(...) را صدا نمیزند — یعنی درخواست هرگز به سرویس مقصد نمیرسد. به این «اتصال کوتاه (short-circuit)» میگویند.
بین key-resolver برای rate limiter (تا محدودسازی نرخ بهازای هر کاربر باشد نه سراسری):
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getHeaders().getFirst("X-User-Id"));
}
این مهمترین گاف گیتوی است. تو روی event loop نتی (Netty) هستی که فقط چند نخ دارد و قرار است هزاران درخواست را بچرخاند. یک فراخوانی بلاککننده (مثل JDBC یا RestTemplate بلاککننده) در فیلتر، یکی از آن نخهای انگشتشمار را قفل میکند؛ زیر بار، حلقه گرسنه میشود و throughput فرومیپاشد. تصور کن چهار نگهبان چرخان داری و یکیشان وسط سالن بیحرکت میایستد — کل صف میخوابد. راهحل: از کلاینتهای واکنشی استفاده کن یا کار سنگین را به یک scheduler محدود بسپار.
تلههای گیتوی
- هرگز در یک فیلتر بلاک نکن. (بالا توضیح داده شد.)
- ترتیب فیلتر مهم است (
Ordered)؛ احراز هویت سراسری باید قبل از فیلترهای مسیریابی/بازنویسی اجرا شود — همانطور که نگهبان باید قبل از راهنمای طبقه باشد. - گیتوی نقطهٔ گلوگاه (chokepoint) طبیعی است — محدودسازی نرخ و قفل مدار درشت را اینجا بگذار، اما تابآوری ریزدانه را داخل سرویسها هم نگه دار (دفاع در عمق).
تنظیمات علی: گیتوی تنها ورودی سایت است؛ routeها از نامهای lb:// که از طریق Eureka resolve میشوند استفاده میکنند و یک فیلتر CircuitBreaker جلوی هر پاییندست است تا سرویس مرده بهجای درخواست معلق، یک fallback برگرداند.
۴. توزیع بار سمت کلاینت — Spring Cloud LoadBalancer
تشبیه: خودت صف را انتخاب کن، نه یک متصدیِ وسط
دو مدل توزیع بار وجود دارد. مدل سمت سرور مثل بانکی است که یک متصدیِ مرکزی دارد که به تو میگوید «برو باجهٔ ۳». مدل سمت کلاینت مثل سوپرمارکتی است که خودت همهٔ صفها را میبینی و یکی را انتخاب میکنی — بدون واسطه، سریعتر، ولی فقط با دیدِ خودت (نمیدانی صف کناری همین الان چقدر شلوغ شده).
Ribbon رفته است؛ Spring Cloud LoadBalancer (SCLB) جایگزین آن است. «سمت کلاینت» یعنی فراخواننده فهرست نمونهها را (از discovery) نگه میدارد و خودش یکی را انتخاب میکند — بدون پرش (hop) به یک LB اختصاصی.
@Configuration
public class ClientConfig {
@Bean
@LoadBalanced // این annotation است که resolution با lb:// را ممکن میکند
RestClient.Builder restClientBuilder() {
return RestClient.builder();
}
}
// استفاده — "order-service" یک نام منطقی Eureka است که خودکار resolve و balance میشود:
String body = restClient.get()
.uri("http://order-service/orders/{id}", id)
.retrieve()
.body(String.class);
آن @LoadBalanced جادوگر ماجراست: بدون آن، order-service را یک hostname واقعی میپندارد و شکست میخورد. با آن، اسپرینگ نام را میگیرد، از discovery فهرست نمونهها را میپرسد و یکی را انتخاب میکند.
راهبرد پیشفرض round-robin است (نوبتی، یکییکی بهترتیب دور میزند)؛ میتوانی RandomLoadBalancer یا یک ReactorLoadBalancer سفارشی جایگزین کنی. SCLB همچنین از فیلتر health-check (فقط نمونههای سالم را انتخاب کن) و zone-aware (ترجیح نمونههای همان منطقهٔ جغرافیایی برای latency کمتر) پشتیبانی میکند. چون سمت کلاینت است، هر فراخواننده مستقل توزیع میکند — عالی برای latency (بدون پرش اضافه) اما بدون دید سراسری از بار.
۵. تابآوری — Resilience4j
تشبیه: فیوز برق خانه
در خانهات یک فیوز (circuit breaker) داری. وقتی یک دستگاه اتصالی میکند، فیوز فوراً میپرد و مدار را قطع میکند — نه چون بیرحم است، بلکه چون اگر جریان ادامه یابد، کل خانه آتش میگیرد. این دقیقاً منطق تابآوری در سیستم توزیعشده است.
مشکل واقعی: در یک سیستم توزیعشده، خرابیِ جزئی (partial failure) حالت عادی است. و نکتهٔ ظریف: یک پاییندست کُند بدتر از یکِ کاملاً خراب است. چرا؟ چون یک سرویسِ مرده فوراً خطا میدهد و نخ آزاد میشود؛ اما یک سرویسِ کند، هر فراخوانی را ثانیهها معلق نگه میدارد، نخها انباشته میشوند و خرابی آبشاری (cascading failure) رخ میدهد — یعنی خرابیِ یک سرویس، سرویس بالادستش را هم میخواباند و همینطور بالا میرود.
Resilience4j دکوراتورهای قابل ترکیب و سبک (تابعی، بدون thread pool پیشفرض) میدهد. پنج الگو و مشکلی که هرکدام حل میکنند:
| الگو | مشکلی که حل میکند |
|---|---|
| Circuit Breaker (قفل مدار) | دست از کوبیدن یک وابستگی خراب بردار؛ سریع شکست بخور و به آن فرصت بازیابی بده |
| Retry (تلاش مجدد) | عبور از خطاهای گذرا (بستهٔ افتاده، مکث کوتاه GC) |
| Bulkhead (دیوارهٔ آببند) | سقف همزمانی تا یک وابستگی کند نتواند همهٔ نخها را تمام کند |
| Rate Limiter (محدودساز نرخ) | احترام به سهمیهٔ پاییندست / محافظت آن از انفجارهای خودت |
| Time Limiter (محدودساز زمان) | مرزبندی مدت انتظار (فقط با ناهمگام/CompletableFuture معنادار است) |
واژهٔ Bulkhead از مهندسی کشتی میآید: بدنهٔ کشتی به چند اتاقکِ آببند تقسیم میشود، پس اگر یک اتاقک سوراخ شد و پر از آب شد، آب به بقیه نفوذ نمیکند و کشتی شناور میماند. در نرمافزار هم Bulkhead سقفی روی تعداد فراخوانی همزمان میگذارد، پس اگر یک وابستگی کند شد، فقط N نخ گیر میکنند نه همهٔ نخها — بقیهٔ برنامه شناور میماند.
حالتهای قفل مدار
قفل مدار یک ماشین حالت (state machine) با سه حالت است:
نرخ خطا >= آستانه
CLOSED ─────────────────────────▶ OPEN
▲ │ سپریشدن مدت انتظار
│ نرخ موفقیت خوب ▼
└──────────────── HALF_OPEN ◀─────┘
(اجازهٔ N فراخوانی آزمایشی)
- CLOSED (بسته): فراخوانیها عبور میکنند (مثل فیوز سالم که برق را رد میکند)؛ خطاها در یک پنجرهٔ لغزان (sliding window) شمرده میشوند. این پنجره میتواند count-based (بر اساس N فراخوانی اخیر) یا time-based (بر اساس بازهٔ زمانی اخیر) باشد.
- OPEN (باز): فراخوانیها فوراً با
CallNotPermittedExceptionرد میشوند (این همان «شکست سریع / fail fast» است — فیوز پریده)؛ پس ازwaitDurationInOpenState→ HALF_OPEN. - HALF_OPEN (نیمهباز): تعداد محدودی فراخوانی آزمایشی (probe) رد میشود تا ببیند وابستگی بهبود یافته یا نه؛ موفقیت کافی → CLOSED، یک خطا → بازگشت به OPEN. مثل اینکه محتاطانه فیوز را دوباره وصل کنی و ببینی باز میپرد یا نه.
پیکربندی
resilience4j:
circuitbreaker:
instances:
inventory:
sliding-window-type: COUNT_BASED
sliding-window-size: 20
failure-rate-threshold: 50 # درصد خطا برای تریپ
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 80 # درصد فراخوانی کند هم تریپ میکند
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
# اینها را خطا بشمار (مثلاً ۴۰۴ را نقص نشمار):
record-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
retry:
instances:
inventory:
max-attempts: 3
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
bulkhead:
instances:
inventory:
max-concurrent-calls: 25
ratelimiter:
instances:
inventory:
limit-for-period: 100 # ۱۰۰ فراخوانی...
limit-refresh-period: 1s # ...در هر ثانیه
timeout-duration: 0 # منتظر permit نمان؛ سریع شکست بخور
timelimiter:
instances:
inventory:
timeout-duration: 3s
cancel-running-future: true
به slow-call-rate-threshold دقت کن: قفل مدار فقط با خطای صریح تریپ نمیشود، بلکه اگر ۸۰٪ فراخوانیها کند باشند هم میپرد — چون همانطور که گفتیم، کند بودن خطرناکتر از مرگ است. و enable-exponential-backoff یعنی فاصلهٔ بین تلاشهای مجدد را دوبرابر میکند (۲۰۰ms، ۴۰۰ms، ۸۰۰ms) تا به وابستگیِ لرزان فرصت نفسکشیدن بدهد.
نمونهکد واقعی: تابآوری لایهای با fallback
@Service
public class InventoryClient {
private final RestClient restClient;
public InventoryClient(RestClient.Builder lbBuilder) {
this.restClient = lbBuilder.baseUrl("http://inventory-service").build();
}
// annotationها روی هم انباشته میشوند؛ هرکدام یک instance پیکربندی بالا را نام میبرد.
// 'fallbackMethod' باید همان امضا + یک پارامتر Throwable انتهایی داشته باشد.
@CircuitBreaker(name = "inventory", fallbackMethod = "fallbackStock")
@Retry(name = "inventory") // توجه: فقط روی exceptionهای قابل retry اعمال میشود
@Bulkhead(name = "inventory")
public int stockFor(String sku) {
return restClient.get()
.uri("/stock/{sku}", sku)
.retrieve()
.body(Integer.class);
}
// وقتی مدار OPEN است یا فراخوانی نهایتاً شکست میخورد صدا زده میشود.
// با ظرافت افت کن — یک پیشفرض امن برگردان، نه exception.
public int fallbackStock(String sku, Throwable t) {
if (t instanceof CallNotPermittedException) {
log.warn("مدار inventory باز است، موجودی کششده برای {} سرو میشود", sku);
}
return 0; // بهجای فروپاشی checkout، ناموجود در نظر بگیر
}
}
نکتهٔ حیاتی امضا: متد fallbackMethod باید دقیقاً همان امضای متد اصلی را داشته باشد بهعلاوهٔ یک پارامتر Throwable در انتها — وگرنه Resilience4j آن را پیدا نمیکند و در زمان اجرا خطا میدهد.
برای TimeLimiter متد باید CompletableFuture برگرداند (محدودساز زمان به یک مرز ناهمگامِ قابلقطع نیاز دارد — نمیتوانی یک فراخوانی بلاککنندهٔ JDBC را ایمن قطع کنی):
@TimeLimiter(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "asyncFallback")
public CompletableFuture<Integer> stockAsync(String sku) {
return CompletableFuture.supplyAsync(() -> stockFor(sku));
}
public CompletableFuture<Integer> asyncFallback(String sku, Throwable t) {
return CompletableFuture.completedFuture(0);
}
تلهٔ سنیور: ترتیب aspect بین Retry و CircuitBreaker
اینجا یکی از سؤالات محبوب مصاحبههای سنیور است. وقتی @Retry و @CircuitBreaker را روی هم میگذاری، کدام کدام را میپیچد (wrap میکند) معنا را کاملاً عوض میکند — و پیشفرض Spring Boot یک تلهٔ شناختهشده است.
ترتیب دکوراسیون پیشفرض (از بیرونی به درونی) این است:
Retry ▶ CircuitBreaker ▶ RateLimiter ▶ TimeLimiter ▶ Bulkhead ▶ method
پس بهصورت پیشفرض Retry بیرونیترین aspect است و CircuitBreaker را میپیچد. این معمولاً همان چیزی است که میخواهی: هر تلاش مجدد از مدار عبور و ثبت میشود؛ مدار نتیجهٔ هر تلاش را میبیند. اما یک تلهٔ مستند وجود دارد (resilience4j issue #2383): وقتی Retry بیرونِ مدار است، بهمحض OPEN شدن مدار، Retry مدام فراخوانیهایی را دوباره صادر میکند که مدار فوراً با CallNotPermittedException رد میکند — اتلاف تلاش، و اگر CallNotPermittedException در retry-exceptions تو باشد، بیفایده میچرخد. برعکس، اگر مدار را بیرونیترین کنی (retry درون آن)، انفجاری از retryها روی یک وابستگیِ لرزان میتواند هرکدام یک خطای جدا شمرده شود و مدار را خیلی سریع تریپ کند.
آن را صریح کنترل میکنی:
# مقدار order بالاتر = دکوراتور بیرونی. مدار را بیرونی و retry را درونی کن:
resilience4j.circuitbreaker.circuit-breaker-aspect-order=2
resilience4j.retry.retry-aspect-order=1
خطاهای گذرای شبکه را درون retry کن، و مدار را بیرون بگذار تا وقتی وابستگی واقعاً خراب است سریع شکست بخوری — و مطمئن شو CallNotPermittedException در مجموعهٔ قابلretry تو نیست. این یک جمله، پاسخ کاملِ آن سؤال سختِ مصاحبه است.
۶. ردیابی توزیعشده — Micrometer Tracing + Zipkin
تشبیه: بستهٔ پستی با کد رهگیری
وقتی یک بسته میفرستی، یک کد رهگیری (tracking number) میگیرد که در هر ایستگاه — انبار، کامیون، مرکز توزیع، درِ خانه — اسکن میشود. با آن یک کد میتوانی کل سفر را ببینی و بفهمی دقیقاً کجا گیر کرده. ردیابی توزیعشده همین است، برای یک درخواست.
مشکل واقعی: یک درخواست کاربر از gateway → order → inventory → payment پخش میشود. وقتی کند یا خراب است، لاگ یک سرویسِ تنها چیزی نمیگوید («سهم من درست بود»). به یک trace (رد) واحد نیاز داری که همهٔ spanها را به هم بدوزد.
در Spring Boot 3، Spring Cloud Sleuth رفته است؛ ردیابی به Micrometer Tracing منتقل شد (یک نما / facade روی Brave/Zipkin یا OpenTelemetry — یعنی یک لایهٔ یکسان که پشتش میتوانی موتورهای مختلف بگذاری). مفاهیم:
- Trace (رد): کل درخواست، که با یک
traceIdمشخص میشود (همان کد رهگیری بسته). - Span (بازه): یک واحد کار (یک فراخوانی HTTP، یک کوئری DB)، با
spanIdو یک والد (هر اسکن در یک ایستگاه). - انتشار context (context propagation):
traceId/spanIdدر هدرهای HTTP سوار میشوند (traceparentبرای استاندارد W3C یا هدرهای B3 برای Brave) تا سرویس بعدی همان trace را ادامه دهد نه اینکه یک رد جدید شروع کند.
وابستگیها (طعم Brave → Zipkin):
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
tracing:
sampling:
probability: 1.0 # در توسعه ۱۰۰٪؛ در تولید نمونهگیری کمتر (مثلاً 0.1)
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
trace id را به لاگها اضافه کن تا یک درخواست کامل را در سراسر سرویسها grep کنی:
logging:
pattern:
level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
خبر خوب: RestClient/WebClient/RestTemplateای که از builderهای auto-configured اسپرینگ ساخته شوند خودکار instrument میشوند — انتشار و ساخت span بدون حتی یک خط کد رخ میدهد. برای spanهای دستی (وقتی میخواهی یک بخش خاص کد را جداگانه اندازه بگیری):
@Autowired ObservationRegistry registry;
Observation.createNotStarted("inventory.reserve", registry)
.observe(() -> inventoryClient.reserve(sku)); // یک span فرزند میسازد
تلههای ردیابی
- نمونهگیری (sampling) یک بدهبستان هزینه/دید است. ۱۰۰٪ در تولید کالکتور (collector، سروری که spanها را جمع میکند) تو را له میکند؛ از نمونهگیری tail-based یا احتمالی استفاده کن، اما یکنواخت نگه دار تا یک trace نمونهگیریشده سرتاسر کامل باشد (نه اینکه وسطش قطع شود).
- context فقط در مرزهای instrumentشده خودکار منتشر میشود. thread poolهای دستی،
@Asyncیا صفهای پیام میتوانند context را گم کنند — باید executor را بپیچی (ContextExecutorService) یا هدرها را روی پیامها منتشر کنی. - ردیابی ≠ متریک ≠ لاگ. تنظیم پخته هر سه است که با
traceIdهمبستهاند (به این «سه ستونِ مشاهدهپذیری / three pillars» میگویند).
تلههای رایج و بهترینشیوهها
اینها را مثل چکلیستِ قبل از پرواز ببین:
- تابآوری اختیاری نیست. discovery تأخیر انتشار دارد؛ نمونهها بین heartbeatها میمیرند. هر فراخوانی شبکه به timeout + مدار + fallback نیاز دارد وگرنه آبشاری خراب میشوی.
- همهجا timeout بگذار. timeout پیشفرض
RestTemplate/کلاینت HTTP اغلب بینهایت است. نبود connect/read timeout رایجترین علتِ تمامشدن thread pool است. - عملیات غیر idempotent را کورکورانه retry نکن. («idempotent» یعنی عملیاتی که چند بار اجرایش همان اثرِ یک بار را دارد — مثل «مقدار را روی ۵ بگذار». اما «۵ واحد کم کن» idempotent نیست.) retry یک POST که موفق شده اما پاسخش گم شده = سفارش تکراری. از کلید idempotency استفاده کن.
- fallback باید افت کند نه دروغ بگوید. برگرداندن دادهٔ کششده/خالی خوب است؛ برگرداندن موفقیتِ جعلی که state را خراب کند نه.
- Config Server یک وابستگی است — اگر هنگام راهاندازی خراب باشد و از import غیر
optional:استفاده کرده باشی، اپها بوت نمیشوند. ازoptional:+ پیشفرض محلی معقول استفاده کن یا عمداً fail-fast باش. - گیتوی را نازک و non-blocking نگه دار. منطق کسبوکار به سرویسها تعلق دارد.
- BOM را قفل کن، هرگز نسخهٔ قطارها را قاطی نکن. ناهماهنگی نسخه بین artifactهای
spring-cloud-*یکی از رایجترین منابعNoSuchMethodErrorدر زمان اجراست.
سؤالات مصاحبه
حالا وقت آن است که هرچه یاد گرفتی را در قالب پرسشوپاسخ سنیور جمع کنیم. هر سؤال را اول خودت جواب بده، بعد پاسخ را باز کن.
Eureka دسترسپذیری و تحمل پارتیشن را ترجیح میدهد: بهجای بلاکشدن برای تضمین سازگاری، رجیستریِ شاید کهنه سرو میکند. نتیجه: کلاینت ممکن است نمونهای تازهمرده بگیرد، پس باید discovery را با قفل مدار/retry و timeout جفت کنی. اگر به سازگاری قوی نیاز داشتی (مثلاً انتخاب رهبر) یک ذخیرهٔ CP مانند ZooKeeper/Consul انتخاب میکردی.
پیشفرض Retry بیرونِ CircuitBreaker است (ترتیب: Retry ▶ CircuitBreaker ▶ RateLimiter ▶ TimeLimiter ▶ Bulkhead). وقتی مدار OPEN است فوراً CallNotPermittedException پرتاب میکند؛ اگر آن exception در retry-exceptions تو باشد، Retry مدام فراخوانیهایی را که مدار فوراً رد میکند دوباره میزند — کار بیهوده. رفع: تنظیم retry-aspect-order/circuit-breaker-aspect-order و حذف CallNotPermittedException از exceptionهای قابل retry.
پیکربندی دوباره گرفته میشود، EnvironmentChangeEvent منتشر میشود، و بینهای @RefreshScope بهعلاوهٔ @ConfigurationProperties بهصورت lazy بازساخته میشوند. یک @Value ساده روی سینگلتونی که refresh-scoped نیست یکبار در راهاندازی bind و منجمد میماند. همچنین refresh بدون Spring Cloud Bus (/busrefresh) فقط per-instance است.
گیتوی روی event loop نتی (چند نخ) اجرا میشود. یک فراخوانی بلاککننده (JDBC، HTTP بلاککننده) یک نخِ event-loop را اشغال میکند، پس زیر بار loop گرسنه میشود و throughput فرومیپاشد. از کلاینتهای واکنشی یا subscribeOn روی scheduler محدود استفاده کن.
CLOSED (فراخوانی عبور، خطا در پنجرهٔ لغزان شمرده). اگر نرخ خطا/فراخوانی کند ≥ آستانه → OPEN (رد فوری، شکست سریع). پس از waitDurationInOpenState → HALF_OPEN با اجازهٔ permitted-number-of-calls-in-half-open-state فراخوانی آزمایشی؛ موفقیت کافی → CLOSED، هر خطا → دوباره OPEN.
فقط روی متدهایی که CompletableFuture/نوع واکنشی برمیگردانند. محدودساز زمان future را لغو / تسک ناهمگام را در timeout قطع میکند — اما نمیتوان یک فراخوانی همگام بلاککنندهٔ JDBC/سوکت را ایمن قطع کرد، پس متد همگام ساده را نمیتواند محدود کند. برای فراخوانی همگام، read timeout خودِ کلاینت را تنظیم کن.
سمت کلاینت: فراخواننده فهرست نمونهها را از discovery نگه میدارد و یکی را انتخاب میکند — بدون پرش شبکهٔ اضافه، latency کمتر، اما هر کلاینت فقط با دید محلی توزیع میکند (بدون تصویر سراسری بار) و به کتابخانهٔ کلاینت نیاز دارد. سمت سرور (مثلاً یک LB/پروکسی) دید سراسری و کلاینتهای زبانناوابسته دارد اما یک پرش و یک جزء عملیاتی اضافه میکند.
Micrometer Tracing (نما روی Brave یا OpenTelemetry) جایگزین Sleuth شد. traceparent هدر W3C Trace Context است که trace-id/parent-span-id/flags را حمل میکند تا سرویس بعدی همان trace را ادامه دهد؛ Brave بهجایش از هدرهای B3 استفاده میکند.
نمونهگیری ۱۰۰٪ هر درخواست را ثبت میکند و Zipkin/کالکتور را غرق و سربار اضافه میکند. به مثلاً 0.1 کاهش بده، ایدهآل با نمونهگیری یکنواخت (head-based یا tail-based) تا traceهای نمونهگیریشده کامل باشند. ۱.۰ فقط در توسعه.
@Retry(name = "payment", fallbackMethod = "fb")
public PaymentResult charge(Order o) {
return paymentClient.charge(o); // POST، بدون کلید idempotency
}
پرداختها idempotent نیستند. اگر charge اول موفق شد اما پاسخش گم شد (timeout)، Retry یک charge دوم صادر میکند → دوبار برداشت. رفع: کلید idempotency مشتق از سفارش بفرست تا ارائهدهنده dedup کند؛ فقط روی خطاهای سطح اتصال retry کن نه timeoutهای مبهم.
Bulkhead همزمانی را محدود میکند (حداکثر فراخوانی درجریان همزمان) تا انزوای منابع بدهد — وابستگی کند فقط N نخ را میبندد. Rate Limiter گذردهی در طول زمان را محدود میکند (N فراخوانی در هر دوره) تا سهمیه را رعایت کند. یکی دربارهٔ همزمانی است، دیگری دربارهٔ فراوانی.
مسیر resolution فعالشده با @LoadBalanced: Spring Cloud LoadBalancer + یک DiscoveryClient (Eureka) تا نام منطقی به نمونههای زنده resolve شود که SCLB سپس آنها را توزیع کند. بدون discovery در classpath و پیکربندیشده، lb:// نمیتواند resolve شود.
با spring.config.import: "configserver:..." (غیر optional) بوت شکست میخورد — اپ راه نمیافتد. با optional:configserver:... با پیشفرضهای محلی ادامه میدهد. عمداً انتخاب کن: fail-fast تضمین میکند هرگز با پیکربندی غلط اجرا نمیشوی اما راهاندازی را به دسترسپذیری config server گره میزند؛ optional: + retry (spring.cloud.config.retry) + fallback محلی معقول، حد وسط تابآور است.
دفاع در عمق. گیتوی ترافیک شمال-جنوب (کلاینت→سیستم) را محافظت میکند، اما فراخوانیهای شرق-غرب (سرویس→سرویس) کاملاً از آن عبور نمیکنند. سرویسی که مستقیم سرویس دیگری را صدا میزند هنوز به قفل مدار/timeout خودش نیاز دارد وگرنه محافظت گیتوی برای آبشارهای داخلی بیربط است.
Ribbon → Spring Cloud LoadBalancer؛ Hystrix → Resilience4j (از طریق Spring Cloud CircuitBreaker)؛ Zuul 1 → Spring Cloud Gateway؛ Sleuth → Micrometer Tracing. پیشنهاد مردهها نشان میدهد پشتهٔ فعلی را لمس نکردهای.
لحظهای که مونولیت را میشکنی، JVM دیگر «کجاست، چطور صدا بزنم، اگر خراب شد چه» را رایگان حل نمیکند؛ اینها مسائل سیستم توزیعشده میشوند و Spring Cloud برای هرکدام یک قطعه دارد.
کشف سرویس (Eureka) دفترچهٔ تلفنِ زنده است و از نوع AP — پس همیشه به تابآوری نیاز دارد.
Config Server تابلوی اعلانات مرکزی پشت Git است؛ با @RefreshScope و Bus زنده بهروزرسانی میشود.
Gateway میز پذیرش واحد است، واکنشی و non-blocking — هرگز در فیلترش بلاک نکن.
Spring Cloud LoadBalancer بار را سمت کلاینت پخش میکند، بدون پرش اضافه.
Resilience4j فیوزِ خانه است: circuit breaker، retry، bulkhead، rate limiter، time limiter — و مراقب ترتیب aspect بین Retry و CircuitBreaker باش.
Micrometer Tracing + Zipkin کد رهگیریِ بسته است که یک درخواست را در سراسر سرویسها به هم میدوزد.
و همیشه: timeout همهجا، BOM را قفل کن، و پروژههای مرده (Ribbon/Hystrix/Zuul 1/Sleuth) را دفنشده بدان.
Picture a small restaurant with everything under one roof: chef, cashier, pantry, waiter. Need something from the pantry? A few steps away. Now turn that restaurant into a ten-branch chain across the city. Suddenly things that were "free" and obvious — "where's the pantry?", "what's today's price?", "what if one branch catches fire?" — become real, hard problems.
Splitting a monolith (an app where everything runs in a single process) into microservices (small, independent services) is exactly this. This lesson is about the tools Spring Cloud hands you for each of these newly-born problems.
First we build a mental model: what the monolith gave you for free and what you lose in microservices. Then you learn Spring Cloud's six building blocks, one at a time and from scratch:
- Service discovery (Eureka) — "where is the service?"
- Config Server — "central, live configuration everywhere."
- API Gateway — "a single front door."
- Client-side load balancing (Spring Cloud LoadBalancer) — "spread calls across instances."
- Resilience (Resilience4j) — "when something breaks, don't die in a cascade."
- Distributed tracing (Micrometer Tracing + Zipkin) — "follow one request across all services." At the end, a full senior interview section with worked answers.
Mental model: what problems does Spring Cloud actually solve?
Let's build the feel of the problem first. In a monolith, when method A called method B, it was just an in-process call — a few nanoseconds, never failed, never left things "half done." The JVM handed you these guarantees for free. The moment you move B to a separate service on another machine, that same call now crosses the network: it can slow down, time out, or die mid-flight.
The table below shows exactly how each "free gift" of the monolith turns into a microservice problem, and which Spring Cloud component answers it:
| Monolith gave you… | In microservices you now need… | Spring Cloud component |
|---|---|---|
| In-process method call | Find where a service lives | Service discovery (Eureka / DiscoveryClient) |
One application.properties |
Config for N services, N envs, changeable at runtime | Config Server + @RefreshScope |
| One front door (the servlet) | A single ingress with auth, routing, rate limits | Spring Cloud Gateway |
| A local method never "fails partway" | Calls that time out, fail, or cascade | Resilience4j (circuit breaker, retry, bulkhead, rate limiter, time limiter) |
| A single stack trace | One request spanning 6 services | Micrometer Tracing + Zipkin |
| The load balancer was "the CPU scheduler" | Spreading calls across N instances | Spring Cloud LoadBalancer |
A common mistake is thinking Spring Cloud is one framework like Spring MVC. It isn't. Think of it as a "coordinated toolbox" a manufacturer packaged so all the tools fit together. In the Spring world this package is called a release train or a BOM (Bill of Materials — a list that pins compatible versions for every piece).
Spring Cloud is not one framework; it's a release train (BOM) that version-aligns many independent projects. Each train is tied to a specific Spring Boot version. As of mid-2025 the relevant trains are (the names are London Underground stations — a team in-joke):
- 2024.0.x ("Moorgate") → Spring Boot 3.4.x
- 2025.0.0 ("Northfields") → Spring Boot 3.5.x
- 2025.1.x ("Oakwood") → Spring Boot 4.0.x
Key point: you should never hand-write the version of individual spring-cloud-* artifacts. Pin the train via the BOM and let it choose the right version of every piece:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2024.0.1</version> <!-- matches your Boot 3.4.x -->
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
If you know the old generation of Spring Cloud, these names are familiar — but all are removed or EOL (End Of Life — no longer supported or patched) in the Boot 3 line: Netflix Ribbon, Hystrix, Zuul 1, and Spring Cloud Sleuth. Their replacements: Spring Cloud LoadBalancer, Resilience4j (via Spring Cloud CircuitBreaker), Spring Cloud Gateway, and Micrometer Tracing. If an interviewer mentions Hystrix or Ribbon, the senior answer is: "those are dead; here's what replaced them and why."
1. Service discovery (Eureka)
Analogy: a living phone book
Imagine you want to call a friend, but his number changes every week. A fixed number written on paper goes stale fast. The fix? A central, living phone book where anyone whose number just changed re-registers themselves immediately; and instead of memorizing the number, you ask the book each time: "what's so-and-so's number right now?"
Real problem: service instances are ephemeral (short-lived, disposable) — autoscaling, restarts, and container schedulers hand out new IPs constantly. Hard-coding http://10.0.3.14:8080 is a fantasy; that IP won't exist tomorrow.
Model: a registry (Eureka Server) — the central phone book. Instances register with it on startup and heartbeat to it (a heartbeat is a small "I'm still alive" ping). Clients query (or cache) the registry to resolve a logical name like order-service to a live host list.
Eureka server
First we build the phone book itself. One annotation is enough:
@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
public static void main(String[] args) {
SpringApplication.run(DiscoveryServerApplication.class, args);
}
}
# discovery-server application.yml
server:
port: 8761
eureka:
client:
register-with-eureka: false # the server doesn't register with itself
fetch-registry: false
server:
# In dev, disable self-preservation so dead instances evict quickly.
# In prod, LEAVE IT ON — it protects against mass-eviction during network blips.
enable-self-preservation: true
Notice the server sets register-with-eureka: false — "I am the phone book, I don't list myself in myself." Port 8761 is Eureka's well-known convention.
A client that registers
Now a real service that wants to be listed:
# order-service application.yml
spring:
application:
name: order-service # THIS becomes the registry key clients look up
eureka:
client:
service-url:
defaultZone: http://localhost:8761/eureka/
instance:
prefer-ip-address: true # register IP, not hostname (important in containers)
lease-renewal-interval-in-seconds: 10 # heartbeat cadence
That line spring.application.name: order-service is the heart of it: this name is the key others use to find you. Nicely, just adding the spring-cloud-starter-netflix-eureka-client dependency auto-registers the app; in modern versions no annotation is required.
Imagine the phone book operator suddenly sees half the city stop sending "I'm alive." Two readings are possible: either half the city really died (unlikely), or the operator's own cable got cut so the pings aren't arriving (more likely). Eureka enters self-preservation here: it assumes the fault is its own network, so it stops evicting instances rather than wrongly wiping everyone. A lifesaver in prod; maddening in dev, where you see "zombie" instances (dead but still listed).
Senior nuances
This is where senior answers diverge from junior ones:
- Eureka is AP, not CP. This comes from the CAP theorem. CAP says that in a distributed system, when the network partitions (parts get cut off from each other), you can only have two of three: Consistency (everyone always sees one answer), Availability (you always get an answer), Partition tolerance. Since partitions are unavoidable, you effectively choose between C and A. Eureka picks Availability: rather than blocking, it serves a possibly stale registry. This is a deliberate choice — you tolerate calling a just-dead instance (your circuit breaker handles that) in exchange for the registry never being a single point of blocking failure. Consul and ZooKeeper lean CP (they prefer consistency).
- Self-preservation mode: if Eureka stops receiving the expected number of heartbeats (e.g., a network partition), it stops evicting instances, assuming the network — not the instances — failed. Great in prod, maddening in dev.
- Client-side caching: clients fetch the registry and cache it (default 30s refresh). So there's always a propagation delay between an instance dying and clients noticing — another reason resilience patterns are mandatory, not optional.
That "30s cache" means there's always a window where an instance is dead but clients still think it's alive and call it. So discovery alone is never enough; it must be paired with timeouts and a circuit breaker (Section 5). Burn this in: discovery tells you where; resilience decides what to do when nobody's home.
Ali's setup: the Eureka server is the backbone that lets the gateway and inter-service calls address lb://order-service instead of IPs — the same discovery registry the gateway's DiscoveryClient route locator reads from.
2. Config Server & centralized config
Analogy: the company's central notice board
Say you run a company with 50 employees and each must know "what are today's working hours?" The dumb way: hand each employee a paper slip to keep; changing the hours means editing 50 slips. The right way: a central notice board everyone reads from; you change it once, everyone sees it.
Real problem: N services × M environments = an explosion of .properties files. You also want to change config without redeploying and keep secrets (like DB passwords) out of your Docker images.
Model: a Config Server backs onto a Git repo (or Vault) and plays the role of that notice board. Clients fetch their config at startup along three axes: {application} (which service), {profile} (which environment, e.g. dev/prod), {label} (which Git branch).
Server
@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication { /* ... */ }
# config-server application.yml
server:
port: 8888
spring:
cloud:
config:
server:
git:
uri: https://github.com/acme/config-repo
default-label: main
search-paths: '{application}' # one folder per service
The beautiful part: the source of truth is a Git repo. That means every config change has history, review, and rollback — just like code. The repo layout drives resolution:
config-repo/
├── application.yml # shared defaults for ALL services
├── order-service.yml # order-service, all profiles
├── order-service-prod.yml # order-service, prod profile (overrides)
└── gateway.yml
application.yml is like "company-wide rules" that apply to everyone; order-service-prod.yml is like "an exception for this department in this situation" that overrides the general rules.
Client
Since Spring Boot 2.4+, use spring.config.import (the old bootstrap.yml requires the legacy bootstrap starter):
# order-service application.yml
spring:
application:
name: order-service
config:
import: "optional:configserver:http://localhost:8888"
Property precedence matters: Config Server values override local application.yml — that's the whole point, so a central change always wins. (Note that optional: at the start of the string; you'll see in the pitfalls why it's crucial.)
Runtime refresh
Here's an important subtlety: Config Server alone does not push changes. You changed the notice board, but the employees haven't looked at it yet. Clients must be told to re-pull. Two mechanisms:
@RefreshScope+/actuator/refresh— beans annotated@RefreshScopeare destroyed and lazily recreated (lazily = on the next access) with new config after a POST to/actuator/refresh.- Spring Cloud Bus — broadcasts a
/actuator/busrefreshover a message broker (a messaging middleman like RabbitMQ/Kafka) so you refresh all instances with one call instead of hitting each.
@RestControllerAdvice
@RefreshScope // this bean gets rebuilt on refresh; without it, @Value is frozen at startup
public class FeatureFlags {
@Value("${features.new-checkout:false}")
private boolean newCheckoutEnabled;
public boolean newCheckout() { return newCheckoutEnabled; }
}
An ordinary bean is like an employee who got an info slip on day one and memorized it; no matter how often you change the board, he keeps reciting what he memorized. @RefreshScope changes that: instead of trying to change that employee's mind, on refresh you simply hire a fresh employee who reads the new slip. That's why a @Value on a bean without @RefreshScope stays frozen forever — that employee is never replaced.
Gotchas
- A raw
@Valuefield on a non-refresh-scoped bean stays frozen forever. But@ConfigurationPropertiesbeans are rebound on refresh by default (they participate in the environment change event). - Refresh is not atomic across instances without the Bus — you'll have a window where instances disagree (some new config, some old).
- Never put secrets in plain Git. Use
{cipher}-encrypted values (server-side symmetric/asymmetric key) or, better, HashiCorp Vault as a backend.
3. API Gateway — Spring Cloud Gateway
Analogy: the reception desk of a big office building
Picture an office tower with dozens of companies. If every visitor wandered up to any floor, chaos. Instead you have a reception desk at the entrance: it checks IDs (authentication), directs you to the right floor (routing), and holds back the crowd when it's busy (rate limiting). No individual company needs to hire its own guard.
Real problem: you need one ingress that handles routing, cross-cutting auth, header manipulation, rate limiting, and TLS termination (decrypting HTTPS right here) — so each service doesn't reimplement it.
Spring Cloud Gateway is reactive (built on Spring WebFlux / Netty). Reactive means non-blocking: instead of each request tying up a thread to completion, a few threads on an event loop juggle thousands of requests. So it handles high concurrency with few threads. (There is also a newer server-web-mvc variant for blocking stacks, but the reactive one is canonical.)
Core abstractions
Three words to know:
- Route = id + destination URI + predicates (conditions: when does this route match?) + filters (mutate request/response).
- Predicate: Path, Method, Header, Host, Query, After/Before/Between (time), Weight (for canary — sending, say, 5% of traffic to a new version).
- Filter: AddRequestHeader, RewritePath, CircuitBreaker, RequestRateLimiter, Retry, and custom
GlobalFilters.
# gateway application.yml
spring:
cloud:
gateway:
discovery:
locator:
enabled: true # auto-create routes from Eureka service ids
lower-case-service-id: true
routes:
- id: order-service
uri: lb://order-service # lb:// = resolve via LoadBalancer + discovery
predicates:
- Path=/api/orders/**
filters:
- RewritePath=/api/orders/(?<segment>.*), /$\{segment}
- name: CircuitBreaker
args:
name: ordersCB
fallbackUri: forward:/fallback/orders
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # tokens/sec
redis-rate-limiter.burstCapacity: 20
key-resolver: "#{@userKeyResolver}"
That lb://order-service is the payoff of discovery + load balancing: the lb:// prefix (short for load-balanced) tells the gateway "this isn't an IP, it's a logical name; resolve it from the registry to a live instance and spread the load."
A custom global filter (auth / correlation id)
A global filter is like a guard every visitor must pass:
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token == null || !token.startsWith("Bearer ")) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete(); // short-circuit; do NOT call chain
}
// enrich the request going downstream
ServerHttpRequest mutated = exchange.getRequest().mutate()
.header("X-User-Id", extractUserId(token))
.build();
return chain.filter(exchange.mutate().request(mutated).build());
}
@Override public int getOrder() { return -100; } // run before routing filters
private String extractUserId(String token) { /* verify + parse JWT */ return "u123"; }
}
Note that setComplete(): when the token is invalid, it closes the response right here and does not call chain.filter(...) — so the request never reaches the target service. This is called "short-circuiting."
Key resolver bean for the rate limiter (rate-limit per user, not globally):
@Bean
KeyResolver userKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getHeaders().getFirst("X-User-Id"));
}
This is the number-one gateway gotcha. You're on the Netty event loop, which has only a few threads meant to juggle thousands of requests. A blocking call (like JDBC or a blocking RestTemplate) in a filter locks up one of those precious threads; under load the loop starves and throughput collapses. Picture four rotating guards and one of them freezing mid-hall — the whole queue stalls. The fix: use reactive clients, or offload heavy work to a bounded scheduler.
Gateway gotchas
- Never block in a filter. (Explained above.)
- Filter order matters (
Ordered); global auth must precede routing/rewrite filters — just as the guard must come before the floor guide. - The gateway is a natural chokepoint — put your coarse rate limiting and circuit breaking here, but keep fine-grained resilience inside services too (defense in depth).
Ali's setup: the gateway is the site's single ingress; routes use lb:// names resolved through Eureka, with a CircuitBreaker filter fronting each downstream so a dead service returns a fallback instead of a hung request.
4. Client-side load balancing — Spring Cloud LoadBalancer
Analogy: pick your own line, no middleman
There are two load-balancing models. The server-side model is like a bank with a central teller who tells you "go to window 3." The client-side model is like a supermarket where you see all the lines yourself and pick one — no middleman, faster, but only with your own view (you don't know how crowded the next lane just got).
Ribbon is gone; Spring Cloud LoadBalancer (SCLB) is the replacement. "Client-side" means the caller holds the instance list (from discovery) and picks one itself — no dedicated LB hop.
@Configuration
public class ClientConfig {
@Bean
@LoadBalanced // this annotation is what makes lb:// resolution work
RestClient.Builder restClientBuilder() {
return RestClient.builder();
}
}
// Usage — "order-service" is a Eureka logical name, resolved + balanced automatically:
String body = restClient.get()
.uri("http://order-service/orders/{id}", id)
.retrieve()
.body(String.class);
That @LoadBalanced is the magic: without it, order-service is treated as a real hostname and fails. With it, Spring takes the name, asks discovery for the instance list, and picks one.
The default strategy is round-robin (take turns, one after another in order); you can swap in RandomLoadBalancer or a custom ReactorLoadBalancer. SCLB also supports health-check filtering (only pick healthy instances) and zone-aware filtering (prefer instances in the same geographic zone for lower latency). Because it's client-side, each caller balances independently — great for latency (no extra hop) but means no global view of load.
5. Resilience — Resilience4j
Analogy: the fuse in your home
Your house has a circuit breaker (fuse). When a device short-circuits, the fuse trips instantly and cuts the circuit — not out of cruelty, but because if current kept flowing, the whole house would burn. That's exactly the logic of resilience in a distributed system.
Real problem: in a distributed system, partial failure is the normal case. And the subtle bit: a slow downstream is worse than one that's fully down. Why? A dead service errors instantly and frees the thread; but a slow service holds each call for seconds, threads pile up, and you get cascading failure — one service's failure drags down its caller, which drags down its caller, all the way up.
Resilience4j gives you composable, lightweight (functional, no thread pool by default) decorators. The five patterns and the problem each solves:
| Pattern | Problem it solves |
|---|---|
| Circuit Breaker | Stop hammering a failing dependency; fail fast and give it time to recover |
| Retry | Ride out transient blips (a dropped packet, a brief GC pause) |
| Bulkhead | Cap concurrent calls so one slow dependency can't exhaust all threads |
| Rate Limiter | Respect a downstream quota / protect it from your own bursts |
| Time Limiter | Bound how long you'll wait (only meaningful with async/CompletableFuture) |
The word bulkhead comes from ship engineering: a hull is split into watertight compartments, so if one is breached and floods, water can't reach the others and the ship stays afloat. In software, a Bulkhead caps the number of concurrent calls, so if one dependency goes slow, only N threads get stuck — not all of them. The rest of the app stays afloat.
Circuit breaker states
The circuit breaker is a state machine with three states:
failure rate >= threshold
CLOSED ─────────────────────────▶ OPEN
▲ │ wait duration elapses
│ success rate ok ▼
└──────────────── HALF_OPEN ◀─────┘
(allow N trial calls)
- CLOSED: calls pass (like a healthy fuse letting current through); failures are counted in a sliding window. This window can be count-based (over the last N calls) or time-based (over the last time span).
- OPEN: calls are rejected instantly with
CallNotPermittedException(this is "fail fast" — the fuse has tripped); afterwaitDurationInOpenState, → HALF_OPEN. - HALF_OPEN: a limited number of probe calls are let through to see if the dependency recovered; enough successes → CLOSED, a failure → back to OPEN. Like cautiously flipping the fuse back on to see if it trips again.
Configuration
resilience4j:
circuitbreaker:
instances:
inventory:
sliding-window-type: COUNT_BASED
sliding-window-size: 20
failure-rate-threshold: 50 # % failures to trip
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 80 # % slow calls also trips it
wait-duration-in-open-state: 10s
permitted-number-of-calls-in-half-open-state: 3
# count these as failures (don't count e.g. 404 as a fault):
record-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
retry:
instances:
inventory:
max-attempts: 3
wait-duration: 200ms
enable-exponential-backoff: true
exponential-backoff-multiplier: 2
retry-exceptions:
- java.io.IOException
bulkhead:
instances:
inventory:
max-concurrent-calls: 25
ratelimiter:
instances:
inventory:
limit-for-period: 100 # 100 calls...
limit-refresh-period: 1s # ...per second
timeout-duration: 0 # don't wait for a permit; fail fast
timelimiter:
instances:
inventory:
timeout-duration: 3s
cancel-running-future: true
Note slow-call-rate-threshold: the breaker doesn't only trip on explicit errors — it also trips if 80% of calls are slow, because as we said, slow is more dangerous than dead. And enable-exponential-backoff means it doubles the gap between retry attempts (200ms, 400ms, 800ms) to give the shaky dependency room to breathe.
Real code example: layered resilience with a fallback
@Service
public class InventoryClient {
private final RestClient restClient;
public InventoryClient(RestClient.Builder lbBuilder) {
this.restClient = lbBuilder.baseUrl("http://inventory-service").build();
}
// Annotations stack; each names a config instance above.
// 'fallbackMethod' MUST have the same signature + a trailing Throwable param.
@CircuitBreaker(name = "inventory", fallbackMethod = "fallbackStock")
@Retry(name = "inventory") // note: applies ONLY to retryable exceptions
@Bulkhead(name = "inventory")
public int stockFor(String sku) {
return restClient.get()
.uri("/stock/{sku}", sku)
.retrieve()
.body(Integer.class);
}
// Called when the circuit is OPEN or the call ultimately fails.
// Degrade gracefully — return a safe default, not an exception.
public int fallbackStock(String sku, Throwable t) {
// Distinguish "circuit open" from a real error if you want different behavior:
if (t instanceof CallNotPermittedException) {
log.warn("inventory circuit OPEN, serving cached stock for {}", sku);
}
return 0; // treat as out-of-stock rather than crashing checkout
}
}
Critical signature rule: the fallbackMethod must have exactly the same signature as the original plus a trailing Throwable parameter — otherwise Resilience4j can't find it and errors at runtime.
For TimeLimiter the method must return a CompletableFuture (the time limiter needs an interruptible async boundary — you can't safely interrupt a blocking JDBC call):
@TimeLimiter(name = "inventory")
@CircuitBreaker(name = "inventory", fallbackMethod = "asyncFallback")
public CompletableFuture<Integer> stockAsync(String sku) {
return CompletableFuture.supplyAsync(() -> stockFor(sku));
}
public CompletableFuture<Integer> asyncFallback(String sku, Throwable t) {
return CompletableFuture.completedFuture(0);
}
The senior gotcha: aspect ordering of Retry vs CircuitBreaker
This is a favorite senior interview question. When you stack @Retry and @CircuitBreaker, which wraps which completely changes the semantics — and the Spring Boot default is a well-known trap.
The default decoration order (outer → inner) is:
Retry ▶ CircuitBreaker ▶ RateLimiter ▶ TimeLimiter ▶ Bulkhead ▶ method
So by default Retry is the outermost aspect and wraps CircuitBreaker. That is usually what you want: each retry attempt passes through the breaker and is recorded; the breaker sees per-attempt outcomes. But there's a documented pitfall (resilience4j issue #2383): with Retry outside the breaker, once the breaker is OPEN, Retry will keep re-issuing calls that the breaker instantly rejects with CallNotPermittedException — wasting attempts and, if CallNotPermittedException is in your retry-exceptions, spinning pointlessly. Conversely, if you put the breaker outermost (retry inside), a burst of retries against a flaky dependency can each be counted as a separate failure and trip the breaker far too fast.
You control it explicitly:
# Higher order value = OUTER decorator. Make the breaker outer, retry inner:
resilience4j.circuitbreaker.circuit-breaker-aspect-order=2
resilience4j.retry.retry-aspect-order=1
Retry transient network errors inside, let the breaker sit outside to fail fast when the dependency is genuinely down — and make sure CallNotPermittedException is not in your retryable set. That one sentence is the full answer to that hard interview question.
6. Distributed tracing — Micrometer Tracing + Zipkin
Analogy: a parcel with a tracking number
When you ship a parcel it gets a tracking number that's scanned at every stop — warehouse, truck, sorting center, your door. With that one number you can see the whole journey and pinpoint exactly where it got stuck. Distributed tracing is that, for a request.
Real problem: one user request fans out through gateway → order → inventory → payment. When it's slow or broken, a single-service log tells you nothing ("my part was fine"). You need one trace stitching all spans together.
In Spring Boot 3, Spring Cloud Sleuth is gone; tracing moved into Micrometer Tracing (a facade over Brave/Zipkin or OpenTelemetry — a uniform layer you can put different engines behind). Concepts:
- Trace: the whole request, identified by a
traceId(the parcel's tracking number). - Span: one unit of work (an HTTP call, a DB query), with a
spanIdand a parent (each scan at a stop). - Context propagation: the
traceId/spanIdride along in HTTP headers (traceparentfor the W3C standard, or B3 headers for Brave) so the next service continues the same trace instead of starting a fresh one.
Dependencies (Brave → Zipkin flavor):
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.zipkin.reporter2</groupId>
<artifactId>zipkin-reporter-brave</artifactId>
</dependency>
management:
tracing:
sampling:
probability: 1.0 # trace 100% in dev; sample down (e.g. 0.1) in prod for volume
zipkin:
tracing:
endpoint: http://localhost:9411/api/v2/spans
Add trace ids to logs so you can grep a whole request across services:
logging:
pattern:
level: "%5p [${spring.application.name},%X{traceId:-},%X{spanId:-}]"
Good news: RestClient/WebClient/RestTemplate built from Spring's auto-configured builders are auto-instrumented — propagation and span creation happen without a single line of code. For manual spans (when you want to measure a specific piece of code separately):
@Autowired ObservationRegistry registry;
Observation.createNotStarted("inventory.reserve", registry)
.observe(() -> inventoryClient.reserve(sku)); // creates a child span
Tracing gotchas
- Sampling is a cost/visibility tradeoff. 100% in prod will crush your collector (the server that gathers spans); use tail-based or probabilistic sampling, but keep it consistent so a sampled trace is complete end-to-end (not cut off halfway).
- Trace context propagates automatically only across instrumented boundaries. Manual thread pools,
@Async, or message queues can lose context — you must wrap the executor (ContextExecutorService) or propagate headers on messages. - Tracing ≠ metrics ≠ logs. The mature setup is all three correlated by
traceId(the "three pillars" of observability).
Common pitfalls & best practices
Treat these like a pre-flight checklist:
- Resilience is not optional. Discovery has propagation lag; instances die between heartbeats. Every network call needs a timeout + breaker + fallback, or you'll cascade.
- Set timeouts everywhere. The default
RestTemplate/HTTP client timeout is often infinite. A missing connect/read timeout is the single most common cause of thread-pool exhaustion. - Don't retry non-idempotent operations blindly. ("Idempotent" means an operation whose repeated execution has the same effect as running it once — like "set the value to 5." But "subtract 5" is not idempotent.) Retrying a POST that already succeeded but whose response was lost = duplicate orders. Use idempotency keys.
- Fallbacks should degrade, not lie. Returning cached/empty data is fine; returning fabricated success that corrupts state is not.
- Config Server is a dependency — if it's down at startup and you used a non-
optional:import, apps won't boot. Useoptional:+ sane local defaults, or fail-fast intentionally. - Keep the gateway thin and non-blocking. Business logic belongs in services.
- Pin the BOM, never mix train versions. Version skew across
spring-cloud-*artifacts is a top source ofNoSuchMethodErrorat runtime.
Interview Questions
Now let's fold everything you learned into senior-style Q&A. Try answering each yourself first, then open the answer.
Eureka favors Availability and Partition-tolerance: it serves a possibly-stale registry instead of blocking to guarantee consistency. Consequence: a client may get an instance that just died, so you must pair discovery with circuit breakers/retries and timeouts. If you needed strong consistency (e.g., leader election), you'd pick a CP store like ZooKeeper/Consul instead.
Default is Retry outside CircuitBreaker (aspect order: Retry ▶ CircuitBreaker ▶ RateLimiter ▶ TimeLimiter ▶ Bulkhead). When the breaker is OPEN it throws CallNotPermittedException instantly; if that exception is in your retry-exceptions, Retry keeps re-attempting calls the breaker instantly rejects — wasted work. Fix by tuning retry-aspect-order/circuit-breaker-aspect-order and excluding CallNotPermittedException from retryable exceptions.
It re-fetches config, publishes an EnvironmentChangeEvent, and rebuilds @RefreshScope beans plus @ConfigurationProperties beans lazily. A plain @Value on a non-refresh-scoped singleton is bound once at startup and stays frozen. Also, refresh is per-instance unless you use Spring Cloud Bus (/busrefresh) to broadcast.
Gateway runs on Netty's event loop (a few threads). A blocking call (JDBC, blocking HTTP) occupies an event-loop thread, so under load the loop starves and throughput collapses. Use reactive clients or subscribeOn a bounded scheduler.
CLOSED (calls pass, failures counted in a sliding window). If failure/slow-call rate ≥ threshold → OPEN (reject instantly, fail fast). After waitDurationInOpenState → HALF_OPEN, allowing permitted-number-of-calls-in-half-open-state probe calls; if enough succeed → CLOSED, any failure → OPEN again.
Only on methods returning a CompletableFuture/reactive type. Resilience4j's time limiter cancels the future / interrupts the async task at the timeout — but you cannot safely interrupt a blocking synchronous JDBC/socket call, so a plain synchronous method can't be time-limited by it. For sync calls, set the client's own read timeout.
Client-side: the caller holds the instance list from discovery and picks one — no extra network hop, lower latency, but each client balances with only its local view (no global load picture) and needs the client library. Server-side (e.g., an LB/proxy) has a global view and language-agnostic clients, but adds a hop and a component to operate.
Micrometer Tracing (a facade over Brave or OpenTelemetry) replaced Sleuth. traceparent is the W3C Trace Context header carrying trace-id/parent-span-id/flags so the next service continues the same trace; Brave alternatively uses B3 headers.
100% sampling records every request, overwhelming the Zipkin/collector and adding overhead. Reduce to e.g. 0.1, ideally with consistent (head-based or tail-based) sampling so sampled traces are complete. Keep 1.0 only in dev.
@Retry(name = "payment", fallbackMethod = "fb")
public PaymentResult charge(Order o) {
return paymentClient.charge(o); // POST, no idempotency key
}
Payments are non-idempotent. If the first charge succeeded but the response was lost (timeout), Retry issues a second charge → double charge. Fix: send an idempotency key derived from the order so the payment provider dedupes; only retry on connection-level errors, not on ambiguous timeouts.
Bulkhead limits concurrency (max in-flight calls at once) to isolate resource exhaustion — a slow dependency can only tie up N threads. Rate Limiter limits throughput over time (N calls per period) to respect a quota. One is about simultaneity, the other about frequency.
A @LoadBalanced-enabled resolution path: Spring Cloud LoadBalancer + a DiscoveryClient (Eureka) so the logical name resolves to live instances, which SCLB then balances. Without discovery on the classpath and configured, lb:// can't resolve.
With spring.config.import: "configserver:..." (non-optional), boot fails — the app won't start. With optional:configserver:... it proceeds using local defaults. Choose deliberately: fail-fast guarantees you never run with wrong config, but couples startup to the config server's availability; optional: + retry (spring.cloud.config.retry) + sane local fallbacks is the resilient middle ground.
Defense in depth. The gateway protects north-south (client→system) traffic, but east-west (service→service) calls bypass it entirely. A service calling another service directly still needs its own breakers/timeouts, or the gateway's protection is irrelevant to internal cascades.
Ribbon → Spring Cloud LoadBalancer; Hystrix → Resilience4j (via Spring Cloud CircuitBreaker); Zuul 1 → Spring Cloud Gateway; Sleuth → Micrometer Tracing. Proposing the dead ones signals you haven't touched the current stack.
The moment you split the monolith, the JVM stops solving "where is it, how do I call it, what if it fails" for free; these become distributed-systems problems, and Spring Cloud has a piece for each.
Service discovery (Eureka) is the living phone book, and it's AP — so it always needs resilience next to it.
Config Server is the central notice board backed by Git; it updates live via @RefreshScope and the Bus.
Gateway is the single reception desk, reactive and non-blocking — never block in its filters.
Spring Cloud LoadBalancer spreads load client-side, with no extra hop.
Resilience4j is your home fuse: circuit breaker, retry, bulkhead, rate limiter, time limiter — and mind the aspect order between Retry and CircuitBreaker.
Micrometer Tracing + Zipkin is the parcel tracking number that stitches one request across all services.
And always: timeouts everywhere, pin the BOM, and treat the dead projects (Ribbon/Hystrix/Zuul 1/Sleuth) as buried.