דפוס הניסיון החוזר: בניית שירותי מיקרו עמידים

דפוס הניסיון החוזר: בניית שירותי מיקרו עמידים

בארכיטקטורת שירותי מיקרו, שירותים מתקשרים דרך רשת ולא שיחות בזיכרון. אמנם הניתוק הזה מאפשר קנה מידה אופקי מסיבי ופריסה עצמאית, אבל הוא גם מציג פגיעות גדולה: הרשת לא אמינה.

בכל רגע, שירות במורד הזרם עלול לחוות תקלת רשת קצרה, זינוק זמני של מעבד, מחלוקת מהירה של נעילת מסד נתונים או הפעלה מחדש של עדכון מתגלגל. כשלים זמניים אלו ידועים כתקלות חולפות.

אם השירות שלך משליך מיד שגיאה ונכשל בבקשה ברגע שבו שיחה במורד נכשל, אתה יוצר חווית משתמש שברירית. במקום זאת, רבות מהשגיאות החולפות הללו ניתנות לפתרון אוטומטי על ידי המתנה של רגע וניסיון שוב. כאן נכנס לתמונה דפוס הניסיון מחדש.

במדריך זה, נחקור את דפוס ה-Retry, כיצד הוא פועל מתחת למכסה המנוע, הסכנות הטמונות בהטמעות נאיביות וכיצד ליישם אותו בצורה נכונה ב-Java (Resilience4j) וב-Go.


האנלוגיה של העולם האמיתי: חיוג חוזר לקו עמוס

תאר לעצמך שאתה מנסה להתקשר לחבר. אתה מחייג למספר שלהם, אבל אתה מקבל אות תפוס כי הם נמצאים כעת בשיחה אחרת.

האם אתה מוותר מיד, מוחק את איש הקשר שלהם ומניח שלעולם לא תוכל לדבר איתם שוב? כמובן שלא. אתה מנתק, מחכה דקה ומחייג שוב את המספר שלהם. אם הם עדיין עסוקים, אולי תחכה חמש דקות לפני שתנסה שוב.

בסופו של דבר, השיחה שלהם מסתיימת, והניסיון החוזר שלך מצליח.

תרשים ארכיטקטורת דפוס נסה שוב המציג ניסיון בקשה, עיכוב השבתה וניסיון שני מוצלח של ניסיון חוזר

בשירותי מיקרו:

  • הקריאה היא בקשת API לשירות במורד הזרם.
  • אות התפוס הוא שגיאת רשת חולפת או תגובת 503 Service Unavailable.
  • החיוג החוזר הוא ניסיון ניסיון חוזר.
  • זמן ההמתנה הוא משך ההפסקה.

הסכנה: ניסיונות נאיביים ו"נסה שוב סערות"

יישום מנגנון ניסיון חוזר נראה טריוויאלי במבט ראשון: פשוט עטוף את קריאת ה-HTTP שלך בלולאה for והמשיכו לנסות עד שזה יצליח. עם זאת, יישום ניסיון חוזר נאיבי יכול בקלות להפוך שיהוק קל להפסקה קטסטרופלית בכל מערכת.

תארו לעצמכם שירות במורד הזרם שנאבק תחת גל פתאומי של תנועה. מסד הנתונים שלו פועל ב-99% ניצול מעבד, והבקשות מתחילות להגיע לפסק זמן.

אם 100 שירותי לקוח יזהו כולם פסק זמן ומיד ינסו שוב 3 פעמים בלי לחכות, הם יכשילו לפתע את נפח התעבורה שתפגע בשירות המורד הזרם שכבר חונק. ההגברה הפתאומית הזו של התנועה ידועה בתור נסה שוב סערה (או בעיית עדר הרועם).

במקום לעזור לשירות במורד הזרם להתאושש, הניסיונות החוזרים שלך ימשיכו לדחוף אותו למטה, ולמנוע ממנו אי פעם להדביק את הקצב.


הפתרון: גיבוי וריצוד

כדי למנוע סערות נסיונות חוזרות, מערכת גמישה חייבת להשתמש בשתי אסטרטגיות חיוניות: Backoff וריצוד.

1. אסטרטגיות גיבוי

Backoff מכתיב כמה זמן לקוח צריך לחכות לפני שהוא עושה ניסיון חוזר לאחר מכן.

  • השבתה קבועה: הלקוח ממתין פרק זמן קבוע (למשל, בדיוק 200 אלפיות השנייה) בין ניסיונות. למרות שזה פשוט, זה עדיין מסתכן של עליות תנועה מסונכרנות.
  • השבתה אקספוננציאלית: זמן ההמתנה גדל באופן אקספוננציאלי עם כל ניסיון שנכשל (למשל, 100ms, 200ms, 400ms, 800ms). זה נותן לשירות במורד הזרם יותר זמן להתאושש ככל שהכשל נמשך.

2. ריצוד (אקראי)

אפילו עם גיבוי אקספוננציאלי, אם בליפ רשת גורם ל-1,000 בקשות להיכשל באותו רגע בדיוק, כל 1,000 הלקוחות יחשבו בדיוק את אותו השהיית החזרה. כתוצאה מכך, כולם ינסו שוב בו-זמנית בגלים, ויפגעו בשירות במורד הזרם עם קוצים מסונכרנים.

ריצוד פותר זאת על ידי הוספת שונות אקראית לעיכוב ההחזרה.

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

על ידי פיזור הניסיונות החוזרים על פני מרווח אקראי, העומס על השירות במורד הזרם מוחלק, ומאפשר לו להתאושש בחן.


כלל הזהב של ניסיונות חוזרים: אימפוטנציה

לפני שתחיל ניסיונות חוזרים על כל נקודת קצה של ממשק API, עליך לשאול שאלה קריטית אחת: האם פעולה זו אינה חזקה?

פעולת אימפוטנטית היא פעולה שבה ביצוע מספר בקשות זהות משפיע בדיוק כמו ביצוע בקשה בודדת.

  • Idempotent: קריאת פרופיל משתמש (GET /users/123), עדכון שדה דוא"ל שלם (PUT /users/123/email), או מחיקת פריט (DELETE /items/456).
  • Non-Idempotent: יצירת הזמנה חדשה (POST /orders), או עיבוד חיוב כרטיס אשראי (POST /payments).

נניח שאתה מתקשר ל-POST /payments כדי לגבות מלקוח $50. שער התשלומים במורד הזרם מקבל את הבקשה, גובה את כרטיס האשראי בהצלחה, אבל אז מתרחשת רשת בליפ לפני שהוא יכול לשלוח את תגובת 200 OK בחזרה אליך.

השירות שלך רושם פסק זמן, מניח שהשיחה נכשלה ומנסה שוב אוטומטית. אם שער התשלום אינו מיועד לטפל בבקשות כפולות, הלקוח יחויב פעמיים.

[!אזהרה] לעולם אל תנסה שוב פעולות לא-אימפוטנטיות, אלא אם השירות במורד הזרם תומך ב-מפתחות אימפוטנציה (מזהי בקשות ייחודיים המשמשים לאיתור וביטול עסקאות כפולות).


דוגמאות ליישום

בואו נסתכל כיצד ליישם את דפוס ה-Retry Pattern ב-Java ו-Go.

1. Java (Resilience4j & Spring Boot)

Resilience4j מספק מודול ניסיון חוזר חזק שניתן להגדרה. הנה איך להגדיר אותו עבור שירות חיוב חיצוני.

תצורה (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

יישום קוד

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. עבור (גולאנג)

ב-Go, נוכל לכתוב רץ ניסוי חוזר אלגנטי הכולל גיבוי אקספוננציאלי וריצוד אקראי באמצעות טיימרים סטנדרטיים של ספרייה.

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

ניסיון חוזר לעומת מפסק: מתי להשתמש באיזה?

לעתים קרובות מפתחים מבלבלים בין דפוס נסה שוב לבין דפוס מפסק מעגלים. בעוד ששניהם שואפים לשפר את עמידות המערכת, הם מטפלים בסוגים שונים מהותית של כשלים:

מדד נסה שוב דפוס תבנית מפסק
מטרה ראשית מתאושש מכשלים חולפים (קצר מועד) אוטומטית. מונע מכשלים מתמשכים (ארוך ימים) להוריד את המתקשר.
אסטרטגיה נסה שוב מיד או לאחר המתנה קצרה. כשל מהיר באופן מיידי מבלי להפעיל את שירות היעד.
השפעת מטה מגביר את העומס על השירות במורד הזרם באופן זמני. מגן על השירות במורד הזרם מפני תעבורה, ומאפשר לו להתאושש.
טריגרים טיפוסיים ​​ ניתוקי רשת קצרים, פסק זמן של מסד נתונים, נפילות שקעים. השירות במורד הזרם מושבת לחלוטין, ומחזיר 500 שניות רצופות או פסקי זמן.

The Power Couple: שילוב שניהם

במערכות ייצור, שני הדפוסים הללו מיועדים לשימוש ביחד.

כאשר אתה מגיש בקשה במורד הזרם, היא צריכה לעבור תחילה דרך מפסק המעגלים, ולאחר מכן את העטיפה של נסה שוב.

אם מתרחשת תקלה חולפת, מנגנון ה-Retry מיירט אותה ומנסה שוב. עם זאת, אם השירות במורד הזרם מת, הכשלים ייערמו. מפסק המעגל מזהה את התקלות הרצופות, “נפתח” וחוסם את כל השיחות העתידיות.

כעת, כאשר אתה מנסה להתקשר לשירות, מפסק המעגלים נכשל במהירות מיידית, עוקף לחלוטין את לולאת ה-Retry וחוסך את משאבי המחשוב שלך.


שיטות עבודה מומלצות עבור דפוס ניסיון חוזר

  1. נסה שוב רק שגיאות חולפות: בדוק את קודי המצב של HTTP. נסה שוב את 503 Service Unavailable, 429 Too Many Requests (תייחס לכותרות Retry-After אם קיימות), ותפוגים לרשת. לא נסה שוב 400 Bad Request, 401 Unauthorized, או 404 Not Found - אלה לעולם לא יצליחו בניסיון חוזר.
  2. החל ריצוד: לעולם אל תשתמש בטיימר קבוע לניסיון חוזר מבלי להכניס ריצוד אקראי.
  3. הגבלת ניסיונות מקסימליים: הפסק לנסות שוב לאחר סף סביר (בדרך כלל 3 עד 5 ניסיונות). ניסיונות חוזרים בלתי מוגבלים צורכים משאבים וגוררים את השהיה.
  4. היזהר עם ניסיונות מדורגים: אם שירות A קורא לשירות B (שמנסה שוב 3 פעמים), ושירות B קורא לשירות C (שמנסה שוב 3 פעמים), כשל ב-C יכול לגרום לשיחות מדורגות של $3 \פעמי 3 = 9$. החל ניסיונות חוזרים רק בגבולות שבהם הם הגיוניים ביותר.
  5. הגדר תמיד פסקי זמן קפדניים: ודא שתקופות הזמן הקצובות של הרשת שלך קצרות מתקופות ההפסקה שלך, כך שהשרשורים לא יישמרו ללא הגבלת זמן.

מסקנה

דפוס הניסיון מחדש הוא קו הגנה ראשון רב עוצמה מפני חוסר אמינות רשת בארכיטקטורות מיקרו-שירותים. בשילוב עם גיבוי אקספוננציאלי, ריצוד ומפסק מעגלים, אתה יכול להגן על המערכות שלך מהפסקות מדורגות וליצור חוויה חלקה של ריפוי עצמי למשתמשים שלך.