SQL Server: שימוש בפונקציות COMPRESS ו- DECOMPRESS לטובת דחיסת נתונים

מזה מספר שנים, SQL Server מציע באופן מובנה יכולת של Data Compression. מדור ביכולת לדחיסת המידע בטבלאות ואינדקסים, בין אם “רגילים” (row store) או clustered / nonclusted columnstore indexes.
כאשר מדובר על דחיסה של אינדקסים “רגילים” – אנחנו יכולים לבחור בין דחיסה ברמת השורה – שהיא בעיקרה רלוונטית ל- fixed length data types, שהשמירה שלהם נעשית יותר יעילה במקום,  או ב- page level compression שמבצע בפועל row level compression של כלל השורות באותו ה- page, ומעל זה מוסיף רובד של prefix compression (שמקטין את הנפח במידה שיש ערכים מייצגים בעמודות, או לחלופין תחיליות משותפות) ו- dictionary compression (שמחליף ערכים חוזרים בהפנייה למילון שנשמר כחלק מה- metadata, ומקטין את הנפח במידה שיש חזרתיות גבוהה של ערכים ברמת ה- page).
בין אם משתמשים ב- row level compression או ב- page level compression, מופעלת גם היכולת של unicode compression שמקטינה את הנפח במידה שהעמודה מוגדרת בתור nvarchar או nchar, אולם בפועל עיקר הטקסט שבו נעשה שימוש לא באמת מתפרס על ה-2 בתים שמוקצים ב- UCS-2 לכל תו (כלומר, לאחר הדחיסה מתקבלת התנהגות שמזכירה יותר את UTF-16 במובן שלא נדרשים בפועל תמיד 2 בתים לכל תו, אלא ניתן להסתפק בתו בודד). זה יכול במקרים רבים לחסוך עד 50% (כי עבור הרבה מאד שפות, לא צריך להתפרס על 2 בתים כדי לייצג את התו).

אולם, יש מס’ חסרונות ל- page/row level compression, בעיקר כשבאמת רוצים לדחוס כמות גדולה של data. החיסרון הראשון הוא אופי הדחיסה – האלגוריתם של מיקרוסופט נבחר (ככה”נ, אני מין הסתם לא יודע את זה בוודאות) מתוך מחשבה על איזון בין חיסכון במקום לבין צריכת CPU בזמן ה- compression וה- decompression. מדובר באיזון חשוב, מאחר שחלק מהיתרונות של ה- compression הוא שהמידע נשמר דחוס גם ב- buffer pool, מה שנותן חיסכון בזיכרון – אבל שכפול של עלות ה-decompression בכל שימוש במידע. אולם, בהשוואה לאלגוריתמי דחיסה אחרים (DEFLATE), הוא מכיל פחות שלבים שיכולים לשפר את האפקטיביות של הדחיסה.
חיסרון נוסף, שנובע ככה”נ מאותו השיקול שהזכרתי קודם – מיקרוסופט בכלל לא מתיימרים לדחוס כמות גדולה של מידע- הדחיסה מתבצעת רק ל- in-row data, כלומר למידע שנשמר ב-page של השורה עצמה. מידע שנשמר ב- row-overflow page לא נדחס כלל. אגב, ה- unicode compession (ודחיסה באופן כללי) לא מופעל בכלל עבור עמודות שמוגדרות בתור varchar(max), nvarchar(max).

במרבית המקרים, ה- trade-off של מיקרוסופט בין העלות CPU של ה-compression/decompression ב- row/page level compression בהחלט נשמע הגיוני. אולם, יש מקרים שבהם אנחנו נעדיף דחיסה טובה משמעותית. מקרה כזה לדוגמא, הוא טבלאות שמכילות מידע שנשמר “אם יהיה צורך”. מדובר למשל בטבלאות, שמכילות עמודות nvarchar(max) עם איזשהו JSON גדול, שאמנם כל המידע המעניין ממנו כבר מחולץ ונשמר בטבלאות אחרות בצורה רלציונית סטנדרטית, אולם שומרים את ה- JSON קלט המלא למקרה שיהיה צורך לתשאל בדיוק איזשהו שדה שאולי לא חולץ, או לעשות פעולה אחרת על המידע שלא תוכננה מראש. אלה עמודות שאול אפשר היה לשמור אותן אפילו כקבצים על הדיסק, ולשמור רק את המיקום ב-DB, אבל מכיוון שיש את היכולת שליפות על ה- JSON-ים באופן מובנה בצורה נוחה ב- SQL Server, מעדיפים בכל זאת לשמור את המידע בטבלה – ולחסוך את הסקריפט שיאסוף את המידע מהדיסק במידת הצורך.
כלומר, מדובר במידע שאנחנו כנראה לא מעוניינים לתשאל (למעט אולי במקרים נדירים), ולכן לא אכפת לנו ממש מה-CPU – אבל כן רוצים לשמור. בדיוק עבור מקרים כאלה, יש לנו (החל מ-SQL Server 2016) את הפונקציות COMPRESS ו- DECOMPRESS.

הפונקציה COMPRESS מקבלת data ומחזירה אותו אחרי דחיסת GZIP, ו-DECOMPRESS מקבלת varbinary שמכיל את ה- GZIP, ומחזירה את ה- plain data שבתוכו.

Benchmark

לטובת ה- Benchmark, לקחתי את ה-DB לדוגמא שמבוסס על ה-DB של Stackoverflow וכולל את כל השאלות שנשאלו, והתשובות שנענו, בשנת 2016. כדי לייצג את הטבלה שלנו, זאת עם ה- JSON הגדול, אני אייצר טבלה שכוללת עבור כל tag שקיים באתר עמודה עם ה- Id של ה- tag ו-JSON שכולל את כל השאלות שנשאלו באותו ה- tag, ולכל שאלה עוד JSON פנימי שכולל את כל התשובות שנענו לאותה השאלה. כלומר, בסופו של דבר נקבל JSON-ים בגודל מכובד למדי.

ה-SQL כדי לייצר את הטבלה ולמלא אותה בנתונים נראה כך:

SET STATISTICS TIME ON

SET STATISTICS IO ON

 

CREATE TABLE [dbo].[TagsExtendedInfoUncompressed](

    [TagId] [int] NOT NULL,

    [TagName]  nvarchar(100) NOT NULL,

    [ExtendedInfoJson] nvarchar(max) NULL,

 CONSTRAINT [PK_TagsExtendedInfoUncompressed] PRIMARY KEY CLUSTERED (TagId)

) 

;with data as (

    SELECT

        TagId = t.Id,

        TagName = t.TagName,

        QuestionsJson =    (

                            SELECT 

                                    q.*,

                                    RepliesJson =    (

                                                        SELECT *

                                                        FROM Posts a WITH(FORCESEEK)

                                                        WHERE a.ParentId = q.Id

                                                        FOR JSON AUTO

                                                    )

                            FROM Posts q WITH(FORCESEEK)

                            JOIN PostsToTags ON PostsToTags.PostId = q.Id AND PostsToTags.TagId = t.Id

                            WHERE q.ParentId IS NULL

                            FOR JSON AUTO

                        ) 

    FROM Tags t

)

 

INSERT INTO [TagsExtendedInfoUncompressed](TagId, TagName, ExtendedInfoJson)

SELECT TagId, TagName,QuestionsJson

FROM data

זמן הריצה שלו היה 308 שניות, ה- CPU time היה 285 שניות  וגודל הטבלה שנוצרה בסוף הוא 8978MB (והיא כוללת 47,392 שורות – מקרה שבו ה- JSON-ים באמת מאד גדולים).

הרצה נוספת של אותו ה- SQL רק עם הוספה של DATA_COMPRESSION = PAGE להגדרת ה- primary key בטבלה, הסתיימה לאחר 281שניות (ה- CPU time היה 270 שניות) וגודל הטבלה 8766MB– שזה כמעט זהה לחלוטין לגודל הטבלה המקורית. למה זה המצב? כי הטבלה שלנו מכילה BLOB-ים גדולים, שה- page compression של SQL Server בכלל לא מיועד לעבוד עליהם, ולא נוגע בהם כלל. כלומר, ה-page compression של SQL Server לחלוטין לא אפקטיבי במקרה הזה.
למעשה, עבור כל JSON שהוא גדול מספיק כדי שיוגדר nvarchar(max), varchar(max) הדחיסה של SQL Server תהיה מאד לא אפקטיבית. לכן, אם יש לכם JSON-ים שאתם שומרים ואתם יודעים שהגודל יהיה פחות מ-8000 תווים עבור טקסט ללא יוניקוד, או 4000 תווים לטקסט עם יוניקוד – אל תגדירו את העמודות כ- max, כי יהיו לזה השפעות משמעותיות על הדחיסה שתקבלו.

אולם, במקרים כמו המקרה שבחרתי ל- test case הנ”ל – יש לנו באמת JSON-ים שעוברים את הגודל הזה (אפילו באופן משמעותי). כאמור, החל מ- SQL Server 2016 אנחנו יכולים לבחור לבצע GZIP Compression ל- data באמצעות הפונקציה COMPRESS, וכך להקטין את הגודל של ה- data.

נעשה עכשיו את אותה הבדיקה עם הפונקציה COMPRESS, ונריץ את ה- SQL הבא (שימו לב שהעמודה בטבלה מוגדרת כ- varbinary(max) במקרה הזה, וכמובן שאנחנו לא מפעילים גם page compression – אין היגיון בלנסות לדחוס מידע שהוא כבר דחוס):

SET STATISTICS TIME ON

SET STATISTICS IO ON

 

CREATE TABLE [dbo].[TagsExtendedInfoGzipCompressed](

    [TagId] [int] NOT NULL,

    [TagName]  nvarchar(100) NOT NULL,

    [ExtendedInfoJson] varbinary(max) NULL,

 CONSTRAINT [PK_TagsExtendedInfoGzipCompressed] PRIMARY KEY CLUSTERED (TagId) WITH(DATA_COMPRESSION=PAGE)

) 

;with data as (

    SELECT

        TagId = t.Id,

        TagName = t.TagName,

        QuestionsJson =    (

                            SELECT 

                                    q.*,

                                    RepliesJson =    (

                                                        SELECT *

                                                        FROM Posts a WITH(FORCESEEK)

                                                        WHERE a.ParentId = q.Id

                                                        FOR JSON AUTO

                                                    )

                            FROM Posts q WITH(FORCESEEK)

                            JOIN PostsToTags ON PostsToTags.PostId = q.Id AND PostsToTags.TagId = t.Id

                            WHERE q.ParentId IS NULL

                            FOR JSON AUTO

                        ) 

    FROM Tags t

)

 

INSERT INTO [TagsExtendedInfoGzipCompressed](TagId, TagName, ExtendedInfoJson)

SELECT TagId, TagName,COMPRESS(QuestionsJson)

FROM data

זמן הריצה במקרה הזה היה ארוך משמעותית בהשוואה להכנסת המידע ללא דחיסה, או עם page compression (שלא מייצר overhead במקרה הזה, מאחר שהוא לא נוגע ב-LOB, ולכן גם לא דוחס לנו כמעט בכלל).  הריצה לקחה 608 שניות, כמעט פי 2 מהריצה עם page compression. אולם, הדחיסה הייתה גם משמעותית יותר טובה – נפח הטבלה שהתקבלה הוא 908MB, שזה חיסכון של כ-90% בנפח המידע.

מבחינת השליפות, אם נריץ את השליפה הבאה:

SET STATISTICS IO ON

SET STATISTICS TIME ON 

SELECT JSON_VALUE(ExtendedInfoJson, '$[0].Id')

FROM [SO-2016].[dbo].[TagsExtendedInfoPageCompressed]

נגלה שעבודה כלשהי מול הטבלה עם ה- page compression עולה לנו (במקרה הזה) 5.3 שניות (מתוכן 1.5 זמן CPU).

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

SET STATISTICS IO ON

SET STATISTICS TIME ON 

SELECT JSON_VALUE(CAST(DECOMPRESS(ExtendedInfoJson) as nvarchar(max)), '$[0].Id')

FROM [SO-2016].[dbo].[TagsExtendedInfoGzipCompressed]

אז נצטרך להמתין משמעותית יותר זמן – 86 שניות – פי 16 יותר זמן.

Offloading של ה- Compression וה- Decompression

ראינו שהפונקציות COMPRESS ו- DECOMPRESS אינן זולות (כי דחיסת GZIP אינה זולה). אולם, העובדה שאנחנו עושים שימוש בשיטת דחיסה סטנדרטית, מאפשר לנו לעשות offload של הדחיסה (שהיא פעולה שיכולה להיות CPU intensive) מהשרת MSSQL שלנו, ל- client שמכניס או שולף. זאת בניגוד ל-page compression, שחייב להתבצע בצד של ה- MSSQL. ביצוע offload ל- client יכול להוריד עומס מה- MSSQL, לבזר את התהליך (אם יש מס’ תהליכים במס’ שרתים שמבצעים את ההכנסה), ולהקטין את התעבורה של המידע שעוברת על הקו (כי הוא כבר יעבור דחוס).

את הדחיסה ניתן לבצע מקוד .NET באמצעות שימוש ב- GZipStream. הדבר היחיד שחשוב לזכור הוא שבמידה שרוצים לדחוס טקסט יוניקודי, כדי לקבל את ה- byte array עליו נבצע את ה- compression יש להשתמש ב- Encoding.Unicode.GetBytes. שימוש ב- UTF-8, למשל, לא יאפשר cast של ה- data ל- nvarchar(max).
ביצוע offload של הדחיסה לאפליקציות שמבצעות את טעינת החומר, יכול להקל את ה- performance penalty שמתלווה לשימוש ב- GZIP Compression, ולמנוע מה- MSSQL להפוך ל- bottleneck באיזור הזה.

סיכום

ראינו שיש מקרים שבהם row/page compression הם פחות אפקטיביים – בין אם כי מדובר ב- LOB-ים, ובין אם כי נדרשת דחיסה טובה יותר מהדחיסה שהם מספקים. במקרים האלה, כאשר ה- data הוא “ארכיוני” באופיו (כזה שאין שליפות קבועות שמתבססות עליו), ניתן לעשות שימוש ב- GZIP Compression ב- SQL Server באמצעות הפונקציות COMPRESS/DECOMPRESS.

השאר תגובה