Skip to content

Database Deadlock: Ketika Dua Transaction Saling Menunggu Tanpa Akhir ​

Pernah melihat error seperti ini muncul di log aplikasi saat traffic sedang ramai?

text
ERROR: deadlock detected
Detail: Process 1234 waits for RowShareLock on transaction 5678;
        blocked by process 5678.
        Process 5678 waits for RowShareLock on transaction 1234;
        blocked by process 1234.
Hint: See server log for query details.

Yang membuatnya menjengkelkan: error ini sering muncul tanpa perubahan kode apa pun. Kode sudah lama berjalan mulus, lalu tiba-tiba beberapa request gagal di jam sibuk. Article ini menjelaskan apa yang sebenarnya terjadi, bagaimana database menanganinya, dan bagaimana mencegahnya.

Kondisi awal yang perlu dipahami ​

Untuk mengikuti article ini, cukup dua konsep dasar:

  • Transaction adalah rangkaian statement SQL yang selesai bersama (COMMIT) atau batal bersama (ROLLBACK).
  • Row lock: ketika transaction mengubah atau mengunci satu baris, baris itu dibekukan untuk transaction lain sampai transaction pemiliknya selesai. Lock ini bekerja per baris, bukan sekali untuk semua baris yang akan disentuh transaction.

Sifat per-baris itulah akar masalahnya. Satu transaction bisa memegang lock pada beberapa baris secara bertahap, satu per satu, seiring eksekusi statement.

Apa itu deadlock? ​

Deadlock terjadi ketika dua transaction (atau lebih) saling menunggu lock yang dipegang satu sama lain, sehingga tidak ada satu pun yang bisa maju.

Analogi paling mudah: perempatan buntu empat arah di mana empat mobil dari empat arah masuk bersamaan dan saling mendahului. Mobil A menunggu mobil B lewat, mobil B menunggu mobil C, dan seterusnya. Tidak ada yang bergerak, semua menunggu selamanya.

Berbeda dengan antrean biasa, deadlock tidak bisa selesai dengan menunggu. Salah satu pihak harus mengalah.

Rekonstruksi kasus: dua transfer berlawanan arah ​

Mari kita bangun deadlock dari nol. Ada dua akun di tabel accounts: ACC-001 dan ACC-002.

  • Transaction 1: mengirim Rp100.000 dari ACC-001 ke ACC-002. Urutan statementnya: kurangi saldo ACC-001 dulu, lalu tambah saldo ACC-002.
  • Transaction 2: mengirim Rp50.000 dari ACC-002 ke ACC-001. Urutan statementnya: kurangi saldo ACC-002 dulu, lalu tambah saldo ACC-001.

Kedua transaction ini sah secara bisnis dan sah secara SQL. Masalahnya hanya urutan baris yang dikunci:

sql
-- Transaction 1
BEGIN;
UPDATE accounts SET balance = balance - 100000 WHERE account_id = 'ACC-001'; -- lock ACC-001
UPDATE accounts SET balance = balance + 100000 WHERE account_id = 'ACC-002'; -- butuh lock ACC-002

-- Transaction 2 (dijalankan hampir bersamaan)
BEGIN;
UPDATE accounts SET balance = balance - 50000 WHERE account_id = 'ACC-002';  -- lock ACC-002
UPDATE accounts SET balance = balance + 50000 WHERE account_id = 'ACC-001';  -- butuh lock ACC-001

Berikut timeline kejadiannya, langkah demi langkah:

Loading diagram...

Membaca timeline di atas:

  1. Transaction 1 mengunci baris ACC-001 saat UPDATE pertamanya dieksekusi.
  2. Transaction 2, yang berjalan hampir bersamaan, mengunci baris ACC-002.
  3. Transaction 1 melanjutkan ke statement kedua dan ingin mengunci ACC-002. Baris itu masih dipegang Transaction 2, jadi Transaction 1 mengantre.
  4. Transaction 2 melanjutkan ke statement keduanya dan ingin mengunci ACC-001. Baris itu dipegang Transaction 1, yang kebetulan sedang mengantre menunggu Transaction 2.
  5. Kedua transaction sekarang saling menunggu. Tidak ada yang bisa maju sebelum pihak lain melepas lock, dan lock tidak akan lepas sebelum masing-masing selesai. Inilah lingkaran tunggu yang disebut deadlock.

Bagaimana database menanganinya? ​

Menunggu selamanya jelas bukan pilihan. Database relasional modern punya deadlock detection yang berjalan otomatis:

  1. Database memantau hubungan antar transaction yang sedang menunggu lock (wait-for graph): T1 menunggu T2, T2 menunggu T1.
  2. Saat hubungan tunggu membentuk lingkaran, deadlock terdeteksi.
  3. Database memilih satu transaction sebagai victim, melakukan ROLLBACK otomatis atas transaction itu, dan melepas semua lock-nya.
  4. Transaction yang di-rollback menerima error deadlock detected. Transaction satunya kini bebas melanjutkan dan selesai dengan sukses.

Karena rollback-nya bersih (semua perubahan victim dibatalkan sejak BEGIN), transaction yang kena deadlock boleh langsung di-retry oleh aplikasi. Percobaan kedua biasanya berhasil, karena transaction lawan sudah selesai dan lock-nya sudah lepas.

Konsep Dasar

Deadlock adalah kondisi yang database tangani sendiri, bukan hang permanen. Dampak praktisnya di aplikasi: beberapa request gagal dengan error yang bisa di-retry. Masalah baru muncul kalau aplikasi tidak menangani error itu dan menganggapnya kegagalan permanen.

Solusi: menghindari deadlock ​

Deadlock dapat dicegah karena penyebabnya selalu satu pola: dua transaction mengunci baris yang sama dengan urutan berbeda. Solusinya pun mengarah ke sana.

1. Lock semua baris di awal dengan urutan konsisten ​

Alih-alih mengunci baris satu per satu seiring eksekusi statement, kunci semua baris yang akan disentuh dalam satu query di awal, dengan urutan yang tetap:

sql
BEGIN;

-- Lock kedua akun sekaligus, selalu dalam urutan account_id
SELECT balance
FROM accounts
WHERE account_id IN ('ACC-001', 'ACC-002')
ORDER BY account_id
FOR UPDATE;

UPDATE accounts SET balance = balance - 100000 WHERE account_id = 'ACC-001';
UPDATE accounts SET balance = balance + 100000 WHERE account_id = 'ACC-002';

COMMIT;

Dengan pola ini, kedua transaction sama-sama mengunci ACC-001 lebih dulu. Yang datang kedua akan mengantre di ACC-001 sejak awal, tidak pernah sempat memegang ACC-002 dengan pola terbalik. Lingkaran tunggu tidak mungkin terbentuk, karena urutan lock yang diambil semua transaction selalu sama.

2. Jaga transaction tetap pendek ​

Makin lama sebuah transaction memegang lock, makin besar jendela waktu bagi transaction lain untuk masuk dan membentuk pola tunggu yang bertabrakan. Jangan menggantung transaction sambil menunggu proses lambat di aplikasi (HTTP call ke service lain, pemrosesan file), dan jangan memasukkan operasi yang tidak perlu ke dalam blok transaction.

3. Tangkap error deadlock dan retry ​

Untuk sistem dengan concurrency tinggi, kadang deadlock tetap bisa terjadi walau desain sudah rapi. Tangani errornya secara eksplisit dan ulangi transaction-nya:

javascript
async function executeWithRetry(fn, maxAttempts = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await fn();
    } catch (error) {
      // Hanya retry error deadlock, biarkan error lain naik ke atas
      if (error.code === "40P01") {
        lastError = error;
        // Backoff sederhana sebelum percobaan berikutnya
        await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
        continue;
      }
      throw error;
    }
  }

  throw lastError;
}

Kode error 40P01 adalah kode SQLSTATE untuk deadlock di PostgreSQL. Rollback dari deadlock bersih, jadi percobaan ulang berjalan dari awal dengan kondisi data yang konsisten.

Contoh kasus lain: batch worker dengan urutan berbeda ​

Deadlock tidak hanya terjadi di kasus transfer uang. Pola yang sama sering muncul di batch job, dan inilah kasus yang paling sering ditemui di pekerjaan.

Bayangkan dua worker yang memproses update invoice dari antrean:

  • Job A mengupdate invoice ID 1, lalu 2, lalu 3.
  • Job B mengupdate invoice ID 3, lalu 2, lalu 1.

Tidak ada uang yang berpindah, tidak ada bug di kode masing-masing worker, tetapi keduanya bisa saling menunggu: Job A memegang lock invoice 1 sambil ingin update 3, Job B memegang lock invoice 3 sambil ingin update 1.

Penyebabnya identik dengan kasus transfer: dataset yang sama, urutan lock yang berbeda. Solusinya juga sama:

  • Urutkan baris yang akan diupdate sebelum memproses, misalnya ORDER BY id.
  • Proses dalam chunk kecil dan commit per chunk, agar satu worker tidak memegang banyak lock dalam waktu lama.

Rangkuman ​

  • Deadlock terjadi ketika dua transaction saling menunggu lock yang dipegang satu sama lain. Tidak ada yang bisa maju dengan menunggu.
  • Penyebab khasnya: transaction mengunci baris per baris secara bertahap dengan urutan berbeda, seperti dua transfer berlawanan arah antara ACC-001 dan ACC-002.
  • Database modern mendeteksi deadlock secara otomatis, memilih satu victim, dan melakukan rollback dengan error deadlock detected. Transaction yang di-rollback boleh di-retry.
  • Pencegahan utamanya: lock semua baris di awal dalam satu SELECT ... FOR UPDATE dengan ORDER BY, jaga transaction tetap pendek, dan tangkap error deadlock untuk di-retry.
  • Pola yang sama muncul di batch worker yang mengupdate dataset yang sama dengan urutan berbeda. Solusinya tetap: urutan lock yang konsisten dan chunk kecil.

References ​