Aplikasi checkout berjalan normal di staging. Semua feature test di CI hijau. Tetapi begitu masuk jam sibuk di production—misalnya saat flash sale atau promo gajian—tiba-tiba satu atau dua transaksi gagal dengan pesan error yang menyeramkan:

SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction

Di Laravel, error ini umumnya naik sebagai Illuminate\Database\QueryException (atau Illuminate\Database\DeadlockException pada transaksi bersarang / nested transaction) yang membungkus error PDO tersebut. Saat kamu mencoba mengulangi skenario yang sama di lokal, error-nya tidak pernah muncul. Pembeli yang checkout sendirian lancar, stok terpotong rapi. Deadlock ini seakan-akan datang dan pergi sesuka hati.

Tenang, deadlock bukan kerusakan data dan bukan bug pada database engine-mu kok. Deadlock justru mekanisme pertahanan database relasional: mesin mendeteksi ada dua transaksi yang saling mengunci dan saling menunggu, lalu dengan sengaja membatalkan salah satunya agar database tidak macet selamanya.

Di artikel ini kita bedah bagaimana siklus deadlock terbentuk, cara membaca log LATEST DETECTED DEADLOCK di MySQL InnoDB, mengapa sekadar menambah retry otomatis bukan solusi yang tuntas, dan bagaimana memutus siklus ini selamanya dengan urutan locking yang deterministik. Semua output di sini diambil langsung dari menjalankan repo contohnya dengan Laravel 13 di MySQL 8.4, jadi kamu bisa mengujinya sendiri.

Bagaimana deadlock terjadi: siklus tunggu

Dokumentasi MySQL 8.4 menjelaskan deadlock sebagai situasi di mana dua atau lebih transaksi saling memegang lock (kunci) pada baris data yang dibutuhkan oleh transaksi lain, sehingga tidak ada satupun transaksi yang bisa melanjutkan proses.

Bayangkan skenario checkout keranjang belanja di toko komputer:

  1. Transaksi 1 (Pembeli A) membeli Keyboard (Produk 1) dan Mouse (Produk 2).
  2. Transaksi 2 (Pembeli B) membeli Mouse (Produk 2) dan Keyboard (Produk 1).

Kedua transaksi ini tiba di database pada milidetik yang hampir bersamaan:

  • Transaksi 1 mengunci baris Produk 1 dengan exclusive lock (lock_mode X).
  • Transaksi 2 mengunci baris Produk 2 dengan exclusive lock.
  • Transaksi 1 ingin mengurangi stok Produk 2, sehingga meminta lock Produk 2. Karena Produk 2 sedang dipegang Transaksi 2, Transaksi 1 masuk ke status LOCK WAIT.
  • Transaksi 2 ingin mengurangi stok Produk 1, sehingga meminta lock Produk 1. Karena Produk 1 sedang dipegang Transaksi 1, Transaksi 2 juga masuk ke status LOCK WAIT.

Sekarang Transaksi 1 menunggu Transaksi 2, dan Transaksi 2 menunggu Transaksi 1. Dalam teori ilmu komputer, ini disebut graf dependensi melingkar (circular wait atau wait-for graph). Tanpa campur tangan mesin, kedua transaksi ini akan membeku selamanya di memori.

Deteksi otomatis oleh InnoDB

Secara default, parameter innodb_deadlock_detect di MySQL bernilai ON. InnoDB terus memantau graf dependensi lock antar-transaksi. Begitu siklus terdeteksi, InnoDB langsung bertindak sebagai wasit:

  1. Memilih salah satu transaksi sebagai korban (victim). Menurut dokumentasi MySQL, korban dipilih dari transaksi yang “paling murah” dibatalkan, yaitu transaksi yang paling sedikit mengubah baris data (inserted, updated, or deleted).
  2. Menjalankan ROLLBACK paksa pada transaksi korban tersebut.
  3. Mengembalikan error code 1213 (SQLSTATE 40001) ke aplikasi pemanggil.
  4. Melepas semua lock milik transaksi korban, sehingga transaksi pemenang bisa segera menyelesaikan proses dan melakukan COMMIT.

Sebagai pembanding, PostgreSQL menerapkan pendekatan berbeda: PostgreSQL tidak langsung menghitung graf dependensi setiap kali ada transaksi menunggu lock. Melalui parameter deadlock_timeout (default 1 detik), PostgreSQL menunda pengecekan graf selama satu detik. Jika lock belum lepas setelah batas waktu itu lewat, barulah PostgreSQL menelusuri graf dan menggagalkan transaksi dengan kode error 40P01 (deadlock_detected).

Dua alur transaksi konkuren sebelum perbaikan dan sesudahnya terlihat pada diagram ini:

Diagram alur dua transaksi konkuren. Pada bagian sebelum perbaikan, Transaksi 1 memegang lock Produk 1 dan meminta Produk 2, sedangkan Transaksi 2 memegang lock Produk 2 dan meminta Produk 1. Garis putus-putus merah saling silang membentuk siklus deadlock, sehingga Transaksi 2 di-rollback dengan error 1213. Pada bagian sesudah perbaikan, kedua transaksi sama-sama mengunci Produk 1 terlebih dahulu; Transaksi 2 mengantre dengan rapi tanpa memegang lock Produk 2, Transaksi 1 menyelesaikan commit, lalu Transaksi 2 melanjutkan dan commit dengan sukses.

Membaca anatomi deadlock dari SHOW ENGINE INNODB STATUS

Saat error 1213 meledak di production, log aplikasi seperti storage/logs/laravel.log hanya mencatat query terakhir yang gagal dieksekusi. Log itu tidak memberi tahu siapa lawan transaksi yang sedang bertabrakan dengannya.

Petunjuk paling lengkap tersimpan di mesin MySQL. Jalankan query ini di konsol database:

SHOW ENGINE INNODB STATUS\G

Gulir ke bagian ------------------------ LATEST DETECTED DEADLOCK ------------------------. Di sana tercatat dua transaksi terakhir yang mengalami tabrakan beserta baris data persis yang mereka perebutkan. Ini output riil dari repo contoh saat dijalankan di MySQL 8.4:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-10-16 14:59:58 0x16fa23000
*** (1) TRANSACTION:
TRANSACTION 9364522, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 3
MySQL thread id 31, OS thread handle 6184775680, query id 874 localhost 127.0.0.1 root statistics
select * from `products` where `id` = 2 limit 1 for update

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 152071 page no 4 n bits 72 index PRIMARY of table `deadlock_laravel`.`products` trx id 9364522 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 0000000000000001; asc         ;;
 ...
 3: len 10; hex 4b422d4d4543482d3031; asc KB-MECH-01;;
 4: len 23; hex 4d656368616e6963616c204b6579626f61726420544b4c; asc Mechanical Keyboard TKL;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 152071 page no 4 n bits 72 index PRIMARY of table `deadlock_laravel`.`products` trx id 9364522 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 0000000000000002; asc         ;;
 ...
 3: len 10; hex 4d532d574952452d3032; asc MS-WIRE-02;;
 4: len 21; hex 576972656c6573732047616d696e67204d6f757365; asc Wireless Gaming Mouse;;

*** (2) TRANSACTION:
TRANSACTION 9364521, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 3
MySQL thread id 32, OS thread handle 6185889792, query id 873 localhost 127.0.0.1 root statistics
select * from `products` where `id` = 1 limit 1 for update

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 152071 page no 4 n bits 72 index PRIMARY of table `deadlock_laravel`.`products` trx id 9364521 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 0000000000000002; asc         ;;
 ...
 3: len 10; hex 4d532d574952452d3032; asc MS-WIRE-02;;
 4: len 21; hex 576972656c6573732047616d696e67204d6f757365; asc Wireless Gaming Mouse;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 152071 page no 4 n bits 72 index PRIMARY of table `deadlock_laravel`.`products` trx id 9364521 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 8; compact format; info bits 0
 0: len 8; hex 0000000000000001; asc         ;;
 ...
 3: len 10; hex 4b422d4d4543482d3031; asc KB-MECH-01;;

*** WE ROLL BACK TRANSACTION (2)

Mari kita baca anatominya baris demi baris:

  • *** (1) TRANSACTION: Transaksi pertama sedang mengeksekusi select * from products where id = 2 limit 1 for update.
  • *** (1) HOLDS THE LOCK(S): Transaksi 1 memegang exclusive record lock (lock_mode X locks rec but not gap) pada index PRIMARY untuk baris dengan id = 1 (produk Keyboard TKL).
  • *** (1) WAITING FOR THIS LOCK TO BE GRANTED: Transaksi 1 terhenti dan menunggu lock pada index PRIMARY untuk baris id = 2 (produk Mouse).
  • *** (2) TRANSACTION: Transaksi kedua sedang mengeksekusi select * from products where id = 1 limit 1 for update.
  • *** (2) HOLDS THE LOCK(S): Transaksi 2 memegang lock pada baris id = 2 (produk Mouse yang ditunggu Transaksi 1).
  • *** (2) WAITING FOR THIS LOCK TO BE GRANTED: Transaksi 2 meminta lock pada baris id = 1 (produk Keyboard yang sedang dipegang Transaksi 1).
  • *** WE ROLL BACK TRANSACTION (2): InnoDB melihat Transaksi 2 memodifikasi jumlah baris yang setara atau lebih sedikit, sehingga Transaksi 2 diputus sebagai korban dan di-rollback.

Jika kamu ingin semua peristiwa deadlock otomatis tercatat ke error log MySQL (bukan hanya satu kejadian terakhir), aktifkan variabel sistem ini di konfigurasi database:

SET GLOBAL innodb_print_all_deadlocks = ON;

Reproduksi: deadlock pada checkout multi-item

Di repo contoh, tabel products menyimpan stok barang, sedangkan orders dan order_items mencatat pesanan checkout.

Pada implementasi awal di tag 1-bug, controller melakukan pengurangan stok dengan pola umum: mengulang item sesuai urutan array yang dikirimkan client, lalu mengunci baris produk satu per satu dengan lockForUpdate():

// app/Http/Controllers/CheckoutController.php (tag 1-bug)
$order = DB::transaction(function () use ($validated) {
    $order = Order::create([
        'order_number' => $validated['order_number'],
        'status' => 'paid',
    ]);

    foreach ($validated['items'] as $item) {
        $product = Product::where('id', $item['product_id'])
            ->lockForUpdate()
            ->firstOrFail();

        if ($product->stock < $item['quantity']) {
            throw new RuntimeException("Stok {$product->name} tidak mencukupi.");
        }

        $product->decrement('stock', $item['quantity']);

        $order->items()->create([
            'product_id' => $product->id,
            'quantity' => $item['quantity'],
        ]);

        if (! empty($validated['delay_ms'])) {
            usleep($validated['delay_ms'] * 1000);
        }
    }

    return $order;
});

Baris usleep mensimulasikan jeda pemrosesan bisnis di dalam transaksi nyata (seperti penghitungan promo, komputasi diskon, atau persiapan payload).

Mengapa test biasa tetap hijau

Handler ini memiliki automated test di tests/Feature/CheckoutTest.php:

public function test_sequential_checkout_succeeds(): void
{
    $this->postJson('/checkout', [
        'order_number' => 'ORD-101',
        'items' => [
            ['product_id' => 1, 'quantity' => 1],
            ['product_id' => 2, 'quantity' => 1],
        ],
    ])->assertOk();

    $this->postJson('/checkout', [
        'order_number' => 'ORD-102',
        'items' => [
            ['product_id' => 2, 'quantity' => 1],
            ['product_id' => 1, 'quantity' => 1],
        ],
    ])->assertOk();

    $this->assertSame(98, Product::find(1)->stock);
    $this->assertSame(98, Product::find(2)->stock);
}

Saat dijalankan:

   PASS  Tests\Feature\CheckoutTest
  ✓ sequential checkout succeeds                                         0.13s
  ✓ insufficient stock is rejected                                       0.01s

  Tests:    2 passed (6 assertions)
  Duration: 0.19s

Test ini hijau karena kedua request dijalankan secara berurutan (sequential). Order 1 selesai dan commit, barulah Order 2 mulai. Tidak pernah ada dua transaksi aktif pada saat yang sama di test ini. Itu sebabnya bug deadlock selalu lolos dari pengujian unit biasa.

Menguji request konkuren

Untuk mensimulasikan kondisi riil di mana dua request masuk bersamaan, kita jalankan built-in server PHP dengan 4 worker:

PHP_CLI_SERVER_WORKERS=4 php artisan serve --port=8012 --no-reload

Lalu command demo:deadlock mengirimkan 10 pasang request simultan lewat Http::pool(), di mana Request A memesan Produk 1 lalu 2, sedangkan Request B memesan Produk 2 lalu 1:

php artisan demo:deadlock --pairs=10 --url=http://127.0.0.1:8012/checkout

Hasilnya di terminal:

Menjalankan demo deadlock: 10 pasang request konkuren ke http://127.0.0.1:8012/checkout..
Status HTTP: {"500":10,"200":10}
Transaksi sukses (200): 10 dari 20
Transaksi gagal karena deadlock (1213): 10 dari 20

Tepat 50% transaksi gagal seketika! Pada setiap pasang request konkuren, salah satunya selalu menjadi korban deadlock MySQL 1213.

Kenapa retry otomatis bukan solusi utama

Banyak pengembang tergoda menyelesaikan masalah ini dengan menambahkan parameter $attempts bawaan Laravel:

DB::transaction(function () {
    // ...
}, 3); // Coba ulang hingga 3 kali jika deadlock

Di balik layar, Laravel (melalui trait Illuminate\Database\DetectsConcurrencyErrors dan class ConcurrencyErrorDetector) menjalankan method causedByConcurrencyError(). Method ini memeriksa apakah exception mengandung kode SQLSTATE 40001 atau pesan error deadlock khas MySQL (‘Deadlock found when trying to get lock’) dan PostgreSQL (‘deadlock detected’), lalu secara otomatis mengulang closure dari awal.

Meskipun fitur ini sangat berguna sebagai jaring pengaman (safety net), menjadikannya solusi tunggal membawa tiga risiko besar:

  1. Menyembunyikan lonjakan latensi (latency spikes). Setiap kali transaksi di-rollback dan diulang, user harus menunggu durasi tambahan transaksi baru. Jika transaksi melibatkan pemrosesan kompleks, waktu respons ke user melonjak drastis.
  2. Bahaya efek samping non-idempoten. Transaksi database me-rollback perubahan tabel, tetapi tidak bisa me-rollback operasi di luar database. Jika di dalam closure ada pengiriman notifikasi, pemanggilan third-party API, atau dispatching event, operasi tersebut bisa terpicu berkali-kali setiap kali transaksi diulang.
  3. Risiko livelock pada beban tinggi. Di situasi puncak traffic (misalnya flash sale di mana ratusan proses berebut lock yang sama secara acak), proses yang di-retry akan kembali bertabrakan dengan proses baru. Hasilnya adalah badai deadlock berulang (thundering herd / livelock) hingga batas attempt habis dan server kehabisan koneksi database.

Retry adalah rem darurat, bukan perbaikan kemudi. Penyebab strukturalnya harus tetap dibereskan.

Test yang menangkap masalah urutan lock

Di tag 2-test-merah, kita menambahkan test yang memeriksa apakah urutan akuisisi lock berjalan secara deterministik:

public function test_checkout_locks_products_in_deterministic_order(): void
{
    $lockedProductIds = [];

    DB::listen(function ($query) use (&$lockedProductIds) {
        $sql = strtolower($query->sql);
        if (str_starts_with($sql, 'select * from') && str_contains($sql, 'products')) {
            foreach ($query->bindings as $binding) {
                if (is_numeric($binding) && in_array((int) $binding, [1, 2], true)) {
                    $lockedProductIds[] = (int) $binding;
                }
            }
        }
    });

    // Request dengan urutan terbalik: Produk 2 dulu, baru Produk 1
    $this->postJson('/checkout', [
        'order_number' => 'ORD-104',
        'items' => [
            ['product_id' => 2, 'quantity' => 1],
            ['product_id' => 1, 'quantity' => 1],
        ],
    ])->assertOk();

    // Aplikasi wajib mengunci produk secara berurutan ascending [1, 2]
    $this->assertSame([1, 2], $lockedProductIds, 'Locks must be acquired in ascending ID order to prevent deadlocks.');
}

Dengan kode lama di tag 1-bug, test ini langsung merah:

   FAIL  Tests\Feature\CheckoutTest
  ✓ sequential checkout succeeds                                         0.13s
  ✓ insufficient stock is rejected                                       0.01s
  ⨯ checkout locks products in deterministic order                       0.02s
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\CheckoutTest > checkout locks products in determini…
  Locks must be acquired in ascending ID order to prevent deadlocks.
  Failed asserting that two arrays are identical.
  --- Expected
  +++ Actual
  @@ @@
   Array &0 [
  -    0 => 1,
  -    1 => 2,
  +    0 => 2,
  +    1 => 1,
   ]

Test ini membuktikan bahwa controller masih tunduk pada urutan acak yang dikirim client.

Solusi 1: Disiplin urutan penguncian (Lock Ordering)

Aturan paling mendasar dalam konkurensi database adalah: semua transaksi di seluruh aplikasi wajib mengunci baris data dalam urutan deterministik yang seragam.

Jika Transaksi 1 dan Transaksi 2 sama-sama butuh Produk 1 dan Produk 2, pastikan keduanya selalu mengunci Produk 1 terlebih dahulu sebelum menyentuh Produk 2.

Ini implementasi di tag 3-perbaikan:

// app/Http/Controllers/CheckoutController.php (tag 3-perbaikan)
$order = DB::transaction(function () use ($validated) {
    $order = Order::create([
        'order_number' => $validated['order_number'],
        'status' => 'paid',
    ]);

    // 1. Petakan item dan urutkan ID produk secara konsisten (ascending)
    $itemsByProduct = collect($validated['items'])->keyBy('product_id');
    $sortedProductIds = $itemsByProduct->keys()->sort()->values();

    // 2. Kunci semua baris dengan satu query terurut (batch locking)
    $products = Product::whereIn('id', $sortedProductIds)
        ->orderBy('id')
        ->lockForUpdate()
        ->get()
        ->keyBy('id');

    foreach ($sortedProductIds as $productId) {
        $product = $products->get($productId);
        $item = $itemsByProduct->get($productId);

        if (! $product || $product->stock < $item['quantity']) {
            throw new RuntimeException("Stok ".($product?->name ?? $productId)." tidak mencukupi.");
        }

        $product->decrement('stock', $item['quantity']);

        $order->items()->create([
            'product_id' => $product->id,
            'quantity' => $item['quantity'],
        ]);

        if (! empty($validated['delay_ms'])) {
            usleep($validated['delay_ms'] * 1000);
        }
    }

    return $order;
}, 3);

Ada dua perbaikan penting di sini:

  1. Urutan ID disortir. Apapun urutan array dari keranjang belanja user, $itemsByProduct->keys()->sort()->values() menjamin produk selalu diproses dari ID terkecil ke terbesar (1, 2, 3...).
  2. Kunci sekaligus dengan batch query. whereIn('id', $sortedProductIds)->orderBy('id')->lockForUpdate()->get() menggantikan N query select ... for update menjadi satu query tunggal.

Mengapa ini secara matematis mematikan deadlock

Mari kita simulasikan kembali saat Pembeli A dan Pembeli B checkout bersamaan:

  • Transaksi 1 dan Transaksi 2 sama-sama mencoba mengunci Produk 1 terlebih dahulu.
  • Transaksi yang tiba lebih dulu (misal Transaksi 1) berhasil mendapatkan lock Produk 1.
  • Transaksi 2 mencoba mengambil lock Produk 1, sehingga Transaksi 2 masuk status menunggu di pintu gerbang.
  • Poin kuncinya: Transaksi 2 belum memegang kunci Produk 2!
  • Transaksi 1 dengan leluasa mengambil lock Produk 2, mengurangi stok, lalu melakukan COMMIT.
  • Saat Transaksi 1 commit, kunci Produk 1 dan 2 dilepas bersamaan.
  • Transaksi 2 terbangun dari antrean, mengambil kunci Produk 1 dan 2, lalu menyelesaikan transaksi.

Graf dependensi melingkar tidak pernah bisa terbentuk karena tidak ada transaksi yang menahan kunci lawan sambil meminta kunci baru.

Setelah perbaikan ini diterapkan, test unit langsung kembali hijau:

   PASS  Tests\Feature\CheckoutTest
  ✓ sequential checkout succeeds                                         0.13s
  ✓ insufficient stock is rejected                                       0.01s
  ✓ checkout locks products in deterministic order                       0.02s

  Tests:    3 passed (8 assertions)
  Duration: 0.19s

Dan saat demo 25 pasang request silang (total 50 request konkuren) dijalankan ulang di server nyata:

php artisan demo:deadlock --pairs=25 --url=http://127.0.0.1:8012/checkout

Hasilnya 100% mulus:

Menjalankan demo deadlock: 25 pasang request konkuren ke http://127.0.0.1:8012/checkout..
Status HTTP: {"200":50}
Transaksi sukses (200): 50 dari 50
Transaksi gagal karena deadlock (1213): 0 dari 50

Semua 50 transaksi sukses, 0 error deadlock.

Solusi 2: Memperpendek jendela lock

Selain urutan lock, faktor penentu lainnya adalah durasi waktu penahanan lock (lock window). Semakin lama transaksi terbuka, semakin lebar celah bagi transaksi lain untuk saling bertubrukan.

Berikut aturan praktis untuk memperpendek jendela lock:

  1. Keluarkan I/O dari transaksi. Jangan pernah memanggil third-party API (seperti payment gateway, cek ongkos kirim ekspedisi, atau kirim email) di dalam blok transaksi database yang memegang lock.
  2. Validasi data di muka. Validasi format, parsing payload, dan kalkulasi non-database sebaiknya selesai sebelum DB::transaction() dibuka.
  3. Gunakan antrean (queue) setelah commit. Seperti dibahas pada artikel idempotent webhook, operasi seperti dispatching email atau sync data pihak ketiga harus dilempar ke background worker setelah transaksi commit.

Checklist pencegahan deadlock di database

Ketika merancang transaksi yang mengupdate baris data secara konkuren, jadikan daftar ini sebagai acuan:

  • Deterministic Lock Ordering: Setiap transaksi multi-row selalu mengurutkan kunci berdasarkan primary key (ORDER BY id ASC).
  • Indeks pada kolom WHERE: Pastikan query UPDATE atau SELECT ... FOR UPDATE mengenai kolom yang memiliki indeks unik/primary key. Query tanpa indeks memaksa InnoDB mengunci seluruh rentang baris (Gap Lock / Next-Key Lock), yang sangat mudah memicu tabrakan antar-query yang tidak berhubungan.
  • Batch Locking: Kunci baris data sekaligus dalam satu perintah whereIn(...)->orderBy(...)->lockForUpdate(), hindari loop query lock individual.
  • Transaksi Ramping: Singkirkan panggilan API pihak ketiga, pengiriman email, dan komputasi berat dari dalam transaksi database.
  • Isolation Level yang Tepat: Jika aplikasimu tidak membutuhkan REPEATABLE READ, pertimbangkan READ COMMITTED di MySQL untuk mengurangi penggunaan gap lock.
  • Retry sebagai Jaring Pengaman: Pasang batas retry terukur (DB::transaction(..., 3)) dengan memastikan logika di dalam transaksi aman diulang (idempoten).
  • Monitoring Aktif: Nyalakan innodb_print_all_deadlocks selama investigasi untuk merekam riwayat tabrakan di error log server.

Kode contoh lengkap di GitHub →