Pengembangan Web

Deadlock MariaDB pada Aplikasi PHP Kecil - Ulangi Transaksi, Bukan Query Terakhir

Deadlock MariaDB pada Aplikasi PHP Kecil - Ulangi Transaksi, Bukan Query Terakhir

Dua transaksi database dapat sama-sama valid ketika berjalan sendiri, tetapi tidak bisa melanjutkan ketika dijalankan bersamaan. Transaksi pertama memegang lock yang dibutuhkan transaksi kedua; transaksi kedua memegang lock yang dibutuhkan transaksi pertama. MariaDB kemudian mengembalikan pesan yang terdengar mengkhawatirkan: Deadlock found when trying to get lock; try restarting transaction.

Apa tepatnya yang harus diulang? Mengulang query terakhir saja dapat memisahkannya dari pemeriksaan dan penulisan yang terjadi sebelumnya. Mengulang tanpa batas dapat menyembunyikan masalah desain. Mengabaikan error berarti meninggalkan pekerjaan yang sebenarnya valid. Respons yang berguna lebih sempit: pahami siklus lock, biarkan InnoDB mengakhiri satu percobaan, lalu ulangi seluruh transaksi database hanya dalam kondisi yang terkendali.

Artikel ini berfokus pada InnoDB melalui PHP PDO. Ini bukan janji bahwa deadlock dapat dihilangkan, dan bukan ketentuan satu kebijakan retry untuk semua aplikasi.

Deadlock adalah siklus, bukan sekadar query lambat

Bayangkan dua transfer yang menyentuh baris akun 1 dan 2. Transaksi A memperbarui baris 1 lebih dahulu, lalu meminta baris 2. Hampir pada saat yang sama, transaksi B memperbarui baris 2 lebih dahulu, lalu meminta baris 1.

  1. A memegang lock untuk baris 1 dan menunggu baris 2.
  2. B memegang lock untuk baris 2 dan menunggu baris 1.
  3. Keduanya tidak dapat melepas lock pertama tanpa menyelesaikan transaksi atau melakukan rollback.

Siklus itulah deadlock. Dokumentasi InnoDB menjelaskan bahwa pola yang sama dapat melibatkan beberapa tabel, baris, index record, atau index gap. Kode aplikasi mungkin berbicara tentang baris logis, sedangkan InnoDB menerapkan row-level locking melalui index record. Karena itu, deadlock nyata dapat lebih sulit terlihat daripada sketsa dua baris ini.

Lock wait biasa berbeda. Jika A memegang lock dan B menunggunya, A mungkin melakukan commit sehingga B dapat melanjutkan. Dalam siklus deadlock, menunggu saja tidak akan menghasilkan kemajuan. MariaDB mendokumentasikan error deadlock 1213 dengan SQLSTATE 40001; lock-wait timeout adalah error terpisah, yaitu 1205 dengan SQLSTATE HY000. Keduanya tidak seharusnya digabungkan ke dalam satu aturan retry yang menangkap semua kondisi.

InnoDB memutus siklus dengan memilih korban

Ketika innodb_deadlock_detect aktif, sebagaimana pengaturan default-nya, InnoDB mendeteksi siklus dan melakukan rollback pada salah satu transaksi yang terlibat agar transaksi lain dapat melanjutkan. Transaksi yang gagal sering disebut korban. Transaksi mana yang kalah merupakan keputusan engine, bukan kontrak aplikasi, sehingga kode tidak boleh bergantung pada request tertentu yang selalu menang.

Rollback ini adalah pemulihan, bukan bukti bahwa InnoDB telah merusak data. Perubahan database dari percobaan yang gagal tidak tertinggal dalam keadaan setengah diterapkan. Namun, aplikasi tetap memiliki operasi bisnis yang belum selesai. Referensi error 1213 MariaDB secara eksplisit menyatakan untuk mencoba memulai ulang transaksi.

Kata transaksi sangat penting. Misalkan percobaan yang gagal membaca saldo, memperbarui akun pertama, menulis baris ledger, lalu mengalami deadlock pada akun kedua. Menjalankan ulang UPDATE terakhir saja akan melewati pembacaan dan penulisan sebelumnya yang telah di-rollback. Percobaan baru harus mengulangi seluruh unit kerja database terhadap keadaan yang ada saat ini.

Pola retry terbatas pada PDO

Helper berikut adalah contoh batas untuk aplikasi kecil. Ia memeriksa SQLSTATE yang disediakan oleh PDOException::getCode() sekaligus nomor error driver MariaDB di errorInfo. Ia hanya mengulangi error 1213, membatasi jumlah percobaan, dan memulai transaksi baru setiap kali.

<?php
declare(strict_types=1);

function runWithDeadlockRetry(
    PDO $pdo,
    callable $work,
    int $maxAttempts = 3
): mixed {
    if ($maxAttempts < 1) {
        throw new InvalidArgumentException('maxAttempts must be at least 1');
    }

    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        try {
            $pdo->beginTransaction();
            $result = $work($pdo);
            $pdo->commit();

            return $result;
        } catch (PDOException $error) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            $driverCode = (int) ($error->errorInfo[1] ?? 0);
            $isMariaDbDeadlock = $error->getCode() === '40001'
                && $driverCode === 1213;

            if (!$isMariaDbDeadlock || $attempt === $maxAttempts) {
                throw $error;
            }

            $delayMicroseconds = random_int(20_000, 80_000) * $attempt;
            usleep($delayMicroseconds);
        } catch (Throwable $error) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();
            }

            throw $error;
        }
    }

    throw new LogicException('Unreachable retry state');
}

Manual PHP mendokumentasikan bahwa PDOException membawa SQLSTATE dan informasi error driver, sedangkan method transaksi PDO menentukan batas begin, commit, dan rollback. Pemeriksaan ganda di atas disengaja: kelas SQLSTATE 40001 dapat mewakili kondisi rollback transaksi selain satu error driver MariaDB ini.

Tiga percobaan dan jeda acak singkat tersebut adalah contoh, bukan nilai universal yang telah diukur. Jeda terbatas dapat mengurangi tabrakan langsung, tetapi tidak memperbaiki urutan locking yang tidak konsisten. Exception terakhir tetap terlihat agar request dapat gagal secara jujur, dicatat dengan konteks yang aman, atau ditempatkan dalam alur pemulihan pada tingkat yang lebih tinggi.

Callback harus aman untuk dijalankan kembali

Rollback database tidak dapat menarik kembali email, membatalkan request HTTP, menghapus pesan yang sudah dikirimkan ke broker, atau membalikkan penulisan file sembarang. Jika $work melakukan salah satu tindakan tersebut sebelum commit, retry dapat melakukannya dua kali meskipun database berakhir dalam keadaan konsisten.

Sebisa mungkin, jaga agar callback yang dapat diulang hanya berisi pekerjaan database. Notifikasi yang tidak kritis dapat dilakukan setelah commit berhasil. Jika pengiriman harus bertahan dari crash di antara commit dan pengiriman, durable outbox dapat mencatat niat tersebut dalam transaksi database yang sama, lalu worker terpisah mengirimkannya. Worker tetap membutuhkan kebijakan penanganan duplikat; transaksi tidak mengubah jaringan menjadi pengiriman exactly-once.

Nilai input juga perlu diperhatikan. Mengulangi data request tervalidasi yang sama berbeda dari meminta waktu saat ini, membuat identifier baru, atau memakai mutable process state pada setiap percobaan. Tentukan nilai mana yang termasuk dalam operasi bisnis dan mana yang seharusnya dihitung ulang di dalam transaksi baru.

Kurangi siklus sebelum mengandalkan retry

Ambil lock dalam urutan yang konsisten

Jika setiap transfer mengunci ID akun yang lebih kecil sebelum ID yang lebih besar, dua operasi yang berlawanan akan membentuk antrean alih-alih mengambil pasangan yang sama dalam urutan terbalik. Baik panduan penanganan InnoDB maupun pembahasan deadlock PostgreSQL merekomendasikan urutan pengambilan lock yang konsisten sebagai pertahanan struktural utama.

Ini mengurangi risiko, bukan membuktikan deadlock mustahil terjadi. Query plan yang berbeda, secondary index, pemeriksaan foreign key, range lock, dan jalur penulisan lain dapat menambahkan lock yang tidak tercakup oleh aturan urutan baris sederhana. Aturan tersebut harus mencakup setiap jalur yang berpartisipasi dalam invariant yang sama.

Jaga transaksi tetap fokus dan singkat

Buka transaksi dekat dengan pekerjaan database yang dilindunginya, lalu segera lakukan commit. Jangan menunggu input pengguna, remote API, upload, atau komputasi yang tidak berkaitan sambil memegang lock. Jendela tumpang tindih yang lebih pendek memberi lebih sedikit kesempatan bagi transaksi bersamaan untuk bertabrakan, meskipun transaksi singkat tetap dapat mengalami deadlock.

Gunakan index yang sesuai dengan pernyataan locking

Index yang sesuai dapat membuat UPDATE atau locking read memeriksa dan mengunci lebih sedikit index record. Kesimpulan yang keliru adalah menganggap penambahan index apa pun akan menyembuhkan deadlock. Tinjau predicate dan plan yang sebenarnya; index mengubah record dan range yang dikunjungi engine, sehingga dapat mengubah perilaku locking dengan cara yang perlu diuji.

Baca laporan deadlock, bukan hanya exception

SHOW ENGINE INNODB STATUS menyertakan bagian LATEST DETECTED DEADLOCK setelah deadlock terjadi. Bagian ini dapat menampilkan transaksi, statement, lock yang dipegang, lock yang diminta, serta transaksi yang di-rollback. Bacalah sebagai siklus tunggu:

  • Statement mana yang terlibat?
  • Index dan record mana yang terlibat?
  • Apa yang sudah dipegang oleh setiap transaksi?
  • Apa yang diminta setiap transaksi berikutnya?
  • Bisakah kedua jalur kode mengambil resource tersebut dalam urutan yang sama?

Command itu menampilkan deadlock terdeteksi terbaru, bukan riwayat permanen setiap insiden. MariaDB juga mendokumentasikan innodb_print_all_deadlocks untuk menulis semua detail deadlock ke server error log. Aktifkan logging yang lebih luas dengan sengaja, lindungi log yang dihasilkan karena statement dapat memuat konteks sensitif, dan jangan menjadikan logging verbose permanen sebagai perbaikan pertama.

Respons yang tampak menarik tetapi memperburuk masalah

  • Mengulang statement yang gagal saja: bagian lain dari transaksi korban sudah di-rollback, sehingga statement tersebut tidak lagi memiliki konteks transaksi semula.
  • Mengulangi setiap PDOException: syntax error, constraint violation, kegagalan autentikasi, dan banyak error lain tidak menjadi benar hanya karena diulang.
  • Mengulang tanpa batas: bug urutan lock yang menetap dapat menghabiskan worker dan meningkatkan contention alih-alih menampilkan kegagalan.
  • Menganggap satu request selalu menang: pemilihan korban bukan aturan bisnis yang stabil.
  • Menonaktifkan deteksi deadlock sembarangan: MariaDB mendokumentasikannya sebagai tradeoff khusus untuk workload high-concurrency tertentu; ketika deteksi nonaktif, server mengandalkan lock-wait timeout. Ini bukan perbaikan aplikasi biasa.
  • Menyembunyikan deadlock yang sering terjadi di balik retry berhasil: retry menjaga availability untuk tabrakan sesekali, tetapi pola yang meningkat atau terkonsentrasi tetap perlu didiagnosis.

Urutan review yang praktis

  1. Konfirmasikan error melalui SQLSTATE dan driver code, bukan dengan mencocokkan teks pesan.
  2. Catat identifier request dan operasi yang aman, tetapi jangan mencatat credential atau bound value yang sensitif.
  3. Periksa laporan deadlock terbaru dan gambarkan urutan lock untuk setiap peserta.
  4. Buat urutan resource konsisten di seluruh jalur kode jika memungkinkan.
  5. Pindahkan pekerjaan non-database yang lambat atau tidak dapat dibalik ke luar transaksi yang terbuka.
  6. Tinjau predicate dan index yang menentukan berapa banyak record atau range yang disentuh.
  7. Tambahkan retry seluruh transaksi yang terbatas untuk kondisi spesifik yang dapat diulang.
  8. Pantau retry yang kehabisan percobaan secara terpisah dari percobaan yang berhasil dipulihkan agar retry tidak menghapus sinyal.

Kesimpulan

Deadlock bukan berarti konsep transaksi telah gagal. Ia berarti transaksi yang berjalan bersamaan membentuk siklus lock, sehingga database harus mengakhiri satu percobaan agar proses dapat berlanjut. Jawaban di sisi aplikasi adalah mengenali kondisi yang tepat, memulai ulang seluruh transaksi database dengan batas yang jelas, dan menjaga side effect yang tidak dapat dibalik tetap berada di luar batas retry tersebut.

Retry menangani race yang sesekali terjadi. Urutan lock yang konsisten, batas transaksi yang sempit, index yang sesuai, dan laporan deadlock menangani struktur yang menghasilkannya. Target yang jujur bukan deadlock tidak akan pernah terjadi, melainkan deadlock yang jarang dapat dipulihkan, sedangkan pola berulang tetap cukup terlihat untuk diperbaiki.

References