Transaksi adalah janji “semua atau tidak sama sekali” — dan janji ini layak diuji sama ketatnya seperti constraint. Aplikasi yang salah urut COMMIT akan menyimpan setengah pesanan; laporan yang membaca data yang belum di-commit menghitung angka hantu; batch yang gagal di tengah jalan meninggalkan jejak setengah jadi. Kabar baiknya: sebagian besar perilaku transaksi bisa diuji dari satu sesi psql dengan pola yang deterministik penuh. Chapter ini menguji rollback, commit, savepoint, dan constraint yang ditunda pemeriksaannya hingga COMMIT — lalu membahas dua hal yang memang butuh dua terminal berdampingan: isolasi dan anomali konkurensi.

Apa yang Bisa Diuji dari Satu Sesi #

Gulung-balik, keawetan lintas sesi, dan pemulihan parsial lewat savepoint semuanya teramati dari urutan perintah yang tetap: catat keadaan awal → jalankan transaksi → bandingkan hasilnya dengan keadaan awal. Polanya sama dengan pengujian query di chapter sebelumnya — hanya “query”-nya kini berupa urutan BEGIN ... ROLLBACK/COMMIT. Yang tidak bisa diuji dari satu sesi adalah interaksi antar transaksi (nonrepeatable read, lost update), karena butuh dua koneksi yang hidup bersamaan — protokolnya dibahas di bagian akhir.

Rollback Memulihkan Keadaan Semula #

Uji pertama adalah yang paling mendasar: BEGIN, ubah data, ROLLBACK, lalu pastikan data kembali identik. Perhatikan bahwa di dalam transaksi, sesi kita sendiri sudah melihat perubahan — pembanding “sebelum” dan “sesudah” harus diambil di posisi yang tepat:

-- Uji rollback: BEGIN -> ubah data -> ROLLBACK -> data harus pulih sepenuhnya.
CREATE TABLE stok_barang (
    kode text PRIMARY KEY,
    nama text NOT NULL,
    stok integer NOT NULL
);

INSERT INTO stok_barang VALUES
    ('STK-1', 'Beras 5kg',         40),
    ('STK-2', 'Minyak Goreng 1L',  25);

-- Keadaan awal (baseline untuk pembanding).
SELECT kode, stok FROM stok_barang ORDER BY kode;

BEGIN;
UPDATE stok_barang SET stok = stok - 10 WHERE kode = 'STK-1';

-- Di DALAM transaksi, perubahan sudah terlihat oleh sesi kita sendiri.
SELECT kode, stok FROM stok_barang ORDER BY kode;

ROLLBACK;

-- Setelah ROLLBACK, keadaan harus identik dengan baseline.
SELECT kode, stok FROM stok_barang ORDER BY kode;

SELECT CASE
         WHEN (SELECT stok FROM stok_barang WHERE kode = 'STK-1') = 40
         THEN 'LULUS: rollback memulihkan stok ke nilai awal 40'
         ELSE 'GAGAL: ekspektasi 40, aktual '
              || (SELECT stok FROM stok_barang WHERE kode = 'STK-1')::text
       END AS uji_rollback;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 2
 kode  | stok 
-------+------
 STK-1 |   40
 STK-2 |   25
(2 rows)

BEGIN
UPDATE 1
 kode  | stok 
-------+------
 STK-1 |   30
 STK-2 |   25
(2 rows)

ROLLBACK
 kode  | stok 
-------+------
 STK-1 |   40
 STK-2 |   25
(2 rows)

                   uji_rollback                   
--------------------------------------------------
 LULUS: rollback memulihkan stok ke nilai awal 40
(1 row)

Tiga SELECT berurutan memperlihatkan tiga momen: stok 40 (baseline), 30 (di dalam transaksi), lalu 40 lagi setelah ROLLBACK. Pernyataan terakhir menerjemahkan pembandingan menjadi verdict — bila stok tidak kembali ke 40, transaksi di lingkungan Anda bocor, dan itu bug kelas berat.

Commit Bertahan, Sesi yang Terputus Digulung Balik #

Menguji COMMIT membutuhkan sudut pandang orang lain: data yang di-commit harus terlihat oleh sesi berbeda, dan data yang tidak di-commit harus lenyap saat sesi penulisnya mati. Skrip berikut mensimulasikan keduanya dengan tiga pemanggilan psql — tiap pemanggilan adalah koneksi baru:

bash uji-commit.sh
#!/usr/bin/env bash
# Uji commit dan batalkan-otomatis: dua pemanggilan psql = dua sesi terpisah.
# Sesi 1 melakukan sesuatu lalu ditutup; sesi 2 memeriksa apa yang bertahan.

echo '==> sesi 1: transaksi di-COMMIT'
psql -X -q <<'SQL' 2>&1
BEGIN;
INSERT INTO stok_barang VALUES ('STK-3', 'Gula Pasir 1kg', 30);
COMMIT;
SQL

echo '==> sesi 1 (lagi): BEGIN + INSERT, lalu sesi ditutup TANPA COMMIT'
psql -X -q <<'SQL' 2>&1
BEGIN;
INSERT INTO stok_barang VALUES ('STK-4', 'Teh Kotak', 50);
SELECT count(*) AS baris_terlihat_sesi_ini FROM stok_barang;
SQL

echo '==> sesi 2: memeriksa hasil kedua transaksi di atas'
psql -X -t -A <<'SQL'
SELECT 'STK-3 (di-commit)   : ' || count(*) || ' baris'
FROM stok_barang WHERE kode = 'STK-3';
SELECT 'STK-4 (tanpa commit): ' || count(*) || ' baris'
FROM stok_barang WHERE kode = 'STK-4';
SQL

Output yang Dihasilkan:

text
==> sesi 1: transaksi di-COMMIT
==> sesi 1 (lagi): BEGIN + INSERT, lalu sesi ditutup TANPA COMMIT
 baris_terlihat_sesi_ini 
-------------------------
                       4
(1 row)

==> sesi 2: memeriksa hasil kedua transaksi di atas
STK-3 (di-commit)   : 1 baris
STK-4 (tanpa commit): 0 baris

Dua perilaku penting terbukti sekaligus. Pertama, STK-3 yang di-commit terlihat oleh sesi berikutnya — COMMIT benar-benar mengawetkan data melewati umur koneksinya. Kedua, STK-4 yang di-insert tanpa COMMIT lenyap begitu sesi penulisnya tertutup: server menggulung balik transaksi terbuka secara otomatis saat koneksi putus. Perhatikan pula bahwa sesi yang sedang menulis melihat 4 baris — termasuk barisnya sendiri yang belum di-commit — sementara sesi lain sama sekali tidak melihatnya. Pemisahan inilah inti MVCC, mesin versi-baris di balik setiap transaksi PostgreSQL.

Savepoint: Membatalkan Sebagian Transaksi #

Savepoint memungkinkan transaksi mundur ke penanda tengah tanpa membuang seluruh pekerjaan. Skenario ujinya: tiga batch insert, satu di antaranya dibatalkan — hasil akhir harus berisi tepat dua batch:

-- Uji savepoint: sebagian transaksi dibatalkan tanpa membuang sisanya.
BEGIN;
INSERT INTO stok_barang VALUES ('STK-5', 'Kopi Bubuk 200g', 15);   -- batch 1: dipertahankan

SAVEPOINT sebelum_import;

INSERT INTO stok_barang VALUES ('STK-6', 'Susu Kental', 12);       -- batch 2: dibatalkan
ROLLBACK TO SAVEPOINT sebelum_import;

INSERT INTO stok_barang VALUES ('STK-7', 'Keju Slice', 8);         -- batch 3: dipertahankan
COMMIT;

SELECT kode, nama, stok FROM stok_barang
WHERE kode IN ('STK-5', 'STK-6', 'STK-7')
ORDER BY kode;

SELECT CASE
         WHEN (SELECT count(*) FROM stok_barang
               WHERE kode IN ('STK-5', 'STK-6', 'STK-7')) = 2
         THEN 'LULUS: STK-5 dan STK-7 tersimpan, STK-6 tergulung balik ke savepoint'
         ELSE 'GAGAL: ekspektasi 2 baris, aktual '
              || (SELECT count(*) FROM stok_barang
                  WHERE kode IN ('STK-5', 'STK-6', 'STK-7'))::text
       END AS uji_savepoint;

Output yang Dihasilkan:

text
BEGIN
INSERT 0 1
SAVEPOINT
INSERT 0 1
ROLLBACK
INSERT 0 1
COMMIT
 kode  |      nama       | stok 
-------+-----------------+------
 STK-5 | Kopi Bubuk 200g |   15
 STK-7 | Keju Slice      |    8
(2 rows)

                            uji_savepoint                             
----------------------------------------------------------------------
 LULUS: STK-5 dan STK-7 tersimpan, STK-6 tergulung balik ke savepoint
(1 row)

Urutan kejadiannya: STK-5 masuk, SAVEPOINT sebelum_import dipasang, STK-6 masuk lalu dibatalkan lewat ROLLBACK TO SAVEPOINT (perhatikan output ROLLBACK — bukan ROLLBACK penutup transaksi), STK-7 masuk, dan COMMIT mengawetkan sisa pekerjaan. Hasil akhirnya berisi STK-5 dan STK-7 saja. Pola ini adalah fondasi penanganan error per-baris di PL/pgSQL — blok EXCEPTION internally bekerja persis seperti savepoint, seperti sudah dibuktikan di chapter Exception Handling .

Constraint Tertunda Diperiksa saat COMMIT #

Foreign key bisa dideklarasikan DEFERRABLE, dan SET CONSTRAINTS ALL DEFERRED menunda pemeriksaannya sampai COMMIT. Ini dua file uji yang menyertainya — yang pertama membuktikan INSERT yatim diterima di dalam transaksi:

-- Uji constraint tertunda: FK DEFERRABLE tidak diperiksa di tiap INSERT,
-- melainkan saat COMMIT. Di dalam transaksi, baris yatim sementara diterima.
CREATE TABLE pemasok (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE barang_masuk (
    id        integer PRIMARY KEY,
    pemasok_id integer REFERENCES pemasok (id) DEFERRABLE INITIALLY IMMEDIATE
);

INSERT INTO pemasok VALUES (1, 'Toko Sumber Makmur');

BEGIN;
SET CONSTRAINTS ALL DEFERRED;

-- pemasok 99 tidak ada; tanpa penundaan, INSERT ini langsung ditolak.
INSERT INTO barang_masuk VALUES (100, 99);

-- Namun di dalam transaksi yang menunda pemeriksaan, barisnya terlihat.
SELECT id, pemasok_id FROM barang_masuk WHERE id = 100;

-- Uji berakhir dengan ROLLBACK supaya tabel bersih untuk contoh berikutnya.
ROLLBACK;

SELECT CASE
         WHEN (SELECT count(*) FROM barang_masuk) = 0
         THEN 'LULUS: baris yatim tidak pernah benar-benar tersimpan'
         ELSE 'GAGAL: ekspektasi 0 baris'
       END AS uji_deferred;

Output yang Dihasilkan:

text
CREATE TABLE
CREATE TABLE
INSERT 0 1
BEGIN
SET CONSTRAINTS
INSERT 0 1
 id  | pemasok_id 
-----+------------
 100 |         99
(1 row)

ROLLBACK
                     uji_deferred                      
-------------------------------------------------------
 LULUS: baris yatim tidak pernah benar-benar tersimpan
(1 row)

INSERT 0 1 dan baris 100 | 99 terlihat — padahal pemasok_id 99 tidak ada di tabel pemasok. Penundaan semacam ini berguna untuk memuat dua tabel yang saling merujuk. Tetapi “ditunda” bukan “dimaafkan”: file kedua membiarkan transaksi yang sama sampai ke COMMIT:

-- Lanjutan uji constraint tertunda: saat COMMIT tiba, pemeriksaan tetap
-- dijalankan — dan karena pemasok 99 tidak ada, COMMIT inilah yang gagal.
-- File ini berakhir dengan error: psql keluar dengan kode 3.
BEGIN;
SET CONSTRAINTS ALL DEFERRED;
INSERT INTO barang_masuk VALUES (100, 99);
COMMIT;

Output yang Dihasilkan:

text
psql:uji-deferred-commit.sql:7: ERROR:  insert or update on table "barang_masuk" violates foreign key constraint "barang_masuk_pemasok_id_fkey"
DETAIL:  Key (pemasok_id)=(99) is not present in table "pemasok".

Error muncul tepat di COMMIT, bukan di INSERT — dan psql keluar dengan kode 3. Transaksi gagal di gerbang akhir: seluruh isinya digulung balik, tabel barang_masuk tetap kosong. Itulah kontrak penundaan: pemeriksaan bergeser, tidak dihapus.

Isolasi Butuh Dua Sesi #

Anomali konkurensi (dirty read, nonrepeatable read, lost update) hanya muncul saat dua transaksi membaca dan menulis data yang sama hampir bersamaan — tidak ada cara mensimulasikannya dalam satu file SQL. Protokol pengujian yang benar memakai dua terminal yang membuka koneksi ke database yang sama:

  1. Terminal A menjalankan BEGIN; lalu UPDATE stok_barang SET stok = stok - 10 WHERE kode = 'STK-1'; — tanpa COMMIT.
  2. Terminal B menjalankan SELECT stok FROM stok_barang WHERE kode = 'STK-1'; — di level READ COMMITTED (default), B masih melihat nilai lama.
  3. Terminal A menjalankan COMMIT;.
  4. Terminal B menjalankan SELECT yang sama sekali — kini melihat nilai baru.

Mengulang protokol ini pada level REPEATABLE READ memberi hasil berbeda di langkah 4 (B tetap melihat nilai lama sampai transaksinya sendiri berakhir), dan pola yang sama dipakai untuk memancing deadlock dua UPDATE yang saling menunggu. Detail level isolasi dan anomali lengkapnya sudah dibahas di chapter Isolation Levels dan Deadlocks .

Ketahanan Terhadap Crash #

Pertanyaan terakhir dari pengujian transaksi: apakah data yang sudah di-commit selamat bila server mati mendadak? Jawabannya dijamin oleh write-ahead logging — setiap perubahan yang di-commit lebih dulu ditulis ke WAL sebelum diakui sukses, sehingga saat server menyala kembali, crash recovery memutar ulang WAL dan memulihkan semua transaksi ter-commit. Pengujian kasarnya bisa dilakukan: insert + commit, kill -9 proses server, jalankan ulang server, lalu periksa bahwa barisnya masih ada. Ini pengujian destruktif yang tidak kita jalankan di chapter ini — mekanisme WAL dan pemulihannya dibahas tuntas di chapter Write-Ahead Logging .

Daftar Periksa Pengujian Transaksi #

Ringkasan hal-hal yang layak diuji setiap kali logika transaksi berubah:

Sifat yang diujiCara ujiTanda lulus
Rollback memulihkan keadaanBEGIN → ubah → ROLLBACK → bandingkan baselinedata identik byte demi byte
Commit mengawetkan lintas sesicommit di sesi 1, baca dari sesi 2sesi 2 melihat datanya
Sesi putus = gulung balik otomatisBEGIN + insert tanpa commit, tutup koneksisesi lain tidak melihat barisnya
Savepoint membatalkan sebagianrollback ke penanda tengah, lanjutkan, commithanya bagian pasca-savepoint yang hilang
Constraint tertundaSET CONSTRAINTS ALL DEFERRED + data melanggarditerima di dalam, ditolak saat COMMIT
Isolasi antar transaksidua terminal, protokol baca-tulis bersamaananomali sesuai level isolasi yang dijanjikan
Ketahanan crashcommit → matikan server paksa → nyalakan ulangdata ter-commit tetap ada

Dari sini pembaca punya kotak perkakas lengkap untuk sisi data: query teruji, integritas terbukti, transaksi terverifikasi. Tetapi pengujian tidak mengajarkan bagaimana memperbaiki query yang sudah terbukti salah. Chapter berikutnya pindah ke sisi itu: debugging SQL secara sistematis — dari membaca pesan error dengan benar sampai membedah query tahap demi tahap.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

uji-commit.sh

Unduh berkas
bash
#!/usr/bin/env bash
# Uji commit dan batalkan-otomatis: dua pemanggilan psql = dua sesi terpisah.
# Sesi 1 melakukan sesuatu lalu ditutup; sesi 2 memeriksa apa yang bertahan.

echo '==> sesi 1: transaksi di-COMMIT'
psql -X -q <<'SQL' 2>&1
BEGIN;
INSERT INTO stok_barang VALUES ('STK-3', 'Gula Pasir 1kg', 30);
COMMIT;
SQL

echo '==> sesi 1 (lagi): BEGIN + INSERT, lalu sesi ditutup TANPA COMMIT'
psql -X -q <<'SQL' 2>&1
BEGIN;
INSERT INTO stok_barang VALUES ('STK-4', 'Teh Kotak', 50);
SELECT count(*) AS baris_terlihat_sesi_ini FROM stok_barang;
SQL

echo '==> sesi 2: memeriksa hasil kedua transaksi di atas'
psql -X -t -A <<'SQL'
SELECT 'STK-3 (di-commit)   : ' || count(*) || ' baris'
FROM stok_barang WHERE kode = 'STK-3';
SELECT 'STK-4 (tanpa commit): ' || count(*) || ' baris'
FROM stok_barang WHERE kode = 'STK-4';
SQL

uji-deferred-commit.sql

Unduh berkas
sql
-- Lanjutan uji constraint tertunda: saat COMMIT tiba, pemeriksaan tetap
-- dijalankan — dan karena pemasok 99 tidak ada, COMMIT inilah yang gagal.
-- File ini berakhir dengan error: psql keluar dengan kode 3.
BEGIN;
SET CONSTRAINTS ALL DEFERRED;
INSERT INTO barang_masuk VALUES (100, 99);
COMMIT;

uji-deferred.sql

Unduh berkas
sql
-- Uji constraint tertunda: FK DEFERRABLE tidak diperiksa di tiap INSERT,
-- melainkan saat COMMIT. Di dalam transaksi, baris yatim sementara diterima.
CREATE TABLE pemasok (
    id   integer PRIMARY KEY,
    nama text NOT NULL
);

CREATE TABLE barang_masuk (
    id        integer PRIMARY KEY,
    pemasok_id integer REFERENCES pemasok (id) DEFERRABLE INITIALLY IMMEDIATE
);

INSERT INTO pemasok VALUES (1, 'Toko Sumber Makmur');

BEGIN;
SET CONSTRAINTS ALL DEFERRED;

-- pemasok 99 tidak ada; tanpa penundaan, INSERT ini langsung ditolak.
INSERT INTO barang_masuk VALUES (100, 99);

-- Namun di dalam transaksi yang menunda pemeriksaan, barisnya terlihat.
SELECT id, pemasok_id FROM barang_masuk WHERE id = 100;

-- Uji berakhir dengan ROLLBACK supaya tabel bersih untuk contoh berikutnya.
ROLLBACK;

SELECT CASE
         WHEN (SELECT count(*) FROM barang_masuk) = 0
         THEN 'LULUS: baris yatim tidak pernah benar-benar tersimpan'
         ELSE 'GAGAL: ekspektasi 0 baris'
       END AS uji_deferred;

uji-rollback.sql

Unduh berkas
sql
-- Uji rollback: BEGIN -> ubah data -> ROLLBACK -> data harus pulih sepenuhnya.
CREATE TABLE stok_barang (
    kode text PRIMARY KEY,
    nama text NOT NULL,
    stok integer NOT NULL
);

INSERT INTO stok_barang VALUES
    ('STK-1', 'Beras 5kg',         40),
    ('STK-2', 'Minyak Goreng 1L',  25);

-- Keadaan awal (baseline untuk pembanding).
SELECT kode, stok FROM stok_barang ORDER BY kode;

BEGIN;
UPDATE stok_barang SET stok = stok - 10 WHERE kode = 'STK-1';

-- Di DALAM transaksi, perubahan sudah terlihat oleh sesi kita sendiri.
SELECT kode, stok FROM stok_barang ORDER BY kode;

ROLLBACK;

-- Setelah ROLLBACK, keadaan harus identik dengan baseline.
SELECT kode, stok FROM stok_barang ORDER BY kode;

SELECT CASE
         WHEN (SELECT stok FROM stok_barang WHERE kode = 'STK-1') = 40
         THEN 'LULUS: rollback memulihkan stok ke nilai awal 40'
         ELSE 'GAGAL: ekspektasi 40, aktual '
              || (SELECT stok FROM stok_barang WHERE kode = 'STK-1')::text
       END AS uji_rollback;

uji-savepoint.sql

Unduh berkas
sql
-- Uji savepoint: sebagian transaksi dibatalkan tanpa membuang sisanya.
BEGIN;
INSERT INTO stok_barang VALUES ('STK-5', 'Kopi Bubuk 200g', 15);   -- batch 1: dipertahankan

SAVEPOINT sebelum_import;

INSERT INTO stok_barang VALUES ('STK-6', 'Susu Kental', 12);       -- batch 2: dibatalkan
ROLLBACK TO SAVEPOINT sebelum_import;

INSERT INTO stok_barang VALUES ('STK-7', 'Keju Slice', 8);         -- batch 3: dipertahankan
COMMIT;

SELECT kode, nama, stok FROM stok_barang
WHERE kode IN ('STK-5', 'STK-6', 'STK-7')
ORDER BY kode;

SELECT CASE
         WHEN (SELECT count(*) FROM stok_barang
               WHERE kode IN ('STK-5', 'STK-6', 'STK-7')) = 2
         THEN 'LULUS: STK-5 dan STK-7 tersimpan, STK-6 tergulung balik ke savepoint'
         ELSE 'GAGAL: ekspektasi 2 baris, aktual '
              || (SELECT count(*) FROM stok_barang
                  WHERE kode IN ('STK-5', 'STK-6', 'STK-7'))::text
       END AS uji_savepoint;