בדף הזה מוסבר על רמות בידוד שונות ואיך הן פועלות ב-Spanner.
רמת הבידוד היא מאפיין של מסד נתונים שמגדיר אילו נתונים גלויים לעסקאות מקבילות. Spanner תומך בשתי רמות בידוד שמוגדרות בתקן ANSI/ISO SQL: serializable ו-repeatable read. כשיוצרים טרנזקציה, צריך לבחור את רמת הבידוד המתאימה ביותר לטרנזקציה. רמת הבידוד שנבחרה מאפשרת לעסקאות בודדות לתת עדיפות לגורמים שונים, כמו זמן האחזור, שיעור הביטולים והאם האפליקציה רגישה להשפעות של אנומליות בנתונים. הבחירה הטובה ביותר תלויה בדרישות הספציפיות של עומס העבודה.
בידוד ניתן לסריאליזציה
בידוד ניתן לסדרות הוא רמת הבידוד שמוגדרת כברירת מחדל ב-Spanner. בבידוד ניתן לסדר את הפעולות בסדר מסוים, ו-Spanner מספק לכם את ההבטחות המחמירות ביותר לגבי בקרת מקביליות של טרנזקציות, שנקראת עקביות חיצונית. ההתנהגות של Spanner היא כאילו כל העסקאות בוצעו ברצף, למרות שבפועל Spanner מריץ אותן בכמה שרתים (ואולי בכמה מרכזי נתונים) כדי להשיג ביצועים וזמינות טובים יותר מאשר במסדי נתונים של שרת יחיד. בנוסף, אם עסקה אחת מסתיימת לפני שעסקה אחרת מתחילה להתחייב, Spanner מבטיח שהלקוחות תמיד יראו את תוצאות העסקאות בסדר עוקב. באופן אינטואיטיבי, Spanner דומה למסד נתונים של מכונה יחידה.
החיסרון הוא שעסקאות ב-Spanner עשויות להתבטל אם עומס העבודה כולל הרבה קריאות וכתיבות בו-זמניות, כלומר הרבה עסקאות קוראות נתונים שעסקה אחרת מעדכנת, בגלל האופי הבסיסי של עסקאות שניתנות לסדרות. עם זאת, זוהי הגדרת ברירת מחדל טובה למסד נתונים תפעולי. כך תוכלו להימנע מבעיות מורכבות שקשורות לתזמון, שבדרך כלל מתרחשות רק כשמספר הבקשות בו-זמנית גבוה. קשה לשחזר את הבעיות האלה ולפתור אותן. לכן, בידוד ניתן לסריאליזציה מספק את ההגנה החזקה ביותר מפני אנומליות בנתונים. אם צריך לנסות שוב לבצע עסקה, יכול להיות שיהיה גידול בחביון בגלל הניסיונות החוזרים לבצע את העסקה.
בידוד קריאה שניתן לחזור עליו
ב-Spanner, בידוד של קריאות חוזרות מיושם באמצעות טכניקה שנקראת בדרך כלל בידוד של תמונת מצב. בידוד קריאה חוזרת ב-Spanner מבטיח שכל פעולות הקריאה בתוך עסקה יראו תמונת מצב עקבית או חזקה של מסד הנתונים, כפי שהיה בתחילת העסקה. היא גם מבטיחה שפעולות כתיבה בו-זמניות לאותם נתונים יצליחו רק אם אין התנגשויות. הגישה הזו מועילה בתרחישים של קונפליקטים רבים של קריאה-כתיבה, שבהם מספר רב של טרנזקציות קוראות נתונים שטרנזקציות אחרות עשויות לשנות. השימוש בתמונת מצב קבועה מאפשר לקריאה חוזרת להימנע מההשפעות על הביצועים של רמת הבידוד המגבילה יותר של סדרות.
במצב ברירת המחדל של מקביליות אופטימית, פעולות קריאה מתבצעות בלי לקבל נעילות ובלי לחסום פעולות כתיבה מקבילות. כך יש פחות טרנזקציות שבוטלו, שאולי צריך לנסות לבצע שוב בגלל התנגשויות פוטנציאליות בסדרות. במקביליות פסימית, פעולות קריאה משתמשות בתמונות מצב, אבל נעילות בלעדיות חלות על נתונים שנקראים משאילתות FOR UPDATE או מרמזים lock_scanned_ranges=exclusive, ועל נתונים שנכתבים באמצעות שאילתות DML.
עבור עומסי עבודה שעוברים מיגרציה ממסדי נתונים אחרים, מומלץ להגדיר את האפליקציה כך שתשתמש בבידוד קריאה חוזרת ב-Spanner. הסמנטיקה של טרנזקציות עם קריאה חוזרת, ובמיוחד הנעילה לקריאות, תואמת לרמות הבידוד שמוגדרות כברירת מחדל ברוב מסדי הנתונים האחרים (לדוגמה, MySQL ו-PostgreSQL). כך אפשר לצמצם את הצורך לעצב מחדש את האפליקציה כדי שתפעל עם רמת הבידוד הסדרתית שמוגדרת כברירת מחדל ב-Spanner.
בניגוד לבידוד שניתן לסדר, קריאה חוזרת עשויה להוביל לאנומליות בנתונים אם האפליקציה שלכם מסתמכת על קשרים או אילוצים ספציפיים של נתונים שלא נאכפים על ידי סכימת מסד הנתונים, במיוחד אם סדר הפעולות חשוב. במקרים כאלה, יכול להיות שטרנזקציה תקרא נתונים, תקבל החלטות על סמך הנתונים האלה ואז תכתוב שינויים שמפירים את האילוצים הספציפיים לאפליקציה, גם אם האילוצים של סכמת מסד הנתונים עדיין מתקיימים. הסיבה לכך היא שבידוד קריאות חוזרות מאפשר לעסקאות מקבילות להתבצע ללא סדרות קפדניות. אנומליה פוטנציאלית אחת נקראת write skew. היא נובעת מסוג מסוים של עדכון מקביל, שבו כל עדכון מתקבל באופן עצמאי, אבל ההשפעה המשולבת שלהם פוגעת בשלמות הנתונים של האפליקציה. לדוגמה, נניח שיש מערכת של בית חולים שבה לפחות רופא אחד צריך להיות בכוננות בכל רגע נתון, ורופאים יכולים לבקש להסיר אותם מהכוננות במשמרת מסוימת. בבידוד של קריאה חוזרת, אם גם ד"ר ריצ'רדס וגם ד"ר סמית' מתוזמנים להיות בכוננות באותה משמרת ומנסים במקביל לבקש להסיר אותם מהכוננות, כל בקשה מצליחה במקביל. הסיבה לכך היא ששתי העסקאות קוראות שיש לפחות רופא אחד נוסף שנקבע לו תור להיות בכוננות בתחילת העסקה, מה שגורם לאנומליה בנתונים אם העסקאות מצליחות. לעומת זאת, שימוש בבידוד ניתן לסריאליזציה מונע מהעסקאות האלה להפר את האילוץ, כי עסקאות שניתנות לסריאליזציה יזהו אנומליות פוטנציאליות בנתונים ויבטלו את העסקה. כך אפשר להבטיח עקביות של האפליקציה על ידי קבלת שיעורי ביטול גבוהים יותר.
בדוגמה הקודמת, אפשר להשתמש בסעיף SELECT FOR UPDATE בבידוד קריאה חוזרת.
הפסקה SELECT ... FOR UPDATE מאמתת אם הנתונים שהיא קראה בתמונת המצב שנבחרה נשארו ללא שינוי בזמן השמירה. באופן דומה, הצהרות DML ושינויים שקוראים נתונים באופן פנימי כדי לוודא את תקינות הכתיבה, גם מאמתים שהנתונים נשארים ללא שינוי בזמן השמירה. בנוסף, במקבילויות פסימיות, הנתונים שנקראים על ידי SELECT ... FOR UPDATE והנתונים שנכתבים על ידי הצהרות DML מקבלים נעילות בלעדיות כדי למנוע מעסקאות עתידיות לבצע שינויים סותרים לפני שהעסקה הנוכחית מתבצעת.
אם אתם מעבירים עומסי עבודה ממסדי נתונים אחרים שמשתמשים בשאילתות FOR UPDATE, מומלץ להגדיר את האפליקציה כך שתשתמש בבידוד קריאה חוזרת עם מקביליות פסימית ב-Spanner. האפליקציה ממשיכה לקבל נעילות לנתונים שנקראים על ידי SELECT ... FOR UPDATE, שזו התנהגות ברירת המחדל במסדי נתונים אחרים.
מידע נוסף זמין במאמר בנושא שימוש בבידוד קריאה חוזרת.
תרחיש שימוש לדוגמה
בדוגמה הבאה אפשר לראות את היתרון בשימוש בבידוד של קריאה חוזרת כדי למנוע תקורה של נעילה. הפעולות Transaction 1 ו-Transaction 2 מופעלות בבידוד של קריאה חוזרת.
Transaction 1 יוצרת חותמת זמן של תמונת מצב כשההצהרה SELECT מופעלת.
GoogleSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
/*-----------+------------------*
| AlbumId | MarketingBudget |
+------------+------------------+
| 1 | 50000 |
| 2 | 100000 |
| 3 | 70000 |
| 4 | 80000 |
*------------+------------------*/
PostgreSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
/*-----------+------------------*
| albumid | marketingbudget |
+------------+------------------+
| 1 | 50000 |
| 2 | 100000 |
| 3 | 70000 |
| 4 | 80000 |
*------------+------------------*/
לאחר מכן, Transaction 2 יוצר חותמת זמן של תמונת מצב אחרי ש-Transaction 1 מתחיל אבל לפני שהוא מתבצע. הנתונים לא עודכנו ב-Transaction 1, ולכן השאילתה SELECT ב-Transaction 2 קוראת את אותם הנתונים כמו ב-Transaction 1.
GoogleSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 > T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 50000);
COMMIT;
PostgreSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 > T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 50000);
COMMIT;
Transaction 1 ממשיך אחרי ש-Transaction 2 מתחייב.
GoogleSQL
-- Transaction 1 continues
SELECT SUM(MarketingBudget) as UsedBudget
FROM Albums
WHERE SingerId = 1;
/*-----------*
| UsedBudget |
+------------+
| 300000 |
*------------*/
PostgreSQL
-- Transaction 1 continues
SELECT SUM(marketingbudget) AS usedbudget
FROM albums
WHERE singerid = 1;
/*-----------*
| usedbudget |
+------------+
| 300000 |
*------------*/
הערך UsedBudget שמוחזר על ידי Spanner הוא סכום התקציב שנקרא על ידי Transaction 1. הסכום הזה משקף רק את הנתונים שמופיעים בתמונת המצב של T1. הסכום לא כולל את התקציב שTransaction 2 הוסיף,
כי Transaction 2 התחייב אחרי שTransaction 1 יצר תמונת מצב
T1. שימוש ב-repeatable read פירושו ש-Transaction 1 לא נאלץ לבטל את הפעולה גם אם Transaction 2 שינה את הנתונים ש-Transaction 1 קרא. עם זאת,
יכול להיות שהתוצאה ש-Spanner מחזיר תהיה התוצאה הרצויה ויכול להיות שלא.
קונפליקטים של קריאה-כתיבה ונכונות
בדוגמה הקודמת, אם הנתונים שנשאלו על ידי הצהרות SELECT ב-Transaction 1 שימשו לקבלת החלטות לגבי תקציב השיווק בהמשך, יכול להיות שיהיו בעיות בדיוק.
לדוגמה, נניח שהתקציב הכולל הוא 400,000. על סמך התוצאה של ההצהרה SELECT ב-Transaction 1, יכול להיות שנחשוב שנותרו 100,000 בתקציב ונחליט להקצות את כולם ל-AlbumId = 4.
GoogleSQL
-- Transaction 1 continues..
UPDATE Albums
SET MarketingBudget = MarketingBudget + 100000
WHERE SingerId = 1 AND AlbumId = 4;
COMMIT;
PostgreSQL
-- Transaction 1 continues..
UPDATE albums
SET marketingbudget = marketingbudget + 100000
WHERE singerid = 1 AND albumid = 4;
COMMIT;
Transaction 1 מתבצעת בהצלחה, למרות ש-Transaction 2 כבר הקצה 50,000 מהתקציב שנותר לאלבום חדש AlbumId = 5.100,000
אתם יכולים להשתמש בתחביר SELECT...FOR UPDATE כדי לוודא שקריאות מסוימות של עסקה לא משתנות במהלך משך החיים של העסקה, וכך להבטיח את התקינות שלה. בדוגמה הבאה, שבה נעשה שימוש ב-SELECT...FOR UPDATE, הפעולה Transaction 1 מבוטלת בזמן השמירה.
GoogleSQL
-- Transaction 1 continues..
SELECT SUM(MarketingBudget) AS TotalBudget
FROM Albums
WHERE SingerId = 1
FOR UPDATE;
/*-----------*
| TotalBudget |
+------------+
| 300000 |
*------------*/
COMMIT;
PostgreSQL
-- Transaction 1 continues..
SELECT SUM(marketingbudget) AS totalbudget
FROM albums
WHERE singerid = 1
FOR UPDATE;
/*-------------*
| totalbudget |
+-------------+
| 300000 |
*-------------*/
COMMIT;
מידע נוסף זמין במאמר בנושא שימוש ב-SELECT FOR UPDATE בבידוד של קריאה חוזרת.
אפשר גם להשתמש במקביליות פסימית, שמשיגה נעילות בלעדיות על נתונים שנקראים על ידי ההצהרה SELECT...FOR UPDATE. לדוגמה, Transaction 1
מתבטלת בזמן ביצוע הפעולה commit כי Transaction 2 ביצעה commit לשינויים שלה
לפני ש-Transaction 1 קיבלה נעילות, מה שמוביל להתנגשות. עם זאת, אם
הסדר של העסקאות גורם לכך ש-Transaction 2 מנסה לעדכן את
תקציב השיווק אחרי ש-Transaction 1 מקבל נעילות, אז Transaction 2
מחכה ש-Transaction 1 יבצע commit וישחרר את הנעילות לפני שהוא יכול
להמשיך. האפשרות 'מקבילות פסימית' מבצעת סריאליזציה של הגישה לנתונים.
מידע נוסף זמין במאמר בנושא בקרת בו-זמניות.
קונפליקטים של כתיבה-כתיבה ונכונות
אם משתמשים ברמת הבידוד של קריאה חוזרת, פעולות כתיבה מקבילות לאותם נתונים מצליחות רק אם אין התנגשויות.
בדוגמה הבאה, Transaction 1 יוצר חותמת זמן של תמונת מצב בהצהרה הראשונה SELECT.
GoogleSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
PostgreSQL
-- Transaction 1
BEGIN;
-- Snapshot established at T1
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
הפקודה הבאה Transaction 2 קוראת את אותם נתונים כמו הפקודה Transaction 1 ומוסיפה פריט חדש. הפעולה Transaction 2 מתבצעת בהצלחה בלי המתנה או ביטול.
GoogleSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 (> T1)
SELECT AlbumId, MarketingBudget
FROM Albums
WHERE SingerId = 1;
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 50000);
COMMIT;
PostgreSQL
-- Transaction 2
BEGIN;
-- Snapshot established at T2 (> T1)
SELECT albumid, marketingbudget
FROM albums
WHERE singerid = 1;
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 50000);
COMMIT;
Transaction 1 ממשיך אחרי ש-Transaction 2 מתחייב.
GoogleSQL
-- Transaction 1 continues
INSERT INTO Albums (SingerId, AlbumId, MarketingBudget) VALUES (1, 5, 30000);
-- Transaction aborts
COMMIT;
PostgreSQL
-- Transaction 1 continues
INSERT INTO albums (singerid, albumid, marketingbudget) VALUES (1, 5, 30000);
-- Transaction aborts
COMMIT;
Transaction 1 aborts since Transaction 2 already committed an insertion
to the AlbumId = 5 row.
המאמרים הבאים
מידע נוסף על סריאליזציה של Spanner ועקביות חיצונית זמין במאמר TrueTime ועקביות חיצונית.