Postgres 17 - Memahami Transactions dan Locking

Memahami Transactions dan Locking

Pendahuluan

Transaksi dan penguncian (locking) adalah dua konsep utama dalam pengelolaan basis data, termasuk PostgreSQL. Keduanya memastikan data tetap konsisten, aman, dan dapat diakses secara konkuren oleh banyak pengguna tanpa konflik. Artikel ini dirancang untuk menjelaskan transaksi dan penguncian (locking) secara terstruktur, jelas, dan mudah dipahami.

Daftar Isi:

  1. Apa Itu Transaksi di PostgreSQL?

  2. Fitur-Fitur Transaksi

    • Perintah Dasar: BEGIN, COMMIT, ROLLBACK

    • Transaction Chaining

    • Savepoint

    • DDL Transaksional

  3. Apa Itu Penguncian (Locking) dan Mengapa Penting?

  4. Mekanisme Penguncian di PostgreSQL

    • Multi-Version Concurrency Control (MVCC)

    • Penguncian Baris (Row-Level Locking)

    • Penguncian Tabel (Table-Level Locking)

    • SELECT FOR UPDATE dan FOR SHARE

    • SKIP LOCKED

    • Advisory Locks

  5. Tingkat Isolasi Transaksi

    • Read Committed

    • Repeatable Read

    • Serializable

  6. Menangani Masalah: Deadlock dan Konflik Serialisasi

  7. Optimasi Penyimpanan dan Pembersihan

    • VACUUM dan Autovacuum

    • Transaction Wraparound

    • VACUUM FULL

  8. Kesimpulan


1. Apa Itu Transaksi di PostgreSQL?

Transaksi adalah sekumpulan perintah SQL yang dijalankan sebagai satu unit kerja. Semua perintah di dalamnya harus berhasil (commit) atau dibatalkan (rollback) jika ada yang gagal. Ini memastikan data tetap konsisten.

Di PostgreSQL, setiap perintah SQL adalah transaksi, bahkan perintah sederhana seperti SELECT. Contoh:

SELECT now();

Perintah ini dijalankan sebagai transaksi tunggal. Fungsi now() mengembalikan waktu transaksi, bukan waktu saat ini. Jika Anda ingin waktu nyata, gunakan clock_timestamp().

Untuk mengelompokkan beberapa perintah dalam satu transaksi, gunakan BEGIN, COMMIT, atau ROLLBACK. Contoh:

BEGIN;
INSERT INTO users (name) VALUES ('Alice');
UPDATE accounts SET balance = balance + 100 WHERE user_id = 1;
COMMIT;

Jika salah satu perintah gagal, Anda bisa menggunakan ROLLBACK untuk membatalkan semua perubahan.


2. Fitur-Fitur Transaksi

a. Perintah Dasar: BEGIN, COMMIT, ROLLBACK

  • BEGIN: Memulai transaksi, memungkinkan beberapa perintah dijalankan sebagai satu unit.

  • COMMIT: Menyimpan semua perubahan dalam transaksi ke basis data.

  • ROLLBACK: Membatalkan semua perubahan dalam transaksi, mengembalikan data ke keadaan sebelumnya.

Contoh:

BEGIN;
INSERT INTO t_test (id) VALUES (1);
SELECT * FROM t_test; -- Melihat id = 1
ROLLBACK;
SELECT * FROM t_test; -- Tidak ada data, karena ROLLBACK

Alternatif untuk COMMIT adalah END, dan untuk ROLLBACK adalah ABORT. Keduanya memiliki fungsi yang sama.

b. Transaction Chaining

Fitur transaction chaining memungkinkan transaksi baru dimulai otomatis dengan properti yang sama setelah transaksi sebelumnya di-commit. Ini berguna untuk mengurangi jumlah perintah yang dikirim ke server, terutama dalam sistem dengan latensi tinggi.

Contoh:

BEGIN TRANSACTION READ ONLY;
SELECT 1;
COMMIT AND CHAIN; -- Memulai transaksi baru dengan mode READ ONLY
SELECT 1;
COMMIT AND NO CHAIN; -- Mengakhiri rantai

Perintah COMMIT AND CHAIN mempertahankan pengaturan seperti READ ONLY, sehingga Anda tidak perlu mengatur ulang.

c. Savepoint

Savepoint memungkinkan Anda membuat "titik simpan" dalam transaksi, sehingga Anda bisa kembali ke titik tersebut jika terjadi kesalahan tanpa membatalkan seluruh transaksi.

Contoh:

BEGIN;
INSERT INTO t_test (id) VALUES (1);
SAVEPOINT my_savepoint;
INSERT INTO t_test (id) VALUES (2 / 0); -- ERROR: division by zero
ROLLBACK TO SAVEPOINT my_savepoint;
INSERT INTO t_test (id) VALUES (2); -- Berhasil
COMMIT;

Setelah ROLLBACK TO SAVEPOINT, transaksi kembali ke keadaan sebelum kesalahan, dan Anda bisa melanjutkan. Anda juga bisa menghapus savepoint dengan RELEASE SAVEPOINT.

d. DDL Transaksional

PostgreSQL mendukung Data Definition Language (DDL) dalam transaksi, seperti CREATE TABLE atau ALTER TABLE. Ini berarti Anda bisa membatalkan perubahan struktur data jika diperlukan.

Contoh:

BEGIN;
CREATE TABLE t_test (id int);
ALTER TABLE t_test ADD COLUMN name text;
ROLLBACK; -- Tabel t_test tidak akan ada

Fitur ini sangat berguna untuk deployment perangkat lunak, memastikan perubahan struktur dilakukan secara atomik.


3. Apa Itu Penguncian (Locking) dan Mengapa Penting?

Penguncian adalah mekanisme untuk mengatur akses konkuren ke data, mencegah konflik antara transaksi. Tanpa penguncian yang tepat, aplikasi bisa mengalami masalah seperti:

  • Data tidak konsisten: Dua transaksi mengubah data yang sama secara bersamaan.

  • Performa buruk: Transaksi menunggu terlalu lama (waiting adalah bentuk eksekusi paling lambat).

  • Deadlock: Dua transaksi saling mengunci, menyebabkan kebuntuan.

Penguncian yang baik memastikan konkurensi tinggi, performa optimal, dan keandalan data.


4. Mekanisme Penguncian di PostgreSQL

a. Multi-Version Concurrency Control (MVCC)

PostgreSQL menggunakan MVCC untuk memungkinkan pembacaan dan penulisan data secara konkuren tanpa pemblokiran. Dalam MVCC:

  • Setiap transaksi melihat snapshot data pada saat transaksi dimulai.

  • Perubahan oleh transaksi lain tidak terlihat hingga di-commit.

Contoh:

Transaksi 1
Transaksi 2

BEGIN;

BEGIN;

UPDATE t_test SET id = id + 1;

SELECT * FROM t_test;

Melihat data lama (sebelum UPDATE)

COMMIT;

COMMIT;

MVCC memastikan transaksi baca tidak terblokir oleh transaksi tulis.

b. Penguncian Baris (Row-Level Locking)

PostgreSQL hanya mengunci baris yang diubah oleh transaksi, bukan seluruh tabel. Ini memungkinkan konkurensi tinggi. Contoh:

Transaksi 1
Transaksi 2

BEGIN;

BEGIN;

UPDATE t_test SET id = id + 1 WHERE id = 1;

UPDATE t_test SET id = id + 1 WHERE id = 2;

Berhasil (id = 2)

Berhasil (id = 3)

COMMIT;

COMMIT;

Karena baris yang berbeda dikunci, kedua transaksi dapat berjalan bersamaan.

c. Penguncian Tabel (Table-Level Locking)

Untuk kasus tertentu, Anda bisa mengunci seluruh tabel menggunakan perintah LOCK. PostgreSQL mendukung beberapa mode penguncian tabel, dari yang paling ringan hingga paling ketat:

  • ACCESS SHARE: Diambil oleh SELECT, hanya konflik dengan ACCESS EXCLUSIVE.

  • ROW EXCLUSIVE: Diambil oleh INSERT, UPDATE, DELETE.

  • ACCESS EXCLUSIVE: Mengunci tabel sepenuhnya, digunakan oleh DROP TABLE.

Contoh:

BEGIN;
LOCK TABLE t_test IN ACCESS EXCLUSIVE MODE;
INSERT INTO t_test VALUES (max(id) + 1);
COMMIT;

Namun, ACCESS EXCLUSIVE sangat ketat dan harus dihindari karena memblokir semua akses lain.

d. SELECT FOR UPDATE dan FOR SHARE

  • SELECT FOR UPDATE: Mengunci baris yang dipilih, mencegah perubahan oleh transaksi lain hingga transaksi selesai. Contoh:

BEGIN;
SELECT * FROM invoice WHERE processed = false FOR UPDATE;
UPDATE invoice SET processed = true;
COMMIT;
  • SELECT FOR SHARE: Mengunci baris untuk dibaca, memungkinkan beberapa transaksi mengunci baris yang sama.

  • NOWAIT: Jika baris sudah terkunci, perintah gagal seketika tanpa menunggu.

  • SKIP LOCKED: Melewati baris yang terkunci, berguna untuk skenario seperti pemesanan tiket:

BEGIN;
SELECT * FROM t_flight LIMIT 2 FOR UPDATE SKIP LOCKED; -- Mengambil 2 baris yang tidak terkunci
COMMIT;

e. Advisory Locks

Advisory locks adalah kunci khusus yang tidak terkait dengan baris atau tabel, melainkan dengan angka tertentu. Kunci ini tidak dilepaskan otomatis saat COMMIT, sehingga harus dilepaskan secara eksplisit.

Contoh:

BEGIN;
SELECT pg_advisory_lock(15); -- Mengunci angka 15
-- Proses
SELECT pg_advisory_unlock(15); -- Melepaskan kunci
COMMIT;

Untuk melepaskan semua advisory locks:

SELECT pg_advisory_unlock_all();

5. Tingkat Isolasi Transaksi

Tingkat isolasi menentukan bagaimana transaksi melihat perubahan oleh transaksi lain. PostgreSQL mendukung tiga tingkat:

a. Read Committed

  • Default: Setiap perintah dalam transaksi melihat snapshot data terbaru yang telah di-commit.

  • Contoh:

Transaksi 1
Transaksi 2

BEGIN;

BEGIN;

SELECT sum(balance) FROM t_account; -- 300

INSERT INTO t_account (balance) VALUES (100);

COMMIT;

SELECT sum(balance) FROM t_account; -- 400

COMMIT;

b. Repeatable Read

  • Menggunakan snapshot yang sama sepanjang transaksi, sehingga perubahan oleh transaksi lain tidak terlihat.

  • Contoh:

Transaksi 1 (REPEATABLE READ)
Transaksi 2

BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN;

SELECT sum(balance) FROM t_account; -- 300

INSERT INTO t_account (balance) VALUES (100);

COMMIT;

SELECT sum(balance) FROM t_account; -- 300

COMMIT;

c. Serializable

  • Memastikan transaksi berjalan seolah-olah tidak ada konkurensi, tetapi transaksi dapat gagal jika ada konflik.

  • Contoh:

BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT * FROM t_deadlock;
DELETE FROM t_deadlock; -- Gagal jika transaksi lain menghapus data

6. Menangani Masalah: Deadlock dan Konflik Serialisasi

a. Deadlock

Deadlock terjadi ketika dua transaksi saling menunggu kunci yang dipegang satu sama lain:

Transaksi 1
Transaksi 2

BEGIN;

BEGIN;

UPDATE t_deadlock SET id = id * 10 WHERE id = 1;

UPDATE t_deadlock SET id = id * 10 WHERE id = 2;

UPDATE t_deadlock SET id = id * 10 WHERE id = 2;

UPDATE t_deadlock SET id = id * 10 WHERE id = 1;

Deadlock terdeteksi, ROLLBACK

PostgreSQL mendeteksi deadlock setelah deadlock_timeout (default 1 detik) dan membatalkan salah satu transaksi.

b. Konflik Serialisasi

Dalam mode Repeatable Read atau Serializable, transaksi dapat gagal jika mencoba mengubah data yang telah diubah oleh transaksi lain. Aplikasi harus menangani kegagalan ini dengan mencoba ulang transaksi.


7. Optimasi Penyimpanan dan Pembersihan

a. VACUUM dan Autovacuum

Karena MVCC, PostgreSQL menyimpan versi lama data hingga transaksi selesai. Proses VACUUM membersihkan dead tuples (baris yang tidak lagi dibutuhkan) dan memperbarui free space map (FSM).

Contoh:

CREATE TABLE t_test (id int) WITH (autovacuum_enabled = off);
INSERT INTO t_test SELECT * FROM generate_series(1, 100000);
UPDATE t_test SET id = id + 1;
VACUUM t_test; -- Tidak mengubah ukuran tabel

Autovacuum menjalankan VACUUM secara otomatis berdasarkan pengaturan seperti:

  • autovacuum_vacuum_scale_factor = 0.2: Berjalan jika 20% baris berubah.

  • autovacuum_vacuum_threshold = 50: Minimal 50 baris berubah.

b. Transaction Wraparound

ID transaksi di PostgreSQL terbatas. Untuk mencegah wraparound, VACUUM menyesuaikan watermark transaksi. Pengaturan penting:

  • autovacuum_freeze_max_age = 200000000

  • autovacuum_multixact_freeze_max_age = 400000000

c. VACUUM FULL

VACUUM FULL menulis ulang tabel dan mengembalikan ruang ke sistem file, tetapi memblokir semua akses ke tabel, sehingga harus digunakan dengan hati-hati.


8. Kesimpulan

Transaksi dan penguncian di PostgreSQL dirancang untuk mendukung konkurensi tinggi dan keandalan data. Dengan fitur seperti MVCC, savepoint, DDL transaksional, dan advisory locks, Anda dapat mengelola data secara efisien. Tingkat isolasi seperti Read Committed dan Serializable memberikan fleksibilitas untuk berbagai kebutuhan aplikasi. Dengan memahami dan mengkonfigurasi VACUUM serta autovacuum, Anda dapat menjaga performa basis data tetap optimal. Dengan pengetahuan ini, Anda dapat membangun aplikasi yang skalabel dan bebas dari masalah seperti deadlock atau konflik data.


8. Contoh Kode

package main

import (
	"context"
	"fmt"
	"log"

	"github.com/jackc/pgx/v5/pgxpool"
)

func main() {
	// Koneksi ke database menggunakan pgxpool
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
	if err != nil {
		log.Fatalf("Gagal koneksi ke database: %v", err)
	}
	defer pool.Close()
	// Menjalankan query sederhana sebagai transaksi
	var now string
	err = pool.QueryRow(ctx, "SELECT now()").Scan(&now)
	if err != nil {
		log.Fatalf("Gagal menjalankan query: %v", err)
	}
	fmt.Printf("Waktu transaksi: %s\n", now)
}

Penjelasan Kode:

  • Kode di atas menggunakan pgxpool untuk membuat koneksi ke database PostgreSQL.

  • Query SELECT now() dijalankan sebagai transaksi tunggal, mengembalikan waktu transaksi.

  • Skenario ini menunjukkan bahwa bahkan query sederhana dianggap sebagai transaksi.


2. Fitur-Fitur Transaksi

a. Perintah Dasar: BEGIN, COMMIT, ROLLBACK

  • BEGIN: Memulai transaksi.

  • COMMIT: Menyimpan perubahan.

  • ROLLBACK: Membatalkan perubahan.

Contoh Skenario: Aplikasi mentransfer saldo antar akun, memastikan kedua operasi berhasil atau dibatalkan.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    // Mulai transaksi
    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Operasi transfer saldo
    _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance - 100 WHERE user_id = 1")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal mengurangi saldo: %v", err)
    }

    _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance + 100 WHERE user_id = 2")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal menambah saldo: %v", err)
    }

    // Commit transaksi
    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit transaksi: %v", err)
    }

    fmt.Println("Transfer berhasil!")
}

Penjelasan Kode:

  • Kode ini mensimulasikan transfer saldo antar dua akun.

  • Transaksi dimulai dengan pool.Begin(ctx).

  • Jika salah satu UPDATE gagal, tx.Rollback(ctx) membatalkan semua perubahan.

  • Jika berhasil, tx.Commit(ctx) menyimpan perubahan.

b. Transaction Chaining

Transaction chaining memungkinkan transaksi baru dimulai otomatis dengan properti yang sama setelah COMMIT.

Contoh Skenario: Aplikasi mencatat log aktivitas pengguna dalam beberapa transaksi berurutan dengan mode READ ONLY.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    // Mulai transaksi READ ONLY
    tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.ReadOnly})
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Query pertama
    var count int
    err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM logs").Scan(&count)
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal query: %v", err)
    }
    fmt.Printf("Jumlah log: %d\n", count)

    // Commit dengan chaining
    _, err = tx.Exec(ctx, "COMMIT AND CHAIN")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal commit and chain: %v", err)
    }

    // Query kedua dalam transaksi baru (masih READ ONLY)
    err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM logs").Scan(&count)
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal query: %v", err)
    }
    fmt.Printf("Jumlah log (transaksi kedua): %d\n", count)

    // Commit tanpa chaining
    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Println("Transaksi selesai!")
}

Penjelasan Kode:

  • Kode ini menggunakan BeginTx dengan opsi ReadOnly untuk memulai transaksi.

  • Perintah COMMIT AND CHAIN memulai transaksi baru dengan properti yang sama.

  • Skenario ini mensimulasikan aplikasi yang memeriksa jumlah log berulang kali dalam mode READ ONLY.

c. Savepoint

Savepoint memungkinkan Anda membuat titik simpan dalam transaksi untuk kembali jika terjadi kesalahan.

Contoh Skenario: Aplikasi mencoba memperbarui data pengguna, tetapi jika gagal, kembali ke titik sebelumnya tanpa membatalkan seluruh transaksi.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Insert data pertama
    _, err = tx.Exec(ctx, "INSERT INTO users (name) VALUES ('Alice')")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal insert: %v", err)
    }

    // Buat savepoint
    _, err = tx.Exec(ctx, "SAVEPOINT my_savepoint")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal membuat savepoint: %v", err)
    }

    // Coba insert yang akan gagal
    _, err = tx.Exec(ctx, "INSERT INTO users (id) VALUES (1/0)") // Error: division by zero
    if err != nil {
        // Kembali ke savepoint
        _, err = tx.Exec(ctx, "ROLLBACK TO SAVEPOINT my_savepoint")
        if err != nil {
            tx.Rollback(ctx)
            log.Fatalf("Gagal rollback ke savepoint: %v", err)
        }
    }

    // Insert data setelah rollback ke savepoint
    _, err = tx.Exec(ctx, "INSERT INTO users (name) VALUES ('Bob')")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal insert: %v", err)
    }

    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Println("Transaksi berhasil, data Alice dan Bob tersimpan!")
}

Penjelasan Kode:

  • Kode ini mensimulasikan aplikasi yang mencoba menyimpan data pengguna.

  • Jika operasi gagal (misalnya, pembagian dengan nol), transaksi kembali ke SAVEPOINT dan melanjutkan dengan operasi lain.

  • Data Alice dan Bob tetap tersimpan setelah COMMIT.

d. DDL Transaksional

PostgreSQL mendukung perintah DDL (seperti CREATE TABLE) dalam transaksi, yang dapat dibatalkan dengan ROLLBACK.

Contoh Skenario: Aplikasi mencoba membuat tabel sementara untuk pengujian, tetapi membatalkannya jika tidak diperlukan.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Buat tabel sementara
    _, err = tx.Exec(ctx, "CREATE TABLE temp_test (id int, name text)")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal membuat tabel: %v", err)
    }

    // Tambahkan kolom
    _, err = tx.Exec(ctx, "ALTER TABLE temp_test ADD COLUMN age int")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal mengubah tabel: %v", err)
    }

    // Simulasi keputusan untuk membatalkan
    fmt.Println("Membatalkan pembuatan tabel...")
    err = tx.Rollback(ctx)
    if err != nil {
        log.Fatalf("Gagal rollback: %v", err)
    }

    // Verifikasi tabel tidak ada
    var exists bool
    err = pool.QueryRow(ctx, "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'temp_test')").Scan(&exists)
    if err != nil {
        log.Fatalf("Gagal memeriksa tabel: %v", err)
    }
    if !exists {
        fmt.Println("Tabel temp_test tidak ada, rollback berhasil!")
    }
}

Penjelasan Kode:

  • Kode ini mensimulasikan pembuatan tabel sementara dalam transaksi.

  • Jika aplikasi memutuskan untuk tidak menggunakan tabel, ROLLBACK menghapus tabel tersebut.

  • Query verifikasi memastikan tabel tidak ada setelah ROLLBACK.


3. Apa Itu Penguncian (Locking) dan Mengapa Penting?

Penguncian mengatur akses konkuren ke data untuk mencegah konflik, seperti data tidak konsisten atau deadlock. Penguncian yang tepat meningkatkan performa dan keandalan aplikasi.


4. Mekanisme Penguncian di PostgreSQL

a. Multi-Version Concurrency Control (MVCC)

MVCC memungkinkan pembacaan dan penulisan data secara konkuren dengan menyimpan snapshot data untuk setiap transaksi.

Contoh Skenario: Aplikasi membaca saldo akun sementara transaksi lain memperbarui saldo.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Update saldo
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE accounts SET balance = balance + 100 WHERE user_id = 1")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update saldo: %v", err)
            return
        }
        time.Sleep(2 * time.Second) // Simulasi proses lama
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
        fmt.Println("Transaksi 1 selesai: Saldo diperbarui")
    }()

    // Transaksi 2: Baca saldo
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        var balance int
        err = tx.QueryRow(ctx, "SELECT balance FROM accounts WHERE user_id = 1").Scan(&balance)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca saldo: %v", err)
            return
        }
        fmt.Printf("Transaksi 2: Saldo = %d (data lama)\n", balance)
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Dua goroutine mensimulasikan dua transaksi konkuren.

  • Transaksi 1 memperbarui saldo, sedangkan Transaksi 2 membaca saldo.

  • Karena MVCC, Transaksi 2 melihat data lama hingga Transaksi 1 di-commit.

b. Penguncian Baris (Row-Level Locking)

PostgreSQL hanya mengunci baris yang diubah, memungkinkan konkurensi tinggi.

Contoh Skenario: Dua transaksi memperbarui baris berbeda dalam tabel yang sama.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Update baris id = 1
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE t_test SET id = id + 1 WHERE id = 1")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update baris 1: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
        fmt.Println("Transaksi 1: Baris id = 1 diperbarui")
    }()

    // Transaksi 2: Update baris id = 2
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE t_test SET id = id + 1 WHERE id = 2")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update baris 2: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
        fmt.Println("Transaksi 2: Baris id = 2 diperbarui")
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Dua transaksi memperbarui baris berbeda (id = 1 dan id = 2) secara konkuren.

  • Karena hanya baris yang diubah yang dikunci, kedua transaksi dapat berjalan tanpa konflik.

c. Penguncian Tabel (Table-Level Locking)

Perintah LOCK mengunci seluruh tabel, berguna untuk operasi kritis seperti pembuatan ID unik.

Contoh Skenario: Aplikasi menghasilkan nomor faktur unik tanpa duplikasi.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Kunci tabel
    _, err = tx.Exec(ctx, "LOCK TABLE invoice IN ACCESS EXCLUSIVE MODE")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal mengunci tabel: %v", err)
    }

    // Ambil ID maksimum dan insert nomor faktur baru
    var maxID int
    err = tx.QueryRow(ctx, "SELECT COALESCE(MAX(id), 0) FROM invoice").Scan(&maxID)
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal mendapatkan max ID: %v", err)
    }

    _, err = tx.Exec(ctx, "INSERT INTO invoice (id, description) VALUES ($1, $2)", maxID+1, "Faktur Baru")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal insert faktur: %v", err)
    }

    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Printf("Faktur baru dengan ID %d berhasil dibuat\n", maxID+1)
}

Penjelasan Kode:

  • Kode ini mengunci tabel invoice dengan ACCESS EXCLUSIVE MODE untuk mencegah duplikasi ID.

  • ID maksimum diambil, lalu nomor faktur baru dimasukkan.

  • Penguncian tabel memastikan tidak ada transaksi lain yang mengganggu.

d. SELECT FOR UPDATE dan FOR SHARE

SELECT FOR UPDATE mengunci baris untuk mencegah perubahan oleh transaksi lain. FOR SHARE memungkinkan pembacaan konkuren.

Contoh Skenario: Aplikasi memproses pesanan yang belum diproses, memastikan tidak ada duplikasi pemrosesan.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Ambil pesanan yang belum diproses dengan FOR UPDATE
    var orderID int
    err = tx.QueryRow(ctx, "SELECT id FROM orders WHERE processed = false LIMIT 1 FOR UPDATE").Scan(&orderID)
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal mengambil pesanan: %v", err)
    }

    // Proses pesanan
    _, err = tx.Exec(ctx, "UPDATE orders SET processed = true WHERE id = $1", orderID)
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal memproses pesanan: %v", err)
    }

    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Printf("Pesanan ID %d berhasil diproses\n", orderID)
}

Penjelasan Kode:

  • Kode ini mengambil pesanan yang belum diproses dengan FOR UPDATE, mengunci baris tersebut.

  • Baris diperbarui untuk menandakan pemrosesan selesai.

  • FOR UPDATE mencegah transaksi lain memproses pesanan yang sama.

e. SKIP LOCKED

SKIP LOCKED memungkinkan transaksi melewati baris yang sudah terkunci.

Contoh Skenario: Aplikasi pemesanan tiket mengambil kursi yang tersedia tanpa menunggu kursi yang terkunci.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Ambil dua kursi
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        var seatID int
        err = tx.QueryRow(ctx, "SELECT id FROM seats LIMIT 2 FOR UPDATE SKIP LOCKED").Scan(&seatID)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal mengambil kursi: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Kursi ID %d diambil\n", seatID)
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
    }()

    // Transaksi 2: Ambil dua kursi lain
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        var seatID int
        err = tx.QueryRow(ctx, "SELECT id FROM seats LIMIT 2 FOR UPDATE SKIP LOCKED").Scan(&seatID)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal mengambil kursi: %v", err)
            return
        }
        fmt.Printf("Transaksi 2: Kursi ID %d diambil\n", seatID)
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Dua transaksi mencoba mengambil kursi dengan FOR UPDATE SKIP LOCKED.

  • SKIP LOCKED memastikan transaksi kedua mengambil kursi yang berbeda tanpa menunggu.

f. Advisory Locks

Advisory locks mengunci angka tertentu, bukan baris atau tabel, dan tidak dilepaskan otomatis saat COMMIT.

Contoh Skenario: Aplikasi mengunci proses pembuatan laporan untuk mencegah duplikasi.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Ambil advisory lock
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "SELECT pg_advisory_lock(15)")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal mengambil advisory lock: %v", err)
            return
        }
        fmt.Println("Transaksi 1: Advisory lock 15 diambil")
        time.Sleep(2 * time.Second) // Simulasi proses laporan
        _, err = tx.Exec(ctx, "SELECT pg_advisory_unlock(15)")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal melepaskan advisory lock: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
        fmt.Println("Transaksi 1: Advisory lock dilepaskan")
    }()

    // Transaksi 2: Coba ambil advisory lock
    go func() {
        defer wg.Done()
        time.Sleep(500 * time.Millisecond) // Tunggu sebentar
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "SELECT pg_advisory_lock(15)")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal mengambil advisory lock: %v", err)
            return
        }
        fmt.Println("Transaksi 2: Advisory lock 15 diambil")
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Transaksi 1 mengambil advisory lock pada angka 15, mensimulasikan proses laporan.

  • Transaksi 2 menunggu hingga lock dilepaskan sebelum melanjutkan.

  • pg_advisory_unlock memastikan lock dilepaskan secara eksplisit.


5. Tingkat Isolasi Transaksi

a. Read Committed

Mode default di mana setiap perintah melihat snapshot data terbaru yang telah di-commit.

Contoh Skenario: Aplikasi memeriksa saldo akun selama transaksi lain menambahkan dana.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Baca saldo
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        var balance int
        err = tx.QueryRow(ctx, "SELECT SUM(balance) FROM accounts").Scan(&balance)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca saldo: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Saldo awal = %d\n", balance)
        time.Sleep(2 * time.Second)
        err = tx.QueryRow(ctx, "SELECT SUM(balance) FROM accounts").Scan(&balance)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca saldo kedua: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Saldo akhir = %d\n", balance)
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
    }()

    // Transaksi 2: Tambah saldo
    go func() {
        defer wg.Done()
        time.Sleep(500 * time.Millisecond)
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "INSERT INTO accounts (balance) VALUES (100)")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal insert saldo: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
        fmt.Println("Transaksi 2: Saldo baru ditambahkan")
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Transaksi 1 membaca saldo dua kali dalam mode Read Committed.

  • Transaksi 2 menambahkan saldo baru di tengah transaksi 1.

  • Transaksi 1 melihat saldo baru pada pembacaan kedua karena mode Read Committed.

b. Repeatable Read

Menggunakan snapshot yang sama sepanjang transaksi, mengabaikan perubahan oleh transaksi lain.

Contoh Skenario: Aplikasi membuat laporan saldo yang konsisten.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Baca saldo dengan REPEATABLE READ
    go func() {
        defer wg.Done()
        tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead})
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        var balance int
        err = tx.QueryRow(ctx, "SELECT SUM(balance) FROM accounts").Scan(&balance)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca saldo: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Saldo awal = %d\n", balance)
        time.Sleep(2 * time.Second)
        err = tx.QueryRow(ctx, "SELECT SUM(balance) FROM accounts").Scan(&balance)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca saldo kedua: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Saldo akhir = %d (sama karena REPEATABLE READ)\n", balance)
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
    }()

    // Transaksi 2: Tambah saldo
    go func() {
        defer wg.Done()
        time.Sleep(500 * time.Millisecond)
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "INSERT INTO accounts (balance) VALUES (100)")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal insert saldo: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
        fmt.Println("Transaksi 2: Saldo baru ditambahkan")
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Transaksi 1 menggunakan RepeatableRead untuk memastikan saldo yang dibaca konsisten.

  • Meskipun Transaksi 2 menambahkan saldo, Transaksi 1 tidak melihat perubahan tersebut.

c. Serializable

Memastikan transaksi berjalan seolah-olah tidak ada konkurensi, tetapi dapat gagal jika ada konflik.

Contoh Skenario: Aplikasi mencoba menghapus data dengan isolasi Serializable.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Baca dan hapus dengan SERIALIZABLE
    go func() {
        defer wg.Done()
        tx, err := pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        var count int
        err = tx.QueryRow(ctx, "SELECT COUNT(*) FROM t_deadlock").Scan(&count)
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal baca data: %v", err)
            return
        }
        fmt.Printf("Transaksi 1: Jumlah baris = %d\n", count)
        time.Sleep(2 * time.Second)
        _, err = tx.Exec(ctx, "DELETE FROM t_deadlock")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal hapus data: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v (mungkin konflik serialisasi)", err)
        } else {
            fmt.Println("Transaksi 1: Data dihapus")
        }
    }()

    // Transaksi 2: Hapus data
    go func() {
        defer wg.Done()
        time.Sleep(500 * time.Millisecond)
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "DELETE FROM t_deadlock")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal hapus data: %v", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
        fmt.Println("Transaksi 2: Data dihapus")
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Transaksi 1 menggunakan mode Serializable dan mencoba menghapus data.

  • Jika Transaksi 2 menghapus data terlebih dahulu, Transaksi 1 akan gagal karena konflik serialisasi.

  • Aplikasi harus menangani kegagalan ini dengan mencoba ulang.


6. Menangani Masalah: Deadlock dan Konflik Serialisasi

a. Deadlock

Deadlock terjadi ketika dua transaksi saling menunggu kunci.

Contoh Skenario: Dua transaksi mencoba memperbarui dua baris dalam urutan berbeda.

package main

import (
    "context"
    "fmt"
    "log"
    "sync"
    "time"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    var wg sync.WaitGroup
    wg.Add(2)

    // Transaksi 1: Update id = 1 lalu id = 2
    go func() {
        defer wg.Done()
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 1: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE t_deadlock SET id = id * 10 WHERE id = 1")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update id = 1: %v", err)
            return
        }
        time.Sleep(1 * time.Second)
        _, err = tx.Exec(ctx, "UPDATE t_deadlock SET id = id * 10 WHERE id = 2")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update id = 2: %v (mungkin deadlock)", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 1: %v", err)
        }
        fmt.Println("Transaksi 1 selesai")
    }()

    // Transaksi 2: Update id = 2 lalu id = 1
    go func() {
        defer wg.Done()
        time.Sleep(500 * time.Millisecond)
        tx, err := pool.Begin(ctx)
        if err != nil {
            log.Printf("Gagal memulai transaksi 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE t_deadlock SET id = id * 10 WHERE id = 2")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update id = 2: %v", err)
            return
        }
        _, err = tx.Exec(ctx, "UPDATE t_deadlock SET id = id * 10 WHERE id = 1")
        if err != nil {
            tx.Rollback(ctx)
            log.Printf("Gagal update id = 1: %v (mungkin deadlock)", err)
            return
        }
        err = tx.Commit(ctx)
        if err != nil {
            log.Printf("Gagal commit transaksi 2: %v", err)
        }
        fmt.Println("Transaksi 2 selesai")
    }()

    wg.Wait()
}

Penjelasan Kode:

  • Dua transaksi mencoba memperbarui baris dalam urutan berbeda, menyebabkan deadlock.

  • PostgreSQL akan membatalkan salah satu transaksi setelah deadlock_timeout.

b. Konflik Serialisasi

Konflik serialisasi terjadi di mode Serializable ketika transaksi tidak dapat diserialkan.

Catatan: Contoh untuk konflik serialisasi sudah disertakan pada bagian Serializable di atas.


7. Optimasi Penyimpanan dan Pembersihan

a. VACUUM dan Autovacuum

VACUUM membersihkan dead tuples yang dihasilkan oleh MVCC. Autovacuum menjalankannya secara otomatis.

Contoh Skenario: Aplikasi menjalankan VACUUM secara manual setelah pembaruan besar.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Update besar
    _, err = tx.Exec(ctx, "UPDATE t_test SET id = id + 1")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal update: %v", err)
    }

    // Jalankan VACUUM
    _, err = tx.Exec(ctx, "VACUUM t_test")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal menjalankan VACUUM: %v", err)
    }

    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Println("VACUUM selesai, ruang kosong dikelola")
}

Penjelasan Kode:

  • Kode ini mensimulasikan pembaruan besar pada tabel, diikuti oleh VACUUM untuk membersihkan dead tuples.

  • VACUUM memastikan ruang kosong dapat digunakan kembali.

b. Transaction Wraparound

Autovacuum mencegah wraparound ID transaksi dengan menyesuaikan watermark.

Catatan: Tidak ada kode Golang spesifik untuk wraparound karena dikelola oleh autovacuum. Konfigurasi dilakukan di postgresql.conf.

c. VACUUM FULL

VACUUM FULL menulis ulang tabel untuk mengembalikan ruang ke sistem file, tetapi memblokir akses.

Contoh Skenario: Aplikasi menjalankan VACUUM FULL pada tabel kecil untuk pengujian.

package main

import (
    "context"
    "fmt"
    "log"
    "github.com/jackc/pgx/v5/pgxpool"
)

func main() {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, "postgres://user:password@localhost:5432/testdb")
    if err != nil {
        log.Fatalf("Gagal koneksi ke database: %v", err)
    }
    defer pool.Close()

    tx, err := pool.Begin(ctx)
    if err != nil {
        log.Fatalf("Gagal memulai transaksi: %v", err)
    }

    // Jalankan VACUUM FULL
    _, err = tx.Exec(ctx, "VACUUM FULL t_test")
    if err != nil {
        tx.Rollback(ctx)
        log.Fatalf("Gagal menjalankan VACUUM FULL: %v", err)
    }

    err = tx.Commit(ctx)
    if err != nil {
        log.Fatalf("Gagal commit: %v", err)
    }

    fmt.Println("VACUUM FULL selesai, tabel ditulis ulang")
}

Penjelasan Kode:

  • Kode ini menjalankan VACUUM FULL untuk menulis ulang tabel.

  • Digunakan untuk tabel kecil karena mengunci tabel sepenuhnya.


8. Kesimpulan

Artikel ini telah menjelaskan transaksi dan penguncian di PostgreSQL dengan contoh kode Golang menggunakan pgxpool. Fitur seperti MVCC, savepoint, DDL transaksional, dan advisory locks memungkinkan pengelolaan data yang efisien dan konkuren. Tingkat isolasi (Read Committed, Repeatable Read, Serializable) memberikan fleksibilitas untuk berbagai kebutuhan aplikasi. Dengan VACUUM dan autovacuum, Anda dapat menjaga performa basis data. Kode-kode di atas mensimulasikan skenario nyata seperti transfer saldo, pemesanan tiket, dan pembuatan laporan, membantu Anda memahami penerapan praktis dari konsep ini.

Catatan Tambahan:

  • Pastikan Anda mengganti string koneksi (postgres://user:password@localhost:5432/testdb) dengan kredensial database Anda.

  • Tabel seperti accounts, t_test, invoice, orders, seats, dan t_deadlock harus dibuat terlebih dahulu di database untuk menjalankan kode.

  • Kode menggunakan sync.WaitGroup untuk mensimulasikan konkurensi dalam skenario seperti MVCC, deadlock, dan SKIP LOCKED.

  • Library pgxpool dipilih karena mendukung koneksi pool yang efisien untuk aplikasi produksi.

Jika Anda memerlukan bantuan untuk membuat tabel atau menjalankan kode, beri tahu saya!

Last updated