מאחורי הקלעים של SQL עם ה-Execution Plan

בכתיבת שאילתת SQL יש קושי אותו איננו חווים בעבודה עם שפות התכנות המקובלות, כמו C, Python, Java או JavaScript. שפות התכנות הללו הן אימפרטיביות, מה שאומר שהקוד הוא רשימת פקודות אותן אנו מעבירים לקומפיילר או לאינטרפרטר. כך גם כאשר איננו מבינים בדיוק לאילו פקודות מכונה מתורגמות הפקודות שלנו, עדיין יש לנו מושג כללי איך נראה המבנה של הקוד. למשל, אנחנו מבינים שכשכתבנו לולאה הקוד שבתוכה ירוץ כמספר הפעמים שהגדרנו, או שכאשר הגדרנו תנאי הקוד שבתוכו ירוץ בהתאם לקיום התנאי.

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

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

שימוש ב-Execution Plan

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

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

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

הפלט של הפקודה הוא תיאור ה-execution plan הזה:

Sort (cost=10.26..10.27 rows=1 width=20) (actual time=0.078..0.079 rows=1 loops=1)
  Sort Key: amount
  Sort Method: quicksort Memory: 25kB nbsp; -> Seq Scan on payment (cost=0.00..10.25 rows=1 width=20) (actual time=0.063..0.072 rows=1 loops=1)
        Filter: (payment_date = '2020-01-13'::date)
        Rows Removed by Filter: 499
Planning time: 0.089 ms
Execution time: 0.095 ms

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

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

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

אינדקס

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

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

נריץ את השאילתא שהרצנו קודם שוב, ונסתכל על ה-execution plan כדי לראות מה השתנה:

Sort (cost=8.30..8.31 rows=1 width=20) (actual time=0.031..0.031 rows=1 loops=1)
  Sort Key: amount
  Sort Method: quicksort Memory: 25kB
  -> Index Scan using ix1 on payment (cost=0.27..8.29 rows=1 width=20) (actual time=0.018..0.019 rows=1 loops=1)
        Index Cond: (payment_date = '2020-01-13'::date)
Planning time: 0.178 ms
Execution time: 0.058 ms

אנו רואים שכעת במקום לסרוק שיטתית את כל שורות הטבלה כדי לקרוא את הערך payment_date, השרת מבצע Index Scan – הוא מסוגל לבצע חיפוש בינארי של כל השורות המתאימות לתנאי של payment_date משום שיצרנו אינדקס לפי הערכים הללו. ואכן, אנו רואים שבמקום 0.043 מילישניות שארכה הסריקה בשאילתא הקודמת, הפעם הסריקה ארכה 0.001 מילישיניות בלבד. שיפור הביצועים ניכר גם בזמן הכללי שארך ביצוע השאילתא – 0.058 מילישיניות במקום 0.133 מילישיניות בשאילתא הקודמת.

אופטימיזציות מתקדמות

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

מבחינה לוגית השאילתא היא אותה שאילתא אבל שרת הפוסטגרס יבצע אותה בדרך אחרת. נסתכל ב-execution plan של השאילתא השניה:

Sort (cost=8.43..8.44 rows=5 width=20) (actual time=0.034..0.035 rows=5 loops=1)
  Sort Key: amount
  Sort Method: quicksort Memory: 25kB
  -> Bitmap Heap Scan on payment (cost=4.31..8.37 rows=5 width=20) (actual time=0.022..0.025 rows=5 loops=1)
        Recheck Cond: (payment_date = '2018-06-09'::date)
        Heap Blocks: exact=3
        -> Bitmap Index Scan on ix1 (cost=0.00..4.31 rows=5 width=0) (actual time=0.015..0.015 rows=5 loops=1)
              Index Cond: (payment_date = '2018-06-09'::date)
Planning time: 0.156 ms
Execution time: 0.061 ms

אנו רואים שהשרת מתחיל עם סריקה על האינדקס אבל הוא מבצע גם Bitmap Heap Scan on payment. מה זה אומר?

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

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

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

לסיכום

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

4 תגובות בנושא “מאחורי הקלעים של SQL עם ה-Execution Plan

הוסיפו את שלכם

  1. מרתק ממש!
    אני עובדת לא מעט עם שאילתות, פשוטות אמנם, ולא ניתקלתי בזה עד עכשיו.
    האם עובד גם ב-mySql?
    אני כ ר יכולה לחשוב על שאילתה שהייתי מאד רוצה לדעת מה קורה מאחורי הקלעים שלה כדי לאפטם אותה.

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

נשמח לשמוע מה אתם חושבים על המאמר

ערכת עיצוב: Baskerville 2 של Anders Noren.

למעלה ↑

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

נרשמת בהצלח. בתודה, הגמל.

שגיאה בלתי צפויה, אנא נסה שוב.

camelCase will use the information you provide on this form to be in touch with you and to provide updates and marketing.