Transaction adalah sekelompok perintah database yang jadi satu paket: semuanya berhasil, atau semuanya dibatalkan. Isolation level mengatur hal lain: kalau dua transaksi jalan bersamaan, seberapa banyak satu transaksi boleh melihat pekerjaan transaksi lainnya. Bayangin dua kasir Kedai Senja yang mencatat stok di satu buku yang sama. Kalau keduanya membaca angka yang sama lalu sama-sama menulis ulang, salah satu catatan bisa tertimpa. Isolation level adalah aturan pakai buku itu bersama.
🧪 Tebakan cepat: stok Kopi Susu tinggal 10 cup. Kasir A dan kasir B masing-masing membaca stok (10), lalu menulis 10 dikurangi 1, yaitu 9, dan COMMIT. Dua cup terjual. Stok akhirnya berapa: 8, 9, atau error? Tebak dulu, jawabannya ada di bagian "Lost update".
Transaksi dan ACID
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders (customer_id, total) VALUES (1, 18000);
COMMIT; -- atau ROLLBACK; untuk membatalkan semuanya
Transaksi yang baik punya empat sifat, disingkat ACID:
- Atomicity: semua perintah berhasil, atau semua dibatalkan. Stok gak berkurang tanpa pesanannya.
- Consistency: aturan database (constraint) tetap terjaga sebelum dan sesudah transaksi.
- Isolation: transaksi yang jalan bersamaan gak saling mengacaukan. Seberapa ketat, itu yang diatur isolation level.
- Durability: setelah COMMIT, datanya tetap ada walau server mati.
Di Laravel, DB::transaction(function () { ... }) menjalankan isinya dalam satu transaksi: COMMIT otomatis kalau selesai, ROLLBACK otomatis kalau ada exception.
Empat masalah saat transaksi jalan bersamaan
| Masalah | Yang terjadi | Contoh di Kedai Senja |
|---|---|---|
| Dirty read | membaca perubahan transaksi lain yang belum di-commit | A mengubah stok jadi 5 lalu ROLLBACK, tapi B sempat membaca 5 |
| Non-repeatable read | membaca baris yang sama dua kali, hasilnya beda | B membaca stok 10, A mengubahnya jadi 5 dan COMMIT, B membaca lagi: 5 |
| Phantom read | query yang sama mengembalikan jumlah baris yang beda | B menghitung pesanan Budi: 2. A menambah satu dan COMMIT. B menghitung lagi: 3 |
| Lost update | dua transaksi membaca nilai lama lalu sama-sama menimpa | dua cup terjual, stok cuma turun 1 |
Lost update
Ini urutan kejadian di tebakan tadi, dengan isolation level bawaan PostgreSQL (READ COMMITTED):
| Langkah | Kasir A | Kasir B | stock di database |
|---|---|---|---|
| 1 | SELECT stock dapat 10 |
10 | |
| 2 | SELECT stock dapat 10 |
10 | |
| 3 | UPDATE ... SET stock = 9 |
10, perubahan A belum di-commit | |
| 4 | COMMIT |
9 | |
| 5 | UPDATE ... SET stock = 9 |
9 | |
| 6 | COMMIT |
9 |
Stok akhirnya 9, tanpa error. Itu jawaban tebakannya. Dua cup terjual, satu pengurangan hilang, karena B menghitung 9 dari angka 10 yang sudah basi.
Obatnya, jangan hitung di aplikasi. Biarkan database yang mengurangi:
UPDATE products SET stock = stock - 1 WHERE id = 1;
Di READ COMMITTED, UPDATE ini mengunci barisnya, dan transaksi kedua menunggu sampai yang pertama selesai, lalu mengurangi dari angka terbaru: stok jadi 8. Kalau aplikasi perlu membaca dulu (misalnya mengecek stok cukup), kunci barisnya saat membaca dengan SELECT stock FROM products WHERE id = 1 FOR UPDATE;.
Empat isolation level
Standar SQL mendefinisikan empat level, dari yang paling longgar ke paling ketat. Makin ketat, makin sedikit masalah, tapi makin sering transaksi saling menunggu atau dibatalkan.
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
| READ UNCOMMITTED | bisa | bisa | bisa |
| READ COMMITTED | dicegah | bisa | bisa |
| REPEATABLE READ | dicegah | dicegah | bisa |
| SERIALIZABLE | dicegah | dicegah | dicegah |
SERIALIZABLE paling ketat: hasilnya dijamin sama dengan kalau transaksi-transaksinya dijalankan satu per satu, berurutan. Dirty read cuma mungkin di READ UNCOMMITTED.
PostgreSQL dan MySQL gak sama
Tabel di atas adalah batas minimum dari standar. Tiap database mengisinya dengan caranya sendiri:
| Level | PostgreSQL | MySQL (InnoDB) |
|---|---|---|
| READ UNCOMMITTED | diperlakukan sama dengan READ COMMITTED: dirty read gak pernah terjadi | dirty read bisa terjadi |
| READ COMMITTED | default. Tiap SELECT melihat data yang sudah di-commit saat itu | sama |
| REPEATABLE READ | satu snapshot untuk seluruh transaksi, jadi phantom juga gak terjadi. Lost update dihentikan dengan ERROR: could not serialize access due to concurrent update |
default. SELECT biasa membaca snapshot, tapi UPDATE membaca angka terbaru: lost update tetap lolos |
| SERIALIZABLE | transaksi jalan tanpa saling menunggu, lalu salah satunya dibatalkan dengan error kalau hasilnya gak mungkin dicapai secara berurutan | SELECT biasa berubah jadi SELECT ... FOR SHARE: transaksi saling menunggu kunci, kadang berakhir Deadlock found when trying to get lock |
SQLite lebih sederhana: cuma satu transaksi yang boleh menulis dalam satu waktu. Penulis kedua menunggu, atau mendapat error database is locked. Hasilnya selalu serializable.
Di semua database, transaksi yang dibatalkan karena konflik (serialization error atau deadlock) harus diulang oleh aplikasi. Di Laravel, angka kedua DB::transaction() adalah jumlah percobaan:
DB::transaction(function () {
DB::table('products')->where('id', 1)->decrement('stock'); // stock = stock - 1
DB::table('orders')->insert(['customer_id' => 1, 'total' => 18000]);
}, 3); // ulangi sampai 3 kali kalau kena deadlock atau serialization error
Memilih level
| Kebutuhan | Level |
|---|---|
| kecepatan maksimal | read uncommitted, paling longgar. Jarang dipakai, dan di PostgreSQL sama saja dengan read committed |
| default, umum | read committed, default PostgreSQL dan pilihan aman untuk kebanyakan aplikasi |
| konsistensi baca | repeatable read, misalnya laporan yang membaca banyak tabel dan harus konsisten satu sama lain |
| keamanan maksimal, financial | serializable, untuk uang dan stok yang gak boleh salah, dengan percobaan ulang di aplikasi |
Cara menyetelnya: di PostgreSQL BEGIN ISOLATION LEVEL SERIALIZABLE;, di MySQL SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; sebelum START TRANSACTION;.
⚠️ Jebakan yang sering ketemu
- Membaca, menghitung di aplikasi, lalu menulis angka jadinya. Itu resep lost update. Pakai
stock = stock - 1atauSELECT ... FOR UPDATE. - Mengira semua database sama. REPEATABLE READ di MySQL masih membiarkan lost update, di PostgreSQL gak. READ UNCOMMITTED di PostgreSQL gak pernah dirty read.
- Naik ke SERIALIZABLE tanpa percobaan ulang. Level ini sengaja membatalkan transaksi yang bentrok. Tanpa retry, pembeli cuma melihat halaman error.
- Di latihan, teksnya huruf kecil.
"read uncommitted","read committed","repeatable read","serializable". Kebutuhan yang gak dikenal dijawab"read committed".
TL;DR: Transaksi menjalankan perintah sebagai satu paket (ACID), dan DB::transaction() membungkusnya di Laravel. Saat transaksi jalan bersamaan, ada empat masalah: dirty read (membaca yang belum di-commit), non-repeatable read (baris yang sama berubah), phantom read (jumlah baris berubah), dan lost update (dua penulis saling menimpa, stok 10 dikurangi dua kali jadi 9). Empat level dari longgar ke ketat: READ UNCOMMITTED, READ COMMITTED (default PostgreSQL), REPEATABLE READ (default MySQL) dan SERIALIZABLE (paling ketat, seolah berurutan). PostgreSQL dan MySQL berbeda di level yang sama, SQLite cuma punya satu penulis. Cegah lost update dengan stock = stock - 1 atau FOR UPDATE, dan ulangi transaksi yang dibatalkan karena konflik.