คุณบันทึกคำสั่งซื้อแล้ว แต่รายการสินค้าในคำสั่งซื้อกลับไม่ถูกบันทึกลงฐานข้อมูล หรือบางทีตัวนับสต็อกอาจจะลดลง แต่ช่องทางการชำระเงินเกิด timeout ทำให้ลูกค้าถูกตัดเงินไปแล้วแต่ไม่มีประวัติคำสั่งซื้อเหลืออยู่เลย สถานการณ์เหล่านี้อาจฟังดูเหมือนเป็นกรณีที่เกิดขึ้นได้ยาก (edge cases) จนกว่ามันจะเกิดขึ้นจริง และสำหรับแอปพลิเคชันที่มีผู้ใช้งานจำนวนมาก สิ่งเหล่านี้จะกลายเป็นปัญหาที่น่าปวดหัวในทุกๆ วัน
สาเหตุหลักมักจะเป็นเรื่องเดียวกันเสมอ นั่นคือลำดับการเขียนข้อมูลลงฐานข้อมูลที่ถูกปฏิบัติเหมือนเป็นเหตุการณ์ที่แยกจากกันและเป็นอิสระต่อกัน เมื่อขั้นตอนหนึ่งล้มเหลว ขั้นตอนอื่นๆ ก็ยังคงค้างอยู่ วิธีแก้ไขคือการใช้ database transaction และใน Laravel เครื่องมือที่คุณต้องใช้คือ DB::transaction()
สิ่งที่ Transaction มอบให้คุณจริงๆ
Database transaction จะรวมการทำงานหลายๆ อย่างเข้าด้วยกันเป็นหน่วยงานเดียว (unit of work) โดย database engine จะรับประกันว่าทุกอย่างที่อยู่ภายในนั้นจะถูกบันทึก (commit) อย่างถาวร หรือไม่ก็ต้องถูกยกเลิก (rollback) ทั้งหมด โดยไม่มีสถานะกึ่งกลาง
ลองนึกถึงการโอนเงินผ่านธนาคาร ระบบจะต้องหักเงินจากบัญชีหนึ่งและเพิ่มเงินให้อีกบัญชีหนึ่ง หากการเพิ่มเงินล้มเหลวหลังจากที่หักเงินไปแล้ว เงินจะไม่หายไปในอากาศเฉยๆ แต่ธนาคารจะทำการยกเลิกการหักเงินนั้น การยกเลิกนี้คือการ rollback และหากทั้งสองขั้นตอนสำเร็จ การโอนเงินก็จะถูก commit ซึ่งหมายความว่ายอดเงินใหม่จะถูกบันทึกไว้อย่างปลอดภัย
พฤติกรรมแบบ "ทั้งหมดหรือไม่มีเลย" (all-or-nothing) นี้เองที่ช่วยรักษาความถูกต้องของข้อมูล (data consistency) หากไม่มีสิ่งนี้ ความล้มเหลวเพียงบางส่วนจะทำให้เกิดข้อมูลที่เขียนค้างไว้ครึ่งๆ กลางๆ กระจัดกระจายอยู่ในตารางต่างๆ และข้อมูลเหล่านั้นจะกลายเป็นขยะในฐานข้อมูลของคุณ เพราะไม่มีกระบวนการอัตโนมัติใดที่รู้วิธีจัดการทำความสะอาดพวกมันได้อย่างปลอดภัย
วิธีที่ Laravel จัดการเรื่องนี้
หากใช้ SQL แบบปกติ คุณจะต้องเขียน BEGIN, COMMIT, และ ROLLBACK ด้วยตัวเอง และต้องไม่ลืมดักจับทุกข้อผิดพลาดที่อาจเกิดขึ้นเพื่อไม่ให้ transaction ค้างอยู่ Laravel ได้รวบรวมโค้ดส่วนที่ซ้ำซากเหล่านั้นมาไว้ใน method เดียว
คุณเพียงแค่ส่ง closure เข้าไปใน DB::transaction() โดย Laravel จะเริ่ม transaction, รันโค้ดของคุณ และหาก closure ทำงานเสร็จสิ้นโดยไม่มีการ throw exception มันจะทำการ commit ให้โดยอัตโนมัติ แต่หากมีอะไรผิดพลาด Laravel จะดักจับ exception นั้น, ทำการ rollback ทุกอย่าง, และ throw error ออกมาอีกครั้ง เพื่อให้ระบบ logging และการจัดการ error ของคุณยังคงทำงานได้ตามปกติ
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 หรือการเชื่อมต่อฐานข้อมูลหลุด ทั้งคำสั่งซื้อ, รายการสินค้า, และการเปลี่ยนแปลงสต็อกจะถูกยกเลิกทั้งหมด คุณจะไม่ต้องเจอกับปัญหาที่สต็อกหายไปอย่างไร้สาเหตุ หรือมีคำสั่งซื้อที่ต้องจัดส่งทั้งที่ยังไม่ได้ชำระเงิน
สถานการณ์ที่คุณจะได้รับประโยชน์จากสิ่งนี้
เวิร์กโฟลว์บางอย่างจำเป็นต้องพึ่งพาความปลอดภัยของ transaction อย่างยิ่ง ตัวอย่างการชำระเงิน (checkout) นั้นชัดเจนที่สุด แต่รูปแบบนี้ยังปรากฏอยู่ในทุกๆ ที่
การลงทะเบียนผู้ใช้ (User registration). การสร้างแถวข้อมูลผู้ใช้, ตามด้วยแถวข้อมูลโปรไฟล์, และการตั้งค่าเริ่มต้น หากการบันทึกโปรไฟล์ล้มเหลวเนื่องจากกรณีขอบเขต (edge case) ของการตรวจสอบข้อมูล (validation) ผู้ใช้ที่ไม่มีโปรไฟล์จะกลายเป็นบัญชีผี และหน้าเว็บใดๆ ที่สมมติว่าผู้ใช้ทุกคนต้องมีโปรไฟล์จะเกิดการ crash หรือแสดงผล UI ที่ผิดพลาด
การนำเข้าข้อมูลจำนวนมาก (Bulk imports). การอัปโหลดไฟล์ CSV ที่ต้องบันทึกข้อมูล 50 รายการ ไม่ควรทิ้งข้อมูลไว้เพียง 25 รายการเพียงเพราะแถวที่ 26 มีรูปแบบวันที่ผิด การครอบการทำงานแบบ batch ด้วย transaction จะช่วยให้การนำเข้าทั้งหมดล้มเหลวพร้อมกันเป็นหน่วยเดียวที่สะอาดตา แอดมินสามารถแก้ไขไฟล์และลองใหม่อีกครั้ง แทนที่จะต้องเสียเวลาหลายชั่วโมงเพื่อตามหาว่ามีแถวไหนบ้างที่หลุดรอดเข้าไปบางส่วน
สต็อกสินค้าและบัญชี (Inventory and accounting). เมื่อใดก็ตามที่ตารางหนึ่งติดตามทรัพยากรทางกายภาพ และอีกตารางหนึ่งติดตามเงินหรือเครดิต ทั้งสองตารางจะต้องเคลื่อนไหวไปพร้อมกัน การแยกพวกมันออกจากกันจะนำไปสู่การตรวจสอบบัญชีที่ไม่ตรงกันและรายงานที่ผิดพลาด
เมื่อคุณจำเป็นต้องควบคุมด้วยตัวเอง (Manual)
วิธีการใช้ closure ครอบคลุมกรณีส่วนใหญ่แล้ว แต่บางครั้งคุณอาจต้องการการควบคุมที่มากขึ้น เช่น ตรรกะเงื่อนไขที่ซับซ้อนภายใน service class หรือความจำเป็นในการตัดสินใจว่าจะ commit หรือไม่ในขณะรันไทม์ ซึ่งอาจทำให้การใช้ closure เพียงอย่างเดียวดูใช้งานยาก ในช่วงเวลาเหล่านั้น คุณสามารถจัดการ transaction ด้วยตัวเองได้:
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: ให้ rollback ก่อน แล้วจึงค่อย throw หากคุณ throw error ก่อนที่จะ rollback transaction จะยังคงเปิดค้างไว้ในการเชื่อมต่อ ซึ่งอาจทำให้เกิดการ lock แถวข้อมูล, เกิด deadlock กับ query อื่นๆ หรือทำให้ connection pool เต็ม การจัดการ transaction ด้วยตัวเองนั้นทรงพลัง แต่คุณต้องเป็นผู้รับผิดชอบในการทำความสะอาดข้อมูลเอง
หลีกเลี่ยง Side Effects ภายใน Transaction
นี่คือกฎที่มักจะสร้างปัญหาให้กับทีมงานในสภาพแวดล้อม production เพราะ transaction สามารถยกเลิกได้เฉพาะงานในฐานข้อมูลเท่านั้น มันไม่สามารถยกเลิกการส่งอีเมล, การลบไฟล์จาก cloud storage หรือการคืนเงินผ่าน payment API ได้
หากคุณใส่คำสั่ง Mail::send() ไว้ภายใน transaction closure และฐานข้อมูลเกิด rollback ในอีกสองบรรทัดต่อมา อีเมลนั้นก็จะถูกส่งไปยังกล่องจดหมายของลูกค้าไปแล้ว ผู้รับจะได้รับใบแจ้งหนี้สำหรับคำสั่งซื้อที่ไม่มีอยู่จริงในระบบของคุณ เช่นเดียวกับกรณีการแจ้งเตือนผ่าน Slack, การอัปโหลดไฟล์ไปยัง S3 หรือการส่ง webhook
ลำดับขั้นตอนที่ถูกต้องคือ:
- ทำรายการ (transaction) ให้เสร็จสมบูรณ์ และบันทึก ID หรือผลลัพธ์ต่างๆ ที่คุณต้องการไว้
- จากนั้นจึงค่อยเริ่มกระบวนการที่มีผลกระทบภายนอก (external side effects)
ตัวอย่างเช่น ให้คิวอีเมลยืนยันหลังจากทำการ commit แล้ว ไม่ใช่ทำภายใน transaction:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
อีกเหตุผลหนึ่งที่ควรแยกการเรียกใช้งานภายนอกไว้นอก transaction คือเรื่องของ "เวลา" เพราะ transaction จะทำการล็อกข้อมูลและทำให้การเชื่อมต่อฐานข้อมูลถูกใช้งานอยู่ การรอการตอบกลับจาก Stripe API นานถึง 3 วินาทีในขณะที่ยังอยู่ใน transaction หมายถึงการเสียเวลาล็อกข้อมูลไปโดยไม่จำเป็นถึง 3 วินาที ดังนั้นควรทำให้ transaction กระชับและรวดเร็วที่สุด
ทำไมบั๊กนี้ถึงซ่อนตัวอยู่
บนเครื่องสำหรับพัฒนาที่มีผู้ใช้งานเพียงคนเดียวและใช้ฐานข้อมูลในเครื่อง การ insert ข้อมูลที่เกี่ยวข้องกันมักจะสำเร็จเสมอ เครือข่ายมีความเสถียร ดิสก์ไม่เคยเต็ม และไม่มีภาระงานอื่นมาแย่งทรัพยากร โค้ดจึงดูเหมือนจะถูกต้องเพราะมันมักจะทำงานได้ปกติ
แต่สภาพแวดล้อมบน Production นั้นต่างออกไป เช่น ลูกค้าสองคนส่งคำสั่งซื้อในเสี้ยววินาทีเดียวกันพอดี, queue worker เริ่มทำงานใหม่กลางคันระหว่างการ deploy, หรือผู้ให้บริการชำระเงินเกิด timeout นานถึง 30 วินาที หากไม่มี transaction เหตุการณ์เหล่านี้จะสร้างข้อมูลที่กำพร้า (orphan records) และยอดรวมที่ไม่ตรงกัน ซึ่งยากต่อการไล่ตรวจสอบ สิ่งที่แย่ที่สุดคือการทดสอบฟีเจอร์แบบมาตรฐานมักจะตรวจไม่พบ เพราะรูปแบบความผิดพลาดขึ้นอยู่กับจังหวะเวลาและโครงสร้างพื้นฐาน ไม่ใช่ข้อผิดพลาดทางตรรกะ (logic errors) ทั่วไป
วิธีแก้ไขไม่ใช่เรื่องซับซ้อน แต่มันเป็นเรื่องของหลักการ เมื่อคุณเห็นการเขียนข้อมูล (write) ตั้งแต่สองรายการขึ้นไปที่มีความเกี่ยวข้องกัน ให้ทำการครอบมันด้วย transaction และเมื่อทำบ่อยเข้า มันจะกลายเป็นเรื่องอัตโนมัติเหมือนกับการตรวจสอบความถูกต้องของ request
ทำให้เป็นสัญชาตญาณ
DB::transaction() ไม่ได้เพิ่มความซับซ้อน แต่มันช่วยกำจัดความซับซ้อนที่ซ่อนอยู่จากการต้องมาตามล้างตามเช็ดข้อมูลที่ค้างคาในภายหลัง หากกลุ่มของการทำงานกับฐานข้อมูลมีความเกี่ยวข้องกัน ให้ปฏิบัติกับมันเช่นนั้นตั้งแต่เริ่มต้น แอปพลิเคชันของคุณจะยังคงทำงานได้อย่างถูกต้องแม่นยำภายใต้ภาระงานหนัก, error logs จะสะอาด และฐานข้อมูลของคุณจะไม่กลายเป็นสุสานของข้อมูลที่ทำค้างไว้เพียงครึ่งเดียว
