DCSIMG
גרי רשף

כשמטפלים במספרים גדולים מאוד מבחינת ערכם המוחלט (כלומר- מספר הספרות משמאל לנקודה) או קטנים מאוד מבחינת ערכם המוחלט (כלומר- מספר הספרות מימין לנקודה) – Decimal הוא אפשרות שתספק את רוב צרכינו; כולל מקרים בהם יש לטפל במספרים שלמים גדולים מאוד שאפילו BigInt מרים ידיים לעומתם..
סוג הנתון הזה מקבל שני פרמטרים המציינים כמה ספרות בסה"כ וכמה מתוכן מימין לנקודה. למשל (Decimal(38,12 מציין שניתן לדייק עד 12 ספרות מימין לנקודה (שברים עשרוניים) ועד 38-12=26 ספרות משמאל לנקודה:

Declare @I Decimal(38,12)=12345678901234567890123456.123456789012;
Select  @I;

clip_image002

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

Declare @I Decimal(38,12)=12345678901234567890123456.123456789012;
Select  @I/1000000;
Select  @I*1000000;

clip_image004

כשחילקנו במיליון – הדיוק של 12 ספרות מימין נותר בעינו, וכתוצאה מכך 6 ספרות "הלכו לאיבוד";
וכשכפלנו במיליון – המערכת הקצתה לנו עוד 6 ספרות משמאל אך "קנסה" אותנו ב-6 ספרות מימין.
במקרה זה – המידע שאבד זניח: אם אנחנו מודדים את המרחק מכדור הארץ לשמש לא נתעקש על דיוק של אלפיות המילימטר, ואם עסקינן בחוב החיצוני של ארגנטינה בדולרים – לא נתעקש לציין את מספר הסנטים..
(אם נבצע את התרגיל הנ"ל בעזרת עשרה מיליון – פעולת הכפל תיכשל: זה לא תמיד עובד!)
יחד עם זאת, ל-Decimal יש כמה פינות אפלות. שורו נא:

Declare @A Decimal(38,10)=22,
        @B Decimal(38,10)=7;
Declare @A Decimal(38,10)=1000,
        @B Decimal(38,10)=1024;
Select  @A [A],
        @B [B],
        @A+@B [A+B],
        @A-@B [A-B],
        @A*@B [A*B],
        @A/@B [A/B],
        @A/@B*@B [A/B*B];

clip_image006

בשתי העמודות הראשונות אנחנו רואים ששני המשתנים קיבלו את מספר הספרות המבוקש מימין לנקודה (10).
כשמחברים או מחסירים אותם (העמודות השלישית והרביעית) – מתקבלת התוצאה המבוקשת בדיוק המבוקש,
אך כשכופלים או מחלקים (העמודות החמישית והשישית) מקבלים משום מה דיוק של 6 ספרות מימין לנקודה, כשבמקרה של החילוק זה קריטי כי מידע הולך לאיבוד! ניתן לראות זאת בעמודה השביעית: את A חילקנו ב-B וכפלנו ב-B, לכאורה היינו אמורים לקבל חזרה את A=1000, אלא שבחלוקה סיפרה אחת מימין הלכה לאיבוד (צריך להיות 0.9765625 ולא 0.976562) והמכפלה לא החזירה אותנו לנקודת המוצא.
מה עושים? פתרון אפשרי במקרה זה – קודם לכפול ולאחר מכן לחלק (יש מספיק מקום משמאל לנקודה):

Declare @A Decimal(38,10)=1000,
        @B Decimal(38,10)=1024;
Select  @A [A],
        @B [B],
        @A*@B/@B [A*B/B];

clip_image008

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

Declare @A Decimal(38,20)=1000,
        @B Decimal(38,20)=1024;
Select @A [A],
        @B [B],
        @A/@B*@B [A/B*B];

clip_image010

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

Declare @A Decimal(38,20)=1000,
        @B Decimal(38,10)=1024;
Select  @A [A],
        @B [B],
        @A/@B [A/B],
        @A/@B*@B [A/B*B];

clip_image012

מה קורה כאן? נכון שדומה שהבעייה נפתרה, אבל מה החוקיות כאן? כיצד המערכת קובעת את רמת הדיוק במקרה של חלוקה של Decimals?
לא מדובר כאן בסתם סקרנות אינטלקטואלית: לעיתים יש להפיק דוחות שמתבצעים בהם חישובי ריבית או שיעורי רווח שמצריכים רמת דיוק גבוהה, במקרים אחרים מתעסקים בסכומים הנקובים במיליונים (נניח – 100 מציין מאה מיליון) ואז הספרה השביעית מימין אולי אינה משמעותית אך הדוחות עלולים לא להתאזן; ומה נסביר למנכ"ל? שה-Decimal גנב לנו כמה שקלים?..
ניסיתי לחקור את הסוגייה עד כמה שאפשר, והנוסחה הכי מדוייקת אליה הצלחתי להגיע היא זו:

MIN(1+X2+Y1,MAX(X2-Y2+Y1-X1,6,(38-MAX(X1,X2))+Y1-Y2))

כאשר המונה הוא (Decimal(X1,Y1 והמכנה הוא (Decimal(X2,Y2.
למשל:

Declare @A Decimal(26,5)=1000,
        @B Decimal(38,7)=1024;
Select  @A [A],
        @B [B],
        @A/@B [A/B];

clip_image014

X1=26
Y1= 5
X2=38
Y2= 7
MIN(1+38+5,MAX(38-7+5-26,6,(38-MAX(26,38))+5-7)) = 10

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

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

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

כתבתי את הערך על פי שיקול דעתי ולא כתרגום של הערך האנגלי, כשהכוונה הייתה ליצור ערך אנציקלופדי עם הסברים מובנים לתם ששואל "מה זאת?", ולא תיעוד טכני או Tutorial (מלכודת שקל ליפול לתוכה).
כלומר- מי ששמע את המושג "SQL Server" ולא יודע אם זה שם של אתר פורנו באינטרנט או שם של ציפור יפנית שניזונה מסושי, או אולי מי שאינו מבין מה ההבדל בין דטבייס וטבלה ובין SQL Server ו-SSMS או סתם רוצה לדעת מה זה Agent (לא איך מקנפגים או מפעילים אלא רק מה זה) – יבוא על סיפוקו..
לא רשימות של features אלא יכולות ומגמות; לא "איך עושים מה" אלא "מה זה מה".

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

תודה רבה מראש!

Posted by גרי רשף | 2 comment(s)
תגים:

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

Use tempdb;
Go
 
If Object_ID('T_Hazmanot','U') Is Not Null Drop Table T_Hazmanot;
Go
 
Create Table T_Hazmanot(ID Int Identity Primary Key,
                        Shulhan Int Not Null,
                        Taarih Date Not Null
                        MiShaa Time Not Null,
                        AdShaa Time Not Null);
Go
 
Insert Into T_Hazmanot Select 1,'20130101','17:00','19:30';
Insert Into T_Hazmanot Select 1,'20130101','20:00','23:00';
Insert Into T_Hazmanot Select 2,'20130101','00:00','02:30';
Insert Into T_Hazmanot Select 2,'20130101','22:00','23:59:59.999';
Insert Into T_Hazmanot Select 1,'20130102','00:00','23:59:59.999';
Go
 
Select *
From T_Hazmanot;

clip_image002

יש להוסיף מנגנוני בקרה שיוודאו שאף הזמנה אינה מתחילה לאחר שהסתיימה (כלומר- לוודא ש-MiShaa<AdShaa), ולמצוא פתרון להזמנות שמתחילות ביום אחד לפני חצות ומסתיימות למחרת לאחר חצות (למשל- לפצל את ההזמנה לשתיים).
ההזמנות ליום 01/01/2013 שולחן 1 הם נתונים "רגילים" (שתי הזמנות באמצע הערב),
נתוני שולחן 2 לאותו היום נצמדים לקצוות של תחילת היום וסוף היום,

וליום 02/01/2013 יש הזמנה של שולחן 1 לכל היום, ואין הזמנה של שולחן 2.

 
With    T As
(Select Row_Number() Over(partition By Shulhan, Taarih Order By MiShaa) N,
        *
From    T_Hazmanot)
Select  IsNull(T1.Shulhan,T2.Shulhan) Shulhan,
        IsNull(T1.Taarih,T2.Taarih) Taarih,
        IsNull(T1.AdShaa,'00:00') MiShaa,
        IsNull(T2.MiShaa,'23:59:59.999') MiShaa
From    T T1
Full Join T T2
        On T1.Shulhan=T2.Shulhan
        And T1.Taarih=T2.Taarih
        And T1.N=T2.N-1
Where   IsNull(T1.AdShaa,'00:00')<>IsNull(T2.MiShaa,'23:59:59.999')
Order By 1,2,3

clip_image004

קודם כל- יש למספר את ההזמנות לכל יום לכל שולחן בסדר עולה כדי שנוכל להשוות כל הזמנה לזו שאחריה וכך למצוא את החלון בינהן.
לאחר מכן מבצעים Full Outer Join כדי לקבל גם את ההזמנות הראשונות והאחרונות, מחשבים להן את החלונות מתחילת היום או עד סוף היום, ומנפים חלונות באורך 0.
כדאי לשים לב שאין תוצאות ליום 02/01/2013: מבחינת שולחן 1 אין חלונות כלל כי הוא מוזמן לכל היום (הגיוני), ומבחינת שולחן 2 הוא אינו מופיע כי אין אף הזמנה לאותו יום שניתן לחשב חלונות ביחס אליה (קצת פחות הגיוני).. כדי לפתור את הבעייה יש ליצור טבלת תאריכים ולעשות Left Join ממנה ל-Full Join הנ"ל (את זה אשאיר לשף המתלמד הנ"ל לעשות בעצמו..).

מה שטרד היום את מנוחתי לא היה האיום האיראני, לא האזהרות מפני רעידת אדמה ממשמשת ובאה, ואף לא הגרעון בתקציב; אלא מה קורה כשמשלבים באותה שליפה פקודות Union (שמבצעת Distinct) ו-Union All..
הנתונים:

Create Table #T(N Int);
Go
 
Insert Into #T Select 1;
Insert Into #T Select 2;
Insert Into #T Select 2;
Insert Into #T Select 3;
Insert Into #T Select 3;
Insert Into #T Select 3;
Go
 
Select * From #T;

clip_image002

והשליפות לבדיקה:

Select * From #T
Union  All
Select * From #T
Union
Select * From #T;
 
Select * From #T
Union
Select * From #T
Union  All
Select * From #T;

clip_image004

כלומר- במקרה הראשון התבצע בשלב הראשון Union All ללא Distinct, אך לאחר מכן Union כולל Distinct והפלט הצטמצם לשלוש שורות;
ובמקרה השני התבצע בשלב הראשון Union הכולל Distinct, ולשלוש השורות שהתקבלו התווספו כל השש בשל ה-Union All בהמשך.

כעת אתפנה לטפל באיום האיראני ואשוב לדווח כשאשלים את משימתי (נתנז ובושהר- אני בדרך!).

בגרסאות 2005 – 2008R2 שליפה של דפים, למשל כמקובל בדפי אינטרנט שמחזירים חלק מהתוצאות, הייתה יכולה להיעשות באמצעות Row_Number: ממספרים את השורות בסט, ובכל פעם מחזירים "מֵאִייָה" אחרת.
אם יש אינדקס על העמודה לפיה התוצאות ממויינות – אזי מה טוב, ניתן לעשות שימוש בו:

Use tempdb;
Go
 
If Object_ID('T_Messages','U') Is Not Null Drop Table T_Messages;
 
Select * Into T_Messages From sys.messages;
 
Create Index Idx_T_Messages On T_Messages(Message_ID, Language_ID);
 
Select * From T_Messages;

clip_image002

With T As
(Select Row_Number() Over(Order By Message_ID, Language_ID) N,
        *
From    T_Messages)
Select  *
From    T
Where   N Between 201 And 300;

clip_image004

clip_image006

כפי שניתן לראות – המערכת מבצעת Index Scan כדי למצוא באמצעותו את מאה השורות שבדף השלישי (300-201), דא עקא שזה כרוך בביצוע Lookup יקר מאוד לטבלה מכיוון שבאינדקס נמצאים רק הערכים של שתי העמודות ולא של השאר (בניגוד ל-Clustered Index או לשימוש ב-Include), והגרוע מכל- המערכת מבצעת את ה-Lookup על כל 300 השורות הראשונות בטרם תשלוף את המאייה השלישית..
היה הרבה יותר יעיל לו הייתה שולפת מהאינדקס את המאייה הרלוונטית, ורק אז מבצעת Lookup, למשל כך:

With T As
(Select Row_Number() Over(Order By Message_ID, Language_ID) N,
        Message_ID,
        Language_ID
From    T_Messages)
Select  Tm.*
From    T
Inner Join T_Messages Tm
        On T.Message_ID=Tm.Message_ID
        And T.Language_ID=Tm.Language_ID
Where   T.N Between 201 And 300;

ואם נשווה את הביצועים:

clip_image008

ההבדל בהחלט משמעותי.
מסקנות:
1. עדיף Clustered Index במידת האפשר.
2. אם כבר Index רגיל – לכלול את העמודות האחרות ב-Include.
3. יש מקרים בהם יש צורך "לתחמן" את האופטימייזר כדי לשפר את הביצועים, וזו דוגמה לכך.

פנה אלי פלוני בשאלה כדלקמן: יש לו טבלה אליה הוא מכניס שורות בעזרת פרוצדורה בכל פרק זמן נתון, והוא רוצה למספר אותן. נניח שהוא הכניס שורות ביום ובשעת כתיבת שורות, אזי בעמודת התאריך יהיה כתוב 29/01/2013 07:29:15, והוא רוצה בנוסף שהשורות של תאריך זה יהיו ממוספרות. הוא מכיר את אופציית ה-Identity ליצירת מספור אוטומטי, אלא שאופציה זו ממספרת בסדר עולה את כל השורות ולא כל תאריך לחוד.. מה עושים?
בניגוד לפוליטיקאים שעונים על השאלות המופנות אליהן ב-"לא זו השאלה אלא.." ומנסחים שאלה חלופית עליה יש להם תשובה מן המוכן, אני משתדל לענות קודם כל לגופו של עניין גם אם השאלה והתשובה מופרכות; ורק לאחר מכן מנסה לברר למה בעצם השואל שואף להגיע, ולהציע פתרון לבעייה ולא תשובה לשאלה.
ניתן איפוא לאפס את ה-Identity בכל ריצה, וכך בכל פעם השורות החדשות ימוספרו מחדש מ-1 ואילך; ולהקפיד כמובן שעמודה זו לא תהיה עמודת מפתח כמקובל, מכיוון שיש בה כפילויות (אא"כ מדובר בשילוב שלה עם עמודת התאריך).
לדוגמה- טבלה בה נשמור את שמות ומספרי האובייקטים שבטבלת המערכת sys.objects:

Use tempdb;
Go
 
If Object_ID('tempdb..T1','U') Is Not Null Drop Table T1;
 
Create Table T1(ID Int Identity Not Null,
                [Date] DateTime Default GetDate() Not Null,
                [Object_ID] Int,
                Name Sysname);
 
Alter Table T1 Add Constraint[PK_T1] Primary Key Clustered([Date],
                                                           ID);
 
Insert  Into T1([Object_ID],Name)
Select  Object_ID,
        Name
From    sys.objects;
 
Select  * From T1;

clip_image002

אם נרצה להריץ שוב- נאפס קודם את ה-Identity:

DBCC CheckIdent(T1,Reseed,0);
 
Insert  Into T1([Object_ID],Name)
Select  Object_ID,
        Name
From    sys.objects;
 
Select  * From T1;

clip_image004

פתרון אחר, כדי להימנע מההתערבות "הכירורגית" בהגדרות הטבלה, יהיה ליצור טבלה ללא כל מספור, אבל עם View שישלוף מהטבלה וימספר את השורות:

If Object_ID('tempdb..T2','U') Is Not Null Drop Table T2;
 
Create Table T2([Date] DateTime Default GetDate() Not Null,
                [Object_ID] Int Not Null,
                Name Sysname);
 
Alter Table T2 Add Constraint[PK_T2] Primary Key Clustered([Date],
                                                           [Object_ID]);
 
If Object_ID('V2','V') Is Not Null Drop View V2;
Go
 
Create  View V2 As
Select  Row_Number() Over(Partition By [Date] Order By [Object_ID]) ID,
        *
From    T2;
Go
 
Insert  Into T2([Object_ID],Name)
Select  Object_ID,
        Name
From    sys.objects;
 
Select  * From V2;

clip_image006

הפתרון שהוחלט עליו לבסוף היה שילוב של שני הנ"ל: טבלה עם עמודת תאריך ועמודת מונה, אך אכלוס עמודת המונה לא תהיה באמצעות מנגנון ה-Identity אלא Row_Number:

If Object_ID('tempdb..T3','U') Is Not Null Drop Table T3;
 
Create Table T3(ID Int Not Null,
                [Date] DateTime Default GetDate() Not Null,
                [Object_ID] Int,
                Name Sysname);
 
Alter Table T3 Add Constraint[PK_T3] Primary Key Clustered([Date],
                                                           ID);
 
Insert Into T3(ID,[Object_ID],Name)
Select  Row_Number() Over(Order By [Object_ID]) ID,
        Object_ID,
        Name
From    sys.objects;
 
Select  * From T3;

clip_image008

כדאי לשים לב שהפרמטר Partition By אינו נדרש בפונקצייה Row_Number במקרה זה מכיוון שבכל פעולת Insert – לכל השורות אותו תאריך. כלומר- גם אם לא נכניס לטבלה כמה מאות שורות אלא כמה מאות מליוני שורות, פעולה שעלולה להימשך לפחות מספר דקות, לכל השורות הנכנסות יהיה אותו תאריך ושעה.

האופרטור In (כחלק מפסוקית ה-Where) הוא מאוד פופלרי כשרוצים להשוות בין ערך לרשימת ערכים ללא שימוש ב-Or מסובך. למשל- במקום Where @X=1 Or @X=2 Or @X=3 אפשר בקיצור (Where @X In (1,2,3.
יתרוהו הגדול של האופרטור In שהוא פשוט להבנה ונוח לשימוש, ולא פעם משמיצים את ביצועיו שלא בצדק.
כל עוד מדובר ברשימה קצרה של מספר ערכים – אין טעם להתעמק בביצועים, אך מה קורה אם מדובר ברשימה ארוכה?
אני אבדוק שלוש אפשרויות:
1. In עם רשימת ערכים מפורשת.
2. In הפונה לטבלה (ממופתחת ומאונדקסת לעילא ולעילא).
3. חלופה ל-In כאשר מדובר ברשימת ערכים המתקבלת כמחרוזת, וכשידוע שלא ניתן להריץ שאילתות בסגנון של:

Declare @S Varchar(Max)='1,2,3';
Select  *
From    MyTable
Where   ID In (@S);

נפתח כמקובל עם רשימת המצרכים- טבלת נתונים (T_Messages), וטבלה לחיפוש עבור אפשרות 2 (T_Where):

Use tempdb;
Go
 
If Object_ID('T_Messages','U') Is Not Null Drop Table T_Messages;
Go
 
Select  *
Into    T_Messages
From    sys.messages;
 
Alter   Table T_Messages Add ID Int Identity Primary Key;
 
Select  * From T_Messages;
 
If Object_ID('T_Where','U') Is Not Null Drop Table T_Where;
Create Table T_Where(ID Int Primary Key);

clip_image002

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

Insert Into T_Where Select 1;
 
Select * From T_Messages Where ID In (1);
Select * From T_Messages Where ID In (Select ID From T_Where);

את פעולת ה-Insert נבצע, ובין שתי האחרות נשווה מבחינת ה-Execution Plan:

clip_image004

האפשרות הראשונה טובה פי שניים מהשניה, אך זו אינה ראייה מכיוון שבמקרה זה אין כל תועלת בטבלה.
נבדוק מה קורה כשמדובר ב-128 ערכים, ולשם כך נעזר בקוד הבא:

Truncate Table T_Where;
 
Insert Into T_Where Select ID From T_Messages Where ID<=128 And ID Not In (Select ID From T_Where);
 
Declare @S Varchar(Max);
Select @S=IsNull(@S+','+Cast(ID As Varchar),Cast(ID As Varchar)) From T_Where;
Print @S;
 
Select * From T_Messages Where ID In (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128);
Select * From T_Messages Where ID In (Select ID From T_Where);

שתי השורות הראשונות ממלאות בערכים מתאימים את טבלת T_Where,
שלוש השורות האמצעיות יוצרות את המחרוזת של המספרים מ-1 ועד 128 למי שאין זמן להקליד בעצמו,
ובין שתי השורות האחרונות נשווה בעזרת Execution Plan:

clip_image006

במקרה זה יש כבר יתרון מסויים לעבודה עם טבלה, כך לפחות בשרת עליו בדקתי את הקוד; אך בשרתים אחרים התוצעות יכולות להיות שונות.
עד כמה התוכנית הזו אמינה? למערכת יש נטייה ליצור Execution Plans מופרכים כשאין מספיק נתונים להעריך את עלות הרצת הקוד (למשל- לקוד דינמי), אלא שבמקרה זה המערכת מנתחת את תכולת הסוגריים ויודעת שמדובר ב-128 ערכים.
מה קורה כשמוסיפים גם את האפשרות השלישית ומחפשים את ערכי ID בתוך מחרוזת הכוללת את כל מה שבתוך הסוגריים?
במקרה זה לא ניתן להיעזר ב-Execution Plan ולפיכך נשווה זמני ריצות (שימו לב שבאופציה השלישית המחרוזת אמורה להגיע כפרמטר טקסטואלי):

 
Set Statistics Time On;
 
Select * From T_Messages Where ID In (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128);
Select * From T_Messages Where ID In (Select ID From T_Where);
Select * From T_Messages Where ',1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,' 
       Like '%,'+Cast(ID As Varchar)+',%';
 
Set Statistics Time Off;

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

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

מספרים משוכללים הם מספרים ששווים לסכום המחלקים הקטנים מהם. למשל- 6 מתחלק ב-1,2,3; וסכום הוא 6. 28 מתחלק ב-14,7,4,2,1; וסכומם הוא 28.
מספרים ידידים הם צמדי מספרים שסכום מחלקי האחד שווים למשנהו (ולהיפך). למשל- 220 ו-284 הם מספרים ידידים מכיוון שסכום המחלקים של 220 הוא 284 וסכום המחלקים של 284 הוא 220.
כדי למצוא יצורים משונים שכאלו ניתן להיעזר בלולאות, אך לא נידרדר לשפל כזה כשניתן להשתמש בטבלת מספרים ולהציג פתרון Set based..
נתחיל מהמשוכללים, כשהפתרון כולו "באוויר"; כלומר- בלי ליצור טבלאות ואובייקטים אחרים, ואפילו טבלת המספרים נוצרת תוך כדי מטבלת sys.messages:

With Nm As
(Select Row_Number() Over(Order By (Select 1)) N
From    sys.messages)
Select  *
From    Nm Nm1
Where   N<=1000
        And N=(Select Sum(N) From Nm Nm2 Where Nm2.N<Nm1.N And Nm1.N%Nm2.N=0);

clip_image002

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

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

With Nm As
(Select Row_Number() Over(Order By (Select 1)) N
From    sys.messages),
T As
(Select *,
        (Select Sum(N) From Nm Nm2 Where Nm2.N<Nm1.N And Nm1.N%Nm2.N=0) N0
From    Nm Nm1
Where   N<=1000)
Select  T1.*
From    T T1
Inner Join T T2
        On T1.N<T2.N
        And T1.N=T2.N0
        And T1.N0=T2.N;

clip_image004

כלומר- עד 1000 נמצא רק זוג מספרים ידידים אחד.
הריצה במקרה זה נמשכה 9 שניות, שוב- לא הרבה, אך כדי שנוכל לחפש גם בטווחים גדולים יותר נבנה פרוצדורה שתיצור טבלת מספרים ממופתחת (=PK) כראוי ותחפש בעזרתה:

If Object_ID('P_MisparimYedidim','P') Is Not Null Drop Proc P_MisparimYedidim;
Go
 
Create Proc P_MisparimYedidim @N Int As
--יצירת טבלת מספרים
If Object_ID('tempdb..#Nm','U') Is Not Null Drop Table #Nm;
With Nm As
(Select Row_Number() Over(Order By (Select 1)) N
From    sys.messages)
Select  N,
        Cast(Null As Int) N0
Into    #Nm
From    Nm
Where   N<=@N;
Alter   Table #Nm Alter Column N Int Not Null;
Alter   Table #Nm Add Constraint PK_#Nm Primary Key Clustered (N);
 
--עדכון סכומי המחלקים
Update  Nm1
Set     N0=(Select Sum(N) From #Nm Nm2 Where Nm2.N<Nm1.N And Nm1.N%Nm2.N=0)
From    #Nm Nm1;
 
--שליפת המספרים הידידים
Select  T1.*
From    #Nm T1
Inner Join #Nm T2
        On T1.N<T2.N
        And T1.N=T2.N0
        And T1.N0=T2.N;
Go

וכעת נחפש זוגות עד 10,000:

Exec P_MisparimYedidim 10000;

clip_image006

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

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

Select  *
From    MyTable
Where   Safa='עברית'
        And Safa='אנגלית'
        And Safa='ספרדית';

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

Select  *
From    MyTable
Where   Safa='עברית'
        Or Safa='אנגלית'
        Or Safa='ספרדית';

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

Use tempdb;
 
If Object_ID('T_Ovdim','U') Is Not Null Drop Table T_Ovdim;
Create Table T_Ovdim(ID_Oved Int Primary Key,
                    Oved Varchar(10));
 
If Object_ID('T_Safot','U') Is Not Null Drop Table T_Safot;
Create Table T_Safot(ID_Safa Int Primary Key,
                    Safa Varchar(10));
 
If Object_ID('T_OvdimSafot','U') Is Not Null Drop Table T_OvdimSafot;
Create Table T_OvdimSafot(ID_Oved Int Not Null,
                        ID_Safa Int Not Null);
Alter Table T_OvdimSafot Add Constraint PK_T_OvdimSafot Primary Key Clustered (ID_Oved,ID_Safa);
Go
 
Insert Into T_Ovdim Select 1,'Ana';
Insert Into T_Ovdim Select 2,'Beni';
Insert Into T_Ovdim Select 3,'Carmel';
 
Insert Into T_Safot Select 1,'Hebrew';
Insert Into T_Safot Select 2,'Spanish';
Insert Into T_Safot Select 3,'English';
 
Insert Into T_OvdimSafot Select 1,1;
Insert Into T_OvdimSafot Select 2,1;
Insert Into T_OvdimSafot Select 2,2;
Insert Into T_OvdimSafot Select 3,1;
Insert Into T_OvdimSafot Select 3,2;
Insert Into T_OvdimSafot Select 3,3;

אנה דוברת עברית; בני דובר עברית וספרדית; וכרמל – עברית, ספרדית ואנגלית.
פתרון אפשרי יכול להיות שיפור של הפתרון עם ה-Or שיכלול ספירה מי מופיע שלוש פעמים:

Select  O.Oved
From    T_Ovdim O
Inner Join T_OvdimSafot OS
        On O.ID_Oved=OS.ID_Oved
Inner Join T_Safot S
        On OS.ID_Safa=S.ID_Safa
Where   S.Safa In ('Hebrew','Spanish','English')
Group By O.Oved
Having  Count(1)=3;

clip_image002

הבעייה נפתרה, אם כי לטעמי לא באופן אלגנטי: בכל פעם שנשנה את רשימת השפות- נצטרך לשנות את התנאי על מספרן.
האם ניתן להימנע מכך?

Select O.Oved
From   T_Ovdim O
Where Not Exists (Select 1
                 From    T_Safot S
                 Where   Safa In ('Hebrew','Spanish','English')
                         And ID_Safa Not In (Select ID_Safa
                                            From    T_OvdimSafot OS
                                            Where   OS.ID_Oved=O.ID_Oved));

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

Select O.Oved
From   T_Ovdim O
Where  Not Exists (Select 1
                   From   T_Safot S
                   Where  ID_Safa Not In (Select ID_Safa
                                          From   T_OvdimSafot OS
                                          Where  OS.ID_Oved=O.ID_Oved));

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

Insert Into T_Safot Select 4,'Chinese';

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

Select O.Oved
From   T_Ovdim O
Where  0 Not In (Select  Max(ID)
                 From   (Select Case When OS1.ID_Oved=O.ID_Oved Then 1 Else 0 End ID,
                                ID_Safa
                         From   T_OvdimSafot OS1) OS
                         Group By ID_Safa);

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

Posted by גרי רשף | 1 comment(s)
תגים:, ,

שאלה שהגיעה לשולחן המערכת: נתונה רשימת מספרים-

407.16, 536.82, 1187.03, 923.94, 80.39, 651.46, 1547.90, 119.99, 110.66, 621.76, 62.6, 1616.24, 227.85, 878.06, 824.76, 950.04, 1125.95, 208.80, 2004.48, 3358.20, 1272.52

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

407.16, 536.82, 1187.03, 1547.90, 119.99, 110.66, 62.6, 1616.24, 227.85, 878.06, 824.76, 1125.95, 2004.48, 3358.20

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

Declare @S1 Varchar(Max);
Select  @S1='407.16, 536.82, 1187.03, 923.94, 80.39, 651.46, 1547.90, 119.99, 110.66, 621.76, 62.6, 1616.24, 227.85, 878.06, 824.76, 950.04, 1125.95, 208.80, 2004.48, 3358.20, 1272.52';
 
If Object_ID('tempdb..#T') Is Not Null Drop Table #T;
Create Table #T(Valor Decimal(12,2));
Create Clustered Index Idx_#T On #T (Valor Desc);
 
Declare @I Int;
While @S1<>''
      Begin
      Set @I=CharIndex(',',@S1+',');
      Insert Into #T(Valor) Select Left(@S1,@I-1);
      Set @S1=Stuff(@S1,1,@I,'');
      End
 
Select * From #T;

clip_image002

ננסה קודם כל בכח: לקבוצה בת N איברים יש 2N תתי קבוצות, ובמקרה זה מדובר ב-221=2,097,152.
נעזר בטבלת המספרים של איציק בן-גן ובטבלה הנ"ל:

Declare @Cnt BigInt;
Select @Cnt=Count(1) From #T;
Select @Cnt=Power(2,@Cnt);
With lv0 As (Select 0 As g Union ALL Select 0),
     lv1 As (Select 0 As g From lv0 As a CROSS JOIN lv0 As b), -- 4
     lv2 As (Select 0 As g From lv1 As a CROSS JOIN lv1 As b), -- 16
     lv3 As (Select 0 As g From lv2 As a CROSS JOIN lv2 As b), -- 256
     lv4 As (Select 0 As g From lv3 As a CROSS JOIN lv3 As b), -- 65,536
     lv5 As (Select 0 As g From lv4 As a CROSS JOIN lv4 As b), -- 4,294,967,296
     Nums As (Select Row_Number() Over (Order By (Select Null)) As n From lv5),
T As
(Select Row_Number() Over(Order By Valor) N,
        *
From    #T),
S As
(Select Sum(Valor) Over (Partition By Nm.N) STotal,
        Nm.N Nm_N,
        T.N T_N,
        T.Valor
From    T
Inner Join (Select * From Nums Where N<=@Cnt) Nm
        On Nm.N%Power(2,T.N)>=Power(2,T.N-1))
Select  *
From    S
Where   STotal=14007.70
Order By STotal,
        Valor;

clip_image004

אצלי הריצה נמשכה כ-1:50 דקות.

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

Drop Index Idx_#T On #T;
Alter Table #T Add Total Decimal(12,2);
Create Clustered Index Idx_#T On #T (Valor Desc, Total Desc);

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

Declare @Valor Decimal(12,2),
        @Total Decimal(12,2),
        @Rc Int;
Select  @RC=1,
        @Total=0;
Select  @Valor=Min(Valor),
        @Total=@Total+Min(Valor)
From    #T
Where   Total Is Null;
 
While   @Valor Is Not Null
        Begin
        Update T
        Set Total=@Total
        From   (Select Top 1 *
                From   #T
                Where  Valor=@Valor
                       And Total Is Null) T;
        Select  @Valor=Min(Valor),
                @Total=@Total+Min(Valor)
        From    #T
        Where   Total Is Null;
        End

והשליפה הרקורסיבית:

Declare @S2 Decimal(12,2);
Select @S2= 14007.70;
With T As
(Select Valor,
        Total,
        Cast(@S2-Valor As Decimal(12,2)) STotal,
        Cast(Valor As Varchar(Max)) SValor
From    #T
Where   Total>=@S2
Union All
Select  T1.Valor,
        T1.Total,
        Cast(T.STotal-T1.Valor As Decimal(12,2)),
        Cast(T.SValor+','+Cast(T1.Valor As Varchar(Max)) As Varchar(Max))
From    T
Inner Join #T T1
        On T.Valor>=T1.Valor
        And T.Total>T1.Total
        And T.STotal<=T1.Total
Where   T.STotal-T1.Valor>=0)
Select  *
From    T
Where   STotal=0;

clip_image006

הפעם הריצה נמשכה פחות משנייה.

להרחבה ניתן לעיין בויקיפדיה.

כאשר יוצרים אינדקס על טבלה לפי עמודה1, עמודה2, עמודה3; הדעת נותנת שהוא יהיה שימושי כשנבצע חיפוש או מיון לפי עמודה1, עמודה2, עמודה3..
מה יקרה אם נחפש לפי עמודה2 דווקא? למשל- יש לנו טבלת מכירות בה העמודה הראשית באינדקס היא התאריך, והעמודה המשנית המוכר; ואנחנו מעוניינים לבצע חיפוש לפי מוכר..
השכל הישר (שלי לפחות) אומר שזה תלוי בסלקטיוויות של העמודה הראשית: אם הסלקטיוויות נמוכה אז כדאי ואם היא גבוהה – לא. כלומר- אם בטבלת המכירות הנ"ל יש רק שני תאריכים שונים (על פני הרבה מאוד שורות) ואנחנו מחפשים את המוכר X, המערכת תבצע Seek ממוקד ותחפש אותו בעזרת האינדקס פעם בתאריך הראשון ופעם בשני; ואם יש הרבה מאוד תאריכים כבר עדיף לבצע Scan מלא על כל הטבלה.
מסתבר למרבה הצער (וכפי שיומחש בהמשך) שאין זה כך: גם אם הסלקטיוויות נמוכה מאוד המערכת תוותר על השימוש באינדקס ותבצע Scan מלא.
לצורך ההמחשה נבנה טבלה בעמודה הראשית יש רק ערך אחד (1), כלומר- אין סלקטיוויות כלל, ונבצע חיפוש לפי העמודה המשנית בה יש ערכים שונים:

Use tempdb;
Go
 
If Object_ID('T_Messages','U') Is Not Null Drop Table T_Messages;
Go
 
Select  1 Col1,
        Row_Number() Over(Order By GetDate()) Col2,
        *
Into    T_Messages
From    sys.messages;
 
Alter Table T_Messages Alter Column Col1 Int Not Null;
Alter Table T_Messages Alter Column Col2 Int Not Null;
Go
 
Alter Table T_Messages Add Constraint PK_T_Messages Primary Key Clustered (Col1, Col2);
Go
 
Select * From T_Messages;

clip_image002

כעת נחפש את השורה בה מתקיים Col2=21 בשתי דרכים שונות:

Set Statistics IO On;
 
Select  *
From    T_Messages
Where   Col2=21;
 
Select  *
From    T_Messages
Where   Col1=1
        And Col2=21;
 
Set Statistics IO Off;

clip_image004

(1 row(s) affected)
Table 'T_Messages'. Scan count 1, logical reads 2853, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
 
(1 row(s) affected)
Table 'T_Messages'. Scan count 0, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

למרות שיש רק ערך אחד בעמודה הראשית Col1 המערכת ביצעה Scan מלא לטבלה למרות שאולי הייתה יכולה לדעת ש-Col1=1 בהכרח גם אם זה לא צויין במפורש.

נעשה בדיקה נוספת, אך הפעם נגדיל את הסלקטיוויות ב-Col1 ל-100 כך:

Update  T_Messages
Set     Col1=Col2%100+1;
 
Select * From T_Messages;

clip_image006

וכעת ננסה למצוא את Col2=21 במספר דרכים:

Set Statistics IO On;
 
Select  *
From    T_Messages
Where   Col2=21;
 
Select  *
From    T_Messages
Where   Col1=22
        And Col2=21;
 
Select  *
From    T_Messages
Where   Col1 In (1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100)
        And Col2=21;
 
Select  *
From    T_Messages
Where   Col1 Between 1 And 100
        And Col2=21;
 
Set Statistics IO Off;

clip_image008

(1 row(s) affected)
Table 'T_Messages'. Scan count 1, logical reads 3827, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
 
(1 row(s) affected)
Table 'T_Messages'. Scan count 0, logical reads 3, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
 
(1 row(s) affected)
Table 'T_Messages'. Scan count 0, logical reads 312, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
 
(1 row(s) affected)
Table 'T_Messages'. Scan count 1, logical reads 3799, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

כאשר יש תנאי רק על העמודה השניה- המערכת מבצעת Scan מלא, וניתן לראות זאת גם ב-Execution Plan וגם ב-Statistics IO (מספר ה-Logical Reads).
כאשר יש תנאי על שתי העמודות (לא חוכמה, אבל עשיתי זאת לצורך ההשוואה עם השליפות הבאות) השליפה כמובן יעילה מאוד, מבצעת Seek ממוקד, ושולפת את הנתון המבוקש מהר (Statiscs IO) ובזול (Execution Plan).

מה שמעניין הוא מה שקורה בשליפה השלישית בה אנו מציבים תנאי טריוויאלי על העמודה הראשונה ומציינים את כל הערכים האפשריים שלה (יש להניח שאיננו יודעים מה הערך של Col1): כמובן שהשליפה פחות טובה מהשניה, אבל הרבה הרבה יותר טובה מהראשונה. היא ניגשה אחד-אחד לכל מאה הערכים השונים של Col1 ועבור כל אחד מהם ביצעה Seek. זו הסיבה שמספר ה-Logical Reads שלה הוא פי מאה משל השניה.
השליפה הרביעית בודקת מה קורה אם אנחנו עושים לעצמנו "חיים קלים" ובמקום לציין במפורש את כל הערכים האפשריים של העמודה הראשונה אנחנו מציינים את הטווח שלה כדי שיהיה עליה תנאי. הביצועים שהיו אמורים להיות לכאורה כמו בשליפה השלישית – דומים יותר לאלו של השליפה הראשונה, אם כי קצת יותר טובים. לפי ה-Statistics IO ניתן להעריך שלאחר שסיימה לחפש את Col2=21 ב-Col1=100 ה-Scan הסתיים ולכן יש קצת פחות Logical Reads.
לסיכום- יש חשיבות רבה לסדר העמודות באינדקס, בעיקר כשמשתמשים רק בחלק מהן. לו היה מתבצע חיפוש רק על Col1 המערכת הייתה יכולה לנצל את האינדקס לשם כך, אך כשהחיפוש הוא על Col2 – היא אינה משתמשת בו, גם כשהיא יכולה עדיין להפיק ממנו תועלת (בשל סלקטיוויות נמוכה בעמודה הראשית).
כדי "לאלץ" אותה להשתמש באינדקס ניתן ליצור תנאי מלאכותי על העמודה הראשית בעזרת האופרטור In ורשימת כל הערכים האפשריים (אנחנו מניחים שמדובר בסלקטיוויות נמוכה). מדוע המערכת אינה עושה זאת בעצמה? אין לדעת..

פרסמתי לפני מספר ימים פוסט שהשווה את הביצועים של In ושל Not In עם החלופות השונות, וציינתי מספר פעמים שהמערכת במקרה של In מבצעת Table Scan (בהנחה שאין אינדקס מתאים) עבור כל שורה מהטבלה הראשית כדי לבדוק אם היא נמצאת במשנית, ועוצרת את החיפוש כשהיא מוצאת כי אין צורך לחפש התאמות נוספות כשדי באחת כדי להציג אותה;
ובמקרה של Not In מבצעת באופן דומה אך הפוך Table Scan עבור כל שורה מהטבלה כדי לבדוק אם היא במשנית, וכשהיא מוצאת היא עוצרת כי די בכך כדי לא להציג אותה.
אם לצטט פתגם סיני עתיק- כדי לדעת שהביצה מקולקלת אין צורך לאכול אותה עד תום, ומספיק לטעום כדי המינימום ההכרחי..
מישהו טען באוזני שלדעתו (או לדעת אותו מקור עלום אותו הוא ציטט) מתבצע בכל מקרה Scan מלא ולכן In אינו יעיל.
זה כמובן אינו נכון, לא צריך להמציא ל-In חסרונות שאינם קיימים ואפשר להסתפק במה שיש.
כיצד נבדוק איפוא אם המערכת מבצעת Table Scan מלא בכל מקרה או רק עד לנתון המתאים שמאשר או מונע את הצגת השורה? לשם כך ניצור טבלה עם מוקש בתוכה, שתשמש בתור הטבלה המשנית:

If Object_ID('tempdb..#T2','U') Is Not Null Drop Table #T2;
 
Create Table #T2(ID Tinyint,
                 M As 12./(4-ID));
 
Insert Into #T2 Select 1;
Insert Into #T2 Select 2;
Insert Into #T2 Select 3;
Insert Into #T2 Select 4;
Go

המוקש טמון בעמודה המחושבת M שהנוסחה שלה יוצרת שגיאה במקרה של ID=4 (חלוקה באפס).
כל עוד לא שלפנו את השורה הרביעית אינה בעייה, אך כשנשלוף אותה נקבל הודעת שגיאה.
בנוסף- בהיעדר אינדקסים בכלל ו-Clustered Index בפרט, השורות שמורות בטבלה על פי סדר הכנסתן, וכך הן גם נשלפות.
נעיין להמחשה בארבע השליפות הבאות ששולפות בכל פעם מספר גדול יותר של שורות:

Select Top 1 * From #T2;
Select Top 2 * From #T2;
Select Top 3 * From #T2;
Select Top 4 * From #T2;

clip_image002

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

If Object_ID('tempdb..#T1','U') Is Not Null Drop Table #T1;
 
Create Table #T1(M Tinyint);
Go

לתוך הטבלה נכניס ערך בודד – 12 (ערך שמופיע בעמודה M בטבלה המשנית):

Insert Into #T1 Select 12;
Go

וכעת נבדוק אם M מהראשית נמצא במשנית (כן!) ותוך כדי כך נוודא שה-Scan נעצר כשאותו ערך נמצא:

Select *
From   #T1
Where  M In (Select M
            From #T2);

clip_image004

מה היה קורה לו הוא לא היה נמצא והמערכת הייתה מבצעת Scan מלא אחריו עד הסוף?
נכניס גם את הערך 99 שאינו קיים בטבלה המשנית:

Insert Into #T1 Select 99;
Go
 
Select *
From   #T1
Where  M In (Select M
            From #T2);

clip_image006

במקרה זה המערכת מצאה ללא כל שגיאה ש-12 נמצא בטבלה המשנית,
אך כשניסתה למצוא את 99 הגיעה עד לשורה הרביעית עם הערך השגוי והחזירה הודעת שגיאה.
כך הם פני הדברים גם עם Not In: נבצע שליפת Not In עם ערך שנמצא ונראה שהמערכת תמצא אותו, תפסיק לחפש ותחזיר סט ריק; וכננסה עם ערך שאינו נמצא – היא תחפש אותו עד הסוף ואז תקבל שגיאה והריצה תיפול.
נתחיל עם ערך שקיים:

Truncate Table #T1;
 
Insert Into #T1 Select 12;
Go
 
Select *
From   #T1
Where  M Not In (Select M
                From #T2);

clip_image008

שוב- המערכת הגיעה עד ל-12 שבטבלה המשנית, עצרה, ולא עלתה על "המוקש".
כעת נוסיף ערך שאינו קיים (ולכן מקיים את תנאי Not In) אך שמחייב לבדוק את כל הטבלה המשנית:

Insert Into #T1 Select 99;
Go
 
Select *
From   #T1
Where  M Not In (Select M
                From #T2);

clip_image010

כעת המערכת גילתה קודם כל ש-12 ישנו ולכן לא הציגה אותו,
אך עלתה על "המוקש" כשבדקה אם 99 נמצא;
והמסקנה- המערכת אינה מבצעת Scan מלא בבדיקות In ו-Not In, אלא רק עד לנתון הרלוונטי הראשון.

כשהייתי בראשית דרכי ב-SQL Server, שמעתי באחד הכנסים ש"בדקו ומצאו" שהשימוש ב-Inner Join מעט יותר יעיל מאשר השימוש ב-In. אינני זוכר בוודאות ממי שמעתי את זה, אך עובדה שבמשך שנים התייחסתי לכך כאל תורה מסיני, ואף ציטטתי זאת בידענות פה ושם.
לאחרונה כשנזכרתי בכך, קמטתי את מצחי ותהיתי האמנם.. דווקא הנסיון והידע שצברתי מאז הובילו אותי לחשוב כיום שאולי דווקא In יעיל יותר: בניגוד ל-Join בו לכל שורה מצד ה-From יש למצוא את כל ההתאמות בצד ה-Join, ב-In יש למצוא רק את ההתאמה הראשונה.. ואם כבר- אז מה עם השימוש ב-Exists? האם מדובר רק ב-In משופר או אולי לא רק?
ואם כבר- מה קורה במקרה ההפוך של Not In?
בקיצור- צריך לבדוק!
יצרתי שלושה העתקים של טבלת sys.Objects (קישור לכל הקודים שבפוסט):
1. Heap
2. Index
3. Primary Key Clustered Index
יצרתי חמישה העתקים של טבלת sys.tables:
1. Heap
2. Index
3. Clustred Index
4. Primary Key Clustered Index
כעת ביצעתי 3*4=12 בדיקות של ה-Execution Plans של טבלת Objects מול טבלת Tables, פעם ב-In, פעם ב-Join ופעם ב-Exists:

Select *
From   T_Objects_
Where  object_id In (Select object_id
                    From    T_Tables_);
 
Select O.*
From   T_Objects_ O
Inner Join T_Tables_ T
       On O.object_id=T.object_id;
 
Select *
From   T_Objects_ O
Where  Exists (Select 1
              From    T_Tables_ T
              Where   T.object_id=O.object_id);

בשני המקרים אני מחפש שורות מ-Objects שנמצאות גם ב-Tables,
ולהלן תוצאות האמת (כולל קולות החיילים):

clip_image002

ברוב המקרים ה-Execution Plans זהים וגם הביצועים, למעט כאשר יש ל-Objects אינדקס פשוט.
רקע כללי כדי להבין את הסיבה לכך: על פניו נראה ש-In יעיל יותר, מכיוון שאז המערכת אמורה לחפש לכל שורה מ-Objects את ההתאמה הראשונה, בעוד שבמקרה של Join המערכת תצטרך למצוא את כל ההתאמות (ולא רק את הראשונה).
יחד עם זאת, הודות לסטטיסטיקות, המערכת יכולה לדעת שהערכים בכל אחת מהטבלאות הם יחודיים פחות או יותר, גם כשאין אינדקסים שמבטיחים זאת (Primary Key), ויוצרת Execution Plans דומים.

להמחשה, נתבונן בסטטיסטיקות של Tables_1 ו-Objects_A (שתיהן Heaps):

DBCC Show_Statistics(T_Tables_1,'object_id') With Histogram;
DBCC Show_Statistics(T_Objects_A,'object_id') With Histogram;

clip_image004
ניתן לראות בעמודה הימנית (Avg_Range_Rows) שכל ערך Object_ID מופיע פעם אחת.
ההבדל באותם מקרים בהם היחס הינו 28:45:28 או 33:35:33 הוא שהמערכת מעדיפה לבצע Group By או Sort על טבלת Tables במקרים של In ושל Exists כדי להבטיח שהערכים יהיו יחודיים וממויינים, דבר שלא תוכל לעשות במקרה של Inner Join בו יש למצוא את כל ההתאמות ולא רק את הראשונה.
שוב- במקרה זה הביצועים בדרך כלל זהים, אם כי במספר מקרים יש יתרון ל-In ול-Exists.
מאי נפקא מינה מקרה זה? במה הוא שונה ממקרים אחרים?
אם בטבלת Tables הערכים לא יהיו יחודיים התוצאות של Inner Join יהיו גרועות יותר מכיוון שהמערכת תצטרך לחפש את כל ההתאמות ולא תוכל להעריך שתהיה רק אחת. לכך יש להוסיף מקרים בהם המשתמש יבצע Select * מיותר במקרה של Join ואז השליפה של Tables תהיה מסורבלת יותר.
סיכום ביניים חלקי: אם אנחנו רוצים לדעת לאילו שורות בטבלה אחת יש שורות מתאימות בטבלה השניה- In ו-Exists עדיפות.
גם במקרה ההפוך בו נרצה למצוא אילו שורות מ-Objects אינן ב-Tables התוצאות יהיו דומות, כאשר השאילתות יהיו כדלקמן:

Select *
From   T_Objects_
Where  object_id Not In (Select object_id
                        From    T_Tables_);
 
Select O.*
From   T_Objects_ O
Left Join T_Tables_ T
       On O.object_id=T.object_id
Where  T.object_id Is Null;
 
Select *
From   T_Objects_ O
Where  Not Exists (Select 1
                  From    T_Tables_ T
                  Where   T.object_id=O.object_id);

clip_image006

התוצאות והמסקנות דומות לקודמות, והשינויים הקטנים באחוזים נובעים מהתמחור של תנאי ה-Where במקרה של Left Join.
עד כאן ניתן לצאת עם כותרת בסגנון של Myth Busters ולהזים את השמועות.
היכן ה-Catch? כדאי לשים לב שאת הטבלאות יצרנו על ידי פקודות Select Into, ובכולן עמודת המפתח Object_ID הוגדרה כ-Not Null (מפתח חייב להיות Not Null אך לא בכל הטבלאות הוא הוגדר ככזה). אם נשנה את ההגדרה ל-Null היכן שאפשר, תהיה לכך השפעה דרמטית על הביצועים והתוצאות.
הקוד מופיע בסקיפט המצורף, ויש רק להדגיש שבטבלאות T_Objects_C & T_Tables_4 בהן הוגדרו Primary Key לא ניתן לשנות את העמודות ל-Null ולכן השארתי אותן על כנן.
בתוצאות במקרה של In לא חלו שינויים דרמטיים (שינויים קוסמטיים באחוזים):

clip_image008

אך שורו הביטו וראו מה קורה במקרה של Not In:

clip_image010

הביצועים של In בכל הגזרות (למעט כאשר בשני הצדדים יש עמודות מסוג Not Null) מתדרדרות פלאים, וה-Execution Plan הופך להזוי; למשל במקרה בו שתי הטבלאות הן Heap (ללא אינדקסים):

clip_image012

מתבצעות שלוש פניות שונות לטבלת Tables, ולמרות כל מאמצי לא הצלחתי לרדת לפשרן.
ה-Table Scan האדום הוא היקר ביותר (32%) והוא משתמש ל-Nested Loops מול Objects שבסופו כל השורות מ-Objects שאין בהן ערך Null עוברות הלאה (כרגע העמודה מוגדרת כ-Null אך בפועל אין בה ערך כזה). לא ברור לי מדוע מתבצע Table Scan ב-Tables, וזה כך גם כש-Tables ריק.
ה-Table Scan הכחול סופר את השורות מ-Tables בהן יש Null (כנ"ל לגבי ה-Null), ושומר את התוצאה בטבלה זמנית, ומבצע Nested Loops עם הפלט של האדום. כלומר- כל שורה מהפלט של האדום מושווית מול התוצאה באותה טבלה זמנית, ורק אם הסכום הוא 0 – היא ממשיכה הלאה.
ה-Table Scan הירוק כל שורה מ-Objects (זה שליד האדום) שצלחה את שני הקודמים (כלומר- היא עצמה אינה Null וב-Tables אין אף Null) נבדקת סופסוף מול Tables ואם אינה שם (הפעם בלי "להתקטנן" על כך שערך קיים אינו שונה מ-Null) היא עוברת הלאה.
כדי להבין מדוע יש טיפול נפרד בערכים וב-Nulls נהפוך בכל טבלה שורה אחת ל-Object_ID=Null (הקוד בסקריפט המצורף), והפעם לא נבדוק את הביצועים אלא את הפלט – כמה שורות חוזרות:

clip_image014

כעת כבר לא מדובר בענייני ביצועים (דבר שלפעמים הוא זניח כשמדובר בשרת חזק ובטבלאות לא גדולות) אלא בפלט המוחזר: מסתבר ש-Not In אינו מחזיר שורות כשיש Null, ולשיטתו Null גם אינו שווה וגם אינו שונה מערך כלשהו, ולכן לא חוזר דבר. במילים אחרות- כל הערכים מ-Objects מושווים מול Null שב-Tables, אף אחד מהם אינו שונה ממנו, ולכן אף ערך לא מוחזר. לוגית זה נכון מפני ש-Null אינו ערך, אך מעשית לא תמיד לכך אנחנו מצפים..
דווקא Left Join ו-Not Exists מחזירים פלט "הגיוני" המתיישב בדרך כלל עם האינטואיציה שלנו (אינטואיציה האומרת ש-Null "שונה" מ-2 למשל..).
סיכום (הפעם סיכום מלא וסופי):
1. In ו-Exists יעילים יותר מ-Join מכיוון שהם מוצאים את ההתאמה הראשונה ולא את כל ההתאמות (בתנאי שמדובר בעמודות המוגדרות כ-Not Null).
2. Not In הוא מאוד לא יעיל כשמדובר בעמודות המוגדרות כ-Null.
3. Not In מחזיר תוצאות לא צפויות כשמדובר בעמודות המוגדרות כ-Null (נכונות לוגית אך לא אינטואיטיבית).
כתוצאה מכל זה- מומלץ להשתמש ב-Exists שבכל הנסיבות הוא יעיל יותר (או לא פחות) מהחלופות ותוצאותיו אינטואיטיביות; כל זאת בצד יכולות משופרות שאין ל-In. יחד עם זאת, הסינטקס שלו מורכב יחסית לזה של In, ומפתחים מתחילים עלולים להסתבך מעט בשימוש בו.

הקודים השונים שנעשה בהם כאן שימוש

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

Create Table #T(ID Int Primary Key, 
                Nm Varchar(20));
Go
 
Insert Into #T Select 1,1230007590
Insert Into #T Select 2,5656781123
Insert Into #T Select 3,3235577910
Insert Into #T Select 5,0235879421
Insert Into #T Select 4,8224479590
Go
 
Select * From #T;

clip_image002

כעת יש להשוות את חמש המחרוזות שבטבלה למחרוזת 123456789 ולבדוק כמה התאמות יש במקומות.
למשל- במחרוזת הראשונה 1230007590 יש שש התאמות (הספרות 1,2,3,7,9,0).
יש בוודאי דרכים שונות לעשות זאת, הפעם אשתמש בטבלת מספרים, אבדוק עבור אילו מספרים יש התאמה, ואסכם לגבי כל מחרוזת:

Declare @Num Varchar(20);
Set @Num='1234567890';
 
With Numbers As
(Select Top 100 Row_Number() Over(Order By GetDate()) N
From    sys.messages),
T1 As
(Select Numbers.N,
        T.*
From    Numbers
Right Join #T T
        On SubString(T.Nm,Numbers.N,1)=SubString(@Num,Numbers.N,1)
        And Numbers.N<=Len(T.Nm)
        And Numbers.N<=Len(@Num))
Select  ID,
        Nm,
        Count(N) Cnt
From    T1
Group BY ID,
        Nm;

clip_image004

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

"הלב" של השליפה הוא T1 שמחפש את כל המספרים עבורם יש התאמה, ולמעשה מפרק את המחרוזות לסט של תווים בודדים. אני משתמש ב-Right Join במקום ב-Inner Join כדי לא לאבד מחרוזות עבורן אין התאמות.
שליפת ה-Group By שבסוף מקבצת מחדש את הסט המפורק לפי המחרוזות המקוריות, וסופרת כמה התאמות יש בכל אחת.

כשמריצים SQL דינמי ניתן לעשות שימוש באחת משתי פרוצדורות המערכת הנ"ל.
ה-DBA-ים הוותיקים בוודאי זוכרים במעורפל משהו על כך ש-SP_ExecuteSQL עדיפה מפני שהיא יכולה למחזר Execution Plans, אך מה זה בדיוק אומר ומי מבין השתיים עדיפה ומתי?
נפתח ברשימת המצרכים- טבלה עליה נוכל להריץ את הקודים הדינמיים:

Use tempdb;
Go
 
If Object_ID('T_Messages','U') Is Not Null Drop Table T_Messages;
Go
 
Select * Into T_Messages From sys.messages;
Select * From T_Messages;

clip_image002

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

Declare @SQL NVarchar(Max);
Select @SQL='Select * From T_Messages Where message_id=21;'
Exec(@SQL);
Exec SP_ExecuteSQL @SQL;
Go

clip_image004

כדאי לציין ש-Exec תסתפק במשתנה Varchar ואילו SP_ExecuteSQL תדרוש NVarchar; אך אלו לא יותר מזוטי דברים, וניתן לדלג במקרה הצורך מעל המהמורה הזו בקלות.
מה קורה אם נרצה ליצור את הפקודה הדינמית "באוויר" (On the fly)?

Declare @SQL NVarchar(Max),
        @ID NVArchar(Max);
Select  @SQL='Select * From T_Messages Where message_id=',
        @ID='21';
Exec(@SQL+@ID);
Exec    SP_ExecuteSQL @SQL+@ID;
Go

clip_image006

הפקודה הדינמית נכשלה בגלל ה-SP_ExecuteSQL: היא צריכה לקבל את הפקודה מוכנה להגשה, בעוד שה-Exec יכול לשרר את המחרוזות תוך כדי. למה זה טוב? בימי קדם, כשהשתמשנו ב-SQL 2000 ובשורת ה-(Nvarchar(Max טרם הגיעה לשרתינו, פקודות דינמיות היו מוגבלות כעקרון לאורך של 4000 תווים, שזו הייתה מגבלת ה-Nvarchar. הפתרון היה להשתמש ב-Exec גם מפני שהוא יכול היה להריץ Varchar שאורכו הגיע ל-8000 תווים, וגם מפני שניתן היה לפצל את הפקודה הדינמית למספר משתנים ולשרשר אותם במהלך ביצוע ה-Exec.
למי שעדיין משתמש ב-SQL 2000 – חשוב לזכור זאת, ומי שלא- יוכל גם במקרה זה להסתדר מצויין גם עם SP_ExecuteSQL.
ומה לגבי אותה שמועה שגונבה לאוזנינו לגבי מיחזור של Execution Plans?
ננסה להריץ עם כל אחת מהפרוצדורות שתי פקודות Select, כל אחת עם פרמטרים שונים (לא לפני שננקה את ה-Cache):

DBCC FreeProcCache;
Go
 
Exec('Select * From T_Messages Where message_id=20;');
Go
 
Exec('Select * From T_Messages Where message_id=21;');
Go
 
Exec SP_ExecuteSQL N'Select * From T_Messages Where message_id=20;';
Go
 
Exec SP_ExecuteSQL N'Select * From T_Messages Where message_id=21;'; 
Go

וכעת נבדוק בטבלאות המערכת מה המצב:

Select  [text],
        usecounts
From    sys.dm_exec_cached_plans CP
Cross Apply sys.dm_exec_sql_text(CP.plan_handle) ST
Where   [text] Like 'Select * From T_Messages Where message_id=%';

clip_image008

חזיז ורעם- יש לנו שני Execution Plans ב-Cache שכל אחד בוצע פעמיים: פעם עם הפרמטר 21 ופעם עם הפרמטר 20, ללא קשר לשאלה אם השתמשנו ב-Exec או ב-SP_ExecuteSQL..
אכן כן- כשהמחרוזות זהות המערכת יודעת למחזר את ה-Execution Plan הקיים, אך כשיש הבדל – ואין זה משנה אם הפרמטר הפך מ-21 ל-20 או אם הוספנו תו רווח תמים בין ה-* ל-From..
אם כך- מה בכל זאת ההבדל בין השתיים? (וכאן אנחנו מגיעים ללב העניין)
ל-SP_ExecuteSQL יש אופציות הפעלה נוספות זולת הנ"ל, שאינן קיימות ב-Exec (שוב נקפיד לנקות ה-Cache):

DBCC FreeProcCache;
Go
 
Exec('Select * From T_Messages Where message_id=20;');
Go
 
Exec('Select * From T_Messages Where message_id=21;');
Go
 
Exec SP_ExecuteSQL N'Select * From T_Messages Where message_id=@ID;',
                   N'@ID Int',
                   @ID=20;
 
Exec SP_ExecuteSQL N'Select * From T_Messages Where message_id=@ID;',
                   N'@ID Int',
                   @ID=21;

כלומר- הפעלה כך שהפרמטר מועבר מבחוץ ובאופן מפורש (יש להגדיר את הפרמטר).
מה אומרות טבלאות המערכת?

Select  [text],
        usecounts
From    sys.dm_exec_cached_plans CP
Cross Apply sys.dm_exec_sql_text(CP.plan_handle) ST
Where   [text] Like '%Select * From T_Messages Where message_id=%';

clip_image010

מהשורה הראשונה אפשר להתעלם- היא מתייחסת לשליפה עצמה מטבלאות המערכת ולא להרצה של הקוד הדינמי.
לגבי הקוד הדינמי- ניתן לראות שהשימוש ב-SP_ExecuteSQL יצר Execution Plan אחד שההמערכת השתמשה בו פעמיים, ואילו השימוש ב-Exec יוצר בכל פעם Execution Plan חדש, אלא אם כן הרצנו אותה פקודה בדיוק נמרץ (גם ללא הבדלים זניחים ברווחים וכו').
יש להדגיש- היכולת הזו קיימת כשמפעילים קוד דינמי כנ"ל הכולל בתוכו פרמטרים פנימיים, ולא בקוד דינמי שנוצר בעזרת פרמטרים חיצוניים. למשל- לא ניתן ליצור טבלה כך ((Create Table @Tbl (ID Int, Txt Varchar(Max (כאשר @Tbl הוא פרמטר המקבל ערך כלשהו), אלא יש ליצור קודם כל את הפקודה הדינמית תוך שירשור הפרמטר לפקודת ה-Create ורק אז לבצע אותה, ובמקרה זה אין כל יתרון לשימוש ב-SP_ExecuteSQL.
ליתרון שהוזכר קודם יש להוסיף את היכולת של SP_ExecuteSQL להעביר תוצאה של חישוב מתוך הקונטקסט של הקוד הדינמי החוצה, יכולת שכתבתי עליה בעבר.
לסיכום: כשמריצים "סתם" קוד דינמי, אין הבדל משמעותי בין שתי הפרוצדורות, ואולי אפילו יתרון קטן במקרים מיוחדים ל-Exec. לעומת זאת, במקרים מאוד ספציפיים, בהפעלה פרמטרית של SP_ExecuteSQL ניתן להפיק תועלת מהיכולת שלה למחזר את ה-Execution Plans שב-Cache.

More Posts Next page »