beginTransaction(), commit(), dan rollBack() sudah cukup untuk transaksi sederhana, tetapi aplikasi nyata menuntut lebih: membatalkan hanya sebagian perubahan, membungkus transaksi dalam API yang aman dipakai berulang, dan memahami apa yang terjadi ketika banyak koneksi memperebutkan data yang sama. Pada chapter ini kita naik satu tingkat dari chapter Transactions dengan tiga alat tambahan — savepoint, helper berbasis callback, dan simulasi transaksi bersarang — lalu menutup dengan konsep isolation level, deadlock, dan kebiasaan menjaga transaksi tetap pendek. Seluruh contoh tetap berjalan pada SQLite in-memory.

Savepoint: Membatalkan Sebagian Transaksi #

Kadang satu transaksi besar berisi tahapan yang boleh gagal sendiri-sendiri. Savepoint menandai titik tengah di dalam transaksi; ROLLBACK TO membatalkan perubahan setelah titik itu tanpa membuang perubahan sebelumnya.

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, nama TEXT, total INTEGER)");

$pdo->beginTransaction();

$pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-001', 150000)");

$pdo->exec("SAVEPOINT item_kedua");
$pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-002', 275000)");
$pdo->exec("ROLLBACK TO item_kedua");
$pdo->exec("RELEASE item_kedua");

$pdo->commit();

foreach ($pdo->query("SELECT nama, total FROM pesanan", PDO::FETCH_ASSOC) as $row) {
    echo "{$row['nama']}: Rp{$row['total']}\n";
}

Output yang Dihasilkan:

text
PSN-001: Rp150000

Urutan peristiwanya begini:

  1. SAVEPOINT item_kedua menandai posisi setelah PSN-001 tersimpan di dalam transaksi.
  2. PSN-002 disisipkan setelah tanda tersebut.
  3. ROLLBACK TO item_kedua memundurkan transaksi ke tanda — PSN-002 hilang, tetapi PSN-001 selamat.
  4. RELEASE item_kedua menghapus tandanya (transaksi tetap berjalan), lalu commit() mengukuhkan hasil akhirnya.

Tanpa savepoint, satu-satunya pilihan adalah rollBack() penuh yang membuang PSN-001 juga. Nama savepoint bebas Anda pilih; gunakan nama yang deskriptif karena database besar memperbolehkan banyak savepoint hidup bersamaan.

Helper Transaksi Berbasis Callback #

Menulis beginTransaction()-try-commit-catch-rollBack di setiap tempat mudah salah — lupa satu rollBack dan koneksi bisa menggantung dengan transaksi terbuka. Solusinya adalah membungkus pola tersebut menjadi satu fungsi yang menerima callback:

php
<?php
function withTransaction(PDO $pdo, callable $callback): mixed
{
    if ($pdo->inTransaction()) {
        throw new RuntimeException("transaksi sudah aktif");
    }

    $pdo->beginTransaction();
    try {
        $hasil = $callback($pdo);
        $pdo->commit();
        return $hasil;
    } catch (Throwable $e) {
        if ($pdo->inTransaction()) {
            $pdo->rollBack();
        }
        throw $e;
    }
}

$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, nama TEXT, total INTEGER)");

$hasil = withTransaction($pdo, function (PDO $pdo): int {
    $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-001', 150000)");
    $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-002', 275000)");
    return (int) $pdo->query("SELECT COUNT(*) FROM pesanan")->fetchColumn();
});
echo "commit sukses, pesanan: $hasil\n";

try {
    withTransaction($pdo, function (PDO $pdo): void {
        $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-003', 99000)");
        throw new RuntimeException("stok habis, batalkan PSN-003");
    });
} catch (RuntimeException $e) {
    echo "ditangani: {$e->getMessage()}\n";
}

echo "pesanan akhir: " . $pdo->query("SELECT COUNT(*) FROM pesanan")->fetchColumn() . "\n";

Output yang Dihasilkan:

text
commit sukses, pesanan: 2
ditangani: stok habis, batalkan PSN-003
pesanan akhir: 2

Helper ini menjamin tiga hal setiap kali dipakai. Transaksi selalu ditutup — sukses lewat commit(), gagal lewat rollBack() di dalam catch. Exception dari callback diteruskan ke pemanggil (throw $e) sehingga kode di atas tetap bebas menanganinya dengan try/catch biasa. Terakhir, callback menerima objek $pdo secara eksplisit, sehingga mudah diganti dengan tiruan (mock) saat pengujian.

Simulasi Transaksi Bersarang #

PDO melempar exception jika beginTransaction() dipanggil saat transaksi masih aktif — PDO memang tidak mendukung transaksi bersarang sungguhan. Namun dengan savepoint, helper di atas bisa diperluas agar pemanggilan bersarang tampak bekerja: lapisan terdalam memakai savepoint, lapisan terluar memakai transaksi sungguhan:

php
<?php
function transaksi(PDO $pdo, callable $callback): mixed
{
    static $kedalaman = 0;

    if ($pdo->inTransaction()) {
        $kedalaman++;
        $savepoint = "sp_{$kedalaman}";
        $pdo->exec("SAVEPOINT {$savepoint}");
        try {
            $hasil = $callback($pdo);
            $pdo->exec("RELEASE {$savepoint}");
            return $hasil;
        } catch (Throwable $e) {
            $pdo->exec("ROLLBACK TO {$savepoint}");
            $pdo->exec("RELEASE {$savepoint}");
            throw $e;
        }
    }

    $pdo->beginTransaction();
    try {
        $hasil = $callback($pdo);
        $pdo->commit();
        return $hasil;
    } catch (Throwable $e) {
        $pdo->rollBack();
        throw $e;
    }
}

$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, nama TEXT, total INTEGER)");

transaksi($pdo, function (PDO $pdo): void {
    $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-001', 150000)");

    try {
        transaksi($pdo, function (PDO $pdo): void {
            $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-002', 275000)");
            throw new RuntimeException("pembayaran PSN-002 gagal");
        });
    } catch (RuntimeException $e) {
        echo "PSN-002 dibatalkan, proses utama berlanjut\n";
    }

    $pdo->exec("INSERT INTO pesanan (nama, total) VALUES ('PSN-003', 50000)");
});

foreach ($pdo->query("SELECT nama FROM pesanan ORDER BY id")->fetchAll(PDO::FETCH_COLUMN) as $nama) {
    echo "$nama\n";
}

Output yang Dihasilkan:

text
PSN-002 dibatalkan, proses utama berlanjut
PSN-001
PSN-003

Karena transaksi luar sedang aktif, pemanggilan transaksi() kedua otomatis masuk cabang savepoint: kegagalan PSN-002 hanya memundurkan ke sp_1, sedangkan PSN-001 dan PSN-003 tetap berjalan sampai commit() terluar. Inilah pola yang dipakai banyak framework — misalnya wrapper transaksi Laravel — untuk memberi pengalaman transaksi bersarang di atas PDO yang datar.

Isolation Level #

Isolation level menentukan seberapa besar satu transaksi “melihat” perubahan transaksi lain yang belum di-commit. Tiga anomali klasik yang bisa terjadi:

  • Dirty read: membaca data yang belum di-commit (bisa dibatalkan besok).
  • Non-repeatable read: satu baris terbaca berbeda antara dua SELECT dalam transaksi yang sama karena transaksi lain mengubahnya.
  • Phantom read: query kedua menampilkan baris baru yang tidak ada pada query pertama karena transaksi lain menyisipkannya.
Isolation levelDirty readNon-repeatable readPhantom read
READ UNCOMMITTEDmungkinmungkinmungkin
READ COMMITTEDtidakmungkinmungkin
REPEATABLE READtidaktidakmungkin
SERIALIZABLEtidaktidaktidak

SQLite membekukan seluruh berkas database saat ada penulis, sehingga efektifnya selalu berperilaku SERIALIZABLE dan tidak menyediakan pengaturan level. Database server umumnya bisa diatur per koneksi atau per transaksi:

php
<?php
// Contoh MySQL — butuh server MySQL yang berjalan
$pdo = new PDO(
    "mysql:host=localhost;dbname=toko;charset=utf8mb4",
    "budi",
    "YOUR_DATABASE_PASSWORD"
);
$pdo->exec("SET TRANSACTION ISOLATION LEVEL READ COMMITTED");
$pdo->beginTransaction();
// ... query di sini mendapat jaminan READ COMMITTED ...
$pdo->commit();

Default tiap database berbeda: MySQL memakai REPEATABLE READ, PostgreSQL memakai READ COMMITTED. Semakin ketat levelnya, semakin sedikit anomali yang lolos — dengan bayaran konkurensi yang semakin sempit, karena database harus menahan lock lebih lama.

Deadlock dan Persaingan Lock #

Deadlock terjadi ketika dua transaksi saling menunggu: transaksi A mengunci baris 1 lalu ingin baris 2, sementara transaksi B mengunci baris 2 lalu ingin baris 1. Database server seperti MySQL mendeteksi kondisi ini, memilih satu transaksi sebagai korban, dan membatalkannya dengan pesan semacam Deadlock found when trying to get lock; try restarting transaction — transasi pemenang lanjut seperti biasa.

Karena korban deadlock gagal bukan karena kesalahan logika Anda, penanganannya adalah mencoba ulang: bungkus transaksi dalam loop yang mengulang beberapa kali ketika exception tersebut muncul. Dua kebiasaan lain jauh lebih murah daripada retry: akses tabel dan baris dalam urutan yang konsisten di seluruh kode, dan jangan pernah menahan transaksi terbuka sambil menunggu input pengguna. SQLite luput dari skenario ini karena penulisarnya mengunci seluruh berkas database sekaligus — tidak ada dua penulis yang bisa saling mengunci.

Bahaya Transaksi yang Terlalu Panjang #

Setiap transaksi memegang lock selama hidupnya. Transaksi yang membaca banyak tabel, memanggil API eksternal, atau menunggu interaksi pengguna akan menahan lock tersebut berlama-lama; transaksi lain yang menyentuh data sama terpaksa antre, dan antrean yang menumpuk berubah menjadi aplikasi yang lambat untuk semua orang.

Praktik yang sehat: buka transaksi sesaat sebelum rangkaian query atomik dimulai, tutup segera setelah selesai, dan pindahkan validasi yang tidak butuh database ke luar transaksi. Prinsip “transaksi sependek mungkin” adalah cara termurah menjaga aplikasi tetap responsif di bawah beban tinggi.

Menguji Perilaku Transaksi #

Sifat atomicity yang dijanjikan helper dan savepoint sebaiknya memang diuji, bukan sekadar dipercaya. SQLite in-memory membuat pengujian ini murah — skema dibangun ulang tiap eksekusi tanpa menyentuh disk:

php
<?php
$pdo = new PDO("sqlite::memory:");
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec("CREATE TABLE pesanan (id INTEGER PRIMARY KEY, nama TEXT)");

$pdo->beginTransaction();
$pdo->exec("INSERT INTO pesanan (nama) VALUES ('PSN-UJI')");
$pdo->rollBack();

$jumlah = (int) $pdo->query("SELECT COUNT(*) FROM pesanan")->fetchColumn();
echo ($jumlah === 0) ? "PASS: rollback membersihkan seluruh perubahan\n" : "FAIL: data tersisa\n";

Output yang Dihasilkan:

text
PASS: rollback membersihkan seluruh perubahan

Pola pengujian seperti ini — siapkan fixture, jalankan operasi, asumsikan keadaan akhir — mudah dibawa ke PHPUnit: setiap test membuka koneksi sqlite::memory: segar, memanggil withTransaction() atau transaksi() dengan callback yang melempar exception tiruan, lalu menyatakan tabel tetap kosong. Ketika helper transaksi lulus uji semacam ini, seluruh kode yang memakainya ikut mendapat jaminan kebenaran yang sama.

Ringkasan #

Savepoint memberi granularitas pembatalan di dalam transaksi, helper withTransaction() menyeragamkan pola commit/rollback, dan trik savepoint memungkinkan simulasi transaksi bersarang di atas PDO. Pemahaman isolation level membantu memilih kompromi antara konsistensi dan konkurensi, sementara disiplin menjaga transaksi pendek adalah pertahanan pertama melawan deadlock dan antrean lock. Meski transaksi sudah rapi, query tetap bisa gagal karena berbagai alasan — dan cara PDO melaporkan kegagalan itulah topik chapter Database Errors .

Referensi #