النمط الاحتياطي: تصميم التدهور اللطيف في الخدمات الصغيرة

النمط الاحتياطي: تصميم التدهور اللطيف في الخدمات الصغيرة

في بنية الخدمات الصغيرة، تشكل الخدمات شبكة من مكالمات الشبكة الموزعة. على الرغم من أن هذا يسمح للفرق ببناء الخدمات وتوسيع نطاقها بشكل مستقل، إلا أنه يعني أيضًا أن الموثوقية الإجمالية لنظامك تكون قوية بقدر قوة أضعف حلقاته. إذا تعطلت خدمة مهمة أو أصبحت غير مستجيبة، فقد يؤدي ذلك إلى حدوث فشل متتالي يؤدي إلى تعطيل التطبيق بأكمله.

يعد إرجاع “500 خطأ داخلي في الخادم” أو صفحة فارغة للمستخدمين في اللحظة التي تفشل فيها تبعية واحدة تجربة مستخدم سيئة. وبدلاً من ذلك، تم تصميم الأنظمة المرنة بحيث تتحلل بشكل جيد عندما تسوء الأمور.

هذا هو المكان الذي يأتي فيه النمط الاحتياطي. من خلال تحديد مسار آمن وبديل للتنفيذ عند فشل استدعاء الخدمة الأساسية، يمكنك الحفاظ على عمل التطبيق الخاص بك - حتى في حالة متدهورة.

في هذا الدليل، سنستكشف النمط الاحتياطي، والاستراتيجيات الشائعة لتنفيذه، وكيفية تفاعله مع أنماط المرونة الأخرى، وكيفية كتابة المنطق الاحتياطي في Java (Resilience4j) وGo.


القياس على العالم الحقيقي: الخطة الاحتياطية للمقهى

تخيل أنك تمشي في مقهى محلي لشراء لاتيه. يقوم صانع القهوة بإدخال طلبك، ولكن عندما تقوم بالنقر على بطاقتك، تومض محطة الدفع بوجود خطأ في الاتصال - يواجه مزود الإنترنت في المتجر انقطاعًا.

هل يقوم المقهى بإطفاء الأنوار على الفور، وإغلاق الأبواب، وإرسال جميع العملاء إلى منازلهم؟

بالطبع لا. ينفذون استراتيجية احتياطية:

  • إذا كان لديهم نقود في متناول اليد، يسألونك عما إذا كان بإمكانك الدفع نقدًا.
  • إذا كنت عميلاً منتظمًا، فقد يكتب صانع القهوة اسمك وطلبك في دفتر الأستاذ ويطلب منك الدفع عند زيارتك التالية.
  • قد يستخدمون قارئ بطاقات غير متصل بالإنترنت يقوم بتخزين الرمز المميز للبطاقة محليًا ومعالجة الدفع لاحقًا عند استعادة الإنترنت.
رسم تخطيطي لبنية النمط الاحتياطي يوضح فشل الخدمة الأساسية والتوجيه إلى معالج احتياطي لاسترداد البيانات المخزنة مؤقتًا/النسخ الاحتياطي

في تصميم البرمجيات:

  • طلب القهوة هو طلب العميل.
  • جهاز البطاقة هو الخدمة الأساسية (على سبيل المثال، واجهة برمجة تطبيقات بوابة الدفع).
  • انقطاع الإنترنت هو انتهاء مهلة الشبكة أو تعطل الخدمة.
  • **قارئ دفتر الأستاذ / غير المتصل ** هو مسار التنفيذ الاحتياطي.

الاستراتيجيات الاحتياطية المشتركة

اعتمادًا على منطق العمل ومدى أهمية الخدمة الفاشلة، يمكنك الاختيار من بين عدة إستراتيجيات احتياطية:

1. القيم الافتراضية الثابتة

إن أبسط استراتيجية هي إرجاع قيمة ثابتة آمنة ومُكونة مسبقًا. يعد هذا فعالاً للغاية بالنسبة للميزات غير الهامة حيث يكون عرض البيانات الفارغة أو الافتراضية مقبولاً.

  • مثال: إذا فشلت خدمة تخصيص الملف الشخصي، قم بإرجاع صورة رمزية افتراضية وتحية عامة.
  • مثال: إذا فشلت خدمة التوصية، قم بإرجاع قائمة فارغة أو قائمة مشفرة بالكتب الأكثر مبيعًا على مستوى العالم بدلاً من إلقاء خطأ.

2. الاستجابات المخزنة مؤقتًا (لا معنى لها أثناء إعادة التحقق)

إذا كانت البيانات المباشرة غير متاحة، فيمكنك الرجوع إلى البيانات القديمة للقراءة فقط من ذاكرة تخزين مؤقت محلية أو من مخزن ذاكرة سريع التوزيع مثل Redis.

  • مثال: إذا تعطلت خدمة مخزون المنتجات، فاعرض الكمية الموجودة في المخزون المخزن مؤقتًا منذ 5 دقائق مضت، بالإضافة إلى رسالة واجهة المستخدم الدقيقة التي تشير إلى أن البيانات قد لا تكون محدثة بالكامل.
  • مثال: إذا فشلت خدمة إعدادات المستخدم، فقم بتحميل ملف تعريف المستخدم المخزن مؤقتًا بدلاً من حظر تدفق تسجيل الدخول الخاص به.

3. الخدمة البديلة (مزود متعدد)

عند تنفيذ عملية هامة يجب أن تنجح، يمكنك تكوين موفر خدمة ثانوي كنسخة احتياطية.

  • مثال: إذا قامت بوابة الدفع الأساسية لديك (على سبيل المثال، Stripe) بإرجاع خطأ 5xx أو انتهت المهلة، فستقوم الآلية الاحتياطية على الفور بإعادة توجيه طلب المعاملة إلى بوابة ثانوية (على سبيل المثال، PayPal أو Adyen).
  • مثال: إذا فشلت واجهة برمجة تطبيقات الترميز الجغرافي، فارجع إلى موفر الخرائط الثانوي.

4. قائمة الانتظار لاحقًا (المخزن المؤقت غير المتزامن)

بالنسبة لعمليات الكتابة التي لا تتطلب معالجة متزامنة فورية، يمكن للاحتياطي تخزين الطلب مؤقتًا في قائمة انتظار محلية أو قاعدة بيانات لإعادة المحاولة لاحقًا.

  • مثال: إذا كانت خدمة إعلام البريد الإلكتروني معطلة، فاكتب حمولة الإعلام في قائمة انتظار الرسائل الميتة أو جدول قاعدة بيانات محلية. سيقوم عامل الخلفية بالقراءة من قائمة الانتظار هذه وتسليم رسائل البريد الإلكتروني بمجرد أن تصبح خدمة الإشعارات سليمة مرة أخرى.

ثلاثي المرونة: إعادة المحاولة مقابل قاطع الدائرة الكهربائية مقابل الإجراء الاحتياطي

لإنشاء بنية عالية المرونة، تحتاج إلى دمج النمط الاحتياطي مع أنماط إعادة المحاولة وقاطع الدائرة. إنهم يشكلون خط دفاع ثلاثي المستويات:

نمط الدور العمل السيناريو
نمط إعادة المحاولة يحل مواطن الخلل عابرة قصيرة الأجل. يكرر الطلب بعد مهلة قصيرة (تراجع + غضب). قطرات قصيرة من حزمة الشبكة، وإعادة تعيين مأخذ التوصيل.
** قاطع الدائرة ** يمنع استنفاد الموارد. الرحلات مفتوحة لحظر المكالمات إلى خدمة فاشلة على الفور (فشل سريع). تعطل الخدمة المستمر، حالة توقف تام لقاعدة البيانات.
النمط الاحتياطي يحافظ على تجربة المستخدم. ينفذ إجراءً بديلاً عند فشل المكالمة الأساسية أو حظرها. عند استنفاد عمليات إعادة المحاولة أو عندما يكون قاطع الدائرة مفتوحًا.

كيف يعملون معًا

  1. يصل الطلب الوارد إلى بوابة الخدمة.
  2. يمر الطلب عبر قاطع الدائرة.
  3. إذا كان قاطع الدائرة مغلقًا، ينتقل الطلب إلى غلاف إعادة المحاولة، الذي يقوم بإجراء المكالمة الفعلية.
  4. في حالة حدوث خطأ عابر، تحاول آلية “إعادة المحاولة” الاتصال مرة أخرى.
  5. إذا فشلت جميع عمليات إعادة المحاولة، أو إذا كان قاطع الدائرة مفتوحًا بالفعل (يفشل بسرعة في حفظ الموارد)، فإن المعالج الاحتياطي يعترض الفشل ويعيد الاستجابة المتدهورة/المخزنة مؤقتًا.

تنفيذ التعليمات البرمجية

دعونا نلقي نظرة على كيفية تنفيذ المنطق الاحتياطي في كل من Java وGo.

1. جافا (Resilience4j)

في Java، يعتبر Resilience4j هو المعيار الصناعي للتسامح مع الأخطاء. يمكننا تحديد الطريقة الاحتياطية بشكل تعريفي باستخدام التعليقات التوضيحية أو برمجيًا.

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;

@Service
public class ProductService {

    private final InventoryClient inventoryClient;
    private final CacheManager cacheManager;

    public ProductService(InventoryClient inventoryClient, CacheManager cacheManager) {
        this.inventoryClient = inventoryClient;
        this.cacheManager = cacheManager;
    }

    // Bind this method to a circuit breaker. If it fails or is open, route to fallback
    @CircuitBreaker(name = "inventoryService", fallbackMethod = "getInventoryFallback")
    public List<String> getProductInventory(String category) {
        return inventoryClient.fetchStockByCategory(category);
    }

    // Fallback method must have the same return type and accept the same parameters,
    // plus a Throwable parameter containing the error that triggered it
    public List<String> getInventoryFallback(String category, Throwable throwable) {
        System.err.println("Primary inventory service failed: " + throwable.getMessage());
        
        // Attempt to fetch from local cache (Strategy 2)
        List<String> cachedStock = cacheManager.get("inventory:" + category, List.class);
        if (cachedStock != null) {
            return cachedStock;
        }

        // Return static empty list if cache is empty (Strategy 1)
        return Collections.emptyList();
    }
}

2. اذهب (جولانج)

في Go، يمكننا كتابة ديكورات احتياطية نظيفة باستخدام أنماط البرمجة الوظيفية، والتي تسمح لنا بتغليف أي معالج تنفيذ مع معالج ثانوي.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// Request represents a simple payload
type Request struct {
	UserID string
}

// Response represents the returned data
type Response struct {
	Data   string
	Status string
}

// ServiceFunc represents our primary function signature
type ServiceFunc func(ctx context.Context, req Request) (Response, error)

// WithFallback wraps a service function with fallback logic
func WithFallback(primary ServiceFunc, fallback ServiceFunc) ServiceFunc {
	return func(ctx context.Context, req Request) (Response, error) {
		res, err := primary(ctx, req)
		if err != nil {
			fmt.Printf("[Warning] Primary call failed: %v. Running fallback...\n", err)
			return fallback(ctx, req)
		}
		return res, nil
	}
}

func main() {
	// 1. Define primary service that occasionally fails
	primaryService := func(ctx context.Context, req Request) (Response, error) {
		return Response{}, errors.New("database connection timeout (504)")
	}

	// 2. Define fallback service that retrieves cached data
	fallbackService := func(ctx context.Context, req Request) (Response, error) {
		// Simulating cached read
		return Response{
			Data:   fmt.Sprintf("Stale cached profile data for user %s", req.UserID),
			Status: "DEGRADED (CACHED)",
		}, nil
	}

	// 3. Wrap them together
	resilientService := WithFallback(primaryService, fallbackService)

	// 4. Execute
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	req := Request{UserID: "user_992"}
	response, err := resilientService(ctx, req)
	if err != nil {
		fmt.Printf("Operation completely failed: %v\n", err)
	} else {
		fmt.Printf("Response Received:\n - Status: %s\n - Data: %s\n", response.Status, response.Data)
	}
}

أفضل الممارسات للنمط الاحتياطي

  1. حافظ على المسار الاحتياطي خاليًا من التبعية: يجب ألا يعتمد المنطق الاحتياطي على نفس الأنظمة النهائية أو البنية التحتية التي فشلت للتو. إذا كانت قاعدة بياناتك معطلة، فمن المرجح أن يفشل الرجوع إلى استعلام قاعدة بيانات أخرى على نفس الخادم أيضًا.
  2. التنفيذ السريع: يجب تنفيذ المنطق الاحتياطي بسرعة. تجنب الحسابات المعقدة أو الاستدعاءات المتداخلة البطيئة في مساراتك الاحتياطية. الهدف هو إعادة الرد إلى المستخدم في أسرع وقت ممكن.
  3. التنبيه والمراقبة: قم دائمًا بتسجيل عمليات التنفيذ الاحتياطية وزيادة عدادات القياس عن بعد. إذا كان نظامك ينفذ مسارات احتياطية، فهذا يعني أن النظام يعمل في حالة متدهورة. تحتاج إلى تنبيهات لمعرفة متى تتجاوز معدلات التراجع الحدود الطبيعية.
  4. جعل واجهة المستخدم مرنة: العمل بشكل وثيق مع مهندسي الواجهة الأمامية للتأكد من أن واجهة المستخدم مصممة لقبول الحمولات الاحتياطية (مثل المجموعات الفارغة أو الحالات الجزئية) دون كسر التخطيط من جانب العميل.

خاتمة

النموذج الاحتياطي هو بوليصة التأمين النهائية لموثوقية الخدمات الصغيرة. من خلال توقع حالات الفشل وتوفير منطق احتياطي أنيق، يمكنك تحويل الأعطال الصعبة إلى اضطرابات خفية يمكن التحكم فيها. إن الجمع بين هذا النمط وإعادة المحاولة وقواطع الدائرة يضمن قدرة تطبيقك على تحمل الانقطاعات الخارجية الكبيرة مع الاستمرار في خدمة المستخدمين.