תבנית Circuit Breaker: איך שירות אחד שנופל לא מפיל את כולם — circuit breaker design pattern במיקרו־שירותים
כאשר מערכת בנויה ממיקרו־שירותים, נפילה של שירות אחד היא לא אירוע מקומי בלבד. קריאה איטית לשירות תשלומים, קטלוג או הרשאות יכולה להחזיק חיבורים, לחסום threads, להגדיל תורים, ולהפוך תקלה קטנה לגל כשל. כאן נכנס circuit breaker design pattern: תבנית שמפסיקה זמנית קריאות לשירות בעייתי, מחזירה תגובה חלופית כשאפשר, ונותנת למערכת להתאושש במקום להמשיך להכות בקיר.
חשוב לדייק: זו לא תבנית GoF קלאסית כמו Strategy, Decorator או Observer, אלא תבנית ארכיטקטונית נפוצה במערכות מבוזרות. היא כן נשענת על רעיונות מוכרים מעיצוב מונחה עצמים: עטיפה של תלות, הפרדת אחריות, פולימורפיזם, ושיתוף פעולה בין אובייקטים. אם אתם רוצים בסיס רחב יותר לפני הצלילה, כדאי להתחיל עם מבוא לתבניות עיצוב, ואז לחזור לכאן עם הראש של “מי משתף פעולה עם מי”.
הבעיה: כשל איטי מסוכן יותר מכשל ברור
במערכת מיקרו־שירותים, כל שירות הוא גם צרכן וגם ספק. שירות הזמנות קורא לשירות מלאי; שירות מלאי קורא לשירות קטלוג; שירות קטלוג אולי קורא למנוע חיפוש. אם אחד מהם מחזיר שגיאות מהר, המערכת יכולה לעיתים להתמודד. אבל אם הוא “כמעט עובד” — מגיב אחרי עשר שניות, נתקע מדי פעם, או מחזיר timeout — הנזק גדול יותר.
כשל איטי יוצר כמה תופעות:
- הצטברות בקשות: לקוחות ממתינים לתשובה, ומשאבים נשארים תפוסים.
- מיצוי מאגרי חיבורים: connection pool מתמלא בקריאות תקועות.
- שרשרת retries: כל שירות מנסה שוב, וכך מכפיל את העומס על שירות שכבר מתקשה.
- כשל מדורג: שירותים שלא היו תקולים מתחילים להיכשל כי הם תלויים בשירות התקול.
לכן השאלה אינה רק “איך מתקנים את השירות שנפל”, אלא “איך מונעים ממנו להפיל את מי שקורא לו”.
מה עושה Circuit Breaker בפועל
מפסק חשמלי בבית מנתק זרם כאשר יש עומס או קצר. Circuit Breaker בתוכנה עושה דבר דומה: הוא עוקב אחרי הצלחות וכישלונות של קריאות לשירות מרוחק, וכאשר הכישלונות עוברים סף מוגדר, הוא מפסיק לשלוח אליו בקשות לזמן קצר.
התבנית בנויה בדרך כלל משלושה מצבים:
| מצב | מה קורה בו | למה זה חשוב |
|---|---|---|
CLOSED |
הקריאות עוברות רגיל לשירות המרוחק. | זה מצב העבודה התקין. |
OPEN |
הקריאות נחסמות מיד, בלי לפנות לשירות המרוחק. | מונע עומס נוסף ומחזיר משאבים למערכת. |
HALF_OPEN |
נשלחת קריאת ניסיון אחת או מעטות כדי לבדוק אם השירות התאושש. | מאפשר חזרה הדרגתית בלי להציף את השירות. |
כאשר המפסק פתוח, הקוד יכול להחזיר תשובה חלופית: מידע מה־cache, הודעה ידידותית, ערך ברירת מחדל, או שגיאה מבוקרת. הבחירה תלויה בדומיין. הזמנה כספית לא אמורה להתבצע על בסיס fallback דמיוני, אבל תצוגת “מוצרים מומלצים” יכולה להיעלם זמנית בלי לגרום נזק עסקי גדול.
circuit breaker design pattern כחלק מעיצוב מונחה עצמים
אף שהתבנית באה מעולם מערכות מבוזרות, אפשר ליישם אותה היטב בעזרת עיצוב מונחה עצמים. הרעיון המרכזי הוא לא לפזר בדיקות כישלון בכל מקום בקוד, אלא לעטוף את התלות המרוחקת באובייקט שמנהל את מדיניות הקריאה.
מבנה נפוץ נראה כך:
- ממשק של השירות: למשל
CatalogClient, שמגדיר מה הצרכן צריך. - מימוש אמיתי: אובייקט שמבצע קריאה לרשת, למסד נתונים או לשירות חיצוני.
- מעטפת Circuit Breaker: אובייקט שמממש את אותו ממשק, אך מחליט האם להעביר את הקריאה, לחסום אותה, או להחזיר fallback.
- צרכן: הקוד העסקי שתלוי בממשק ולא יודע אם הקריאה עברה דרך מפסק.
הגישה הזו מתכתבת עם עקרונות SOLID, בעיקר עם Dependency Inversion: הקוד העסקי תלוי בהפשטה, לא במימוש רשת ספציפי. היא גם דומה ברוח לתבניות מבניות כמו Proxy או Decorator, אף שלא כדאי לבלבל ביניהן. מי שרוצה לראות איך תבניות יצירה מארגנות בניית אובייקטים יכול לקרוא גם על תבניות יצירה; כאן הדגש הוא פחות “איך יוצרים” ויותר “איך שולטים בשיתוף פעולה בין שירותים”.
דוגמת Java מלאה: מעטפת פשוטה סביב שירות קטלוג
הדוגמה הבאה אינה מדריך Java ואינה מימוש production מלא. היא מדגימה את מבנה האובייקטים: ממשק, שירות לא יציב, ומעטפת CircuitBreakerCatalog שמנהלת מצבים.
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
public class CircuitBreakerDemo {
public static void main(String[] args) throws InterruptedException {
CatalogClient remote = new UnstableCatalogClient();
CatalogClient catalog = new CircuitBreakerCatalog(remote, 2, Duration.ofSeconds(2));
for (int i = 1; i <= 8; i++) {
System.out.println("Call " + i + ": " + catalog.featuredProducts());
Thread.sleep(700);
}
}
}
interface CatalogClient {
List<String> featuredProducts();
}
class UnstableCatalogClient implements CatalogClient {
private int calls = 0;
@Override
public List<String> featuredProducts() {
calls++;
if (calls == 2 || calls == 3 || calls == 4) {
throw new RuntimeException("Catalog service is unavailable");
}
return Arrays.asList("Keyboard", "Mouse", "Monitor");
}
}
class CircuitBreakerCatalog implements CatalogClient {
private enum State {
CLOSED,
OPEN,
HALF_OPEN
}
private final CatalogClient target;
private final int failureThreshold;
private final Duration openDuration;
private State state = State.CLOSED;
private int failures = 0;
private Instant openedAt = Instant.MIN;
CircuitBreakerCatalog(CatalogClient target, int failureThreshold, Duration openDuration) {
this.target = target;
this.failureThreshold = failureThreshold;
this.openDuration = openDuration;
}
@Override
public List<String> featuredProducts() {
if (state == State.OPEN) {
if (Instant.now().isBefore(openedAt.plus(openDuration))) {
return fallback();
}
state = State.HALF_OPEN;
}
try {
List<String> result = target.featuredProducts();
onSuccess();
return result;
} catch (RuntimeException ex) {
onFailure();
return fallback();
}
}
private void onSuccess() {
failures = 0;
state = State.CLOSED;
}
private void onFailure() {
failures++;
if (state == State.HALF_OPEN || failures >= failureThreshold) {
state = State.OPEN;
openedAt = Instant.now();
}
}
private List<String> fallback() {
return Arrays.asList("Popular item temporarily unavailable");
}
}בדוגמה, CatalogClient הוא ההפשטה שהקוד העסקי מכיר. UnstableCatalogClient מייצג תלות חיצונית שעלולה להיכשל. CircuitBreakerCatalog הוא המעטפת: הוא מחזיק מצב פנימי, סופר כישלונות, פותח את המפסק אחרי סף מסוים, ומחזיר fallback במקום להמשיך לקרוא לשירות לא זמין. הלקוח ב־main עובד מול אותו ממשק, ולכן אינו צריך להכיר את פרטי ההגנה.
מה מכינים לפני שמיישמים Circuit Breaker
לפני שמוסיפים תבנית כזו לכל קריאת HTTP, כדאי לעצור ולענות על כמה שאלות. Circuit Breaker בלי הגדרות נכונות עלול להסתיר בעיות, לחסום שירותים מוקדם מדי, או לתת תחושת ביטחון מזויפת.
1. מגדירים מהו כישלון
לא כל תשובה לא מושלמת היא כישלון. צריך להחליט אילו מצבים נספרים:
timeoutאחרי זמן מוגדר.- שגיאות רשת או חוסר זמינות.
- קודי HTTP מסוימים, למשל שגיאות שרת.
- תשובה תקינה טכנית אך לא שמישה עסקית.
לעומת זאת, שגיאת ולידציה מצד הלקוח בדרך כלל לא אמורה לפתוח Circuit Breaker. אם המשתמש שלח בקשה לא תקינה, השירות המרוחק לא בהכרח תקול.
2. קובעים timeout לפני retry
טעות נפוצה היא להוסיף retries לפני שמגדירים timeout קצר וברור. בלי timeout, הקריאה יכולה להיתקע מספיק זמן כדי לפגוע בכל השרשרת. סדר בריא יותר הוא:
- לקבוע timeout מתאים לפעולה.
- להחליט אם בכלל מותר לבצע retry.
- להוסיף Circuit Breaker סביב התלות.
- למדוד את ההשפעה בפועל.
Retry יכול לעזור בתקלה רגעית, אבל הוא גם מגדיל עומס. לכן צריך להיזהר במיוחד משילוב אוטומטי של retry בכל שכבה.
3. בוחרים fallback לפי הסיכון
Fallback טוב הוא לא “להמציא תשובה”. הוא החלטה עסקית מודעת. לדוגמה:
- בתצוגת המלצות: אפשר להחזיר רשימה ריקה או פריטים פופולריים מ־cache.
- בבדיקת מלאי: אולי עדיף להציג “לא ניתן לבדוק כרגע” במקום לאפשר הזמנה.
- בתשלום: ברוב המקרים לא מחליפים שירות תשלום בתשובת הצלחה מלאכותית.
ככל שהפעולה בלתי הפיכה יותר, כך fallback צריך להיות שמרני יותר.
שלבי יישום מומלצים במערכת קיימת
במערכת קיימת לא מתחילים מהתלות הכי מורכבת. מתחילים מקריאה חיצונית אחת שיש לה השפעה גבוהה וכישלונות ברורים. המטרה היא ללמוד, לא לכסות הכול ביום אחד.
שלב ראשון: ממפים תלות אחת
בחרו שירות שהקריאות אליו מופיעות בלוגים בזמן תקלות. תעדו:
- מי קורא אליו.
- כמה הקריאה קריטית לפעולה.
- מהו זמן תגובה רגיל.
- מה קורה כשהוא לא זמין.
- איזה fallback אפשרי, אם בכלל.
שלב שני: עוטפים את התלות בהפשטה
אם הקוד מפוזר עם קריאות ישירות, קשה להוסיף מדיניות אחידה. עדיף ליצור ממשק קטן שמייצג את הצורך העסקי, למשל PaymentGateway או InventoryClient. המימוש יכול להיות HTTP, gRPC או כל מנגנון אחר; הצרכן לא צריך לדעת.
שלב שלישי: מגדירים מדדי תצפית
בלי מדידה, Circuit Breaker הופך לקופסה שחורה. כדאי לתעד לפחות:
- כמה קריאות הצליחו ונכשלו.
- מתי המפסק עבר ל־
OPEN. - כמה בקשות קיבלו fallback.
- כמה זמן השירות נשאר חסום.
- האם המעבר ל־
HALF_OPENהצליח.
הנתונים האלה חשובים גם לצוות שמפעיל את השירות וגם לצוות שמחזיק את התלות המרוחקת.
שלב רביעי: בודקים תרחישי כשל
בדיקה שבה הכול עובד אינה מספיקה. צריך לבדוק במפורש:
- שירות שמחזיר שגיאה מהר.
- שירות שלא עונה עד timeout.
- שירות שמתאושש אחרי זמן קצר.
- שירות שחוזר לעבוד ואז נכשל שוב.
מומלץ לוודא שהמערכת לא רק “לא נופלת”, אלא גם מחזירה תגובות צפויות שאפשר להסביר למשתמש או למערכת הקוראת.
טעויות נפוצות ומה עושים במקום
פתיחת מפסק על כל שגיאה
אם כל שגיאת לקוח נספרת ככשל של השירות המרוחק, המפסק ייפתח בלי סיבה. צריך להפריד בין שגיאות שנובעות מהתלות לבין שגיאות שנובעות מהבקשה.
Fallback שמסתיר תקלה חמורה
אם fallback תמיד מחזיר ערך “נעים”, תקלות עלולות להיעלם מהרדאר. הפתרון הוא למדוד fallback כאירוע משמעותי, לא כאילו מדובר בהצלחה רגילה.
ספים זהים לכל השירותים
קריאה להצגת באנר אינה דומה לקריאה לאישור הזמנה. לכל תלות יש פרופיל אחר: זמן תגובה, חשיבות, נפח, ואפשרות התאוששות. סף כישלון וזמן פתיחה צריכים להתאים להקשר.
שילוב אגרסיבי מדי עם retries
שלושה retries בכל שירות לאורך שרשרת של ארבעה שירותים יכולים ליצור כמות קריאות גדולה בהרבה מהמקור. אם מוסיפים retry, כדאי להשתמש בהשהיה, jitter, ומספר ניסיונות נמוך — ורק כאשר הפעולה בטוחה לחזרה.
Trade-offs: מה מרוויחים ומה משלמים
היתרונות ברורים: בידוד תקלות, תגובה מהירה יותר בזמן כשל, פחות עומס על שירות פגוע, וקוד שמרכז מדיניות במקום לפזר אותה. במערכות עם מיקרו־שירותים, אלה יתרונות משמעותיים.
אבל יש גם מחיר:
- מורכבות נוספת: עוד מצב פנימי, עוד מדדים, עוד החלטות קונפיגורציה.
- סיכון לחסימה מוקדמת: סף נמוך מדי עלול לחסום שירות שעוד מסוגל לענות.
- Fallback לא מדויק: תשובה חלופית יכולה להיות פחות טובה, מיושנת או חלקית.
- בדיקות מורכבות יותר: צריך לבדוק מצבים בזמן, לא רק קלט ופלט.
לכן Circuit Breaker הוא כלי חזק, אבל לא קסם. הוא לא מתקן שירות שבור; הוא מונע מהשבר להתפשט.
מתי לא להשתמש בתבנית
לא כל קריאה צריכה Circuit Breaker. במערכת פשוטה, שירות יחיד, או קריאה פנימית מהירה שאינה תלויה ברשת, התבנית יכולה להיות עודף הנדסי. גם כאשר אין fallback סביר והפעולה חייבת להיכשל באופן גלוי, ייתכן ש־timeout ברור ושגיאה מפורשת יספיקו.
כדאי להיזהר במיוחד במקרים הבאים:
- פעולות כתיבה רגישות שאסור “להניח” שהצליחו.
- שירותים שבהם מספר הכישלונות נמוך מאוד ואין סימן לכשל מדורג.
- קוד שבו התלות עדיין לא מבודדת בממשק ברור.
- מערכות שבהן אין ניטור בסיסי; בלי תצפית, קשה לדעת מה התבנית עושה.
קשר ל־UML ולעיצוב API
ב־UML class diagram פשוט, היינו מציירים ממשק CatalogClient, שני מימושים שלו, וקשר תלות מהצרכן אל הממשק בלבד. זה עוזר לראות שהצרכן לא תלוי ישירות בשירות המרוחק. במונחי API design, חשוב שה־API הפנימי של הלקוח יבטא צורך עסקי ולא פרטים טכניים: featuredProducts() ברור יותר לצרכן מאשר GET /v1/catalog?type=featured שמחלחל לכל שכבה.
הפרדה כזו מאפשרת להחליף מדיניות: היום Circuit Breaker פשוט, מחר ספרייה ייעודית, ובעתיד אולי cache או bulkhead. כל עוד החוזה נשאר יציב, הקוד העסקי פחות רגיש לשינוי.
שאלות שקוראים באמת שואלים
האם Circuit Breaker מחליף timeout?
לא. הוא צריך לעבוד יחד עם timeout. בלי timeout, הקריאה עלולה להיתקע לפני שהמפסק מצליח לקבל החלטה. timeout קובע כמה זמן מחכים; Circuit Breaker קובע האם בכלל כדאי לנסות כרגע.
האם חייבים fallback?
לא תמיד. לפעמים fallback הוא שגיאה מהירה וברורה. גם זה עדיף על המתנה ארוכה שמעמיסה על כל המערכת. השאלה היא מה ההתנהגות הנכונה לדומיין.
האם זו תבנית GoF?
לא. הספר Design Patterns, Gamma et al., 1994, עוסק בתבניות עיצוב מונחות עצמים כמו Factory Method, Composite, Observer ואחרות. Circuit Breaker הגיע מהצורך לבנות מערכות מבוזרות עמידות יותר. עם זאת, היישום שלו משתמש היטב ברעיונות של הפשטה, עטיפה ושיתוף פעולה בין אובייקטים.
סיכום: מפסיקים להפיץ כשל, לא מתעלמים ממנו
תבנית Circuit Breaker עוזרת למיקרו־שירותים להיכשל בצורה מבוקרת. היא מזהה רצף כישלונות, מפסיקה זמנית קריאות מזיקות, מאפשרת התאוששות הדרגתית, ומחזירה fallback כאשר זה נכון. הערך שלה אינו רק בקוד, אלא בהחלטות: מה נחשב כישלון, מה עושים בזמן תקלה, ואיך מודדים את ההשפעה.
היישום הטוב ביותר מתחיל קטן: תלות אחת, ממשק ברור, timeout מוגדר, מדדים בסיסיים, ובדיקות כשל אמיתיות. משם אפשר להרחיב. אם התבנית נשארת קרובה לעקרונות עיצוב נקיים ולא הופכת לאוסף תנאים מפוזרים, היא הופכת לאחד הכלים החשובים בארכיטקטורת מיקרו־שירותים יציבה.


