איך מעצבים Rate Limiter מבוזר - מקרה בוחן¶
Rate Limiter, מגביל קצב, נשמע כמו רכיב פשוט - סופרים בקשות, ואם עוברים סף מסוים, חוסמים. אבל ברגע שיש לכם כמה שרתי API במקביל מאחורי מאזן עומסים, הבעיה הופכת מעניינת בהרבה. זה בדיוק המקרה שממחיש למה עיצוב מערכות זה לא רק "מה האלגוריתם", אלא גם "איפה הוא רץ ומי שומר את המצב שלו".
שלב 1 - למה בכלל צריך מגביל קצב¶
מגביל קצב מגן על מערכת מפני עומס יתר - בין אם זה משתמש בודד ששולח בקשות מהר מדי בגלל באג בקוד שלו, ובין אם זה תקיפת מניעת שירות מכוונת. הוא גם משמש כדי לאכוף מדיניות עסקית, כמו מגבלת קריאות ל-API לפי חבילת מנוי - חינמי מקבל מאה קריאות בדקה, בתשלום מקבל אלף.
שלב 2 - האלגוריתם הפשוט - Fixed Window¶
הגישה הכי אינטואיטיבית היא Fixed Window - מחלקים את הזמן לחלונות קבועים, למשל כל דקה, וסופרים בקשות בתוך כל חלון. אם המונה עובר את הסף, חוסמים עד לחלון הבא. הבעיה שלה היא בגבולות בין חלונות - משתמש יכול לשלוח מאה בקשות בסוף דקה אחת, ועוד מאה בתחילת הדקה הבאה, ולמעשה לשלוח מאתיים בקשות תוך שתי שניות, למרות שהמגבלה אמורה להיות מאה בדקה.
שלב 3 - הפתרון המדויק יותר - Sliding Window ו-Token Bucket¶
Sliding Window פותר את בעיית הגבולות על ידי בחינה של חלון זמן שנע ברציפות, לא קפוץ בין חלונות קבועים - כל בקשה בודקת כמה בקשות היו בדקה האחרונה בדיוק, לא מהתחלת הדקה הקלנדרית. Token Bucket הוא גישה אחרת ופופולרית לא פחות - יש "דלי" עם כמות מוגבלת של אסימונים שמתמלא בקצב קבוע, וכל בקשה צורכת אסימון אחד. אם הדלי ריק, הבקשה נדחית. היתרון של Token Bucket הוא שהוא מאפשר גם "פרצי" בקשות קצרים כל עוד יש מספיק אסימונים צבורים, מה שמתאים יותר לתעבורה אמיתית שאף פעם לא באמת אחידה.
שלב 4 - הבעיה האמיתית - מצב משותף בין שרתים מרובים¶
כל האלגוריתמים האלה נשמעים פשוטים כשיש שרת אחד. הבעיה מתחילה כשיש חמישה שרתי API מאחורי מאזן עומסים, וכל בקשה עלולה להגיע לשרת אחר. אם כל שרת סופר בקשות בזיכרון המקומי שלו בלבד, משתמש יכול לעקוף את המגבלה בקלות - פשוט על ידי שליחת בקשות שמתפזרות בין השרתים השונים, וכל שרת חושב שהוא רואה רק חלק קטן מהתעבורה.
הפתרון הוא לרכז את המונה במקום משותף ומהיר - בדרך כלל Redis. כל שרת, לפני שהוא מטפל בבקשה, פונה ל-Redis, מגדיל את המונה של המשתמש הזה, ובודק אם הוא עבר את הסף. Redis מספיק מהיר לתמוך בזה גם תחת עומס גבוה, ותומך בפקודות אטומיות שמונעות מצב מרוץ כשכמה שרתים מנסים לעדכן את אותו מונה בו זמנית.
שלב 5 - עלות וזמינות של המצב המשותף¶
הפנייה ל-Redis על כל בקשה מוסיפה זמן המתנה, וגם יוצרת תלות - אם Redis נופל, כל בקשה שתלויה בו נתקעת. פתרון נפוץ הוא גישה היברידית - מגבלה גסה ומהירה בזיכרון המקומי של כל שרת בתור קו הגנה ראשון, ומגבלה מדויקת יותר מול Redis בתור קו שני. חלק מהמערכות גם מוכנות "לפתוח" את המגביל זמנית אם Redis לא זמין, במקום לחסום את כל התעבורה - החלטה עסקית שצריך לקבל במודע, כי היא אומרת שבמצב תקלה מעדיפים זמינות על פני אכיפה מדויקת.
שלב 6 - איפה מציבים את המגביל¶
שאלה נוספת היא איפה בדיוק המגביל יושב - בקוד השירות עצמו, או בשכבה נפרדת כמו API Gateway שמטפלת בזה עבור כל השירותים מאחוריה. מיקום ב-Gateway נותן אכיפה עקבית במקום אחד, ופחות קוד כפול בכל שירות, וזו בדיוק אחת הסיבות שמגביל קצב הוא רכיב סטנדרטי כמעט בכל API Gateway מודרני.
מקרה הבוחן הזה מדגים היטב איך רכיב שנראה טריוויאלי בקוד יחיד הופך לבעיית עיצוב מערכות אמיתית ברגע שמכניסים מדרגיות לתמונה, וזה בדיוק סוג החשיבה שאנחנו מפתחים בקורס ארכיטקטורת תוכנה.
בניתם מגביל קצב ונתקלתם בבעיה שלא ציפיתם לה? הצטרפו לקהילה בדיסקורד ותספרו איך התמודדתם.
לסיכום¶
מגביל קצב מבוזר דורש שילוב בין אלגוריתם נכון - Token Bucket או Sliding Window עדיפים בדרך כלל על Fixed Window הפשוט - לבין מקום משותף ומהיר לשמור בו מצב בין שרתים מרובים, לרוב Redis. השאלות האמיתיות בראיון לא נוגעות רק לאלגוריתם, אלא למה קורה כשהמצב המשותף נופל, ואיפה נכון להציב את המגביל במערכת כולה.