Entity Framework והטעויות שיגרמו לכם לבעיות ביצועים

בעשור האחרון, מרבית הפיתוח מול הדטאבייסים עושה שימוש ב- ORM-ים כאלה ואחרים. אחד ה- ORM-ים הפופולריים בעולם הדוטנט, הוא Entity Framework. בפוסט הזה, אסקור טעויות נפוצות שמשליכות על ביצועים של עבודה מול ה- DB בעבודה עם ORM-ים באופן כללי, ובפרט בעבודה עם Entity Framework.
כלל הדוגמאות בפוסט מבוססות על ה- DB של Stack Overflow, בגרסה שהתאמתי עבור הקורס SQL שהקלטתי בעבר. הדוגמאות מבוססות על Entity Framework 6.2, על אף שחלקן רלוונטיות לכל סוגי ה- ORM-ים.

טעות #1 : בעיית ה- N+1

בואו נניח שאנחנו רוצים להציג את כל השאלות ב- Stack Overflow, ולצד כל שאלה – את המשתמש שכתב אותה. נסתכל בקוד הבא:

using (var data = new Entities())

{

    foreach (var question in data.Posts.Where(k=>k.Title != null))

    {

        Console.WriteLine($"{question.Title} was asked by {question.User.DisplayName}");

    }

}

ועכשיו נשאלת השאלה – כמה שליפות SQL רצו פה מול ה- DB? אם אמרתם ששליפה בודדת, אז טעיתם. נכון, אפשר להביא את המידע הזה בשליפה בודדת, ואף רצוי להביא אותו בשליפה בודדת, אבל מתבצעים לנו פה הרבה יותר שליפות: השליפה הראשונה נגרמת בגלל הלולאת foreach. אנחנו רוצים לעבור על כל הפוסטים שהכותרת אינה null, ולכן מתבצעת שליפה מול ה- DB כדי להביא את כלל הפוסטים הללו.
לאחר מכן, עבור כל פוסט, אנחנו מציגים את הכותרת שלו (שזה מידע שכבר הבאנו) ואת שם המחבר – את המידע על שם המחבר לא הבאנו, אבל Entity Framework עושה שימוש באחד הפיצ’רים שהופכים אותו למאד נוח – Lazy loading. בנק’ הזאת, כשאנחנו צריכים את המידע על המשתמש שכתב את הפוסט, הוא הולך להביא אותו. כל פעם עבור פוסט בודד, ואת זה הוא עושה n פעמים – כאשר n הוא מס’ האיטרציות של הלולאה, כלומר מס’ הפוסטים שענו על התנאי בתחילת ה- foreach.

למה זה בעייתי? דבר ראשון, מבחינת ביצועים: במקום לעבור roundtrip בודד מול ה- DB, אנחנו עוברים הרבה יותר (מס’ שגדל לינארית לפי כמות המידע). בהנחה שלכל שליפה, פשוטה ככל שתהיה, יש overhead (לכל הפחות, ה-IO הרשתי) – הכמות של זה עלולה להיות בעייתית.
דבר שני, לפעמים השליפה שרוצים לעשות כדי להביא את המידע הפנימי היא קצת יותר מורכבת, ודורשת מה- DB עבודה קשה יותר. במקרה הזה, במקום לעשות זאת פעם אחת – נידרש לעשות את זה הרבה יותר פעמים.

גרסה מתוקנת של הקוד הזה, תיראה כך:

using (var data = new Entities())

{

    var relevantData = data.Posts.Where(k => k.Title != null).Select(k =>

        new

        {

            Post = k,

            UserDisplayName = k.OwnerUser.DisplayName

        });

    foreach (var question in relevantData)

    {

        Console.WriteLine($"{question.Post.Title} was asked by {question.UserDisplayName ?? "Unknown"}");

    }

}

חשוב לשים לב, שבעוד שבדוגמא הנ”ל הבעייה מאד בולטת – במציאות, בעיית ה- N+1 עלולה להיות עטופה בהרבה מאד קוד מסביב ופחות ברורה כדי לשים לב אליו. כמובן, שאם מפעילים Profiler (או מסתכלים ב- Intellitrace על השליפות שה- Entity Framework מריץ) – זה נעשה מאד בולט.

טעות #2: להביא את כל המידע ולפלטר ב- client

public IEnumerable<Post> GetPosts()

     {

         using (var data = new Entities())

         {

             return data.Posts;

         }

     }

 

ND SOMEWHERE ELSE */

 

     public void DoSomething()

     {

         foreach (var post in GetPosts().Where(k=>k.Title == null))

         {

             Console.WriteLine(post.Title);

         }

     }

הרבה אפליקציות מכילות קוד כמו בדוגמא הנ”ל: איפשהו, באיזשהו חלק תשתיתי, יש מתודות להבאת ה- data, ובמקומות אחרים עושים שימוש ב- data. לקוד כזה יכולות להיות כל מיני בעיות – אם מסתכלים ספיציפית על הדוגמא שאני שמתי, אז הקוד ב- DoSomething שעובד עם ה- Posts, לא מחובר למעשה למודל של ה- EF (אבל גם את זה יש דרכים לפתור, ע”י דיזיין מתאים). אבל, הבעייה שבה אני רוצה להתמקד היא אחרת – איפה נעשה הפילטור של המידע. במקרה הזה, מכיוון ש- GetPosts מחזירה IEnumerable, כלל הפילטורים (כמו הפילטור על ה- Posts שהכותרת שלהם שווה ל- null), מתבצעים ב- client – ולא בשרת.
מה הבעייה? אנחנו עלולים למצוא את עצמנו מביאים הרבה מאד data לחינם, לפעמים גם פעמים רבות, במקום לבצע את הפעולות ב-DB (ולעשות שימוש למשל באינדקסים שמוגדרים).

מה הלקח? להיות מודעים לאיפה הפילטורים/פעולות שאתם עושים על ה- data רצים – האם בזיכרון של ה- client, לאחר שכל ה- data הגיע לשם, או בצד השרת כחלק מה-SQL שנוצר מתרגום ה- expression tree.

מעשית, אם יש לכם שכבה תשתיתית שמביאה מידע שיעבור פילטורים ופעולות נוספות לאחר מכן, הקפידו על שני הדברים הבאים: שהפעולות יהיו תחת אותו instance של ה- context, ושהמתודות שלכם שמחזירות תוצאות לא סופיות, שעוד יעברו פילטורים בהמשך, יחזירו IQueryable – כלומר, אובייקט שמייצג את ה- expression tree, שעוד יכול לעבוד לשינויים, טרם התרגום ל- SQL הסופי שמורץ.
אם אתם לא מכירים את הנושאים הללו, ממליץ לכם לקרוא עוד על ההבדל בין IQueryable ו- IEnumerable.

טעות #3: לעשות ב- MSSQL פעולות מורכבות, שהיה עדיף לעשות ב- Client

בעייה מקבילה לבעייה הקודמת, היא לבצע דברים בצד של ה- MSSQL שיהיה יותר יעיל וזול (גם מבחינת זמן, וגם מבחינה כלכלית) לבצע ב- client.
שרת ה- MSSQL אחראי לשרת הרבה מאד לקוחות. לעשות לשרת הזה scaling זאת גם משימה עם מורכבויות משלה – גם טכנולוגיות (כמה חומרה חזקה אפשר לדחוף) וגם מבחינה כלכלית (רישוי של MSSQL הוא לא זול במיוחד). למעשה, אם השרת MSSQL עמוס ועובדות מולו הרבה אפליקציות, והרבה צרכנים, סיכוי סביר שהוא ה- bottleneck (או הכי קרוב להיות כזה).
ולכן, חשוב לחשוב כל הזמן איפה נכון שיתבצעו הפעולות על ה- data. לפעמים, התשובה היא לא בשרת MSSQL, אלא בשרת האפליקציה (כי יותר קל לעשות לו scale-out, יותר זול להרים instance-ים נוספים שלו, כי כשמכירים בדיוק מה רוצים שיקרה אפשר לבנות לזה תהליך ספיציפי ומותאם הרבה יותר).
במקרים הללו אין כלל אצבע מובהק, אבל כן צריך להיות מודעים לכך שעצם העובדה שאפשר לבטא הכל בשאילתה, לא אומר שצריך לעשות הכל בשאילתה – ואפשר חלק מזה לעשות גם בקוד C#, אחרי שהמידע הגיע – גם אם לא בדיוק בצורה שאתם רוצים אותו בה בסוף, ולא רק המידע הספיציפי שמעניין אתכם.

טעות #4: להביא Data שלא צריך

כשכותבים קוד עם EF ו- LINQ to Entities, קל לשכוח שלא עובדים עם data שכבר נמצא בזיכרון, אלא עם מידע שצריך להגיע אלינו מ- resource מרוחק. במקרה הזה, עלולה להתערב לפעמים עלות הבאת המידע. נסתכל למשל בקוד הבא:

using (var data = new Entities())

{

    foreach (var question in data.Posts)

    {

        Console.WriteLine($"{question.Title} was asked at {question.CreationDate)}");

    }

}

סה”כ נראה תמים – אבל מה אם היינו יודעים שהטבלה Posts מכילה עמודה שמכילה את כל ה- content של הפוסט? מכיוון שאין בקוד שכתבנו שום דבר שמתייחס לעמודה הזאת, קל מאד לקרוא את הקוד הנ”ל ולחשוב שהכל בסדר – אנחנו רק עובדים עם ה- Title וה- CreationDate. בפועל, EF ממלא את כל האובייקט של ה- Posts (הוא הרי לא יודע באיזה שדות עשינו שימוש בפועל), מה שגורם לכך שאנחנו מערבים הרבה עמודות מיותרות.

הבאה של עמודות מיותרות (בדגש חזק על עמודות גדולות) עולה לנו לאורך כל הדרך:

  • משפיעות על ה- execution plan שנוצר, ועלולות לגרום ליצירת execution plan פחות אופטימלי מאשר אם היינו שולפים רק את העמודות שהיינו רוצים (גם מבחינת צורת הרצת השאילתה עצמה, גם מבחינת ה- memory grants)
  • עלולות לייצר לנו wait-ים על RESOUCE_SEMAPHORE, אם כללנו עמודה גדולה שגרמה לכך שהערכת הזיכרון הנדרשת לשליפה היא גדולה משמעותית
  • מגדיל את ה- IO שהשליפה דורשת (למשל, במקרה שיש spills [שזאת כבר בעייה בשליפות שאנחנו רוצים שיהיו מאד מהירות] – ייכתב יותר מידע)
  • דורש את העברת המידע בסוף, כחלק מהתוצאה.

מכיוון שלצרף בסוף של כל שליפה גם קריאה ל- Select, שבה מציינים בדיוק מה רוצים זה לא הכי נוח (מסרבל את הקוד, מקשה על להעביר את ה- anonymous object שמתקבל למתודה אחרת), צריך לעשות את ה- trade-off בין נוחות לביצועים. במקרה הזה, ההמלצה המרכזית שלי היא להקפיד לכל הפחות, במידה שיש עמודה עם ערכים גדולים, להפריד אותה ברמת המודל לאובייקט נפרד, עם association לאובייקט המקורי – כך שללא פנייה מפורשת ל- navigation property, המידע הגדול לא יישלף.

טעות #5: להתעלם מהמבנה הפיזי של הטבלאות ב-DB

כאמור, כאשר אנחנו כותבים קוד ב- EF אנחנו אולי מרגישים שאנחנו עובדים עם data שכבר זמין לנו בזיכרון, אבל בפועל הוא לא שם – ויש את העלות של להביא אותו. לכן, כל שליפה ב- EF צריכה להיכתב בדיוק כפי שהייתה נכתבת אילו הייתה שליפת SQL. בפרט, כאשר כותבים שליפת SQL, מקפידים לחשוב על מבנה הטבלה – מי ה- clustered index, איזה nonclustered indexes קיימים וכו’. גם כשכותבים קוד ב- Entity Framework חשוב לשים לב לדברים האלה.
למשל: אתם רוצים לעשות OrderBy לטבלה כדי להביא את 10 הרשומות האחרונות. ונניח שאתם יודעים שלוגית המיון לפי ה- Id ולפי ה- CreationDate הם שקולים. לפי מה תמיינו? התשובה, במקרה הזה, היא לפי העמודה שמוגדרת כ- clustered index בטבלה – כי זאת העמודה שהמיון לפיה יהיה הכי זול.
גם כשכותבים קוד ב-EF, צריך להיות מודעים כל הזמן למבנה הפיזי של הטבלה, ול- data structures שקיימים לנו, כדי לעשות את ההחלטות הכי טובות תוך כדי פיתוח.

טעות #6: להתעלם מאיך ש- Entity Framework מתרגם את השליפות שלכם ל- SQL

Entity Framework (והרבה ORM-ים אחרים), הם למעשה parser שממיר איזשהו expresion tree שמייצג את הפעולות שאנחנו רוצים לעשות, ל- SQL שזאת השפה שבה בסוף השאילתה צריכה להיות מנוסחת מול ה- DB.
לא תמיד ה-parser הזה עובד בצורה שאתם רוצים. לפעמים, יכולים להיות מקומות שבהם ה- SQL שנוצר פועל בצורה פחות יעילה. זה יכול להיות שהוא נוצר בצורה כזאת שגוררת יצירת execution plan פחות טוב מזה שהיה נוצר אם הייתם כותבים את השליפה קצת אחרת, אבל בצורה ששקולה מבחינת התוצאות.
פעמים אחרות, זה יכול להיות כתוצאה מניואנסים בצורה שבה ה- SQL נוצר שנוגעות בנקודות ש-“כואבות” בזמן הרצת השליפה – למשל, חוסר היכולת להתשמש באינדקסים מסויימים או להסתמך על סטטיסטיקות. דוגמא לכך, אפשר לראות בפוסט שכתבתי בעבר על הפונקציה TruncateTime שמייצרת non-SARGable predicate (כזה שמונע שימוש יעיל באינדקסים ו-pushdown של הפעולה לרמת ה-storage engine), וגוררת בעיית ביצועים.

כלומר, כשאתם כותבים קוד שעושה שימוש ב- EF ונתקלים בבעיות ביצועים – חשוב להסתכל גם על ה- SQL שנוצר, ולראות שהוא באמת תואם לאיך שהייתם רוצים שהוא יהיה. חשוב לזכור שאותם הדברים שגורמים לבעייות ביצועים כשכותבים שליפות (non-SARGable queries, המרות [cast-ים] וכו’) – יגררו בעיות ביצועים באותה המידה גם אם הם יהיו בשליפות ש-EF מייצר. ההבדל הוא שמכיוון שאנחנו לא כותבים את השאילתות, אנחנו לא רואים את זה וזה לא “צועק” לנו בעין.

טעות #7: לשכוח ש- Entity Framework עושה פעולות CRUD אחת-אחת

מוסיפים שורות? מוחקים שורות? מעדכנים שורות? הצורה שבה Entity Framework מבצע את הפעולות הללו היא אחת-אחת. כלומר, אם כדי לעדכן ב- SQL ערכים הייתם כותבים שליפה בסגנון:

UPDATE Posts SET ViewCount = ViewCount + 1 WHERE OwnerUserId = 9

כדי לבצע עדכון של מס’ שורות שעונות על תנאי מסויים, ב- entity framework אם תכתבו את הקוד הבא:

using (var data = new Entities())

{

    foreach (var post in data.Posts.Where(k=>k.OwnerUserId == 9))

    {

        post.ViewCount++;

    }

 

    data.SaveChanges();

}

אז בפועל תורץ שליפת SELECT, כדי להביא את כל המשתמשים שה- OwnerUserId הוא 9 (נניח שיש n שורות כאלה) ואז יורצו n שליפות UPDATE, כל אחת על ה- ID הספיציפי שצריך להתעדכן.

הסיבה שהמנגנון עובד כך היא כפולה: גם כדי לשמר נוחות פיתוח, וגם כדי לשמור על “נכונות” הפעולות עדכון שעושים (נעשה שימוש ב- optimistic concurrency, כלומר כחלק מה- UPDATE – מתווסף WHERE שכולל בדיקה שהתוכן של כל השדות [או אם יש rowversion, אז של ה- rowversion] זהה לתוכן שאפליקטיבית אנחנו חושבים שיש לכל השדות, כדי שבמידה שזה לא מתקיים יתעדכנו 0 שורות – וייזרק exception). זה כמובן נדרש בגלל שבין הרגע שבו קיבלנו את המידע על האובייקט כחלק מה- SELECT, ועד שעדכנו אותו, יכלו לקרות פעולות במקביל.

על צורת העבודה הזאת אנחנו משלמים לא מעט. גם תוספת ה- roundtrips, גם המתנה (פוטנציאלית) לקבלת ה- lock עבור כל אחת מהפעולות, גם העדכון עצמו.
גם במקרה של הוספת שורות, ל-MSSQL יש מנגנונים לטעינת כמות גדולה של שורות – BULK INSERT ש- Entity Framework לא כולל את התמיכה בהם.

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

טעות #8: לא להכיר את המנגנון של ה- Change Tracking

כשאתם עושים SaveChanges, באופן “קסום”, Entity Framework יודע איזה שליפות להריץ כדי לעדכן את ה- DB בהתאם לשינויים שעשיתם על ה- entities השונים.
כדי להבין איך זה קורה, בואו נסתכל בצילום-מסך הבא:

image

הקוד בדוגמא הזאת פשוט מאד, אנחנו לוקחים את ה- 10 פוסטים הראשונים. כצפוי, ה- type של items זה מערך של Post. אבל מה האובייקטים שנמצאים באיברי המערך? אנחנו יכולים לראות שמדובר באובייקטים של איזשהו type עם שם מוזר. נעים להכיר, אלה ה- dynamic proxies. מדובר ב-types שנוצרים דינמית, שיורשים מה-type המקורי (Post, במקרה שלנו), ומכילים עבור ה- properties השונים getters/setters שמעדכנים את ה- entry המתאים ש- Entity Framework מנהל עבור ה- context הספיציפי בכך שהאובייקט הזה השתנה.
כשנעשה SaveChanges, בפועל EF יעבור על כל ה- entries ש- attached ל- DbContext, יזהה איזה מהם עברו שינויים שמחייבים כתיבה ל-DB, ויריץ את הפעולות המתאימות מול ה- DB כדי לבצע את השינויים.
מה שחשוב לזכור – זה של- Change Tracking הזה יש עלות. בואו נסתכל בקוד הבא:

Stopwatch sw = new Stopwatch();

using (var data = new Entities())

{

    var items = data.Posts.Take(100_000).ToList(); 

    sw.Start(); 

    foreach (var item in items)

    {

        //We change, and then reverse the change - 

        //no UPDATE statements will run.

        item.CreationDate = item.CreationDate.AddYears(1);

        item.CreationDate = item.CreationDate.AddYears(-1);

    }

 

    Console.WriteLine(sw.ElapsedMilliseconds); //~40ms

    data.SaveChanges(); 

    sw.Stop();

    Console.WriteLine(sw.ElapsedMilliseconds); //~900ms

 

}

מה שאנחנו עושים פה, זה להביא 100,000 רשומות ש- attached ל- context, לעשות לכל אחת מהן שינוי – ואז להחזיר אותו אחורה (זה אומר שאנחנו מבצעים שינוי, אבל לא כזה שיגרור הרצת UPDATE על ה- DB, כי EF חכם מספיק כדי להימנע מזה). החלק הזה לוקח כ- 40ms (אגב, קוד שעושה שינוי זהה על instance רגיל של אובייקט Post, ולא של Dynamic Proxy, מסתיים תוך 2ms). החלק של ה- SaveChanges, שבמסגרתו מזוהים מה השינויים שצריך להריץ (או לא צריך להריץ, במקרה הזה) מול ה- DB – לוקח עוד 900ms.
אם היינו מגדירים את data.Configuration.AutoDetectChangesEnabled ל- false, אז הקריאה ל- SaveChanges לא הייתה גוררת את הקריאה ל- DetectChanges, והביצועים היו יותר מהירים. אולם, במקרה הזה, האחריות על לעדכן את ה- state של כל entry שהשתנה ל- Modified (כדי שאכן יוכל להתעדכן) – הייתה עלינו.

עלויות דומות אנחנו משלמים על קריאה ל- Add לאובייקטים כדי להוסיף אותם ל- DB.

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

טעות #9: להשתמש ב- MSDTC כשלא באמת רציתם להשתמש ב- MSDTC

התבוננו רגע בקטע קוד הבא:

using (var data1 = new Entities())

using (var data2 = new MSSQLTrackEntities())

{

    using (var transaction = new TransactionScope())

    {

        var user1 = data1.Users.First(k => k.Id == 1);

        var d2 = data2.AlwaysOnStates.FirstOrDefault();

        transaction.Complete();

    }

}

לכאורה, קטע קוד פשוט שכולל טרנזקציה (ללא אפילו ביצוע שינויים ב- DB). בפועל, יש פה נק’ מעניינת – אנחנו עושים שימוש בשני DbContext-ים שעובדים מול דטאבייסים שונים (data1 ו-data2).
השימוש הזה בשני דטאבייסים, הוא רק דוגמא אחת מני כמה למצבים שגורמים לכך שטרנזקציה לוקאלית (מול אחד הדטאבייסים, או מול שניהם – כל אחד בנפרד) לא הייתה נותנת את האפקט הרצוי. במצב כזה, rollback מול DB אחד לא היה גורר rollback מול ה- DB השני.
לכן, הקוד הנ”ל יגרום לרכיב בשם MSDTC (Microsoft Distributed Transaction Coordinator) להיכנס לתמונה. התפקיד של MSDTC הוא לנהל טרנזקציה שמבוזרת על פני מס’ resource-ים. בין אם טרנזקציה שכוללת שרתי DB שונים, ובין אם טרנזקציה שכוללת סוגים שונים של משאבים (למשל, טרנזקציה שכוללת MSSQL ואורקל, או DB וקבצים).

מכיוון שאנחנו עושים שימוש ב- TransactionScope, ומכניסים לטרנזקציה עבודה מול שני דטאבייסים שונים – מתבצעת פנייה ל- MSDTC ונוצרת טרנזקציה מולו (שמאחורי הקלעים, הוא מתפעל טרנזקציה בכל אחד משרתי ה- DB בנפרד, ועושה two-phase commit).

עד עכשיו, ניתן היה לחשוב שהנושא הזה רלוונטי רק למי שמשלב מס’ דטאבייסים, אבל לא כך הדבר. גם קוד שעובד מול אותו ה- DB, ויוצר מולו מס’ instance-ים של ה- DbContext, עלול להפוך לטרנזקציה מבוזרת מול ה- MSDTC. למשל:

using (var data1 = new Entities())

using (var data2 = new Entities())

{

    using (var transaction = new TransactionScope())

    {

        foreach (var user in data2.Users)

        {

            var user1 = data1.Users.First(k => k.Id == 1);

            user.CreationDate = user1.CreationDate;

        }

        transaction.Complete();

    }

}

ו-flow כזה, שבו נוצר nesting של context-ים של Entity Framework גם יכול לקרות בקוד, אם לא משתמשים בדיזיין שמונע את זה (ועדיף למנוע, כי זה עלול לגרור גם בעיות נוספות הקשורות ל- change tracking, בלבול בין אובייקטים וה- context שאליו הם attached וכו’).

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

אז מה אפשר לעשות? להבין איך עובדות טרנזקציות ב- Entity Framework ולפעול בהתאם, לנקוט צעדים כדי למנוע שימוש ב- MSDTC באופן גורף, אם אתם יודעים שזאת לא התנהגות רצוייה מבחינתכם (הוספת Enlist=False ל- connection string), וכמובן – כל פעם שמשתמשים ב- TransactionScope, לחשוב טוב מה למעשה כלול בטרנזקציה הזאת.
”תת טעות” נוספת, שצריך להיות מודעים אליה (על אף שהיא ממש לא ייחודית רק ל- Entity Framework), זה שה- Isolation Level הדיפולטי של TransactionScope הוא Serializable. בחירה ב- Serializable אמנם נותנת את ההבטחות הכי חזקות, אבל מונעת מיקבול.

טעות #10: שליפות גנריות

בואו נסתכל על הקוד הבא:

private static IEnumerable<User> Search(int? userId, DateTime? creationDate, decimal? age, string emailHash)

{

    using (var data = new Entities())

    {

        return data.Users.Where(k=>

            (userId == null || k.Id == userId) &&

            (creationDate == null || k.CreationDate == creationDate) &&

            (age == null || k.Age == age) &&

            (emailHash == null || k.EmailHash == emailHash)

        ).ToArray();

    }

}

קריאה לקוד הזה, תגרור הרצה של ה- SQL הבא:

exec sp_executesql N'SELECT 

    [Extent1].[Id] AS [Id], 

    [Extent1].[AboutMe] AS [AboutMe], 

    [Extent1].[Age] AS [Age], 

    [Extent1].[CreationDate] AS [CreationDate], 

    [Extent1].[DisplayName] AS [DisplayName], 

    [Extent1].[DownVotes] AS [DownVotes], 

    [Extent1].[EmailHash] AS [EmailHash], 

    [Extent1].[LastAccessDate] AS [LastAccessDate], 

    [Extent1].[Location] AS [Location], 

    [Extent1].[Reputation] AS [Reputation], 

    [Extent1].[UpVotes] AS [UpVotes], 

    [Extent1].[Views] AS [Views], 

    [Extent1].[WebsiteUrl] AS [WebsiteUrl], 

    [Extent1].[AccountId] AS [AccountId]

    FROM [dbo].[Users] AS [Extent1]

    WHERE (@p__linq__0 IS NULL OR [Extent1].[Id] = @p__linq__1) AND (@p__linq__2 IS NULL OR @p__linq__3 =  CAST( [Extent1].[CreationDate] AS datetime2)) AND ((@p__linq__4 IS NULL) OR ( CAST( [Extent1].[Age] AS decimal(19,0)) = @p__linq__5) OR (([Extent1].[Age] IS NULL) AND (@p__linq__5 IS NULL))) AND ((@p__linq__6 IS NULL) OR ([Extent1].[EmailHash] = @p__linq__7) OR (([Extent1].[EmailHash] IS NULL) AND (@p__linq__7 IS NULL)))',N'@p__linq__0 int,@p__linq__1 int,@p__linq__2 datetime2(7),@p__linq__3 datetime2(7),@p__linq__4 decimal(2,0),@p__linq__5 decimal(2,0),@p__linq__6 nvarchar(4000),@p__linq__7 nvarchar(4000)',@p__linq__0=NULL,@p__linq__1=NULL,@p__linq__2=NULL,@p__linq__3=NULL,@p__linq__4=21,@p__linq__5=21,@p__linq__6=NULL,@p__linq__7=NULL

כמו הקוד המקורי שלנו, גם ה- SQL הוא גנרי במובן הזה שהוא אחיד עבור כל מה שנחפש (בהתאם לפרמטרים שנעביר למתודה בקוד, חלק מהפרמטרים שיועברו ב-SQL יהיו NULL או יכילו ערכים).

כדי להבין את הבעייתיות בזה, צריך להבין מה קורה כאשר ה-SQL הזה רץ מול ה- DB.
בפעם הראשונה שהשליפה הזאת רצה, נוצר execution plan – הצורה שבה MSSQL יריץ את השאילתה הזאת. ה-execution plan הזה יישמר ב- cache, וישמש גם שליפות עתידיות באותו המבנה (גם אם הפרמטרים שונים).
ה- caching הזה חיובי, מאחר שהוא חוסך את העלות של הקומפילציה של השליפה כל פעם (שיכולה לקחת זמן, במיוחד בשליפות מסובכות).

כאשר ה- execution plan נקבע, SQL Server נעזר במה שהוא יודע על הטבלה והסכימה שלה, האינדקסים, הסטטיסטיקות שיש לו על התפלגות הערכים השונים, והערכים עצמם של הפרמטרים שמועברים בהרצה הראשונה (נקרא parameter sniffing). כל אלה, משמשים אותו לטובת קביעת ה- execution plan המיטבי.

בשליפה כזאת – שכוללת הרבה OR-ים והתניות, SQL Server במצב מורכב: אם יש לו מס’ אינדקסים שונים, ומס’ דרכים שונות להריץ את השאילתה – הוא צריך לבחור את הדרך האופטימלית ביותר מבינהן. אולם, ופה מתחילה הבעייה, לא בטוח כלל שהדרך האופטימלית ביותר להרצת השליפה הזאת תהיה גם הדרך האופטימלית ביותר להרצת השליפה עם סט אחר של פרמטרים (בטח לא כאשר יכול להיות שפרמטרים אחרים יהיו עם ערכים, והפרמטרים האלה יהיו NULL-ים).

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

טעות #11: יותר מדי Execution Plans

ככלל, אנחנו רוצים להימנע מריבוי execution plans שונים לאותה השליפה, ומריבוי של execution plans מיותרים.
ריבוי execution plans לאותה השליפה גורר חוסר אחידות בביצועים ובהתנהגות בין הרצות שונות של אותה השליפה, מגדיל את צריכת הזיכרון (שבאה על חשבון execution plans אחרים שיישמרו ב- cache, ועל חשבון buffer pool cache) ומאריך את הזמן של השליפה עצמה – כאשר נדרש קימפול מחודש לקבלת execution plan, על אף שקיים אחד כזה.

איך יכול להיגרם ריבוי execution plans מאותה השליפה? בואו נסתכל בקוד הבא:

private static IEnumerable<string> GetUsersExceptIds(int[] ids)

{

    using (var data = new Entities())

    {

        return data.Users.Where(k => !ids.Contains(k.Id)).Select(k=>k.DisplayName).ToList();

    }

}

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

SELECT 

    [Extent1].[DisplayName] AS [DisplayName]

    FROM [dbo].[Users] AS [Extent1]

    WHERE  NOT ([Extent1].[Id] IN (1, 2, 3))

וכאשר זה יהיה ממערך עם 4 איברים, נראה בשורה האחרונה 4 איברים ברשימה שמופיעים במפורש ברשימה וכן הלאה.

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

using (var data = new Entities())

{

    var skip = 153;

    var take = 500;

    var posts = data.Posts.OrderBy(k => k.CreationDate).Skip(skip).Take(take).ToArray();

}

ה-SQL שנוצר במקרה הזה, נראה כך:

SELECT 

    [Extent1].[Id] AS [Id], 

    [Extent1].[AcceptedAnswerId] AS [AcceptedAnswerId], 

    [Extent1].[AnswerCount] AS [AnswerCount], 

    [Extent1].[ClosedDate] AS [ClosedDate], 

    [Extent1].[CommentCount] AS [CommentCount], 

    [Extent1].[CommunityOwnedDate] AS [CommunityOwnedDate], 

    [Extent1].[CreationDate] AS [CreationDate], 

    [Extent1].[FavoriteCount] AS [FavoriteCount], 

    [Extent1].[LastActivityDate] AS [LastActivityDate], 

    [Extent1].[LastEditDate] AS [LastEditDate], 

    [Extent1].[LastEditorDisplayName] AS [LastEditorDisplayName], 

    [Extent1].[LastEditorUserId] AS [LastEditorUserId], 

    [Extent1].[OwnerUserId] AS [OwnerUserId], 

    [Extent1].[ParentId] AS [ParentId], 

    [Extent1].[PostTypeId] AS [PostTypeId], 

    [Extent1].[Score] AS [Score], 

    [Extent1].[Title] AS [Title], 

    [Extent1].[ViewCount] AS [ViewCount]

    FROM (SELECT 

    [Posts].[Id] AS [Id], 

    [Posts].[AcceptedAnswerId] AS [AcceptedAnswerId], 

    [Posts].[AnswerCount] AS [AnswerCount], 

    [Posts].[ClosedDate] AS [ClosedDate], 

    [Posts].[CommentCount] AS [CommentCount], 

    [Posts].[CommunityOwnedDate] AS [CommunityOwnedDate], 

    [Posts].[CreationDate] AS [CreationDate], 

    [Posts].[FavoriteCount] AS [FavoriteCount], 

    [Posts].[LastActivityDate] AS [LastActivityDate], 

    [Posts].[LastEditDate] AS [LastEditDate], 

    [Posts].[LastEditorDisplayName] AS [LastEditorDisplayName], 

    [Posts].[LastEditorUserId] AS [LastEditorUserId], 

    [Posts].[OwnerUserId] AS [OwnerUserId], 

    [Posts].[ParentId] AS [ParentId], 

    [Posts].[PostTypeId] AS [PostTypeId], 

    [Posts].[Score] AS [Score], 

    [Posts].[Title] AS [Title], 

    [Posts].[ViewCount] AS [ViewCount]

    FROM [dbo].[Posts] AS [Posts]) AS [Extent1]

    ORDER BY row_number() OVER (ORDER BY [Extent1].[CreationDate] ASC)

    OFFSET 153 ROWS FETCH NEXT 500 ROWS ONLY 

 

בפרט, שימו לב לערכים המספריים שכתובים במפורש בסוף השליפה, כחלק מה- OFFSET. גם במקרה הזה, עבור כל ערך שונה של skip/take, נקבל execution plan נפרד.
במקרה של ה- Skip… Take, קיים פיתרון אלגנטי, והוא להשתמש ב- overload של Skip ו- Take שמקבל Func<int>, ואז יודע לייצג את הערכים כפרמטרים.
במקרים אחרים, לא תמיד יש פיתרון מובהק לבעיית ריבוי ה- execution plans שנוצרת מקוד ב-EF, וצריך לבחור בין מס’ אפשרויות שלכל אחת מהן חסרונות משלה.

סיכום

כאשר עובדים עם מרבית ה- ORM-ים,ובינהם גם עם Entity Framework, אנחנו מוסיפים שכבת אבסטרקציה ביננו לבין ה- DB.
קיומה של השכבה הזאת מקל עלינו את הפיתוח, אבל לא מעלים את הצורך לדעת מה היא עושה. מרבית בעיות הביצועים שתיארתי בפוסט הזה, יכולות להיגרם כאשר לא מחודדים מספיק מה EF עושה מאחורי הקלעים. ולכן, כל פעם שאתם מגיעים לשורה שקשורה לעבודה מול ה- DB, שנעשית עם Entity Framework זכרו שאתם עובדים עם resource מרוחק, לא in-memory, שיש לו חוקים ודרך פעולה משלו.
על אף שהפיתוח וכתיבת שליפות של LINQ to Entities הן “טבעיות” – אל תיתנו לזה להיות טבעי מדי, וזכרו את ההשלכות של כל מה שאתם כותבים.

בהצלחה!

2 Replies to “Entity Framework והטעויות שיגרמו לכם לבעיות ביצועים”

השאר תגובה