आपने ऑर्डर तो सेव कर लिया, लेकिन ऑर्डर आइटम्स डेटाबेस तक कभी नहीं पहुँच पाए। या शायद स्टॉक काउंटर कम हो गया, पेमेंट गेटवे ने टाइमआउट दे दिया, और अब ग्राहक के कार्ड से पैसे कट गए हैं लेकिन ऑर्डर का कोई रिकॉर्ड नहीं है। ये स्थितियाँ तब तक 'एज केस' (edge cases) लगती हैं जब तक कि वे असल में न हो जाएँ। एक व्यस्त एप्लिकेशन में, ये रोज़ाना की सिरदर्द बन जाती हैं।
इसका मूल कारण लगभग हमेशा एक ही होता है: डेटाबेस राइट्स (writes) का एक ऐसा क्रम जिसे अलग-अलग, स्वतंत्र घटनाओं के रूप में माना गया था। जब एक विफल होता है, तो बाकी पीछे छूट जाते हैं। इसका समाधान एक डेटाबेस ट्रांजेक्शन (database transaction) है, और Laravel में, इसके लिए टूल DB::transaction() है।
एक ट्रांजेक्शन वास्तव में क्या वादा करता है
एक डेटाबेस ट्रांजेक्शन कई ऑपरेशन्स को काम की एक एकल इकाई (single unit of work) में बांध देता है। डेटाबेस इंजन यह गारंटी देता है कि इसके अंदर सब कुछ या तो स्थायी रूप से कमिट (commit) होगा या पूरी तरह से रोलबैक (rollback) हो जाएगा। इसमें कोई बीच का रास्ता नहीं होता।
एक बैंक ट्रांसफर के बारे में सोचें। सिस्टम को एक खाते से पैसे काटने (debit) और दूसरे में जमा (credit) करने चाहिए। यदि डेबिट के बाद क्रेडिट विफल हो जाता है, तो पैसा बस शून्य में गायब नहीं हो जाता। बैंक डेबिट को रिवर्स कर देता है। वह रिवर्सल ही रोलबैक है। यदि दोनों चरण सफल होते हैं, तो ट्रांसफर कमिट हो जाता है, जिसका अर्थ है कि नए बैलेंस स्थायी रूप से सेव हो गए हैं।
यह 'सब-कुछ-या-कुछ-नहीं' (all-or-nothing) वाला व्यवहार ही आपके डेटा को सुसंगत (consistent) रखता है। इसके बिना, आंशिक विफलताएँ (partial failures) आपकी टेबल्स में आधे-अधूरे रिकॉर्ड बिखेर देती हैं, और वे रिकॉर्ड आपके डेटाबेस में बेकार पड़े रहते हैं क्योंकि किसी भी ऑटोमेटेड प्रोसेस को यह नहीं पता होता कि उन्हें सुरक्षित रूप से कैसे साफ किया जाए।
Laravel इसे कैसे मैनेज करता है
सादे SQL में, आपको BEGIN, COMMIT, और ROLLBACK खुद लिखना होगा, और यह याद रखना होगा कि हर संभावित एरर को कैच (catch) किया जाए ताकि कोई ट्रांजेक्शन अधूरा न छूटे। Laravel उस बॉयलरप्लेट (boilerplate) कोड को एक सिंगल मेथड में लपेट देता है।
आप DB::transaction() को एक क्लोजर (closure) पास करते हैं। Laravel ट्रांजेक्शन शुरू करता है, आपके कोड को चलाता है, और यदि क्लोजर बिना किसी एक्सेप्शन (exception) के समाप्त हो जाता है, तो यह अपने आप कमिट हो जाता है। यदि कुछ भी विफल होता है, तो Laravel एक्सेप्शन को कैच करता है, सब कुछ रोलबैक कर देता है, और एरर को फिर से थ्रो (rethrow) कर देता है ताकि आपकी लॉगिंग और एरर हैंडलिंग उम्मीद के मुताबिक काम करती रहे।
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) गायब होने या डेटाबेस कनेक्शन टूटने के कारण पेमेंट इंसर्ट विफल हो जाता है, तो ऑर्डर, ऑर्डर आइटम्स और स्टॉक में किए गए बदलाव, सभी वापस ले लिए जाते हैं। आपके पास ऐसा इन्वेंट्री नहीं बचता जो बिना किसी कारण के गायब हो गया हो, और न ही आपके पास ऐसा ऑर्डर बचता है जिसकी शिपमेंट की मांग की जा रही हो लेकिन उसका भुगतान कभी नहीं किया गया हो।
यह आपको कहाँ बचाता है
कुछ वर्कफ़्लो पूरी तरह से ट्रांजेक्शनल सुरक्षा पर निर्भर करते हैं। चेकआउट का उदाहरण सबसे स्पष्ट है, लेकिन यह पैटर्न हर जगह दिखाई देता है।
यूज़र रजिस्ट्रेशन। एक यूज़र रो (row) बनाना, फिर एक प्रोफाइल रो, और फिर डिफॉल्ट सेटिंग्स। यदि वैलिडेशन के किसी एज केस के कारण प्रोफाइल इंसर्ट विफल हो जाता है, तो बिना प्रोफाइल वाला यूज़र एक 'घोस्ट अकाउंट' बन जाता है। कोई भी पेज जो यह मानकर चलता है कि हर यूज़र की एक प्रोफाइल है, वह क्रैश हो जाएगा या टूटा हुआ UI दिखाएगा।
बल्क इम्पोर्ट्स। एक CSV अपलोड जो पचास रिकॉर्ड इंसर्ट करता है, उसे पच्चीस रिकॉर्ड्स पर नहीं रुकना चाहिए क्योंकि छब्बीसवें रो में तारीख गलत थी। पूरे बैच को एक ट्रांजेक्शन में लपेटने से पूरा इम्पोर्ट एक साफ इकाई के रूप में विफल हो जाता है। एडमिन को घंटों तक यह खोजने में समय बर्बाद करने के बजाय कि कौन से रो आंशिक रूप से लीक हुए हैं, बस फ़ाइल ठीक करके फिर से कोशिश करनी होती है।
इन्वेंट्री और अकाउंटिंग। जब भी एक टेबल किसी भौतिक संसाधन (physical resource) को ट्रैक करती है और दूसरी टेबल पैसे या क्रेडिट को ट्रैक करती है, तो दोनों को एक साथ चलना चाहिए। उन्हें अलग करने से ऐसे ऑडिट का खतरा रहता है जो मिलान (reconcile) नहीं कर पाते और ऐसी रिपोर्ट मिलती हैं जो गलत होती हैं।
जब आपको मैन्युअल रूप से कंट्रोल करने की ज़रूरत हो
क्लोजर वाला तरीका अधिकांश मामलों को कवर करता है, लेकिन कभी-कभी आपको अधिक नियंत्रण की आवश्यकता होती है। सर्विस क्लास के अंदर जटिल कंडीशनल लॉजिक, या रनटाइम पर यह तय करने की आवश्यकता कि कमिट करना है या नहीं, एक सिंगल क्लोजर को अजीब बना सकती है। उन क्षणों में, आप ट्रांजेक्शन को खुद मैनेज कर सकते हैं:
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 block) के अंदर क्रम पर ध्यान दें: पहले रोल बैक करें, फिर थ्रो करें। यदि आप रोल बैक करने से पहले थ्रो करते हैं, तो ट्रांजेक्शन कनेक्शन पर खुला रह जाता है। इससे रोज़ (rows) लॉक हो सकते हैं, अन्य क्वेरीज़ डेडलॉक (deadlock) हो सकती हैं, या आपका कनेक्शन पूल समाप्त हो सकता है। मैन्युअल ट्रांजेक्शन शक्तिशाली होते हैं, लेकिन वे सफाई का बोझ आप पर डाल देते हैं।
साइड इफेक्ट्स (Side Effects) को ट्रांजेक्शन से बाहर रखें
यह वह नियम है जो प्रोडक्शन में टीमों को मुश्किल में डाल देता है। एक ट्रांजेक्शन केवल डेटाबेस के काम को ही अनडू (undo) कर सकता है। यह ईमेल वापस नहीं भेज सकता, क्लाउड स्टोरेज से फ़ाइल डिलीट नहीं कर सकता, या पेमेंट API के माध्यम से चार्ज रिफंड नहीं कर सकता।
यदि आप ट्रांजेक्शन क्लोजर के अंदर Mail::send() कॉल रखते हैं, और दो लाइन बाद डेटाबेस रोलबैक हो जाता है, तो वह ईमेल फिर भी ग्राहक के इनबॉक्स में पहुँच जाएगा। प्राप्तकर्ता के पास अब एक ऐसे ऑर्डर का इनवॉइस होगा जो आपके सिस्टम में मौजूद ही नहीं है। यही बात स्लैक (Slack) नोटिफिकेशन, S3 पर फ़ाइल अपलोड, या वेबहुक डिस्पैच पर भी लागू होती है।
सही क्रम यह है:
- ट्रांजेक्शन पूरा करें और आवश्यक सभी IDs या परिणाम प्राप्त करें।
- उसके बाद ही बाहरी साइड इफेक्ट्स (external side effects) को ट्रिगर करें।
उदाहरण के लिए, कन्फर्मेशन ईमेल को कमिट (commit) के बाद कतार (queue) में डालें, उसके अंदर नहीं:
$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 रिस्पॉन्स के लिए तीन सेकंड इंतज़ार करना, तीन सेकंड का अनावश्यक लॉक समय है। ट्रांजेक्शन को संक्षिप्त और तेज़ रखें।
यह बग क्यों छिप जाता है
एक सिंगल यूजर और लोकल डेटाबेस वाली डेवलपमेंट मशीन पर, संबंधित इंसर्ट्स (inserts) लगभग हमेशा सफल हो जाते हैं। नेटवर्क स्थिर रहता है, डिस्क कभी नहीं भरती, और कोई प्रतिस्पर्धी लोड (competing load) नहीं होता। कोड सही लगता है क्योंकि यह आमतौर पर काम करता है।
प्रोडक्शन अलग होता है। दो ग्राहक बिल्कुल एक ही मिलीसेकंड में ऑर्डर सबमिट करते हैं। डिप्लॉयमेंट के दौरान एक क्यू वर्कर (queue worker) काम के बीच में ही रीस्टार्ट हो जाता है। कोई पेमेंट प्रोवाइडर तीस सेकंड के लिए टाइम आउट हो जाता है। ट्रांजेक्शन के बिना, ये घटनाएँ अनाथ रिकॉर्ड (orphan records) और गलत टोटल (mismatched totals) पैदा करती हैं जिन्हें ट्रैक करना बहुत कठिन होता है। सबसे बुरा हिस्सा यह है कि स्टैंडर्ड फीचर टेस्ट इन्हें शायद ही कभी पकड़ पाते हैं, क्योंकि विफलता का तरीका समय पर निर्भर (timing-dependent) होता है और इंफ्रास्ट्रक्चर से जुड़ा होता है, न कि साधारण लॉजिक एरर से।
इसका समाधान कोई जटिल चीज़ नहीं है। यह मैकेनिकल है। यदि आप दो या अधिक संबंधित राइट्स (writes) देखते हैं, तो उन्हें रैप (wrap) कर दें। समय के साथ, यह एक रिक्वेस्ट को वैलिडेट करने जितना ही स्वचालित हो जाना चाहिए।
इसे अपनी आदत बनाएं
DB::transaction() जटिलता नहीं बढ़ाता है। यह बाद में आंशिक डेटा (partial data) को साफ करने की छिपी हुई जटिलता को दूर करता है। यदि डेटाबेस ऑपरेशन्स का एक समूह एक साथ संबंधित है, तो शुरुआत से ही उनके साथ वैसा ही व्यवहार करें। आपका एप्लिकेशन लोड के दौरान सटीक रहेगा, आपके एरर लॉग्स साफ रहेंगे, और आपका डेटाबेस आधे-अधूरे रिकॉर्ड्स का कब्रिस्तान नहीं बनेगा।
