תבנית Bulkhead מוסברת¶
השם Bulkhead לקוח מבניית ספינות - מחיצה אטימה למים שמחלקת את גוף הספינה לתאים נפרדים. אם מים חודרים לתא אחד בגלל נזק, המחיצות מונעות מהם להתפשט לשאר התאים, והספינה נשארת צפה גם עם תא אחד מוצף. תבנית Bulkhead בתוכנה עושה בדיוק את זה למשאבי מחשוב - מבודדת חלקים שונים של המערכת, כדי שכשל או עומס באחד לא יגמור את המשאבים שכל שאר המערכת צריכה.
הבעיה - Thread Pool משותף שמתרוקן¶
נניח שירות אחד שמטפל בשלוש קריאות שונות - קריאה למסד הנתונים המהיר של המערכת עצמה, קריאה לשירות המלצות חיצוני, וקריאה לשירות דוחות חיצוני שידוע כאיטי מדי פעם. אם כל שלוש הקריאות משתמשות באותו pool משותף של threads או חיבורים, ושירות הדוחות האיטי מתחיל להיתקע, כל ה-threads בpool מתמלאים בהמתנה לתשובה ממנו. בשלב הזה, גם קריאות למסד הנתונים המהיר - שלא קשור בכלל לבעיה - נתקעות, כי אין threads פנויים לטפל בהן. שירות אחד איטי הצליח להפיל את כל השירות, כולל חלקים בריאים לגמרי.
הפתרון - הפרדת משאבים לפי תא¶
Bulkhead מציע להפריד את המשאבים - threads, חיבורים, תורי המתנה - לפי סוג הקריאה, כך שלכל אחד יש מכסה נפרדת שלא יכולה לחרוג מהמשאבים של האחרים.
from concurrent.futures import ThreadPoolExecutor
# כל שירות מקבל pool נפרד עם גבול משלו
db_pool = ThreadPoolExecutor(max_workers=20)
recommendations_pool = ThreadPoolExecutor(max_workers=10)
reports_pool = ThreadPoolExecutor(max_workers=5)
def call_reports_service(request):
future = reports_pool.submit(fetch_reports, request)
return future.result(timeout=5)
עכשיו, גם אם שירות הדוחות נתקע לגמרי וכל חמשת ה-threads שהוקצו לו תפוסים, ל-db_pool ול-recommendations_pool יש עדיין את המשאבים המלאים שלהם, ושאר המערכת ממשיכה לעבוד כרגיל. הכשל נשאר מוכל בתוך "תא" אחד, בדיוק כמו מחיצה אטימה בספינה.
Bulkhead ברמת התהליך - עותקים נפרדים לגמרי¶
הגרסה הקיצונית יותר של Bulkhead היא לא רק להפריד pools בתוך אותו תהליך, אלא להריץ שירותים קריטיים על מופעים (instances) נפרדים לגמרי - שרתים שונים, containers שונים - כך שגם אם שירות אחד מנצל את כל ה-CPU או הזיכרון של המכונה שהוא רץ עליה, זה לא משפיע בכלל על שירותים שרצים על מכונות אחרות. זה יקר יותר מבחינת תשתית, אבל נותן בידוד מלא, לא רק בידוד ברמת ה-threads בתוך אותו תהליך.
Bulkhead ו-Circuit Breaker - שני דפוסים משלימים¶
Bulkhead ו-Circuit Breaker פותרים בעיות קרובות אבל לא זהות. Circuit Breaker עוצר לגמרי קריאות לשירות שנכשל שוב ושוב, כדי לא לבזבז זמן על קריאות שממילא ייכשלו. Bulkhead מבטיח שגם אם קריאות לשירות מסוים כן ממשיכות לצאת, הן לא יכולות לצרוך יותר מהמכסה שהוקצתה להן, ולא יפגעו במשאבים של קריאות אחרות. בפועל, המערכות היציבות ביותר משתמשות בשניהם יחד - Bulkhead מבודד את המשאבים מראש, ו-Circuit Breaker נכנס לפעולה כשכשלים חוזרים מצדיקים הפסקה זמנית מוחלטת.
מתי זה מוגזם¶
בשירות פנימי קטן עם קריאה אחת או שתיים בלבד, לא הרבה תלויות חיצוניות, Bulkhead מוסיף תקורה של קונפיגורציה בלי תועלת ממשית. הוא משתלם במיוחד כשיש כמה תלויות חיצוניות בעלות פרופיל אמינות שונה - חלקן יציבות מאוד, חלקן ידועות כאיטיות או לא יציבות - וממש לא רוצים שהבעיה של האחת תדביק את השאר.
בקורס ארכיטקטורת תוכנה שלנו עוברים על Bulkhead לצד דפוסי חוסן נוספים כמו Circuit Breaker וretry, ומראים איך הם עובדים יחד במערכת אמיתית.
נתקלתם במצב שבו שירות חיצוני איטי הפיל שירותים בריאים לגמרי? הצטרפו לקהילה בדיסקורד ותספרו איך פתרתם את זה.
לסיכום¶
תבנית Bulkhead מבודדת משאבים - threads, חיבורים, ולפעמים מכונות שלמות - בין חלקים שונים של המערכת, כך שכשל או עומס בקריאה לתלות אחת לא יגמור את המשאבים שכל שאר המערכת צריכה. יחד עם Circuit Breaker, היא חלק ממשפחת דפוסי חוסן שהופכת מערכת מבוזרת מרגישה יציבה גם כשחלק מהתלויות שלה נופלות.