معالجة الأعطال بين الخدمات
في الأنظمة الموزّعة، العطل ليس استثناءً — بل هو قيد تصميمي. أي استدعاء شبكي بين خدمتين قد يكون بطيئًا أو ينتهي مهلته أو يُعيد خطأً. السؤال الحقيقي ليس هل سيعاني نظامك من أعطال جزئية، بل كيف يتعامل معها بشكل لطيف. يغطّي هذا الدرس ثلاث تقنيات أساسية تحتاجها كل خدمة مبنية بـ Spring Boot: ضبط المهلات الزمنية، ومعالجة الأخطاء برمجيًا، وتطبيق التدهّر اللطيف حتى لا يتسبّب عطل في خدمة واحدة بانهيار النظام بأكمله.
لماذا تختلف أعطال الأنظمة الموزّعة
في التطبيق المتراص (monolith)، استدعاء الدالة إما يُعيد نتيجة أو يرمي استثناءً على الفور. أما استدعاء HTTP عن بُعد فيُضيف نتيجةً ثالثة: قد يتعلّق إلى أجل غير مسمى. بدون مهلة زمنية صريحة، يحتجز الخيط المنتظر لخدمة بطيئة مواردَه — اتصال من HikariCP وخيط من مجموعة طلبات servlet — حتى ينفد الخادم من طاقته. قد تُسبّب تبعية واحدة بطيئة انهيار خدمة سليمة تمامًا.
نمطا الفشل اللذان يجب الدفاع ضدهما: (1) التأخّر (latency) — ينتهي الاستدعاء في نهاية المطاف لكن بشكل بطيء جدًا؛ (2) الخطأ (error) — يُعيد الاستدعاء حالة 5xx أو يرمي استثناءً. المهلات الزمنية تُعالج التأخّر، ومعالجة الأخطاء تُعالج الحالة الثانية. كلتاهما ضروريتان.
ضبط المهلات الزمنية مع WebClient
يتيح لك WebClient التفاعلي في Spring Boot ضبط المهلات على مستويين: مستوى اتصال TCP (المدة اللازمة لإتمام المصافحة) ومستوى الاستجابة (المدة اللازمة لتلقّي الاستجابة الكاملة).
import io.netty.channel.ChannelOption;
import io.netty.handler.timeout.ReadTimeoutHandler;
import io.netty.handler.timeout.WriteTimeoutHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
public class WebClientConfig {
@Bean
public WebClient inventoryClient() {
HttpClient httpClient = HttpClient.create()
// مستوى TCP: إلغاء الاتصال إن لم يُنشأ خلال 2 ثانية
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
// مهلتا القراءة والكتابة على القناة
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(5, TimeUnit.SECONDS))
.addHandlerLast(new WriteTimeoutHandler(5, TimeUnit.SECONDS)));
return WebClient.builder()
.baseUrl("http://inventory-service")
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
}
لـ RestClient المتزامن (Spring Boot 3.2+)، الحل المعادل هو استخدام SimpleClientHttpRequestFactory أو مصنع Apache HttpClient مع ضبط مهلة الاتصال والقراءة عبر setConnectTimeout و setReadTimeout.
اضبط دائمًا مهلتَي الاتصال والقراءة معًا. مهلة الاتصال تُكتشف بها الأجهزة الميتة؛ مهلة القراءة تُكتشف بها الأجهزة التي قبلت الاتصال ثم توقّفت. كلٌّ منهما وحده يُبقي ثغرة مفتوحة.
معالجة أخطاء HTTP برمجيًا
حين تُعيد خدمة مجرى البيانات السفلي حالة 4xx أو 5xx، لا يرمي WebClient استثناءً افتراضيًا — بل يُسلّم الاستجابة كما هي. يجب أن تُعلن معالجة خطأ صريحة باستخدام onStatus:
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
public class InventoryServiceClient {
private final WebClient client;
public InventoryServiceClient(WebClient inventoryClient) {
this.client = inventoryClient;
}
public Mono<InventoryResponse> getStock(String productId) {
return client.get()
.uri("/inventory/{id}", productId)
.retrieve()
// تعامل مع 404 كإشارة على مستوى النطاق "غير موجود"
.onStatus(status -> status.value() == 404,
resp -> Mono.error(new ProductNotFoundException(productId)))
// تعامل مع أي 5xx كعطل بنية تحتية عابر
.onStatus(status -> status.is5xxServerError(),
resp -> resp.bodyToMono(String.class)
.flatMap(body -> Mono.error(
new DownstreamServiceException("inventory-service returned 5xx: " + body))))
.bodyToMono(InventoryResponse.class);
}
}
ربط رموز حالة HTTP باستثناءات مكتوبة بشكل صريح هو النمط الأساسي: فهو يُبقي منطق معالجة الأخطاء قريبًا من حدود الشبكة ويتيح لبقية كود أعمالك الاستجابة لإشارات نطاق ذات معنى بدلًا من رموز HTTP الخام.
التدهّر اللطيف — نمط الاحتياط (Fallback)
التدهّر اللطيف يعني أنه حين تفشل تبعية ما، يُعيد النظام نتيجة جزئية مفيدة بدلًا من نشر الخطأ للمستخدم. عاملا onErrorReturn و onErrorResume على Mono أو Flux هما أبسط طريقة للتعبير عن هذا:
public Mono<ProductDetailDto> getProductDetail(String productId) {
Mono<ProductDto> product = productRepository.findById(productId);
// استدعاء خدمة المخزون؛ العودة إلى "غير معروف" إن كانت متوقفة
Mono<InventoryResponse> stock = inventoryClient.getStock(productId)
.onErrorReturn(new InventoryResponse(productId, -1, "UNKNOWN"));
return Mono.zip(product, stock)
.map(tuple -> new ProductDetailDto(tuple.getT1(), tuple.getT2()));
}
يرى المستخدم معلومات المنتج مع مؤشر "المخزون غير معروف" بدلًا من خطأ 500. هذا قرار تصميمي مقصود: قيمة عرض المعلومات الجزئية يجب أن تفوق خطر عرض بيانات ناقصة أو قديمة. وثّق هذه المقايضات صراحةً في كودك.
المهل الزمنية على الاستدعاءات الفردية
فضلًا عن مهلة طبقة الموصّل، يمكنك تطبيق مهلة لكل طلب على حدة مباشرةً في أنبوب التفاعل باستخدام عامل timeout. هذا مفيد بشكل خاص حين تكون اتفاقية مستوى خدمة (SLA) الخدمة السفلى معروفة:
import java.time.Duration;
public Mono<InventoryResponse> getStockWithTimeout(String productId) {
return inventoryClient.getStock(productId)
// الفشل السريع إن لم تستجب المخزون خلال 3 ثوان
.timeout(Duration.ofSeconds(3))
// ثم العودة إلى القيمة الاحتياطية
.onErrorResume(ex -> {
log.warn("Inventory call timed out or failed for {}: {}", productId, ex.getMessage());
return Mono.just(new InventoryResponse(productId, -1, "UNAVAILABLE"));
});
}
لا تبتلع الأخطاء بصمت. سجّل الاستثناء دائمًا قبل إعادة القيمة الاحتياطية. في الإنتاج، القيم الاحتياطية الصامتة تُخفي المشاكل الحقيقية وتجعل تشخيص الأعطال شبه مستحيل. التسجيل المنظّم مع معرّف المنتج واسم الخدمة هو الحد الأدنى — ويُستحسن إضافة معرّف الارتباط (مغطى في الدرس الثامن).
الحاجز (Bulkhead): عزل مجموعات الخيوط
حتى مع وجود مهلات زمنية، يمكن لانتشار الاستدعاءات البطيئة المتزامنة استنزاف مجموعة الخيوط المشتركة وتجويع ميزات أخرى. يُخصّص الحاجز (bulkhead) موردًا محدودًا (مجموعة خيوط أو سيمافور) لكل تبعية خادم مجرى بيانات سفلي، حتى لا تستهلك خدمة بطيئة واحدة كل الطاقة المتاحة.
مع Resilience4j (التنفيذ المعياري لـ Spring Cloud Circuit Breaker)، يتطلّب حاجز مجموعة الخيوط بضعة أسطر من الضبط:
# application.yml
resilience4j:
thread-pool-bulkhead:
instances:
inventory-service:
maxThreadPoolSize: 10
coreThreadPoolSize: 5
queueCapacity: 20
import io.github.resilience4j.bulkhead.annotation.Bulkhead;
import org.springframework.stereotype.Service;
@Service
public class InventoryServiceClient {
@Bulkhead(name = "inventory-service", fallbackMethod = "stockFallback")
public InventoryResponse getStock(String productId) {
// استدعاء متزامن لخدمة المخزون
}
public InventoryResponse stockFallback(String productId, Throwable t) {
log.warn("Bulkhead triggered for inventory-service: {}", t.getMessage());
return new InventoryResponse(productId, -1, "UNAVAILABLE");
}
}
الاعتبارات الأمنية
للقيم الاحتياطية بُعد أمني يسهل إغفاله. إن أعادت خدمة المخزون استجابةً افتراضية "متوفر في المخزون" حين تتوقّف، وثقت خدمة الطلبات بتلك الاستجابة، يستطيع مهاجم يستطيع إيقاف خدمة المخزون إنشاء طلبات لمنتجات غير متوفرة. فكّر في:
- ما إذا كانت القيمة الاحتياطية آمنة للتصرّف بناءً عليها، أم يجب أن تُطلق تحذيرًا للمستخدم فحسب.
- نشر سياق HTTP الأمني (ترويسة
Authorization ومعرّف الارتباط) عبر مسارات الاحتياط — مسار احتياطي يتخطّى إعادة توجيه رمز المصادقة قد يُتيح تصعيد الصلاحيات بين الخدمات.
- تقييد معدّل إعادة المحاولات: بدون حدود، تُضاعف إعادة المحاولة التلقائية تحت الإخفاق الحِمل على خدمة تعاني أصلًا (عواصف إعادة المحاولة).
الخلاصة
يتطلّب الاتصال القوي بين الخدمات ثلاث طبقات دفاع: مهلات زمنية على مستويَي TCP والاستجابة لمنع استنزاف الخيوط، ومعالجات أخطاء تُحوّل رموز حالة HTTP إلى استثناءات مكتوبة، وتدهّر لطيف عبر قيم احتياطية تتيح للنظام تقديم نتائج جزئية مفيدة. أضف إليها التسجيل المنظّم، وعند الحاجة، عزل الحواجز. في الدرس القادم ستطبّق هذه الأنماط أثناء تصميم كيفية امتلاك كل خدمة لمخزن بياناتها الخاص وإدارته.