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:

sql buat-tabel.sql
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:

text
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:

sql versi-baru.sql
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:

text
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)

COMMIT

Dua SELECT di dalam satu transaksi memperlihatkan peristiwa yang sebenarnya:

  1. Sebelum UPDATE, baris Budi tersimpan sebagai versi buatan transaksi xmin = 9156, dengan xmax = 0 — tidak ada transaksi lain yang menghapusnya.
  2. UPDATE mengurangi saldo Rp1.000 — dan perhatikan SELECT kedua: baris yang terbaca kini punya xmin = 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.
  3. COMMIT menjadikan versi 9157 resmi. Versi lama 9156 — 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:

sql tuple-mati.sql
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:

text
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:

  1. Tiga UPDATE berturut-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.
  2. pg_stat_force_next_flush() memaksa statistik yang masih menggantung di memori ikut dihitung, supaya pembacaan pg_stat_user_tables langsung segar.
  3. Statistik pertama melaporkan n_live_tup = 3 — tiga baris hidup, satu per rekening — dan n_dead_tup = 3: jejak ketiga UPDATE tadi.
  4. VACUUM rekening menyapu ruang yang ditempati versi mati tanpa mengunci pembaca atau penulis.
  5. Statistik kedua menunjukkan n_dead_tup = 0 — tabel bersih, n_live_tup tetap 3.

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:

text
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:

  1. Setelah INSERT, versi baris Rina distempel xmin yang sama dengan nomor transaksi yang berjalan — perbandingan t.
  2. 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 = 9157 di sana adalah nomor transaksi yang membuat versi Rp499.000 itu.
  3. Setelah COMMIT, perintah baru berjalan sebagai transaksi baru dengan nomor baru — perbandingan menjadi f. 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 berkas
sql
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;

tuple-mati.sql

Unduh berkas
sql
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';

versi-baru.sql

Unduh berkas
sql
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;

xid-pembuat-baris.sql

Unduh berkas
sql
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;