הארכיטקטורה המבוזרת של Spanner מאפשרת לעצב את הסכימה כדי להימנע מנקודות חמות – מצבים שבהם נשלחות יותר מדי בקשות לאותו שרת, מה שגורם לניצול יתר של משאבי השרת ועלול להוביל לזמני אחזור ארוכים.
בדף הזה מתוארות שיטות מומלצות לעיצוב הסכימות כדי למנוע יצירה של אזורים פעילים. אחת הדרכים להימנע מנקודות חמות היא להתאים את עיצוב הסכימה כדי לאפשר ל-Spanner לפצל את הנתונים ולהפיץ אותם בין כמה שרתים. חלוקת הנתונים בין השרתים עוזרת למסד הנתונים של Spanner לפעול ביעילות, במיוחד כשמבצעים הוספות של נתונים בכמות גדולה.
מערכת Spanner מזהה באופן אוטומטי הזדמנויות ליישום שיטות מומלצות לעיצוב סכימה. אם יש המלצות למסד נתונים, אפשר לראות אותן בדף Spanner Studio של מסד הנתונים הזה. מידע נוסף זמין במאמר בנושא המלצות לשיטות מומלצות לעיצוב סכימה.
בחירת מפתח ראשי כדי למנוע נקודות חמות
כדי להימנע מיצירת נקודות חמות במסד הנתונים, חשוב לבחור מפתח ראשי בקפידה במהלך תכנון הסכימה.
אחת הסיבות הנפוצות לנקודות חמות היא שימוש במפתח שעולה או יורד באופן מונוטוני, כמו חותמת זמן. מפתחות מונוטוניים גורמים לכך שכל הרשומות החדשות ייכתבו לאותו טווח של מרחב המפתחות. מכיוון שמערכת Spanner משתמשת בטווחים של מפתחות כדי להפיץ את הנתונים בין השרתים, מפתח מונוטוני מפנה את כל תנועת ההוספה לשרת יחיד, ויוצר צוואר בקבוק.
לדוגמה, נניח שרוצים לשמור עמודה עם חותמת הזמן של הגישה האחרונה בשורות של הטבלה UserAccessLogs. בהגדרת הטבלה הבאה נעשה שימוש במפתח ראשי מבוסס חותמת זמן כחלק הראשון של המפתח. לא מומלץ להשתמש באפשרות הזו אם קצב ההוספה לטבלה גבוה:
GoogleSQL
CREATE TABLE UserAccessLogs ( LastAccess TIMESTAMP NOT NULL, UserId STRING(1024), ... ) PRIMARY KEY (LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( lastaccess timestamptz NOT NULL, userid text, ... PRIMARY KEY (lastaccess, userid) );
הבעיה כאן היא שהשורות נכתבות בטבלה הזו לפי סדר חותמות הזמן של הגישה האחרונה, ומכיוון שחותמות הזמן של הגישה האחרונה תמיד עולות, הן תמיד נכתבות בסוף הטבלה. הנקודה לשיתוף אינטרנט נוצרת כי שרת Spanner יחיד מקבל את כל פעולות הכתיבה, מה שיוצר עומס יתר על השרת הזה.
התרשים הבא מדגים את הבעיה הזו:
בטבלה הקודמת UserAccessLogs יש חמש דוגמאות לשורות נתונים, שמייצגות חמישה משתמשים שונים שמבצעים פעולת משתמש כלשהי בהפרש של אלפית השנייה אחד מהשני. בדיאגרמה מצוין גם הסדר שבו Spanner מוסיף את השורות (החיצים המסומנים מציינים את סדר הכתיבה של כל שורה). מכיוון שהוספות מסודרות לפי חותמת זמן, וערך חותמת הזמן תמיד עולה, Spanner תמיד מוסיף את ההוספות לסוף הטבלה ומפנה אותן לאותו פיצול. (כמו שמוסבר במאמר סכימה ומודל נתונים, פיצול הוא קבוצה של שורות מטבלה אחת או יותר שקשורות זו לזו, ש-Spanner מאחסן לפי סדר מפתח השורה).
זו בעיה כי Spanner מקצה עבודה לשרתים שונים ביחידות של פיצולים, ולכן השרת שהוקצה לפיצול הספציפי הזה מטפל בכל בקשות ההוספה. ככל שתדירות האירועים של גישת משתמשים עולה, כך עולה גם התדירות של בקשות ההוספה לשרת המתאים. השרת הופך לנקודת hotspot, ומוצג עם הגבול האדום והרקע שמופיעים בתמונה הקודמת. באיור הפשוט הזה, כל שרת מטפל בפילוג אחד לכל היותר, אבל Spanner יכול להקצות לכל שרת יותר מפילוג אחד.
כש-Spanner מוסיף עוד שורות לטבלה, הפיצול גדל, ואז Spanner יוצר פיצולים חדשים לפי הצורך. מידע נוסף על יצירת חלוקות זמין במאמר חלוקה מבוססת-עומס. Spanner מוסיף שורות חדשות עוקבות לפיצול החדש הזה, והשרת שהוקצה לפיצול הופך לנקודה חמה פוטנציאלית חדשה.
כשמתרחשים אזורים חמים, יכול להיות שתבחינו שההוספות שלכם איטיות ושהעבודה האחרת באותו השרת מתבצעת לאט יותר. שינוי הסדר של העמודה LastAccess לסדר עולה לא פותר את הבעיה, כי אז כל פעולות הכתיבה מוכנסות בחלק העליון של הטבלה, ועדיין נשלחות לשרת יחיד.
שיטה מומלצת לעיצוב סכימה מספר 1: אל תבחרו עמודה שהערך שלה עולה או יורד באופן מונוטוני כחלק הראשון של המפתח בטבלה עם קצב כתיבה גבוה.
שימוש במזהה ייחודי אוניברסלי (UUID)
אתם יכולים להשתמש במזהה ייחודי אוניברסלי (UUID) כפי שהוגדר על ידי RFC 9562 כמפתח הראשי. מומלץ להשתמש ב-UUID גרסה 4, כי הוא משתמש בערכים אקראיים ברצף הביטים. אנחנו לא ממליצים על מזהי UUID בגרסה 1 כי חותמת הזמן מאוחסנת בביטים הגבוהים. אפשר לאחסן ערכים של UUID בגרסה 4 בעמודה UUID ב-Spanner.
לפני שמחליטים להשתמש ב-UUID, כדאי לשקול את הנקודות הבאות:
- הם פועלים באופן עצמאי, ללא קשר לתוכן הרשומה. בניגוד למפתחות סמנטיים כמו
SingerIdו-AlbumId, מזהה ייחודי אוניברסלי (UUID) הוא מזהה ייחודי שלא קשור לנתונים עצמם. - הם לא שומרים על המיקום בין רשומות קשורות, ולכן שימוש ב-UUID מבטל את הנקודות החמות.
כדי ליצור ערכי UUID בעמודה UUID, אפשר להשתמש בפונקציית Spanner NEW_UUID() GoogleSQL או בפונקציית PostgreSQL gen_random_uuid().
לדוגמה, בטבלה הבאה:
GoogleSQL
CREATE TABLE UserAccessLogs (
LogEntryId UUID DEFAULT (NEW_UUID()),
LastAccess TIMESTAMP NOT NULL,
UserId STRING(1024)
) PRIMARY KEY (LogEntryId);
PostgreSQL
CREATE TABLE useraccesslogs (
logentryid uuid PRIMARY KEY DEFAULT gen_random_uuid(),
lastaccess timestamptz NOT NULL,
userid text);
אפשר להשתמש בפונקציה ליצירת UUID כדי ליצור ערכים חדשים של LogEntryId.
GoogleSQL
INSERT INTO UserAccessLogs (LastAccess, UserId)
VALUES ('2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO UserAccessLogs (LastAccess, UserId)
VALUES ('2016-01-25 10:10:10.555555-05:00', 'TomSmith');
בעמודה UUID, אפשר להשתמש בפונקציית NEW_UUID() של Spanner GoogleSQL או בפונקציית gen_random_uuid() של PostgreSQL כערך ברירת המחדל של העמודה, כדי שמערכת Spanner תיצור ערכי UUID באופן אוטומטי.
לדוגמה, בטבלה הבאה:
GoogleSQL
CREATE TABLE UserAccessLogs (
LogEntryId UUID NOT NULL,
LastAccess TIMESTAMP NOT NULL,
UserId STRING(1024),
...
) PRIMARY KEY (LogEntryId);
PostgreSQL
CREATE TABLE useraccesslogs (
logentryid uuid PRIMARY KEY NOT NULL,
lastaccess timestamptz NOT NULL,
userid text);
אפשר להוסיף GoogleSQL NEW_UUID() או PostgreSQL
gen_random_uuid() כדי ליצור את הערכים LogEntryId.
הפונקציות האלה מחזירות ערך UUID, ולכן בעמודה LogEntryId צריך להשתמש בסוג UUID עבור GoogleSQL או PostgreSQL.
GoogleSQL
INSERT INTO
UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
(NEW_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO
useraccesslogs (logentryid, lastaccess, userid)
VALUES
(gen_random_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');
אפשר גם להוסיף ערכי UUID שיצרתם במקום אחר, כמו באפליקציית ה-Backend שלכם. הסיבה לכך היא שמזהי UUID הם ייחודיים, לא משנה איפה הם נוצרים.
GoogleSQL
INSERT INTO
UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
('4192bff0-e1e0-43ce-a4db-912808c32493', '2016-01-25 10:10:10.555555-05:00', 'TomSmith');
PostgreSQL
INSERT INTO
useraccesslogs (logentryid, lastaccess, userid)
VALUES
('4192bff0-e1e0-43ce-a4db-912808c32493','2016-01-25 10:10:10.555555-05:00', 'TomSmith');
ערכים עוקבים הפוכים בביטים
צריך לוודא שמפתחות ראשיים מספריים (INT64 ב-GoogleSQL או bigint ב-PostgreSQL) לא גדלים או קטנים באופן עקבי. מפתחות ראשיים עוקבים עלולים לגרום לנקודות חמות בקנה מידה גדול. דרך אחת להימנע מהבעיה הזו היא להפוך את הערכים העוקבים, ולוודא שערכי המפתח הראשי מפוזרים באופן שווה במרחב המפתחות.
Spanner תומך ברצף הפוך-ביטים, שיוצר ערכים ייחודיים של מספרים שלמים הפוכים-ביטים. כדי למנוע בעיות בנקודות חמות, אפשר להשתמש ברצף ברכיב הראשון (או היחיד) במפתח ראשי. מידע נוסף זמין במאמר בנושא Bit-reversed sequence (רצף הפוך ביטים).
החלפת הסדר של המקשים
דרך אחת לפזר את פעולות הכתיבה באופן אחיד יותר על פני מרחב המפתחות היא להחליף את סדר המפתחות כך שהעמודה שמכילה את הערך המונוטוני לא תהיה החלק הראשון של המפתח:
GoogleSQL
CREATE TABLE UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess);
PostgreSQL
CREATE TABLE useraccesslogs ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ NOT NULL, ... PRIMARY KEY (UserId, LastAccess) );
בסכימה ששונתה, ההוספות מסודרות עכשיו לפי UserId, ולא לפי חותמת הזמן הכרונולוגית של הגישה האחרונה. הסכימה הזו מפזרת את פעולות הכתיבה בין פיצולים שונים, כי לא סביר שמשתמש יחיד ייצור אלפי אירועים בשנייה.
בתמונה הבאה מוצגות חמש השורות מהטבלה UserAccessLogs ש-Spanner מסדר לפי UserId במקום לפי חותמת הזמן של הגישה:

במקרה כזה, יכול להיות ש-Spanner יחלק את נתוני UserAccessLogs לשלושה פיצולים, וכל פיצול יכיל בערך אלף שורות של ערכי UserId מסודרים. למרות שהאירועים של המשתמש התרחשו בהפרש של כאלפית השנייה,
כל אירוע הופעל על ידי משתמש אחר, ולכן הסיכוי שהסדר של ההוספות ייצור נקודה חמה נמוך בהרבה בהשוואה לשימוש בחותמת הזמן לצורך סידור. מידע נוסף על אופן יצירת הפיצולים זמין במאמר פיצול מבוסס-עומס
כדאי לעיין גם בשיטה המומלצת הקשורה לסידור מפתחות שמבוססים על חותמת זמן.
מבצעים גיבוב (hash) של המפתח הייחודי ומפיצים את פעולות הכתיבה על פני רסיסים לוגיים
טכניקה נפוצה נוספת לחלוקת העומס בין כמה שרתים היא ליצור עמודה שמכילה את הגיבוב של המפתח הייחודי בפועל, ואז להשתמש בעמודת הגיבוב (או בעמודת הגיבוב ובעמודות המפתח הייחודיות יחד) כמפתח הראשי. הדפוס הזה עוזר להימנע מנקודות חמות, כי השורות החדשות מפוזרות בצורה אחידה יותר במרחב המפתחות.
אפשר להשתמש בערך הגיבוב כדי ליצור רסיסים לוגיים, או מחיצות, במסד הנתונים. במסד נתונים עם שרדינג פיזי, השורות מפוזרות בין כמה שרתי מסד נתונים. במסד נתונים עם חלוקה לוגית, הנתונים בטבלה מגדירים את הרסיסים. לדוגמה, כדי לפזר פעולות כתיבה בטבלה UserAccessLogs על פני N רסיסים לוגיים, אפשר להוסיף עמודת מפתח ShardId לטבלה:
GoogleSQL
CREATE TABLE UserAccessLogs ( ShardId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, UserId INT64 NOT NULL, ... ) PRIMARY KEY (ShardId, LastAccess, UserId);
PostgreSQL
CREATE TABLE useraccesslogs ( shardid bigint NOT NULL, lastaccess TIMESTAMPTZ NOT NULL, userid bigint NOT NULL, ... PRIMARY KEY (shardid, lastaccess, userid) );
כדי לחשב את ShardId, מבצעים גיבוב (hash) של שילוב של עמודות המפתח הראשי ואז מחשבים את המודולו N של הגיבוב. לדוגמה:
GoogleSQL
ShardId = hash(LastAccess and UserId) % N
הבחירה של פונקציית הגיבוב ושילוב העמודות קובעת איך השורות יתפזרו במרחב המפתחות. לאחר מכן, Spanner יפצל את השורות כדי לשפר את הביצועים.
הדיאגרמה הבאה ממחישה כיצד שימוש בגיבוב ליצירת שלושה מחיצות Shard לוגיות יכול לפזר את תפוקת הכתיבה בצורה אחידה יותר בין השרתים:

כאן הטבלה UserAccessLogs מסודרת לפי ShardId, שמחושב כפונקציית גיבוב של עמודות המפתח. חמש השורות UserAccessLogs מחולקות לשלושה רסיסים לוגיים, שכל אחד מהם נמצא במקרה בפיצול אחר. ההוספות מתבצעות באופן שווה בין הפיצולים, וכך מתאזן קצב העברת הנתונים לכתיבה בשלושת השרתים שמטפלים בפיצולים.
ב-Spanner אפשר גם ליצור פונקציית גיבוב בעמודה שנוצרה.
כדי לעשות את זה ב-GoogleSQL, משתמשים בפונקציה FARM_FINGERPRINT בזמן הכתיבה, כמו בדוגמה הבאה:
GoogleSQL
CREATE TABLE UserAccessLogs (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);
הבחירה שלכם בפונקציית הגיבוב קובעת את מידת הפיזור של ההוספות בטווח המפתחות. לא צריך גיבוב קריפטוגרפי, אבל גיבוב קריפטוגרפי יכול להיות בחירה טובה. כשבוחרים פונקציית גיבוב, צריך לקחת בחשבון את הגורמים הבאים:
- הימנעות מנקודות לשיתוף אינטרנט. פונקציה שמחזירה יותר ערכי גיבוב נוטה להקטין את מספר הנקודות החמות.
- לקרוא את נתוני היעילות. קריאות בכל ערכי הגיבוב מהירות יותר אם יש פחות ערכי גיבוב לסריקה.
- מספר הצמתים.
כשמשתמשים ב-ShardId כדי למנוע נקודות חמות, צריך לפעול לפי ההנחיות הבאות כדי לבחור את הערך של N, מספר הרסיסים הלוגיים:
התאמת N למספר הצמתים: מגדירים את N כך שיהיה שווה למספר הצמתים שצפויים במופע. לדוגמה, אם אתם מצפים שהמופע יתרחב עד ל-10 צמתים, ערך של N=10 הוא נקודת התחלה יעילה. כך Spanner יכול להפיץ את עומס הכתיבה באופן שווה בין הצמתים.
N הוא ערך סטטי: כדי לשנות את N אחרי ההגדרה הראשונית, צריך לעדכן את הסכימה ואולי גם לבצע השלמת חוסר בנתונים (data backfill). לכן, צריך לבחור ערך ל-N שיתאים לצרכים שלכם מבחינת יכולת ההתאמה.
מומלץ להימנע מערכים גדולים מדי של N: למרות שזה עשוי להיות מפתה לבחור ערך גדול מאוד של N כדי להתכונן לצמיחה, בדרך כלל אין צורך בכך. יותר רסיסים מהשרתים הפיזיים לא ישפרו את הביצועים באופן משמעותי בהשוואה לעלות הנוספת של Spanner. התאמה של N למספר הצמתים היא אסטרטגיה יעילה לחלוקת עומס העבודה.
שימוש בסדר יורד למפתחות שמבוססים על חותמת זמן
אם יש לכם טבלה של ההיסטוריה שבה חותמת הזמן משמשת כמפתח, כדאי להשתמש בסדר יורד בעמודת המפתח אם אחד מהתנאים הבאים מתקיים:
- אם רוצים לקרוא את ההיסטוריה האחרונה, משתמשים בטבלה משולבת להיסטוריה וקוראים את שורת האב. במקרה כזה, אם יש עמודה של חותמות זמן
DESC, הרשומות האחרונות בהיסטוריה מאוחסנות בסמוך לשורת ההורה. אחרת, כדי לקרוא את השורה של ההורה ואת ההיסטוריה האחרונה שלה, יהיה צורך לבצע חיפוש באמצע כדי לדלג על ההיסטוריה הישנה יותר. - אם קוראים רשומות עוקבות בסדר כרונולוגי הפוך, ולא יודעים בדיוק כמה זמן אחורה חוזרים. לדוגמה, אפשר להשתמש בשאילתת SQL עם
LIMITכדי לקבל את N האירועים האחרונים, או לתכנן לבטל את הקריאה אחרי שקוראים מספר מסוים של שורות. במקרים כאלה, כדאי להתחיל עם הרשומות האחרונות ולקרוא ברצף רשומות ישנות יותר עד שהתנאי מתקיים. מערכת Spanner עושה את זה בצורה יעילה יותר עבור מפתחות של חותמות זמן שהיא מאחסנת בסדר יורד.
מוסיפים את מילת המפתח DESC כדי שהמפתח של חותמת הזמן יהיה בסדר יורד. לדוגמה:
GoogleSQL
CREATE TABLE UserAccessLogs ( UserId INT64 NOT NULL, LastAccess TIMESTAMP NOT NULL, ... ) PRIMARY KEY (UserId, LastAccess DESC);
שיטה מומלצת לעיצוב סכימה מספר 2: סדר יורד או סדר עולה תלוי בשאילתות של המשתמשים. לדוגמה, הפריט הראשון הוא החדש ביותר או הפריט הראשון הוא הישן ביותר.
מתי כדאי להשתמש באינדקס משולב
הנחיות לבחירה בין אינדקסים משולבים ואינדקסים גלובליים זמינות במאמר בחירה בין אינדקסים משולבים ואינדקסים גלובליים.
בדומה לדוגמה הקודמת של מפתח ראשי שאסור להשתמש בו, לא מומלץ ליצור אינדקסים לא משולבים בעמודות שהערכים שלהן עולים או יורדים באופן מונוטוני, גם אם הן לא עמודות של מפתח ראשי.
לדוגמה, נניח שהגדרתם את הטבלה הבאה, שבה LastAccess היא עמודה שאינה מפתח ראשי:
GoogleSQL
CREATE TABLE Users ( UserId INT64 NOT NULL, LastAccess TIMESTAMP, ... ) PRIMARY KEY (UserId);
PostgreSQL
CREATE TABLE Users ( userid bigint NOT NULL, lastaccess TIMESTAMPTZ, ... PRIMARY KEY (userid) );
יכול להיות שיהיה לכם נוח להגדיר אינדקס בעמודה LastAccess כדי לבצע שאילתות במהירות במסד הנתונים לגבי גישות של משתמשים 'מאז זמן X', באופן הבא:
GoogleSQL
CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);
PostgreSQL
CREATE INDEX usersbylastaccess ON users(lastaccess) WHERE lastaccess IS NOT NULL;
עם זאת, התוצאה היא אותה בעיה שמתוארת בשיטה המומלצת הקודמת, כי Spanner מטמיע אינדקסים כטבלאות מתחת לפני השטח, וטבלת האינדקס שמתקבלת משתמשת בעמודה שהערך שלה גדל באופן מונוטוני כחלק הראשון של המפתח.
אפשר ליצור אינדקס משולב שבו השורות של הגישה האחרונה משולבות מתחת לשורת המשתמש המתאימה. הסיבה לכך היא שלא סביר ששורה אחת של הורה תפיק אלפי אירועים בשנייה.
GoogleSQL
CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(UserId, LastAccess), INTERLEAVE IN Users;
PostgreSQL
CREATE INDEX usersbylastaccess ON users(userid, lastaccess) WHERE lastaccess IS NOT NULL, INTERLEAVE IN Users;
שיטה מומלצת לעיצוב סכימה מספר 3: אל תיצרו אינדקס לא משולב בעמודה עם קצב כתיבה גבוה, שבה הערך עולה או יורד באופן מונוטוני. כדי לעצב עמודות אינדקס, כדאי להשתמש באינדקס משולב או בטכניקות דומות לאלה שמשמשות לעיצוב מפתח ראשי של טבלת בסיס. לדוגמה, אפשר להוסיף את shardId.