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

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

מה טרנזקציה מבטיחה באמת

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

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

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

איך Laravel מבצעת את החיבור

ב-SQL רגיל, הייתם כותבים BEGIN, COMMIT, ו-ROLLBACK בעצמכם, תוך הקפדה לתפוס כל שגיאה אפשרית כדי שלא תשאירו טרנזקציה תלויה. Laravel עוטפת את הקוד השגרתי הזה (boilerplate) במתודה אחת.

אתם מעבירים closure ל-DB::transaction(). Laravel מתחילה את הטרנזקציה, מריצה את הקוד שלכם, ואם ה-closure מסתיים מבלי לזרוק exception, היא מבצעת commit באופן אוטומטי. אם משהו נכשל, Laravel תופסת את ה-exception, מבצעת rollback לכל הפעולות, וזורקת מחדש את השגיאה כדי שהלוגים וטיפול השגיאות שלכם ימשיכו לעבוד כמצופה.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $order = Order::create([/* ... */]);
    
    foreach ($cart->items as $item) {
        OrderItem::create([
            'order_id' => $order->id,
            'product_id' => $item->product_id,
            'quantity' => $item->quantity,
        ]);
        
        Product::find($item->product_id)
               ->decrement('stock', $item->quantity);
    }
    
    Payment::create([
        'order_id' => $order->id,
        'amount' => $cart->total,
        'status' => 'completed',
    ]);
});

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

איפה זה חוסך לכם

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

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

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

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

מתי צריך לנהוג ידנית

גישת ה-closure מכסה את רוב המקרים, אך לעיתים אתם זקוקים ליותר שליטה. לוגיקה מותנית מורכבת בתוך service class, או הצורך להחליט בזמן ריצה האם לבצע commit, יכולים לגרום לשימוש ב-closure בודד להרגיש מגושם. ברגעים כאלה, תוכלו לנהל את הטרנזקציה בעצמכם:

DB::beginTransaction();

try {
    // Run your operations
    $order = Order::create([/* ... */]);
    // ... more work ...
    
    if ($someBusinessRulePasses) {
        DB::commit();
    } else {
        DB::rollBack();
    }
} catch (\Throwable $e) {
    DB::rollBack();
    throw $e;
}

שימו לב לסדר בתוך בלוק ה-catch: לבצע rollback קודם, ואז לזרוק (throw) את השגיאה. אם תזרקו שגיאה לפני ה-rollback, הטרנזקציה תישאר פתוחה בחיבור. זה עלול לנעול שורות, לגרום ל-deadlock בשאילתות אחרות, או לרוקן את מאגר החיבורים (connection pool) שלכם. טרנזקציות ידניות הן עוצמתיות, אך הן מטילות עליכם את נטל הניקוי.

הימנעו מתוצאות לוואי בתוך הטרנזקציה

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

אם תשימו קריאה ל-Mail::send() בתוך ה-closure של הטרנזקציה, ואז מסד הנתונים מבצע rollback שתי שורות לאחר מכן, האימייל הזה עדיין יגיע לתיבת הדואר של הלקוח. למקבל יש כעת חשבונית עבור הזמנה שאינה קיימת במערכת שלכם. הדבר תקף גם להודעות Slack, העלאת קבצים ל-S3, או שליחת webhooks.

הרצף הנכון הוא:

  1. השלם את העסקה ושמור כל מזהה (ID) או תוצאה שאתה זקוק להם.
  2. רק לאחר מכן הפעל השפעות לוואי חיצוניות.

לדוגמה, הכנס את אימייל האישור לתור לאחר ה-commit, ולא בתוכו:

$order = DB::transaction(function () {
    // database work only
    return Order::create([/* ... */]);
});

// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);

יש סיבה שנייה להשאיר קריאות חיצוניות מחוץ לעסקה: זמן. עסקה מחזיקה נעילות (locks) ושומרת על חיבור מסד נתונים עסוק. המתנה של שלוש שניות לתגובה מ-Stripe API בזמן שנמצאים בתוך עסקה היא שלוש שניות של זמן נעילה מיותר. שמור על העסקה קצרה ומהירה.

למה הבאג הזה מתחבא

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

סביבת הייצור (Production) שונה. שני לקוחות שולחים הזמנות בדיוק באותו מילישנייה. עובד תור (queue worker) עובר הפעלה מחדש באמצע עבודה במהלך פריסה (deployment). ספק תשלום חווה timeout למשך שלושים שניות. ללא עסקאות, אירועים אלו יוצרים רשומות יתומות (orphan records) וסכומים לא תואמים שקשה מאוד לעקוב אחריהם. החלק הגרוע ביותר הוא שבדיקות פיצ'רים סטנדרטיות כמעט ולא תופסות אותם, מכיוון שצורת הכשל תלויה בתזמון וקשורה לתשתית, ולא לשגיאות לוגיקה פשוטות.

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

הפוך את זה לרפלקס

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