Semua yang kita pelajari tentang transaksi di Section Transactions — BEGIN, COMMIT, ROLLBACK, savepoint, isolation level — pada praktiknya dikendalikan dari kode aplikasi, bukan dari psql. Driver yang berbeda mengelolanya dengan cara yang berbeda: psycopg membuka transaksi secara implisit, node-postgres menyerahkan kendali penuh ke aplikasi, dan keduanya harus menghadapi kenyataan bahwa transaksi bisa gagal di tengah jalan karena alasan di luar kode kita. Chapter ini membedah keempat situasi tersebut dengan kode yang benar-benar berjalan: commit implisit, context manager, savepoint per aplikasi, dan pola retry untuk kegagalan serialisasi.
Commit Implisit psycopg #
psycopg membuka transaksi otomatis pada pernyataan pertama yang dieksekusi — dan melakukan rollback implisit jika koneksi ditutup tanpa commit(). Perilaku ini paling mudah dipahami dengan melihat kedua sisinya berdampingan:
# Perilaku commit di psycopg: transaksi implisit sejak execute()
# pertama, dan rollback implisit bila koneksi ditutup tanpa commit.
import psycopg
conn = psycopg.connect()
conn.execute("""
CREATE TABLE item_pesanan (
id integer PRIMARY KEY,
pesanan integer NOT NULL,
produk text NOT NULL,
jumlah integer NOT NULL
)
""")
conn.commit()
# psycopg membuka transaksi implisit pada execute() pertama.
conn.execute(
"INSERT INTO item_pesanan VALUES (1, 101, 'Beras 5 kg', 2)")
conn.close() # ditutup TANPA commit
with psycopg.connect() as cek:
print("A) INSERT tanpa commit, koneksi ditutup:",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
conn = psycopg.connect()
conn.execute(
"INSERT INTO item_pesanan VALUES (1, 101, 'Beras 5 kg', 2)")
conn.commit() # commit eksplisit
conn.close()
with psycopg.connect() as cek:
print("B) INSERT lalu conn.commit() :",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")Output yang Dihasilkan:
A) INSERT tanpa commit, koneksi ditutup: 0 baris
B) INSERT lalu conn.commit() : 1 barisKontras pada output menutup pembahasannya sendiri: kasus A menghasilkan 0 baris karena koneksi ditutup sebelum commit(), sedangkan kasus B menghasilkan 1 baris karena commit() dipanggil. Perlu dicatat bahwa CREATE TABLE di awal juga ditutup dengan commit() — perintah DDL ikut dalam transaksi yang sama, dan tanpa commit, tabelnya pun ikut lenyap.
Context Manager: Commit dan Rollback Otomatis #
with psycopg.connect() as conn: merangkum seluruh disiplin transaksi dalam satu blok: sukses berarti commit otomatis, exception berarti rollback otomatis, dan koneksi ditutup saat blok selesai:
# Context manager koneksi psycopg: commit saat blok sukses,
# rollback saat blok error, koneksi ditutup saat blok selesai.
import psycopg
# Blok sukses: seluruh INSERT di dalamnya di-commit otomatis
# saat blok keluar tanpa error.
with psycopg.connect() as conn:
conn.execute(
"INSERT INTO item_pesanan VALUES (2, 101, 'Gula 1 kg', 1)")
conn.execute(
"INSERT INTO item_pesanan VALUES (3, 101, 'Kopi sachet', 12)")
print("di dalam blok, koneksi masih terbuka:", not conn.closed)
with psycopg.connect() as cek:
print("setelah blok sukses (auto-commit) :",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
# Blok error: exception di tengah blok memicu rollback otomatis.
try:
with psycopg.connect() as conn:
conn.execute(
"INSERT INTO item_pesanan VALUES (4, 102, 'Teh celup', 3)")
raise RuntimeError("aplikasi gagal di tengah proses")
except RuntimeError as e:
print("exception ditangkap:", e)
with psycopg.connect() as cek:
print("setelah blok error (auto-rollback):",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
# psycopg 3 menutup koneksinya saat blok selesai (berbeda dari
# psycopg 2 yang hanya commit/rollback tanpa menutup koneksi).
print("koneksi ditutup otomatis setelah blok:", conn.closed)Output yang Dihasilkan:
di dalam blok, koneksi masih terbuka: True
setelah blok sukses (auto-commit) : 3 baris
exception ditangkap: aplikasi gagal di tengah proses
setelah blok error (auto-rollback): 3 baris
koneksi ditutup otomatis setelah blok: TrueTiga fakta dari output tersebut. Pertama, blok sukses menaikkan hitungan tabel dari 1 menjadi 3 baris — dua INSERT ter-commit bersama. Kedua, blok error tidak meninggalkan jejak: Teh celup tidak pernah tersimpan karena exception memicu rollback, dan hitungan tetap 3. Ketiga, conn.closed bernilai True setelah blok — psycopg 3 menutup koneksinya juga, berbeda dari psycopg 2 yang hanya meng-commit/rollback.
Savepoint dari Aplikasi #
psycopg menyediakan conn.transaction() sebagai context manager transaksi. Dipanggil di dalam transaksi yang sedang berjalan, ia otomatis menjadi SAVEPOINT — sehingga pekerjaan tambahan yang berisiko gagal bisa dibatalkan sebagian tanpa membatalkan seluruh pesanan:
# Savepoint dari aplikasi: conn.transaction() yang dipanggil di dalam
# transaksi yang sedang berjalan otomatis menjadi SAVEPOINT.
import psycopg
from psycopg import errors
conn = psycopg.connect()
conn.execute("""
CREATE TABLE stok (
id integer PRIMARY KEY,
barang text NOT NULL,
jumlah integer NOT NULL
)
""")
conn.execute(
"INSERT INTO stok VALUES (1, 'Gula pasir', 100), (2, 'Kopi bubuk', 80)")
conn.commit()
with conn.transaction():
# Transaksi luar: mengurangi stok untuk satu pesanan.
conn.execute("UPDATE stok SET jumlah = jumlah - 10 WHERE id = 1")
try:
with conn.transaction():
# Transaksi dalam -> SAVEPOINT: pekerjaan tambahan
# yang boleh gagal sendiri tanpa membatalkan pesanan.
# Pembagian dengan nol mensimulasikan bug di kode diskon.
conn.execute(
"UPDATE stok SET jumlah = jumlah - 100 / 0 WHERE id = 2")
except errors.DivisionByZero as e:
print(f"bug diskon tertangkap, rollback ke savepoint "
f"(sqlstate {e.sqlstate})")
# Pekerjaan luar berlanjut seolah bagian dalam tidak pernah terjadi.
conn.execute("UPDATE stok SET jumlah = jumlah - 5 WHERE id = 1")
cur = conn.execute("SELECT id, barang, jumlah FROM stok ORDER BY id")
for id_barang, barang, jumlah in cur.fetchall():
print(f"{id_barang} {barang}: {jumlah}")
conn.close()Output yang Dihasilkan:
bug diskon tertangkap, rollback ke savepoint (sqlstate 22012)
1 Gula pasir: 85
2 Kopi bubuk: 80Mari kita telusuri hasil akhirnya di tabel stok. Gula pasir turun dari 100 menjadi 85 — pengurangan 10 dan 5 dari transaksi luar keduanya menempel. Kopi bubuk tetap 80: pernyataan yang membagi dengan nol memicu DivisionByZero (sqlstate 22012), rollback mengembalikan keadaan ke savepoint, dan transaksi luar tetap berlanjut hingga commit. Inilah pola “pekerjaan inti + pekerjaan tambahan yang boleh gagal” yang dibahas di chapter SAVEPOINT — tanpa satu pun perintah SAVEPOINT yang ditulis manual.
Transaksi Eksplisit di node-postgres #
node-postgres memilih filosofi berbeda: tidak ada transaksi implisit sama sekali. Aplikasi mengirim BEGIN, COMMIT, dan ROLLBACK sebagai query biasa:
// BEGIN/COMMIT/ROLLBACK eksplisit di node-postgres.
// Driver ini tidak mengelola transaksi implisit: aplikasi mengirim
// perintah BEGIN dan COMMIT sebagai query biasa.
import pg from "pg";
const client = new pg.Client();
await client.connect();
await client.query(`
CREATE TABLE transfer_node (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
dari text NOT NULL,
ke text NOT NULL,
jumlah integer NOT NULL
)
`);
// Transaksi pertama: BEGIN, dua INSERT, COMMIT -- keduanya masuk
// atau keduanya batal.
await client.query("BEGIN");
await client.query(
"INSERT INTO transfer_node (dari, ke, jumlah) VALUES ($1, $2, $3)",
["Budi", "Siti", 50000],
);
await client.query(
"INSERT INTO transfer_node (dari, ke, jumlah) VALUES ($1, $2, $3)",
["Siti", "Ani", 10000],
);
await client.query("COMMIT");
// Transaksi kedua: gagal di tengah blok, dibatalkan dengan ROLLBACK.
await client.query("BEGIN");
try {
await client.query(
"INSERT INTO transfer_node (dari, ke, jumlah) VALUES ($1, $2, $3)",
["Ani", "Budi", 999999],
);
// Nama tabel yang salah memicu error sebelum COMMIT sempat dikirim.
await client.query("INSERT INTO transfer_salah (x) VALUES (1)");
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
console.log(`transaksi kedua dibatalkan: sqlstate=${err.code}`);
}
const total = await client.query("SELECT count(*) AS n FROM transfer_node");
console.log("baris transfer tersimpan:", total.rows[0].n);
await client.end();Output yang Dihasilkan:
transaksi kedua dibatalkan: sqlstate=42P01
baris transfer tersimpan: 2Dua transfer pertama tersimpan karena COMMIT terkirim setelah keduanya berhasil. Transaksi kedua dijatuhkan oleh ROLLBACK di blok catch — error 42P01 (undefined table) terjadi sebelum COMMIT sempat dikirim, dan hasil verifikasi akhir membuktikan baris Ani -> Budi tidak pernah masuk. Perhatikan juga pola err.code: node-postgres mengekspos SQLSTATE langsung sebagai properti code, selaras dengan tabel kode yang dibahas di chapter Menangani Database Errors.
Retry Transaksi yang Gagal Serialisasi #
Skenario penutup sekaligus paling realistis: dua koneksi SERIALIZABLE berlomba menulis data yang sama, server menolak yang kalah dengan sqlstate 40001, dan aplikasi bertanggung jawab mengulanginya. Skrip berikut memainkan seluruh drama itu dengan dua koneksi psycopg sungguhan dalam satu proses:
# Retry transaksi SERIALIZABLE: dua proses membaca keadaan yang sama,
# keduanya menulis, server menolak transaksi kedua dengan 40001,
# lalu transaksi tersebut diulang dari awal dengan data segar.
import psycopg
from psycopg import errors
MAKS_PERCOBAAN = 3
# Siapkan data: dua petugas sedang bertugas jaga.
setup = psycopg.connect()
setup.execute("""
CREATE TABLE petugas_jaga (
id integer PRIMARY KEY,
nama text NOT NULL,
jaga boolean NOT NULL
)
""")
setup.execute(
"INSERT INTO petugas_jaga VALUES (1, 'Budi', TRUE), (2, 'Siti', TRUE)")
setup.commit()
setup.close()
# Aturan bisnis: petugas boleh berhenti jaga hanya bila masih ada
# petugas lain yang jaga -- minimal satu harus tetap bertugas.
a = psycopg.connect()
b = psycopg.connect()
a.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
b.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
# --- Percobaan pertama: dua transaksi berjalan tumpang tindih. ---
# A membaca dulu, B membaca sebelum A menulis, keduanya lalu menulis.
lain_a = a.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 1"
).fetchone()[0]
lain_b = b.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 2"
).fetchone()[0]
print(f"A melihat {lain_a} petugas lain jaga -> Budi berhenti")
print(f"B melihat {lain_b} petugas lain jaga -> Siti berhenti")
a.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 1")
a.commit()
print("A commit sukses: Budi berhenti jaga")
# SerializationFailure bisa muncul pada pernyataan maupun pada commit,
# jadi keduanya dibungkus satu try yang sama.
try:
b.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 2")
b.commit()
print("B commit sukses (tidak diharapkan)")
except errors.SerializationFailure as e:
print(f"B ditolak server: sqlstate {e.sqlstate}")
b.rollback()
# --- Retry: ulangi transaksi B dari awal dengan pembacaan segar. ---
for percobaan in range(1, MAKS_PERCOBAAN + 1):
print(f"percobaan ulang B ke-{percobaan}")
try:
lain = b.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 2"
).fetchone()[0]
if lain == 0:
b.rollback()
print(" Budi sudah berhenti -> Siti tetap jaga")
break
b.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 2")
b.commit()
print(f" Siti berhenti jaga ({lain} petugas lain masih jaga)")
break
except errors.SerializationFailure:
b.rollback()
print(" bentrok lagi, ulangi transaksi")
# --- Keadaan akhir setelah retry. ---
with psycopg.connect() as cek:
for id_petugas, nama, jaga in cek.execute(
"SELECT id, nama, jaga FROM petugas_jaga ORDER BY id").fetchall():
print(f"{id_petugas} {nama}: {'jaga' if jaga else 'berhenti'}")
a.close()
b.close()Output yang Dihasilkan:
A melihat 1 petugas lain jaga -> Budi berhenti
B melihat 1 petugas lain jaga -> Siti berhenti
A commit sukses: Budi berhenti jaga
B ditolak server: sqlstate 40001
percobaan ulang B ke-1
Budi sudah berhenti -> Siti tetap jaga
1 Budi: berhenti
2 Siti: jagaAlurnya terbaca lengkap dari output. Kedua transaksi membaca keadaan awal yang sama (masing-masing melihat satu petugas lain jaga), keduanya menulis — lalu server menolak transaksi B dengan sqlstate 40001 tepat seperti teori Serializable Transactions. Loop retry kemudian mengulang transaksi B dari pembacaan segar: kali ini B melihat Budi sudah berhenti, aturan bisnis “minimal satu bertugas” menghalangi, dan Siti tetap jaga. Hasil akhirnya konsisten — tanpa retry, aturan itu bisa terlanggar diam-diam.
Prinsip Penutup #
Empat pola pada chapter ini membentuk tangga prioritas yang bisa dipakai langsung: gunakan context manager sebagai bawaan (disiplin commit/rollback gratis), naik ke savepoint aplikasi ketika sebagian pekerjaan boleh gagal sendiri, pilih transaksi eksplisit bila driver menuntut kendali manual, dan bungkus semuanya dengan retry bersyarat ketika isolation level menjanjikan penolakan. Satu larangan menutup semuanya: jangan pernah menahan transaksi terbuka selama waktu tunggu pengguna — user think time di dalam transaksi adalah resep kebocoran koneksi dan deadlock yang dibahas di Section Transactions.
Dengan integrasi aplikasi tuntas, satu wilayah terakhir menunggu sebelum proyek akhir: membuktikan bahwa semua yang kita bangun benar-benar benar — Section Pengujian dan Debugging.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
commit-implisit.py
Unduh berkas# Perilaku commit di psycopg: transaksi implisit sejak execute()
# pertama, dan rollback implisit bila koneksi ditutup tanpa commit.
import psycopg
conn = psycopg.connect()
conn.execute("""
CREATE TABLE item_pesanan (
id integer PRIMARY KEY,
pesanan integer NOT NULL,
produk text NOT NULL,
jumlah integer NOT NULL
)
""")
conn.commit()
# psycopg membuka transaksi implisit pada execute() pertama.
conn.execute(
"INSERT INTO item_pesanan VALUES (1, 101, 'Beras 5 kg', 2)")
conn.close() # ditutup TANPA commit
with psycopg.connect() as cek:
print("A) INSERT tanpa commit, koneksi ditutup:",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
conn = psycopg.connect()
conn.execute(
"INSERT INTO item_pesanan VALUES (1, 101, 'Beras 5 kg', 2)")
conn.commit() # commit eksplisit
conn.close()
with psycopg.connect() as cek:
print("B) INSERT lalu conn.commit() :",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
context-manager.py
Unduh berkas# Context manager koneksi psycopg: commit saat blok sukses,
# rollback saat blok error, koneksi ditutup saat blok selesai.
import psycopg
# Blok sukses: seluruh INSERT di dalamnya di-commit otomatis
# saat blok keluar tanpa error.
with psycopg.connect() as conn:
conn.execute(
"INSERT INTO item_pesanan VALUES (2, 101, 'Gula 1 kg', 1)")
conn.execute(
"INSERT INTO item_pesanan VALUES (3, 101, 'Kopi sachet', 12)")
print("di dalam blok, koneksi masih terbuka:", not conn.closed)
with psycopg.connect() as cek:
print("setelah blok sukses (auto-commit) :",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
# Blok error: exception di tengah blok memicu rollback otomatis.
try:
with psycopg.connect() as conn:
conn.execute(
"INSERT INTO item_pesanan VALUES (4, 102, 'Teh celup', 3)")
raise RuntimeError("aplikasi gagal di tengah proses")
except RuntimeError as e:
print("exception ditangkap:", e)
with psycopg.connect() as cek:
print("setelah blok error (auto-rollback):",
cek.execute("SELECT count(*) FROM item_pesanan").fetchone()[0],
"baris")
# psycopg 3 menutup koneksinya saat blok selesai (berbeda dari
# psycopg 2 yang hanya commit/rollback tanpa menutup koneksi).
print("koneksi ditutup otomatis setelah blok:", conn.closed)
retry-serialisasi.py
Unduh berkas# Retry transaksi SERIALIZABLE: dua proses membaca keadaan yang sama,
# keduanya menulis, server menolak transaksi kedua dengan 40001,
# lalu transaksi tersebut diulang dari awal dengan data segar.
import psycopg
from psycopg import errors
MAKS_PERCOBAAN = 3
# Siapkan data: dua petugas sedang bertugas jaga.
setup = psycopg.connect()
setup.execute("""
CREATE TABLE petugas_jaga (
id integer PRIMARY KEY,
nama text NOT NULL,
jaga boolean NOT NULL
)
""")
setup.execute(
"INSERT INTO petugas_jaga VALUES (1, 'Budi', TRUE), (2, 'Siti', TRUE)")
setup.commit()
setup.close()
# Aturan bisnis: petugas boleh berhenti jaga hanya bila masih ada
# petugas lain yang jaga -- minimal satu harus tetap bertugas.
a = psycopg.connect()
b = psycopg.connect()
a.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
b.isolation_level = psycopg.IsolationLevel.SERIALIZABLE
# --- Percobaan pertama: dua transaksi berjalan tumpang tindih. ---
# A membaca dulu, B membaca sebelum A menulis, keduanya lalu menulis.
lain_a = a.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 1"
).fetchone()[0]
lain_b = b.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 2"
).fetchone()[0]
print(f"A melihat {lain_a} petugas lain jaga -> Budi berhenti")
print(f"B melihat {lain_b} petugas lain jaga -> Siti berhenti")
a.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 1")
a.commit()
print("A commit sukses: Budi berhenti jaga")
# SerializationFailure bisa muncul pada pernyataan maupun pada commit,
# jadi keduanya dibungkus satu try yang sama.
try:
b.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 2")
b.commit()
print("B commit sukses (tidak diharapkan)")
except errors.SerializationFailure as e:
print(f"B ditolak server: sqlstate {e.sqlstate}")
b.rollback()
# --- Retry: ulangi transaksi B dari awal dengan pembacaan segar. ---
for percobaan in range(1, MAKS_PERCOBAAN + 1):
print(f"percobaan ulang B ke-{percobaan}")
try:
lain = b.execute(
"SELECT count(*) FROM petugas_jaga WHERE jaga AND id <> 2"
).fetchone()[0]
if lain == 0:
b.rollback()
print(" Budi sudah berhenti -> Siti tetap jaga")
break
b.execute("UPDATE petugas_jaga SET jaga = FALSE WHERE id = 2")
b.commit()
print(f" Siti berhenti jaga ({lain} petugas lain masih jaga)")
break
except errors.SerializationFailure:
b.rollback()
print(" bentrok lagi, ulangi transaksi")
# --- Keadaan akhir setelah retry. ---
with psycopg.connect() as cek:
for id_petugas, nama, jaga in cek.execute(
"SELECT id, nama, jaga FROM petugas_jaga ORDER BY id").fetchall():
print(f"{id_petugas} {nama}: {'jaga' if jaga else 'berhenti'}")
a.close()
b.close()
savepoint-psycopg.py
Unduh berkas# Savepoint dari aplikasi: conn.transaction() yang dipanggil di dalam
# transaksi yang sedang berjalan otomatis menjadi SAVEPOINT.
import psycopg
from psycopg import errors
conn = psycopg.connect()
conn.execute("""
CREATE TABLE stok (
id integer PRIMARY KEY,
barang text NOT NULL,
jumlah integer NOT NULL
)
""")
conn.execute(
"INSERT INTO stok VALUES (1, 'Gula pasir', 100), (2, 'Kopi bubuk', 80)")
conn.commit()
with conn.transaction():
# Transaksi luar: mengurangi stok untuk satu pesanan.
conn.execute("UPDATE stok SET jumlah = jumlah - 10 WHERE id = 1")
try:
with conn.transaction():
# Transaksi dalam -> SAVEPOINT: pekerjaan tambahan
# yang boleh gagal sendiri tanpa membatalkan pesanan.
# Pembagian dengan nol mensimulasikan bug di kode diskon.
conn.execute(
"UPDATE stok SET jumlah = jumlah - 100 / 0 WHERE id = 2")
except errors.DivisionByZero as e:
print(f"bug diskon tertangkap, rollback ke savepoint "
f"(sqlstate {e.sqlstate})")
# Pekerjaan luar berlanjut seolah bagian dalam tidak pernah terjadi.
conn.execute("UPDATE stok SET jumlah = jumlah - 5 WHERE id = 1")
cur = conn.execute("SELECT id, barang, jumlah FROM stok ORDER BY id")
for id_barang, barang, jumlah in cur.fetchall():
print(f"{id_barang} {barang}: {jumlah}")
conn.close()