Pengembangan Web

Transaksi Database untuk Aplikasi Web Kecil — Mencegah Write Multi-Langkah Terhenti Separuh Jalan

Transaksi Database untuk Aplikasi Web Kecil — Mencegah Write Multi-Langkah Terhenti Separuh Jalan

Sebuah aplikasi web kecil dapat menjalankan dua statement database dengan sukses ratusan kali, tetapi tetap menyimpan celah desain yang serius. Apa yang terjadi ketika statement pertama berhasil, sedangkan statement kedua gagal? Order mungkin tersimpan tanpa itemnya, stok mungkin berkurang tanpa adanya order, atau catatan pembayaran mungkin tersimpan tanpa entri ledger yang sesuai. Setiap query bisa saja valid, sementara hasil keseluruhannya tidak.

Database transaction menangani masalah khusus ini dengan membuat batas di sekeliling pekerjaan yang saling terkait. Di dalam batas itu, aplikasi meminta database untuk membuat semua perubahan permanen secara bersama-sama atau membatalkan semuanya. Kedengarannya sederhana, tetapi memakai transaction dengan tepat memerlukan lebih dari sekadar mengapit sembarang SQL dengan beginTransaction() dan commit().

Pertanyaan yang berguna bukan "Ada berapa query?"

Transaction sering dijelaskan sebagai sekumpulan statement, tetapi jumlah statement hanya petunjuk. Pertanyaan yang lebih berguna adalah: perubahan mana yang mewakili satu keputusan bisnis?

Membuat order dan line item-nya mungkin merupakan satu keputusan. Mencadangkan stok dan mencatat pencadangan itu mungkin merupakan satu keputusan. Memperbarui profil dan menulis event analytics opsional kemungkinan bukan satu keputusan, sebab hilangnya event analytics tidak semestinya selalu menggagalkan pembaruan profil.

Tutorial transaction PostgreSQL memberikan model dasarnya: beberapa langkah menjadi satu operasi all-or-nothing, dan keadaan di antara langkah-langkah tersebut tidak ditampilkan sebagai pekerjaan yang sudah selesai. Walaupun contoh di sana memakai PostgreSQL, model dasar itu juga berlaku pada transaction di database relasional lain. Detail tiap vendor tetap berbeda, sehingga dokumentasi database dan client tetap penting.

Batas ini merupakan keputusan desain. Transaction tidak dapat menentukan operasi mana yang semestinya dikelompokkan. Aplikasi harus mendefinisikannya terlebih dahulu.

Autocommit menyembunyikan celah ant statement

Koneksi PDO biasanya berjalan dalam mode autocommit, sebagaimana dijelaskan dalam manual PHP. MariaDB juga mendokumentasikan bahwa autocommit aktif secara default. Secara praktis, statement yang berhasil menjadi permanen tanpa menunggu statement aplikasi berikutnya.

Perilaku itu masuk akal untuk satu write yang independen. Masalah muncul ketika dua atau lebih write harus terjadi seluruhnya atau tidak sama sekali. Perhatikan urutan ini tanpa transaction eksplisit:

  1. Kurangi stok yang tersedia.
  2. Buat order.
  3. Buat item order.

Jika langkah kedua atau ketiga gagal, autocommit tidak memundurkan langkah pertama. Kode aplikasi dapat mencoba update kompensasi, tetapi langkah itu menciptakan operasi lain yang juga dapat gagal serta kasus concurrency lain yang perlu dipikirkan.

Transaction eksplisit mengubah urutan tersebut. beginTransaction() memulai batas, commit() menerimanya, dan rollBack() membatalkan perubahan database yang belum di-commit. MariaDB menjelaskan operasi SQL yang setara dalam dokumentasi START TRANSACTION.

Pola PDO sederhana

Contoh berikut bersifat ilustratif, bukan kode yang diambil dari sistem production. Contoh ini mengasumsikan $pdo sebagai koneksi PDO yang sudah tersedia, tabel MariaDB terkait memakai transactional storage engine, serta ID dan quantity sudah divalidasi di batas aplikasi.

<?php
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$orderId = $validatedOrderId;
$productId = $validatedProductId;
$quantity = $validatedQuantity;

try {
    $pdo->beginTransaction();

    $reserve = $pdo->prepare(
        'UPDATE products
         SET stock = stock - :quantity
         WHERE id = :product_id AND stock >= :quantity'
    );
    $reserve->execute([
        'quantity' => $quantity,
        'product_id' => $productId,
    ]);

    if ($reserve->rowCount() !== 1) {
        throw new RuntimeException('Product unavailable or stock insufficient');
    }

    $createOrder = $pdo->prepare(
        'INSERT INTO orders (id, status) VALUES (:id, :status)'
    );
    $createOrder->execute([
        'id' => $orderId,
        'status' => 'pending',
    ]);

    $createItem = $pdo->prepare(
        'INSERT INTO order_items (order_id, product_id, quantity)
         VALUES (:order_id, :product_id, :quantity)'
    );
    $createItem->execute([
        'order_id' => $orderId,
        'product_id' => $productId,
        'quantity' => $quantity,
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    throw $error;
}

Ada dua perlindungan yang berbeda di sini. Transaction mencegah sebagian rangkaian write database menjadi permanen. Kondisi stock >= :quantity melindungi business invariant di dalam update stok itu sendiri. Jika tidak ada baris yang diperbarui, kode menolak operasi dan melakukan rollback.

Memisahkan kedua gagasan ini penting. Transaction dapat melakukan commit secara atomic terhadap perhitungan yang keliru. Atomicity berarti operasi yang dipilih tetap bersama; bukan berarti aturan yang dipilih pasti benar.

Concurrency tetap memerlukan aturan yang disengaja

Transaction kadang dianggap otomatis membuat setiap rangkaian read-then-write aman. Anggapan itu terlalu luas. Misalnya, aplikasi lebih dulu membaca nilai stok, memutuskan di PHP bahwa jumlahnya cukup, lalu mengirim update tanpa kondisi. Transaction lain dapat mengubah produk yang sama di antara kedua operasi tersebut. Hasil tepatnya bergantung pada statement, index, lock, dan isolation level.

Guarded update di atas menghindari satu race condition yang sempit dengan menempatkan pengecekan dan pengurangan dalam satu statement database. Workflow lain mungkin memerlukan locking read seperti SELECT ... FOR UPDATE, unique constraint, optimistic version checking, atau model data yang berbeda. Tool yang tepat bergantung pada invariant yang hendak dilindungi.

MariaDB mendukung beberapa isolation level dan mendokumentasikan REPEATABLE READ sebagai default InnoDB dalam referensi SET TRANSACTION. Isolation level yang terdengar lebih kuat tidak otomatis menjadi pengaturan global yang lebih baik: perubahan itu memengaruhi visibility dan perilaku locking, serta dapat mengurangi concurrency yang berguna. Lebih aman mengenali anomali yang perlu dicegah sebuah workflow sebelum mengganti level tersebut.

Hal yang tidak dapat di-rollback oleh database transaction

Batas database berakhir di database. Jika kode aplikasi mengirim email, memanggil payment API, memublikasikan message, atau menulis ke sistem independen lain sebelum commit, rollback database setelahnya tidak menarik kembali side effect eksternal tersebut.

Untuk workflow sederhana, aplikasi dapat menjalankan tindakan nonkritis hanya setelah commit berhasil. Untuk delivery yang harus bertahan dari crash dan retry, durable outbox pattern dapat mencatat message yang ingin dikirim di dalam database transaction yang sama, kemudian worker terpisah mengirimkannya. Meski begitu, aturan retry dan penanganan duplikat tetap perlu dirancang; kata "transaction" tidak membuat network call menjadi exactly-once.

Transaction juga tidak menggantikan constraint. Foreign key, unique constraint, atau CHECK constraint yang sesuai dapat melindungi data ketika write datang melalui jalur kode yang berbeda. Validasi aplikasi membantu pengguna, sedangkan database constraint mempertahankan invariant lebih dekat dengan data yang tersimpan. Lapisan-lapisan ini menangani masalah yang berkaitan, tetapi tidak sama.

Empat jebakan yang layak diperiksa

1. Jalur penyimpanan harus benar-benar mendukung transaction

PDO dapat menyediakan method transaction sementara kondisi runtime atau table engine di bawahnya mencegah perilaku rollback yang diharapkan. Manual PHP secara khusus mengingatkan bahwa pemeriksaan kemampuan PDO terjadi pada level driver. Untuk aplikasi MariaDB, pastikan setiap tabel yang terlibat dalam unit pekerjaan memakai transactional engine yang sesuai, umumnya InnoDB.

2. Perubahan schema dapat melakukan commit tanpa disadari

Jangan mencampur write request biasa dengan statement migrasi schema lalu menganggap satu rollback mencakup keduanya. MariaDB mendokumentasikan banyak statement, terutama DDL seperti CREATE TABLE, ALTER TABLE, dan DROP TABLE, yang menyebabkan implicit commit. Referensi implicit commit menjelaskan bahwa commit terjadi sebelum eksekusi untuk statement yang tercantum, sehingga statement yang gagal sekalipun mungkin sudah mengakhiri transaction sebelumnya.

3. Transaction panjang memiliki biaya operasional

Transaction biasanya sebaiknya hanya memuat pekerjaan database yang dibutuhkan invariant-nya, bukan HTTP request yang lambat, interaksi pengguna, upload file, atau komputasi yang tidak berkaitan. Membiarkan transaction terbuka dapat mempertahankan lock dan membuat pekerjaan lain menunggu. Lebih singkat bukan jaminan ajaib, tetapi batas yang sempit lebih mudah dipahami dan biasanya mengurangi contention.

4. Kepemilikan batas harus jelas

Helper level rendah yang diam-diam memulai atau melakukan commit transaction dapat berbenturan dengan transaction yang sudah dimiliki caller. PDO juga tidak menyediakan nested transaction yang portabel dan transparan. Tentukan layer aplikasi yang memiliki batas tersebut. Jika partial rollback memang dibutuhkan, pelajari perilaku savepoint database secara eksplisit alih-alih memperlakukan pemanggilan beginTransaction() berikutnya sebagai nesting.

Checklist untuk meninjau satu write path

  • Nyatakan invariant dalam satu kalimat.
  • Daftar setiap perubahan database yang dibutuhkan untuk mempertahankannya.
  • Letakkan logging atau analytics opsional di luar batas kritis, kecuali memang benar-benar diwajibkan.
  • Periksa PDO driver dan storage engine semua tabel yang terlibat.
  • Pastikan kegagalan statement terlihat sebagai exception atau return value yang diperiksa.
  • Lakukan rollback hanya ketika transaction masih aktif, lalu lemparkan kembali atau tangani kegagalan awal secara sengaja.
  • Pertimbangkan request concurrent, bukan hanya success path satu request.
  • Jauhkan network call dan side effect lain yang tidak dapat dibatalkan dari transaction yang terbuka.
  • Uji kegagalan setelah setiap statement penting di environment yang dapat dibuang.

Kesimpulan

Transaction paling berguna ketika mengekspresikan satu janji yang jelas: perubahan database ini saling terkait. Ia menutup celah partial write yang ditinggalkan autocommit, tetapi tidak memilih aturan bisnis yang benar, mengoordinasikan setiap sistem eksternal, atau menghapus keputusan tentang concurrency.

Untuk aplikasi PHP kecil, titik awal yang praktis cukup sederhana: definisikan invariant, buka transaction tepat sebelum pekerjaan database terkait, tegakkan kondisi penting sedekat mungkin dengan write, lakukan commit hanya setelah setiap statement wajib berhasil, dan rollback ketika gagal. Setelah itu, periksa apa yang masih berada di luar batas tersebut. Pertanyaan terakhir itu sering menjadi tempat munculnya masalah desain nyata berikutnya.

References