עיבוד בדיוק פעם אחת ואישור לכל היותר פעם אחת

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

מושגים מרכזיים והדילמה של אידמפוטנטיות

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

  • At-least-once: ההודעות מועברות ומעובדות בוודאות. אם יש כשלים ברשת, יכול להיות שהודעה תימסר ותעובד כמה פעמים.
  • At-most-once: ההודעות נמסרות לכל היותר פעם אחת. העיבוד של עותקים כפולים נמנע, אבל אם מתרחש כשל, יכול להיות שההודעה תאבד או תימחק.
  • בדיוק פעם אחת: כל הודעה מעובדת בדיוק פעם אחת – היא לא הולכת לאיבוד ולא משוכפלת.

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

הבעיה של סטטוס מחויבות לא ידוע

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

כשהאפליקציה מעבדת הודעה ושולחת אישור כמו DELETE FROM MessageQueue WHERE message_id = '123' ASSERT_ROWS_MODIFIED 1, הבקשה עוברת דרך כמה קפיצות ברשת:

[Your App] <--(gRPC)--> [Spanner API] <--(Google network)--> [Spanner Storage]

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

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

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

למה ניסיונות חוזרים אוטומטיים עלולים להיות מסוכנים

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

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

אסטרטגיות בעולם האמיתי לעיבוד בדיוק פעם אחת

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

בדיקת שורות ששונו

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

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

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

  • מאפשרים לשגיאה להתפשט: אם משתמשים ב-API מבוסס-runner (כמו ReadWriteTransaction ב-Go), מאפשרים לשגיאה OUT_OF_RANGE להתפשט מחוץ לפונקציית העסקה. ספריית הלקוח תבטל את העסקה ותבצע rollback, כדי לוודא שלא יבוצעו כתיבות של לוגיקה עסקית.
  • לא לוכדים וממשיכים: אף פעם לא לוכדים את שגיאת האסרטיביות בתוך פונקציית העסקה וממשיכים. אם תעשו את זה, Spanner עדיין יבצע את שאר ההצהרות, ויגרום לעדכון הכפול המדויק שהתבנית הזו אמורה למנוע.
  • ביטולים ידניים: אם אתם מנהלים את העסקאות באופן ידני (לדוגמה, באמצעות ReadWriteStmtBasedTransaction ב-Go או ממשקי ה-API ל-REST או ל-gRPC), אתם צריכים להפעיל באופן מפורש את שיטת הביטול המתאימה כשהאימות נכשל.

תיבת דואר יוצא של עסקאות

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

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

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

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

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

אידמפוטנטיות של סשן מרובה ערוצים

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

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

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