כיצד מטפלים ב-200 אלף בקשות לדקה? [פיתוח]

מבוסס על סיפור אמיתי

מאת אייל פוסט

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

בעיית C10K

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

סביב שנת 2000, דן קיגל (Kegel) טבע את המונח C10K, שקשור לטיפול בו-זמני ב-10,000 חיבורים לשרת יחיד. עד לאותה עת, מודל הפעולה המקובל היה ליצור תהליכון (thread) חדש עבור כל התחברות. לדוגמה, בשרת ווב המבוסס על מערכת אפצ’י, כאשר רכיב לקוח התחבר, השרת יצר תהליכון שהוקצה לחיבור זה, וטיפל בכל הבקשות שבאו מאותו לקוח. בהיבט התכנות, זו גישה מאד פשוטה וקלה ליישום. עם זאת, יש לה מספר חסרונות.

הבעיה ביצירת תהליכון עבור כל קישור

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

זיכרון – כל תהליכון צריך ערימה (stack) עבור המשתנים המקומיים, הפרמטרים של הפונקציות וערכי החזרה, והערימה הזו צריכה להיות גדולה מספיק כדי למנוע מצב של גלישה (stack overflow), כלומר, שאין די שטח אחסון, שיכול לגרום להתהליכון לקרוס. אם ניקח לדוגמה ערימה בגודל סטנדרטי של 1 מגה בייט לכל תהליכון, הרי שנצטרך זיכרון פנימי של 10 גיגה בייט רק עבור יצירת 10 אלף חיבורים.

קלט/פלט (I/O) – בהרבה מאד סיטואציות כיום רוב פעילות השרת קשורה בפעולות קלט/פלט, כגון קריאה ממסד נתונים או שליחת בקשה למיקרו-שירות אחר. כתוצאה מכך, בכל רגע נתון, רוב התהליכונים יהיו במצב המתנה (idle), וימתינו לחזרת התשובה מהמשאב החיצוני.

לולאות אירוע (event loops)

כדי להתמודד עם הבעיות הללו, פותח מודל ה event loop  שנמצא בשימוש בטכנולוגיות כמו Nginx ו-Node.js. שרתים אלו משתמשים בתהליכון יחיד שמריץ בלולאה קוד מוכוון פעילות מעבד, ומשתמש ב callbacks לניהול פעולות קלט/פלט. במקרים אלו, פעולות הקלט/פלט משתמשות בפקודות API אסינכרוניות של מערכת ההפעלה. שיטה זו מטפלת בכל החסרונות שתיארנו קודם:

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

בשלב מסוים נתקלנו בקשיים שנבעו מהגדלת היקף הפעילות של שירות מיקום הנהגים אצלנו, ולכן חיפשנו חלופה. בגלל היתרונות שמנינו, Node.js נבחר ואכן נתן מענה לבעיית הסקייל. מודל ה-event loop אמנם פותר חלק מהבעיות שקיימות בשרתים בתצורת תהליכון-לכל-חיבור, אך גם יוצר בעיות חדשות, לדוגמה:

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

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

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

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

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

גודל ערימה דינאמי בשפת Go

בשפת Go, כל בקשה מהשרת מטופלת תוך שימוש ב-Goroutine. שגרות אלו דומות לתהליכונים, אך הן מטופלות ברמת שפת התכנות ולא על-ידי מערכת ההפעלה. עוד מעט נפרט יותר בקשר לזה, אך תחילה, בואו נראה כיצד Go מתמודדת עם הבעיה של שימוש רב בזיכרון. כל תהליכון או Goroutine צריכים ערימה משלהם. ראינו קודם ש-10,000 תהליכונים שכל אחד משתמש ב-1 מגה בייט צורכים ביחד 10 גיגה בייט זיכרון, וזהו שימוש בזבזני. הגישה של Go היא להקצות כברירת מחדל ערימה קטנה לכל goroutine, ולאחר מכן להגדיל או להקטין אותה בצורה דינאמית בהתאם לצורכי השגרה בפועל. המהדר (קומפיילר) של Go מנתח את הקוד ומזהה לאיזה שטח ערימה נזקקת כל פונקציה. כאשר קיים סיכוי שפונקציה תזדקק ליותר שטח ערימה, המהדר שותל קוד שבודק את מצב הערימה תוך כדי ריצה, ויודע האם יש צורך להגדיל או להקטין את גודל הערימה. באופן זה, Go יכולה להתחיל עם גודל ערימה קטן מאד של 2 קילו בייט בלבד. הודות לכך, 10,000 בקשות מהשרת מתחילות עם שטח זיכרון של 20 מגה בייט לאחסון הערמות, במקום 10 גיגה בשימוש בגודל ערמות קבוע של 1 מגה בייט.

המתזמן של Go

הבעיה הבאה אתה התמודדנו בשיטת התהליכונים המרובים הייתה העלות הגבוהה של מעבר בין תהליכונים (context switching). מערכת ההפעלה יכולה לבצע מעבר בין תהליכונים בכל רגע נתון.. מנגד, מעבר בין ה-Goroutines מתבצע על ידי המתזמן (scheduler) תוך מודעות לקוד, והמעבר מתקיים רק בנקודות ספציפיות. הנקודות הללו הן השלבים בקוד שמתאימים ביותר למעבר, וכוללות אירועי קלט/פלט, קבלה ושליחה של נתונים על channel, פעולת sleep או במהלך ביצוע בדיקה של גודל הערימה הנדרש תוך כדי ריצה. הודות לכך שהמתזמן של Go מודע לקוד ועובר בין שגרות רק בנקודות מוגדרות, הוא נדרש לשמור רק שלושה אוגרים (רגיסטרים), שמספיקים כדי לאחזר את השגרה לאחר מכן. לשם השוואה, כאשר מערכת ההפעלה מבצעת מעבר בין תהליכונים, כיוון שהיא לא מודעת לקוד שהתהליך מריץ, היא נאלצת לשמור את כל האוגרים של המעבד, מה שגורם למעבר בין התהליכונים לבזבז כל-כך הרבה משאבים. בזכות התזמון השיתופי שמבצע המתזמן של Go, מוקטנת בצורה משמעותית התקורה של מעבר בין תהליכונים, והשימוש בשגרת Goroutine עבור כל בקשה מהשרת הרבה יותר יעיל מאשר הגישה של תהליכון עבור כל בקשה.

כיוון ששגרות ה-Goroutine רצות על התהליכונים של מערכת ההפעלה, חשוב לא לחסום את התהליכונים הללו, אחרת ל-Go לא תהייה דרך להעביר את התהליכון לטיפול בשגרה אחרת. כלומר, כל פעולה שעלולה לחסום יש ליישם תוך שימוש בפקודות API אסינכרוניות או שימוש בקוד Go נייטיב. למשל, כדי לוודא ששימוש במנעולים (mutexes) אינו חוסם את התהליכון, הם מיושמים ב”מרחב משתמש” (user space) בשפת Go במקום שימוש במנעולים המבוססים על ליבת מערכת ההפעלה (kernel). כשאחת השגרות ממתינה במנעול, המתזמן שם אותה בצד ומיידע אותה כשהמנעול פנוי. אם שגרה מבצעת קריאת מערכת, המתזמן של Go יוצר תהליכון חדש עבור שגרת ה-Go החדשה, ומתזמן את השגרה הראשונה כאשר הקריאה חוזרת. עבור פעולות קלט/פלט ברשת, Go משתמש בתת-מערכת הנקראת Netpoller. מערכת זו מריצה תהליכון יחיד, בדומה לאופן בו מיושמות לולאות האירועים ב-Node.js, והיא משתמשת בקריאות API אסינכרוניות של מערכת ההפעלה לביצוע קריאות רשת או קלט/פלט מקומי. ההבדל פה מבחינת המפתחים הוא שהם לא נדרשים לטפל בקריאות חוזרות (callbacks). ניתן לכתוב את הקוד בצורה סדרתית, ומערכת ה Netpoller מטפלת ברקע בכל הקריאות בחזרה. באופן זה, Go מאפשרת את היעילות של קריאות רשת אסינכרונית אך ללא המורכבות בקוד.

כפי שניתן לראות, Go מצליחה להשיג את הטוב שבשני העולמות. היא מאפשרת עבודה במודל הדומה להקצאת תהליכון לכל בקשה, אך מאפשרת גדילה משמעותית בכמות הבקשות המטופלות, וזאת תוך מינימום בזבוז משאבים בגלל ערמות גדולות מהדרוש ו-context switching. היא מונעת חסימה של המעבד על-ידי מטלות עיבוד כבדות, משתמשת בכל ליבות המעבד הזמינות, ומפשטת מאד את מודל התכנות, כך שיהיה פשוט וקל ללמידה. ניתן לצפות בחלק מתהליך הלמידה שלנו, במפגש בו נידונה השאלה האם Go היא שפת תכנות מכוונת עצמים. לאחר ש-Go החליפה את Node.js בשירות מיקום הנהגים, הצלחנו להשיג זמן תגובה ממוצע של 2 מילישניות בלבד בעת טיפול ביותר מ-200 אלף בקשות לדקה. זה בהחלט מסביר את ההחלטה שלנו לעבור ל-Go כשפת התכנות העיקרית שלנו בכל הפיתוחים העתידיים.

הכותב הינו סיסטם ארכיטקט ב-Gett

כתב אורח

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

הגב

23 תגובות על "כיצד מטפלים ב-200 אלף בקשות לדקה? [פיתוח]"

avatar
Photo and Image Files
 
 
 
Audio and Video Files
 
 
 
Other File Types
 
 
 

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

סידור לפי:   חדש | ישן | הכי מדורגים
חחו
Guest

רמה ביזיונית

Tzachi
Guest

relax : )

ניר
Guest

למה? פרט, נמק והסבר אם תרצה שייקחו אותך ברצינות

יואב
Guest

מעניין מאד,
אנחנו גם נתקלנו בבעיה של 33,000 קריאות בשעה
החלטנו להשתמש בphp gearman כפתרון זמני והוא עבד נהדר

יוני
Guest

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

Vg jdah
Guest

שום מילה טכנית. הזוי

מעריב לנוער שלום
Guest
מעריב לנוער שלום

מה אני קורא על טכנולוגיה במעריב לנוער?
פוסט מגוכח

cap
Guest

“הטיפול ב-callbacks מסובך מאד ברמת התכנות”?? אתה מעסיק מפתחים נטולי ניסיון?
“כשמשתמשים רק בתהליכון אחד, המשמעות היא שמשתמשים רק בליבה אחת של המעבד.”…. אז משתמשים בcluster…

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

יוני
Guest

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

יוני
Guest

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

אביעד
Guest

היום, שימוש ב async/await מאפשר לכתוב קוד node.js בלי שום התייחסות ל callbacks

ארכיטקט
Guest

כתוב מאוד ברור, כך שכל ילד בן 5 מסוגל להבין.
לכתוב מסובך עם ים של מוסגים מסובכים כך שבסוף אף אחד לא יבין.. זה כל אחד יכול.
לכתוב כך זה אומנות!

א.ב
Guest

כתבה טכנולוגית בהחלט מעניינת. תודה! אני לא מכיר את GO אבל מהתיאור זה נראה מורכב ולא פשוט לדיבוג, במיוחד שתגיעו לבעיות ביצועים. אגב לא אמרת כמה חומרה זרקת בשביל לקבל את הביצועים שציינת.(2 ms)

אלעד
Guest

הכתבה נראית מושקעת.. אבל הפסקתי לקרוא אחרי הפסקה הראשונה. יש פה איזה ניסיון להדר את Go ולפי הכותרת של הכתבה משתמע שזאת הדרך היחידה להגיע לסקייל יפה..
מנסיון אישי הצלחתי להגיע לconcurrency של 15 מיליון בשניה עם 40 CPUs .. ככה ש200k בדקה זה נחמד אבל לא מאוד מרשים אם אתה מדבר תכלס על performance.
ולא היה לזה שום קשר לGo במקרה שלי..
הכתבה הייתה הרבה יותר לגיטימית אם הכותרת הייתה:
“למה Go מאפשרת להגיע לסקייל של…״

טללל
Guest

יאללה אתה והvertx שלך

הסרתי את gett
Guest

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

Gett
Guest

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

pery mimon
Guest

מעניין מאוד . תודה

eran
Guest

stack זה מחסנית, קצת מבלבל כשקוראים לזה ערימה

עושה קופי פסט מגוגל
Guest
עושה קופי פסט מגוגל

יאללה יאללה סתם התלהבתם ללמוד שפה חדשה ומצאתם אחלה של תירוץ למנהלים.

לירון
Guest

ומה לגבי שימוש בזה :
https://github.com/audreyt/node-webworker-threads
גם היה פותר לך את הבעיה בלי לעבור ל go

עידן
Guest

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

Steve
Guest

כתבה כתובה טוב. אהבתי גם א הסקירה ההיסטורית וגם את היתרונות/חסרונות של כל שיטה

wpDiscuz

תגיות לכתבה: