Database server klasik menghadapi dilemma lama: teller yang sedang menghitung ulang saldo (membaca) harus menunggu nasabah yang sedang transfer (menulis), atau sebaliknya. Membaca dan menulis data yang sama pada saat bersamaan selalu berakhir dengan antrean. PostgreSQL menolak dilemma itu dengan trik yang menjadi fondasi seluruh sistem konkurensinya: Multi-Version Concurrency Control (MVCC). Alih-alih satu salinan baris yang diperebutkan, server menyimpan beberapa versi baris — pembaca mengambil versi yang cocok dengan snapshot-nya, penulis membuat versi baru, dan keduanya berjalan tanpa saling mengunci. Chapter ini membuka kotak mesinnya: kolom sistem xmin/xmax, versi mati yang ditinggalkan UPDATE, tugas VACUUM, dan hubungan semua itu dengan isolation level.
Masalah: Pembaca Melawan Penulis #
Bayangkan database yang menyimpan tepat satu salinan tiap baris. Sesi A membaca rekening untuk laporan; agar angkanya konsisten, database mengunci tabelnya sekejap. Di saat yang sama Sesi B ingin meng-update saldo — B menunggu A selesai membaca. Pada database sibuk, rantai tunggu seperti ini berlipat: laporan berat melumpuhkan transaksi harian.
MVCC membalik pendekatannya. UPDATE tidak pernah menimpa baris lama di tempatnya; PostgreSQL menulis versi baru di tempat lain di dalam tabel dan menandai versi lamanya sebagai tidak lagi terbaru. Setiap transaksi pembaca membawa snapshot — catatan versi mana yang tampak untuk transaksi itu. Pembaca lama terus melihat versi lama, pembaca baru melihat versi baru, dan penulis tidak pernah menunggu pembaca. Isolation level yang Anda pelajari di chapter sebelumnya bukan sihir terpisah — semuanya dibangun di atas pilihan versi ini.
Menyiapkan Tabel Latihan #
Demo memakai tabel rekening yang sama dengan chapter-chapter sebelumnya, dibuat ulang supaya chapter ini mandiri:
CREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(1, 'Budi Santoso', 'tabungan', 500000),
(2, 'Siti Aminah', 'tabungan', 300000),
(3, 'Dewi Lestari', 'e-wallet', 950000);
SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 3
id | nama | jenis | saldo
----+--------------+----------+-----------
1 | Budi Santoso | tabungan | 500000.00
2 | Siti Aminah | tabungan | 300000.00
3 | Dewi Lestari | e-wallet | 950000.00
(3 rows)UPDATE Membuat Versi Baru, Bukan Menimpa #
Setiap baris PostgreSQL diam-diam membawa kolom sistem yang tidak muncul di SELECT * tetapi bisa diminta eksplisit: xmin berisi nomor transaksi (xid) yang membuat versi baris itu, dan xmax berisi nomor transaksi yang menghapus atau menggantinya — 0 berarti masih hidup dan terbaru. Demo berikut membaca xmin/xmax sebelum dan sesudah satu UPDATE. Nilai xid terus bertambah sepanjang hidup server, jadi angka di output Anda pasti berbeda dari contoh ini — yang penting justru pola perubahannya:
BEGIN;
SELECT id, saldo, xmin, xmax FROM rekening WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
SELECT id, saldo, xmin, xmax FROM rekening WHERE id = 1;
COMMIT;Output yang Dihasilkan:
BEGIN
id | saldo | xmin | xmax
----+-----------+------+------
1 | 500000.00 | 9156 | 0
(1 row)
UPDATE 1
id | saldo | xmin | xmax
----+-----------+------+------
1 | 499000.00 | 9157 | 0
(1 row)
COMMITDua SELECT di dalam satu transaksi memperlihatkan peristiwa yang sebenarnya:
- Sebelum
UPDATE, baris Budi tersimpan sebagai versi buatan transaksixmin = 9156, denganxmax = 0— tidak ada transaksi lain yang menghapusnya. UPDATEmengurangi saldo Rp1.000 — dan perhatikanSELECTkedua: baris yang terbaca kini punyaxmin = 9157, nomor transaksi yang sedang berjalan ini sendiri. Angka saldo berubah dan penanda pembuatnya ikut berganti — bukti bahwa yang terbaca adalah baris yang berbeda, bukan baris lama yang ditimpa di tempatnya.COMMITmenjadikan versi9157resmi. Versi lama9156— bekas baris saldo Rp500.000 — masih tergeletak di dalam file tabel, tidak lagi terlihat siapa pun.
Di sinilah letak perbedaan filosofisnya: menimpa berarti menghancurkan bukti; membuat versi baru berarti sejarah data masih ada di disk. Konsekuensinya ada dua sisi — pembaca bersamaan dilayani tanpa tunggu, tetapi versi lama menumpuk sebagai sampah yang harus dikurus.
Tuple Mati dan Tugas VACUUM #
Versi lama yang tidak lagi terlihat oleh transaksi mana pun disebut dead tuple (baris mati). PostgreSQL tidak menghapusnya seketika — versi itu mungkin masih dibutuhkan transaksi lama yang sedang berjalan. Statistik tabel mencatat jumlahnya di kolom n_dead_tup, dan VACUUM adalah pembersih resminya. Demo berikut meng-update saldo Budi tiga kali berturut-turut, membaca statistiknya, lalu membersihkan:
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
SELECT pg_stat_force_next_flush();
SELECT n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'rekening';
VACUUM rekening;
SELECT n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'rekening';Output yang Dihasilkan:
UPDATE 1
UPDATE 1
UPDATE 1
pg_stat_force_next_flush
--------------------------
(1 row)
n_live_tup | n_dead_tup
------------+------------
3 | 3
(1 row)
VACUUM
n_live_tup | n_dead_tup
------------+------------
3 | 0
(1 row)Angka-angkanya bercerita lugas:
- Tiga
UPDATEberturut-turut mengubah saldo Budi dari Rp500.000 ke Rp499.000, Rp498.000, lalu Rp497.000. Setiap perubahan meninggalkan satu versi lama — total tiga dead tuple. pg_stat_force_next_flush()memaksa statistik yang masih menggantung di memori ikut dihitung, supaya pembacaanpg_stat_user_tableslangsung segar.- Statistik pertama melaporkan
n_live_tup = 3— tiga baris hidup, satu per rekening — dann_dead_tup = 3: jejak ketigaUPDATEtadi. VACUUM rekeningmenyapu ruang yang ditempati versi mati tanpa mengunci pembaca atau penulis.- Statistik kedua menunjukkan
n_dead_tup = 0— tabel bersih,n_live_tuptetap3.
xmin adalah Nomor Transaksi Pembuatnya #
Kalau xmin memuat nomor transaksi pembuat baris, kita bisa membuktikannya langsung: bandingkan xmin dengan nomor transaksi yang sedang berjalan, yang bisa diambil lewat fungsi pg_current_xact_id(). Demo berikut memasukkan rekening baru dan mengubahnya di dalam satu transaksi, lalu memeriksa lagi setelah commit. Hasil perbandingan adalah t/f, jadi output-nya deterministik meskipun nomor xid-nya sendiri tidak:
BEGIN;
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(4, 'Rina Putri', 'tabungan', 250000);
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS versi_dibuat_txn_ini
FROM rekening WHERE id = 4;
UPDATE rekening SET saldo = saldo - 50000 WHERE id = 4;
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS versi_dibuat_txn_ini
FROM rekening WHERE id = 4;
COMMIT;
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS masih_txn_yang_sama
FROM rekening WHERE id = 4;Output yang Dihasilkan:
BEGIN
INSERT 0 1
id | saldo | versi_dibuat_txn_ini
----+-----------+----------------------
4 | 250000.00 | t
(1 row)
UPDATE 1
id | saldo | versi_dibuat_txn_ini
----+-----------+----------------------
4 | 200000.00 | t
(1 row)
COMMIT
id | saldo | masih_txn_yang_sama
----+-----------+---------------------
4 | 200000.00 | f
(1 row)Kolom hasil memperlihatkan pola t, t, f:
- Setelah
INSERT, versi baris Rina distempelxminyang sama dengan nomor transaksi yang berjalan — perbandingant. - Setelah
UPDATE, versi barunya tetap bergaris tangan transaksi yang sama: satu transaksi menstempel semua versi yang ia buat, apa pun jumlah perintahnya. Inilah benang merahnya dengan demo sebelumnya —xmin = 9157di sana adalah nomor transaksi yang membuat versi Rp499.000 itu. - Setelah
COMMIT, perintah baru berjalan sebagai transaksi baru dengan nomor baru — perbandingan menjadif. Nomor xid bersifat global dan terus bertambah sepanjang umur cluster, satu alasan mengapa nilainya tidak pernah cocok di antara dua mesin.
Pembaca Tidak Memblok Penulis #
Semua bagian sebelumnya kini bisa dirangkai jadi satu gambar utuh. Ketika dua sesi bekerja pada baris yang sama, server tidak memindahkan satu salinan bolak-balik — ia menyusun deretan versi dan membiarkan tiap transaksi memilih versinya sendiri:
Sesi A membekukan snapshot-nya di awal, sehingga SELECT keduanya menemukan versi yang sama — Rp500.000 — meski Sesi B sudah meng-commit versi baru di antara keduanya. Sesi B sama sekali tidak menunggu Sesi A: penulis tidak terblokir pembaca, pembaca tidak terblokir penulis. Versi 1 yang tertinggal kemudian menjadi dead tuple dan akhirnya disapu VACUUM.
Snapshot inilah mata uang resmi isolation level dari chapter sebelumnya: Read Committed mengambil snapshot baru untuk tiap perintah (jadi bisa melihat perubahan yang di-commit di sela-sela), sementara Repeatable Read membekukan satu snapshot untuk seluruh transaksi. Detail lengkap fenomena yang masing-masing level izinkan dibahas di Transaction Isolation dan sudah kita demonstrasikan pada chapter Isolation Levels .
MVCC menjelaskan dua pertanyaan besar sekaligus: mengapa membaca tidak pernah mengantre di belakang penulisan, dan mengapa UPDATE meninggalkan jejak yang harus dibersihkan. Satu wilayah yang belum tersentuh: dua penulis yang menyasar baris yang sama pada saat bersamaan — di sanalah kunci baris (FOR UPDATE, row lock) mengambil peran, topik chapter Row Locking berikutnya.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkasCREATE TABLE rekening (
id integer PRIMARY KEY,
nama text NOT NULL,
jenis text NOT NULL,
saldo numeric(12, 2) NOT NULL CHECK (saldo >= 0),
CONSTRAINT saldo_ewallet_maks
CHECK (jenis <> 'e-wallet' OR saldo <= 1000000)
);
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(1, 'Budi Santoso', 'tabungan', 500000),
(2, 'Siti Aminah', 'tabungan', 300000),
(3, 'Dewi Lestari', 'e-wallet', 950000);
SELECT id, nama, jenis, saldo FROM rekening ORDER BY id;
tuple-mati.sql
Unduh berkasUPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
SELECT pg_stat_force_next_flush();
SELECT n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'rekening';
VACUUM rekening;
SELECT n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'rekening';
versi-baru.sql
Unduh berkasBEGIN;
SELECT id, saldo, xmin, xmax FROM rekening WHERE id = 1;
UPDATE rekening SET saldo = saldo - 1000 WHERE id = 1;
SELECT id, saldo, xmin, xmax FROM rekening WHERE id = 1;
COMMIT;
xid-pembuat-baris.sql
Unduh berkasBEGIN;
INSERT INTO rekening (id, nama, jenis, saldo) VALUES
(4, 'Rina Putri', 'tabungan', 250000);
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS versi_dibuat_txn_ini
FROM rekening WHERE id = 4;
UPDATE rekening SET saldo = saldo - 50000 WHERE id = 4;
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS versi_dibuat_txn_ini
FROM rekening WHERE id = 4;
COMMIT;
SELECT id, saldo,
pg_current_xact_id()::text = xmin::text AS masih_txn_yang_sama
FROM rekening WHERE id = 4;