שיטות מומלצות לעיצוב סכימה

הארכיטקטורה המבוזרת של 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 יחיד מקבל את כל פעולות הכתיבה, מה שגורם לעומס יתר בשרת הזה.

התרשים הבא מדגים את הבעיה הזו:

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

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

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

כש-Spanner מוסיף עוד שורות לטבלה, הפיצול גדל, וכשהוא מגיע לכ-8GB, ‏ Spanner יוצר פיצול נוסף, כמו שמתואר במאמר פיצול לפי עומס. ‫Spanner מוסיף שורות חדשות בהמשך לפיצול החדש הזה, והשרת שהוקצה לפיצול הופך לנקודה חמה פוטנציאלית חדשה.

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

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

שימוש במזהה ייחודי אוניברסלי (UUID)

אתם יכולים להשתמש במזהה ייחודי אוניברסלי (UUID) כפי שהוגדר על ידי RFC 9562 כמפתח הראשי. מומלץ להשתמש ב-UUID גרסה 4, כי הוא משתמש בערכים אקראיים ברצף הביטים. אנחנו לא ממליצים על מזהי UUID בגרסה 1 כי חותמת הזמן מאוחסנת בביטים הגבוהים. אפשר לאחסן ערכים של UUID בגרסה 4 בעמודה UUID ב-Spanner.

לפני שמחליטים להשתמש ב-UUID, כדאי לשקול את הנקודות הבאות:

  • הן פועלות באופן עצמאי, ללא קשר לתוכן הרשומה. בניגוד למפתחות סמנטיים כמו SingerId ו-AlbumId, ‏ UUID הוא מזהה ייחודי שלא קשור לנתונים עצמם.
  • הם לא שומרים על המיקום בין רשומות קשורות, ולכן שימוש במזהה ייחודי אוניברסלי (UUID) מבטל נקודות חמות.

כדי ליצור ערכי UUID בעמודה UUID, אפשר להשתמש בפונקציה NEW_UUID() של Spanner GoogleSQL או בפונקציה gen_random_uuid() של PostgreSQL.

לדוגמה, בטבלה הבאה:

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) לא גדלים או קטנים באופן עקבי. מפתחות ראשיים עוקבים עלולים לגרום לשימוש בלתי מאוזן במשאבים (hotspotting) בקנה מידה גדול. אחת הדרכים להימנע מהבעיה הזו היא להפוך את הערכים העוקבים, ולוודא שערכי המפתח הראשי מפוזרים באופן שווה במרחב המפתחות.

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

החלפת הסדר של המקשים

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

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 במקום לפי חותמת הזמן של הגישה:

טבלת UserAccessLogs מסודרת לפי UserId עם תפוקת כתיבה מאוזנת

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

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

גיבוב המפתח הייחודי ופיזור פעולות הכתיבה על פני רסיסים לוגיים

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

אתם יכולים להשתמש בערך הגיבוב כדי ליצור רסיסים לוגיים, או מחיצות, במסד הנתונים. במסד נתונים עם שרדינג פיזי, השורות מפוזרות בין כמה שרתי מסד נתונים. במסד נתונים עם חלוקה לוגית, הנתונים בטבלה מגדירים את הרסיסים. לדוגמה, כדי לפצל כתיבות לטבלה 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 יפצל את השורות כדי לשפר את הביצועים.

הדיאגרמה הבאה ממחישה איך שימוש בגיבוב (hash) כדי ליצור שלושה רסיסים לוגיים יכול לפזר את תפוקת הכתיבה בצורה אחידה יותר בין השרתים:

טבלה UserAccessLogs מסודרת לפי ShardId עם תפוקת כתיבה מאוזנת

כאן הטבלה 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 אחרי ההגדרה הראשונית, צריך לעדכן את הסכימה ואולי גם לבצע השלמת חוסר בנתונים. לכן, צריך לבחור ערך ל-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.

המאמרים הבאים