לדלג לתוכן

9.6 - הודעות לא אמינות ופרוטוקול התאוששות - הרצאה

הודעה נשלחה אבל אין תשובה

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

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

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

גבולות הודעה אינם שלמות הודעה

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

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

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

ניסוי עם תשובה שנעלמת בכוונה

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

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

use std::collections::BTreeMap;
use std::io;
use std::net::UdpSocket;
use std::time::Duration;

fn main() -> io::Result<()> {
    let server = UdpSocket::bind(("127.0.0.1", 0))?;
    server.set_read_timeout(Some(Duration::from_secs(2)))?;
    let address = server.local_addr()?;
    let worker = std::thread::spawn(move || -> io::Result<u64> {
        let mut counter = 0_u64;
        let mut saved = BTreeMap::new();
        for attempt in 0..2 {
            let mut packet = [0_u8; 32];
            let (count, peer) = server.recv_from(&mut packet)?;
            if count != 12 { return Err(io::Error::new(io::ErrorKind::InvalidData, "bad request")); }
            let id = u64::from_be_bytes(packet[..8].try_into().unwrap());
            let amount = u32::from_be_bytes(packet[8..12].try_into().unwrap());
            let key = (peer, id);
            let result = if let Some(&(previous_amount, result)) = saved.get(&key) {
                if previous_amount != amount {
                    return Err(io::Error::new(io::ErrorKind::InvalidData, "identity reused"));
                }
                result
            } else {
                counter = counter.checked_add(amount as u64)
                    .ok_or_else(|| io::Error::other("counter overflow"))?;
                saved.insert(key, (amount, counter));
                counter
            };
            if attempt == 0 { continue; }
            let mut response = id.to_be_bytes().to_vec();
            response.extend_from_slice(&result.to_be_bytes());
            assert_eq!(server.send_to(&response, peer)?, response.len());
        }
        Ok(counter)
    });
    let client = UdpSocket::bind(("127.0.0.1", 0))?;
    client.connect(address)?;
    client.set_read_timeout(Some(Duration::from_millis(30)))?;
    let mut request = 77_u64.to_be_bytes().to_vec();
    request.extend_from_slice(&5_u32.to_be_bytes());
    client.send(&request)?;
    let mut response = [0_u8; 32];
    let error = client.recv(&mut response).unwrap_err();
    assert!(matches!(error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut));
    client.set_read_timeout(Some(Duration::from_secs(2)))?;
    client.send(&request)?;
    assert_eq!(client.recv(&mut response)?, 16);
    assert_eq!(u64::from_be_bytes(response[..8].try_into().unwrap()), 77);
    assert_eq!(u64::from_be_bytes(response[8..16].try_into().unwrap()), 5);
    assert_eq!(worker.join().unwrap()?, 5);
    println!("two deliveries, one state change");
    Ok(())
}

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

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

מה בדיוק מובטח כאן

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

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

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

מחיר שמירת הזהויות

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

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

גודל הודעה ונתיב

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

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

ניסיון חוזר הוא גם עומס

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

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