Deadlock adalah masalah yang umum pada aplikasi Laravel yang menangani concurrent write, terutama pada kasus seperti update stok, mutasi saldo, pembayaran, atau pemrosesan order. Gejalanya biasanya muncul secara acak saat traffic meningkat: sebagian request gagal walaupun query terlihat benar, lalu MySQL mengembalikan error seperti SQLSTATE[40001] atau kode MySQL 1213 Deadlock found when trying to get lock.
Hal penting yang perlu dipahami: deadlock bukan bug yang selalu bisa dihilangkan 100%. Deadlock adalah konsekuensi normal dari transaksi yang saling menunggu lock dalam urutan berbeda. Karena itu, penanganan yang tepat bukan hanya “retry semua error”, tetapi memahami pola query pemicunya, menata urutan locking, memperkecil scope transaksi, dan melakukan retry secara terbatas hanya untuk error yang memang layak dicoba ulang.
Dalam brief teknis ini, fokus utamanya adalah penanganan deadlock pada operasi update stok atau saldo menggunakan DB::transaction di Laravel.
Memahami deadlock pada update stok dan saldo
Pada InnoDB, setiap UPDATE, DELETE, dan sebagian SELECT ... FOR UPDATE akan mengambil lock pada baris tertentu. Deadlock terjadi ketika dua transaksi atau lebih saling memegang lock yang dibutuhkan transaksi lain, sehingga terbentuk siklus tunggu.
Contoh sederhana:
- Transaksi A mengunci baris produk ID 10.
- Transaksi B mengunci baris produk ID 20.
- Transaksi A lalu mencoba mengunci produk ID 20.
- Transaksi B lalu mencoba mengunci produk ID 10.
Keduanya sekarang saling menunggu. InnoDB akan memilih salah satu transaksi sebagai korban dan me-rollback-nya. Dari sisi aplikasi Laravel, korban deadlock biasanya muncul sebagai exception dari PDO atau query builder.
Pola ini sangat sering terjadi pada:
- Transfer saldo antar akun.
- Update stok banyak item dalam satu order.
- Pemrosesan job paralel yang menyentuh baris yang sama.
- Proses sinkronisasi inventory yang berjalan bersamaan dengan checkout.
Pola query yang sering memicu deadlock
1. Urutan akses baris tidak konsisten
Ini penyebab paling umum. Misalnya satu request memproses item order sesuai urutan input pengguna, sementara request lain memproses item yang sama tetapi dengan urutan berbeda. Jika keduanya melakukan SELECT ... FOR UPDATE atau UPDATE per item, kemungkinan deadlock meningkat.
Contoh buruk:
// Request A: [5, 9]
// Request B: [9, 5]
DB::transaction(function () use ($productIds) {
foreach ($productIds as $id) {
$product = Product::whereKey($id)->lockForUpdate()->firstOrFail();
$product->stock -= 1;
$product->save();
}
});Walaupun logika bisnisnya benar, urutan locking yang tidak tetap membuat dua transaksi bisa saling silang.
2. Membaca lalu mengubah banyak baris di dalam transaksi panjang
Transaksi yang terlalu lama memperbesar jendela konflik. Contohnya: melakukan validasi bisnis, memanggil service eksternal, menghitung diskon, membaca tabel lain, lalu baru update stok. Semakin lama transaksi menahan lock, semakin besar peluang deadlock.
3. Update dengan pola yang tidak deterministik
Query yang mengunci baris berdasarkan hasil pencarian tanpa indeks yang tepat dapat memperluas area lock. Pada InnoDB, terutama pada level isolasi tertentu, pencarian yang buruk dapat memicu lock lebih banyak dari yang diperkirakan. Untuk tabel saldo atau stok, pastikan kolom pencarian seperti id, sku, atau account_id memiliki indeks yang sesuai.
4. Transfer dua arah antar akun
Kasus klasik:
- Request 1 mentransfer dari akun A ke B.
- Request 2 mentransfer dari akun B ke A.
Jika request pertama mengunci akun asal dulu lalu akun tujuan, sementara request kedua melakukan kebalikannya, deadlock hampir pasti akan muncul pada beban tinggi.
Strategi utama: jaga urutan locking tetap konsisten
Cara paling efektif mengurangi deadlock adalah memastikan semua transaksi mengunci resource dalam urutan yang sama. Prinsip ini sederhana, tetapi sangat kuat.
Contoh pada transfer saldo
Jangan kunci akun berdasarkan arah transfer. Kunci selalu berdasarkan ID terkecil lebih dulu.
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($fromId, $toId, $amount) {
$ids = collect([$fromId, $toId])->sort()->values();
$accounts = DB::table('accounts')
->whereIn('id', $ids)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$from = $accounts[$fromId];
$to = $accounts[$toId];
if ($from->balance < $amount) {
throw new RuntimeException('Saldo tidak cukup');
}
DB::table('accounts')->where('id', $fromId)->update([
'balance' => $from->balance - $amount,
]);
DB::table('accounts')->where('id', $toId)->update([
'balance' => $to->balance + $amount,
]);
});Mengapa ini membantu? Karena semua transaksi akan mengambil lock pada urutan yang sama, sehingga tidak ada siklus tunggu silang akibat perbedaan urutan akses.
Contoh pada update stok banyak produk
Untuk checkout yang memotong stok banyak item, urutkan semua product ID sebelum mengambil lock.
DB::transaction(function () use ($items) {
$productIds = collect($items)->pluck('product_id')->unique()->sort()->values();
$products = Product::query()
->whereIn('id', $productIds)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
foreach ($items as $item) {
$product = $products[$item['product_id']];
if ($product->stock < $item['qty']) {
throw new RuntimeException('Stok tidak cukup');
}
}
foreach ($items as $item) {
$product = $products[$item['product_id']];
$newStock = $product->stock - $item['qty'];
Product::whereKey($product->id)->update([
'stock' => $newStock,
]);
$product->stock = $newStock;
}
});Poin pentingnya bukan sekadar menggunakan lockForUpdate(), tetapi memastikan urutan penguncian deterministik.
Retry transaction: perlu, tetapi harus terbatas
Karena deadlock adalah kondisi normal pada sistem concurrent, retry masuk akal. Namun retry yang benar harus memenuhi beberapa syarat:
- Hanya retry untuk error yang relevan, misalnya SQLSTATE
40001atau MySQL error code1213. - Batasi jumlah percobaan, misalnya 2 sampai 3 kali.
- Gunakan sedikit jeda sebelum retry agar konflik tidak langsung berulang.
- Pastikan isi transaksi aman untuk diulang. Ini sangat penting.
Kesalahan umum adalah me-retry seluruh blok yang di dalamnya ada efek samping non-database, misalnya mengirim email, memanggil payment gateway, menerbitkan event eksternal, atau menulis file. Jika transaksi diulang, efek samping tersebut bisa terjadi lebih dari sekali.
Prinsip praktis: blok yang di-retry sebaiknya hanya berisi operasi database yang idempoten terhadap skenario retry, atau efek samping dijalankan setelah transaksi benar-benar sukses.
Helper retry transaction yang ringkas
Berikut helper sederhana yang bisa dipakai di service Laravel:
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
function retryTransaction(callable $callback, int $maxAttempts = 3, int $sleepMs = 100)
{
$attempt = 0;
beginning:
$attempt++;
try {
return DB::transaction($callback);
} catch (QueryException $e) {
$sqlState = $e->errorInfo[0] ?? null;
$driverCode = $e->errorInfo[1] ?? null;
$isDeadlock = $sqlState === '40001' || (int) $driverCode === 1213;
Log::warning('Transaction failed', [
'attempt' => $attempt,
'max_attempts' => $maxAttempts,
'sql_state' => $sqlState,
'driver_code' => $driverCode,
'message' => $e->getMessage(),
]);
if ($isDeadlock && $attempt < $maxAttempts) {
usleep($sleepMs * 1000);
goto beginning;
}
throw $e;
}
}Contoh pemakaian:
retryTransaction(function () use ($fromId, $toId, $amount) {
$ids = collect([$fromId, $toId])->sort()->values();
$accounts = DB::table('accounts')
->whereIn('id', $ids)
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$from = $accounts[$fromId];
$to = $accounts[$toId];
if ($from->balance < $amount) {
throw new RuntimeException('Saldo tidak cukup');
}
DB::table('accounts')->where('id', $fromId)->decrement('balance', $amount);
DB::table('accounts')->where('id', $toId)->increment('balance', $amount);
});Implementasi di atas sengaja ringkas. Di aplikasi produksi, Anda bisa memindahkannya ke class helper atau service khusus agar lebih mudah diuji dan dipakai ulang.
Logging yang berguna untuk investigasi
Jika hanya mencatat pesan exception mentah, analisis deadlock akan lambat. Saat deadlock terjadi, minimal log-kan informasi berikut:
- SQLSTATE, terutama
40001. - MySQL driver code, terutama
1213. - Nama operasi bisnis, misalnya
stock_deductionataubalance_transfer. - ID resource yang terlibat, misalnya
account_id,order_id,product_ids. - Percobaan ke berapa saat retry.
- Request ID, trace ID, atau correlation ID bila tersedia.
Contoh logging yang lebih terstruktur:
Log::warning('Deadlock detected during stock update', [
'operation' => 'stock_update',
'order_id' => $orderId,
'product_ids' => $productIds,
'attempt' => $attempt,
'sql_state' => $sqlState,
'driver_code' => $driverCode,
]);Dengan log seperti ini, Anda bisa melihat apakah deadlock dominan terjadi pada kombinasi produk tertentu, job tertentu, atau jalur kode tertentu. Ini jauh lebih berguna daripada hanya mengetahui bahwa query gagal.
Catatan tentang SQLSTATE 40001 dan kode 1213
Di MySQL, deadlock umumnya muncul dengan kode driver 1213, dan sering dipetakan ke SQLSTATE 40001. Dalam praktik Laravel, memeriksa keduanya adalah pendekatan yang aman karena struktur exception bergantung pada driver PDO dan konteks query.
Praktik penting agar retry tidak menjadi solusi palsu
1. Perkecil scope transaksi
Jangan taruh operasi yang tidak perlu di dalam DB::transaction. Hitung data yang bisa dihitung di luar transaksi. Validasi input umum juga sebaiknya dilakukan sebelum transaksi dibuka.
2. Hindari efek samping di dalam blok retry
Jika Anda mengirim notifikasi, publish message, atau memanggil API pihak ketiga di dalam callback transaksi, retry dapat menyebabkan duplikasi. Simpan data dulu, commit transaksi, baru jalankan efek samping setelahnya.
3. Pastikan query memakai indeks yang benar
Pencarian tanpa indeks dapat menyebabkan lock lebih luas dan durasi query lebih lama. Untuk tabel transaksi tinggi, periksa indeks pada kolom yang dipakai di WHERE, JOIN, dan urutan locking.
4. Jangan gunakan retry terlalu banyak
Jika deadlock tetap sering muncul setelah 3 kali percobaan, biasanya akar masalah ada pada desain locking atau pola query. Menambah retry menjadi 10 kali hanya menyembunyikan masalah dan memperburuk latensi.
5. Konsisten antara semua jalur kode
Deadlock sering tetap terjadi walaupun satu service sudah rapi, karena service lain mengakses tabel yang sama dengan urutan berbeda. Aturan urutan locking harus menjadi konvensi lintas kode, bukan hanya lokal pada satu fungsi.
Checklist implementasi untuk kasus stok dan saldo
- Urutkan ID resource sebelum
lockForUpdate(). - Ambil lock pada semua baris yang dibutuhkan sedini mungkin dalam transaksi.
- Jaga transaksi tetap singkat.
- Retry hanya untuk
40001atau1213. - Batasi retry, umumnya 2-3 kali.
- Tambahkan jeda singkat antar retry.
- Log metadata yang cukup untuk investigasi.
- Hindari efek samping non-database di dalam blok retry.
Penutup
Menangani deadlock MySQL di Laravel bukan soal menambahkan try-catch lalu selesai. Solusi yang benar dimulai dari desain akses data yang konsisten: urutan locking harus deterministik, transaksi harus singkat, dan query harus jelas serta terindeks dengan baik. Setelah itu, retry transaction yang terbatas menjadi lapisan pertahanan yang masuk akal untuk menghadapi konflik sesekali pada sistem concurrent.
Untuk kasus update stok dan saldo, dua hal yang paling berdampak adalah mengunci baris dalam urutan tetap dan mencatat deadlock dengan SQLSTATE 40001/1213 secara terstruktur. Dengan kombinasi ini, Anda bukan hanya mengurangi error di produksi, tetapi juga membuat akar masalah jauh lebih mudah dilacak saat traffic meningkat.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!