Pengecualian django.db.utils.OperationalError: deadlock detected terjadi ketika dua transaksi konkuren saling menunggu pelepasan kunci (lock) yang dipegang oleh transaksi lawan. Di PostgreSQL, kondisi ini memicu intervensi deadlock detector yang otomatis membatalkan salah satu transaksi untuk memutus siklus saling tunggu (circular wait).

Masalah ini umum ditemukan pada endpoint pembayaran, reservasi tiket, atau mutasi inventaris saat beban konkurensi tinggi. Solusi definitifnya bukan memperbesar timeout database, melainkan menegakkan urutan penguncian baris yang deterministik di tingkat aplikasi.

Membaca Log Deadlock PostgreSQL

Saat PostgreSQL mendeteksi siklus deadlock, engine menuliskan graf dependensi transaksi ke log server. Membaca pesan ini menentukan baris data dan query yang terlibat:

LOG:  deadlock detected
DETAIL:  Process 24102 waits for ShareLock on transaction 891204; blocked by process 24103.
Process 24103 waits for ExclusiveLock on tuple (0,14) of relation "wallets_wallet" of database "production_db"; blocked by process 24102.
PROCESS 24102: SELECT * FROM wallets_wallet WHERE id IN (5, 2) FOR UPDATE
PROCESS 24103: SELECT * FROM wallets_wallet WHERE id IN (2, 5) FOR UPDATE

Komponen krusial dalam log tersebut:

  • Process IDs: PID sistem operasi PostgreSQL yang bersaing (PID 24102 vs PID 24103).
  • Lock target: Target seperti tuple (0,14) of relation "wallets_wallet" merujuk pada blok fisik tabel dan nomor offset baris spesifik yang terkunci.
  • Query buffer: Menampilkan query SQL mentah yang dieksekusi masing-masing transaksi saat deadlock terjadi.

Akar Masalah: Penguncian Non-Deterministik

Secara teori sistem operasi (Kondisi Coffman), deadlock memerlukan empat prasyarat: mutual exclusion, hold and wait, no preemption, dan circular wait. Menghilangkan circular wait adalah cara paling efektif dalam database relasional.

Perhatikan alur transfer saldo antar dua dompet: Dompet A (ID: 2) dan Dompet B (ID: 5).

  • Transaksi 1 (A kirim ke B): Mengunci ID 2, lalu berusaha mengunci ID 5.
  • Transaksi 2 (B kirim ke A): Mengunci ID 5, lalu berusaha mengunci ID 2.

Jika kedua query select_for_update() berjalan bersamaan, Transaksi 1 memegang kunci ID 2 dan menunggu ID 5. Transaksi 2 memegang ID 5 dan menunggu ID 2. Siklus terbentuk.

Masalah ini juga kerap muncul pada klausa filter(id__in=[...]).select_for_update(). Tanpa pengurutan eksplisit, urutan baris yang dikunci mengikuti urutan scanning engine PostgreSQL (biasanya sequential scan atau index scan), yang tidak menjamin urutan deterministik saat dipanggil dengan kombinasi ID acak.

Solusi Utama: Deterministic Row Ordering

Pencegahan deadlock paling fundamental adalah memastikan semua transaksi mengunci baris dalam urutan global yang sama, misalnya selalu urut menaik berdasarkan id (Primary Key).

Kode Bermasalah (Sebelum)

from django.db import transaction
from .models import Wallet

def transfer_funds(sender_id, recipient_id, amount):
    with transaction.atomic():
        # Urutan lock bergantung pada argumen fungsi (bisa 2 lalu 5, atau 5 lalu 2)
        sender = Wallet.objects.select_for_update().get(id=sender_id)
        recipient = Wallet.objects.select_for_update().get(id=recipient_id)
        
        sender.balance -= amount
        recipient.balance += amount
        sender.save(update_fields=['balance'])
        recipient.save(update_fields=['balance'])

Kode Diperbaiki (Sesudah)

from django.db import transaction
from .models import Wallet

def transfer_funds(sender_id, recipient_id, amount):
    with transaction.atomic():
        # Ambil kedua akun dengan satu query yang diurutkan secara deterministik
        wallets = list(
            Wallet.objects.filter(id__in=[sender_id, recipient_id])
            .order_by('id')
            .select_for_update()
        )
        
        wallet_map = {w.id: w for w in wallets}
        sender = wallet_map[sender_id]
        recipient = wallet_map[recipient_id]
        
        if sender.balance < amount:
            raise ValueError("Saldo tidak mencukupi")
            
        sender.balance -= amount
        recipient.balance += amount
        sender.save(update_fields=['balance'])
        recipient.save(update_fields=['balance'])

Dengan .order_by('id'), baris ID 2 akan selalu dikunci sebelum baris ID 5, terlepas dari akun mana yang bertindak sebagai pengirim atau penerima.

Evaluasi Parameter nowait dan skip_locked

Django ORM menyediakan parameter tambahan pada select_for_update():

1. nowait=True

Secara default, select_for_update() akan memblokir eksekusi thread sampai lock yang bersangkutan dilepas transaksi lain. Mengaktifkan nowait=True mengubah perilaku ini menjadi fail-fast. Database langsung melempar OperationalError jika baris sedang terkunci oleh transaksi lain tanpa menunggu.

# Langsung error jika baris sedang terkunci, mencegah worker hanging
Wallet.objects.select_for_update(nowait=True).filter(id=wallet_id)

2. skip_locked=True

Melewati baris-baris yang sedang terkunci dan hanya mengembalikan baris yang bebas. Opsi ini tidak boleh digunakan untuk transaksi perbankan/saldo karena akan mengabaikan akun yang aktif bertransaksi. Opsi ini didesain khusus untuk pola antrean tugas (task queue atau job processing).

# Tepat untuk worker antrean pemrosesan notifikasi/pesanan
pending_task = Task.objects.select_for_update(skip_locked=True).filter(status='PENDING').first()

Menangani Transient Deadlock dengan Retry Decorator

Meskipun aplikasi sudah menerapkan pengurutan deterministik pada baris utama, deadlock masih bisa dipicu oleh lock sekunder—seperti foreign key check, table-level lock sementara, atau index splits. Untuk menangani kegagalan sementara (transient failures) ini, gunakan decorator retry dengan exponential backoff dan full jitter.

import time
import random
import logging
from functools import wraps
from django.db import OperationalError

logger = logging.getLogger(__name__)

def retry_on_deadlock(max_retries=3, base_delay=0.05, max_delay=0.5):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            attempts = 0
            while True:
                try:
                    return func(*args, **kwargs)
                except OperationalError as exc:
                    attempts += 1
                    # Error code PostgreSQL 40P01 adalah deadlock_detected
                    is_deadlock = 'deadlock detected' in str(exc).lower()
                    if not is_deadlock or attempts > max_retries:
                        raise
                    
                    # Exponential backoff + full jitter
                    sleep_limit = min(max_delay, base_delay * (2 ** attempts))
                    sleep_duration = random.uniform(0, sleep_limit)
                    logger.warning(f"Deadlock terdeteksi. Mencoba ulang ({attempts}/{max_retries}) dalam {sleep_duration:.3f}s")
                    time.sleep(sleep_duration)
        return wrapper
    return decorator

Pengujian Verifikasi Konkurensi

Validasi perbaikan menggunakan skrip threading untuk mensimulasikan dua worker yang saling menukar saldo pada waktu yang bersamaan:

import concurrent.futures
from django.test import TransactionTestCase
from .models import Wallet
from .services import transfer_funds

class ConcurrencyLockTestCase(TransactionTestCase):
    def setUp(self):
        self.w1 = Wallet.objects.create(id=1, balance=1000)
        self.w2 = Wallet.objects.create(id=2, balance=1000)

    def test_concurrent_transfers_no_deadlock(self):
        def task_a():
            transfer_funds(sender_id=1, recipient_id=2, amount=100)

        def task_b():
            transfer_funds(sender_id=2, recipient_id=1, amount=50)

        with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
            future_a = executor.submit(task_a)
            future_b = executor.submit(task_b)
            
            # Pastikan kedua worker selesai tanpa exception OperationalError
            future_a.result()
            future_b.result()

        self.w1.refresh_from_db()
        self.w2.refresh_from_db()
        self.assertEqual(self.w1.balance, 950)
        self.assertEqual(self.w2.balance, 1050)

Catatan: Pengujian transaksi konkuren wajib menggunakan django.test.TransactionTestCase, bukan TestCase. TestCase standar membungkus seluruh test method dalam satu transaksi global yang menghalangi simulasi multi-koneksi nyata ke database.