Anda telah menyimpan pesanan, tetapi item pesanan tidak pernah sampai ke pangkalan data. Atau mungkin pengira stok berkurang, gerbang pembayaran mengalami masa tamat (timeout), dan kini pelanggan telah dikenakan caj pada kad mereka tetapi tiada rekod pesanan. Senario ini kedengaran seperti kes terpencil (edge cases) sehinggalah ia tidak lagi menjadi begitu. Dalam aplikasi yang sibuk, ia menjadi sakit kepala harian.
Punca utamanya hampir sentiasa sama: satu siri penulisan pangkalan data yang dianggap sebagai acara yang berasingan dan terasing. Apabila satu gagal, yang lain tertinggal. Penyelesaiannya ialah transaksi pangkalan data, dan dalam Laravel, alatnya ialah DB::transaction().
Apa yang Sebenarnya Dijanjikan oleh Transaksi
Transaksi pangkalan data mengikat pelbagai operasi ke dalam satu unit kerja tunggal. Enjin pangkalan data menjamin bahawa semua perkara di dalamnya sama ada akan disimpan secara kekal (commit) atau dibatalkan sepenuhnya (rollback). Tiada jalan tengah.
Fikirkan tentang pemindahan bank. Sistem mesti mendebit satu akaun dan mengkreditkan akaun yang lain. Jika pengkreditan gagal selepas pendebitan, wang tersebut tidak hilang begitu sahaja ke dalam ruang kosong. Bank akan membatalkan pendebitan tersebut. Pembatalan itu adalah rollback. Jika kedua-dua langkah berjaya, pemindahan tersebut akan disimpan (committed), bermakna baki baharu disimpan dengan selamat.
Tingkah laku "semua-atau-tiada" inilah yang mengekalkan ketekalan (consistency) data anda. Tanpanya, kegagalan separa akan menyebarkan rekod yang ditulis separuh jalan ke seluruh jadual anda, dan rekod tersebut akan rosak dalam pangkalan data anda kerana tiada proses automatik yang tahu cara untuk membersihkannya dengan selamat.
Bagaimana Laravel Menguruskan Sambungannya
Dalam SQL biasa, anda perlu menulis BEGIN, COMMIT, dan ROLLBACK sendiri, sambil mengingati untuk menangkap setiap ralat yang mungkin supaya anda tidak membiarkan transaksi tergantung. Laravel membungkus kod boilerplate tersebut ke dalam satu kaedah tunggal.
Anda menghantar satu closure kepada DB::transaction(). Laravel memulakan transaksi, menjalankan kod anda, dan jika closure tersebut selesai tanpa mencetuskan pengecualian (exception), ia akan melakukan commit secara automatik. Jika berlaku sebarang kegagalan, Laravel menangkap pengecualian tersebut, melakukan rollback pada semua perkara, dan mencetuskan semula ralat tersebut supaya log dan pengendalian ralat anda tetap berfungsi seperti yang diharapkan.
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',
]);
});
Jika kemasukan pembayaran gagal kerana kunci asing (foreign key) hilang atau sambungan pangkalan data terputus, pesanan, item pesanan, dan perubahan stok semuanya akan dibatalkan. Anda tidak akan dibiarkan dengan inventori yang hilang tanpa sebab, dan anda tidak akan dibiarkan dengan pesanan yang memerlukan penghantaran tetapi tidak pernah dibayar.
Di Mana Ini Menyelamatkan Anda
Sesetengah aliran kerja sangat bergantung pada keselamatan transaksi. Contoh daftar keluar (checkout) adalah yang paling jelas, tetapi corak ini muncul di mana-mana sahaja.
Pendaftaran pengguna. Mencipta baris pengguna, kemudian baris profil, kemudian tetapan lalai. Jika kemasukan profil gagal disebabkan kes terpencil pengesahan (validation edge case), pengguna tanpa profil akan menjadi akaun hantu. Sebarang halaman yang menganggap setiap pengguna mempunyai profil akan mengalami ralat (crash) atau menunjukkan UI yang rosak.
Import pukal. Muat naik CSV yang memasukkan lima puluh rekod tidak sepatutnya meninggalkan dua puluh lima rekod kerana baris kedua puluh enam mempunyai tarikh yang salah format. Membungkus kelompok tersebut dalam satu transaksi membolehkan keseluruhan import gagal sebagai satu unit yang bersih. Pentadbir akan membaiki fail dan mencuba lagi, daripada menghabiskan masa berjam-jam mencari baris mana yang bocor secara separa.
Inventori dan perakaunan. Setiap kali satu jadual menjejaki sumber fizikal dan satu lagi menjejaki wang atau kredit, kedua-duanya mesti bergerak bersama. Memisahkan mereka akan mengundang audit yang tidak sepadan dan laporan yang tidak tepat.
Apabila Anda Perlu Mengendalikannya Secara Manual
Pendekatan closure merangkumi kebanyakan kes, tetapi kadangkala anda memerlukan lebih kawalan. Logik bersyarat yang kompleks di dalam kelas servis, atau keperluan untuk memutuskan sama ada mahu melakukan commit pada waktu larian (runtime), boleh membuatkan satu closure terasa janggal. Dalam saat-saat tersebut, anda boleh menguruskan transaksi itu sendiri:
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;
}
Perhatikan urutan di dalam blok catch: lakukan rollback terlebih dahulu, kemudian barulah throw. Jika anda melakukan throw sebelum rollback, transaksi akan kekal terbuka pada sambungan tersebut. Ini boleh mengunci baris (lock rows), menyebabkan deadlock pada pertanyaan (query) lain, atau menghabiskan kolam sambungan (connection pool) anda. Transaksi manual adalah berkuasa, tetapi ia meletakkan beban pembersihan kepada anda.
Jauhkan Kesan Sampingan (Side Effects) daripada Transaksi
Inilah peraturan yang sering menyusahkan pasukan semasa dalam pengeluaran (production). Transaksi hanya boleh membatalkan kerja pangkalan data. Ia tidak boleh membatalkan penghantaran e-mel, memadam fail daripada storan awan, atau memulangkan bayaran melalui API pembayaran.
Jika anda meletakkan panggilan Mail::send() di dalam closure transaksi, dan pangkalan data melakukan rollback dua baris kemudian, e-mel tersebut tetap akan sampai ke peti masuk pelanggan. Penerima kini mempunyai invois untuk pesanan yang tidak wujud dalam sistem anda. Perkara yang sama berlaku kepada pemberitahuan Slack, muat naik fail ke S3, atau penghantaran webhook.
The correct sequence is:
- Complete the transaction and capture any IDs or results you need.
- Only then trigger external side effects.
For example, queue the confirmation email after the commit, not inside it:
$order = DB::transaction(function () {
// database work only
return Order::create([/* ... */]);
});
// Side effects happen after the database state is solid
OrderConfirmationJob::dispatch($order);
There is a second reason to keep external calls outside the transaction: time. A transaction holds locks and keeps a database connection busy. Waiting three seconds for a Stripe API response while inside a transaction is three seconds of unnecessary lock time. Keep the transaction tight and fast.
Why This Bug Hides
On a development machine with a single user and a local database, related inserts almost always succeed. The network is stable, the disk never fills, and there is no competing load. The code looks correct because it usually works.
Production is different. Two customers submit orders at the exact same millisecond. A queue worker restarts mid-job during a deployment. A payment provider times out for thirty seconds. Without transactions, these events create orphan records and mismatched totals that are painful to trace. The worst part is that standard feature tests rarely catch them, because the failure mode is timing-dependent and tied to infrastructure, not simple logic errors.
The fix is not exotic. It is mechanical. You see two or more related writes, and you wrap them. Over time, it should become as automatic as validating a request.
Make It a Reflex
DB::transaction() does not add complexity. It removes the hidden complexity of trying to clean up partial data after the fact. If a group of database operations belongs together, treat them that way from the start. Your application will stay honest under load, your error logs will stay clean, and your database will not become a graveyard of half-finished records.
