Server latihan kita adalah replica set rs0, sehingga transaksi multi-document bisa dijalankan sungguhan — bukan sekadar dibayangkan. Chapter ini mengerjakan transfer saldo dompet digital dari awal sampai akhir: mengcommit perubahan dua document dengan core API, membatalkannya dengan abortTransaction(), memadatkannya lewat callback API withTransaction(), lalu mengamati apa yang terjadi ketika operasi di dalam transaksi gagal — termasuk error label yang menjadi petunjuk coba-ulang. Seluruh output di bawah adalah hasil eksekusi asli di rs0.
Data Awal: Dua Pengguna Dompet #
Kita siapkan collection dompet berisi saldo Budi dan Siti, lalu mutasi dengan unique index untuk keperluan ujian kegagalan di akhir chapter:
use latihan_relasi
db.dompet.drop()
db.mutasi.drop()
db.dompet.insertMany([
{ pengguna: "Budi", saldo: 100000 },
{ pengguna: "Siti", saldo: 50000 }
])
db.mutasi.createIndex({ kode: 1 }, { unique: true })Output yang Dihasilkan:
switched to db latihan_relasi
true
true
{
acknowledged: true,
insertedIds: {
'0': ObjectId('6ad39e9b83b7c32b2dc288e5'),
'1': ObjectId('6ad39e9b83b7c32b2dc288e6')
}
}
kode_1Core API: startTransaction sampai commitTransaction #
Transaksi menempel pada session. Setiap operasi di dalam transaksi harus dijalankan melalui collection yang diperoleh dari session.getDatabase(), bukan lewat db biasa — itulah tanda bagi server bahwa operasi ini bagian dari transaksi. Urutan lengkapnya: buat session, mulai transaksi, jalankan operasi, lalu commit:
const session = db.getMongo().startSession()
session.startTransaction()
const dompet = session.getDatabase("latihan_relasi").dompet
dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -25000 } })
dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 25000 } })
session.commitTransaction()
session.endSession()
db.dompet.find({}, { _id: 0, pengguna: 1, saldo: 1 }).sort({ pengguna: 1 })Output yang Dihasilkan:
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
[
{ pengguna: 'Budi', saldo: 75000 },
{ pengguna: 'Siti', saldo: 75000 }
]Sebelum lanjut, satu catatan kecil: blok terakhir di atas memakai projection { _id: 0, pengguna: 1, saldo: 1 } agar hasil verifikasi ringkas dibaca — _id yang panjang tidak relevan untuk pengamatan saldo.
Transfer 25000 tuntas: Budi turun ke 75000, Siti naik ke 75000, keduanya berubah bersama. Mari kita bedah baris-barisnya:
const session = db.getMongo().startSession(): membuat session baru dari koneksi mongosh saat ini.session.startTransaction(): menandai bahwa operasi berikutnya pada session ini masuk satu transaksi.session.getDatabase("latihan_relasi").dompet: mengambil handle collection versi session — operasi lewat handle ini membawa identitas transaksi.- Dua
updateOne(...): memotong saldo Budi dan menambah saldo Siti; keduanya belum permanen sampai commit. session.commitTransaction(): membuat seluruh perubahan permanen secara atomik.session.endSession(): melepaskan session; setelah inidbbiasa kembali dipakai untuk memverifikasi hasil.
abortTransaction: Menggulung Perubahan #
Skenario yang sama dijalankan lagi — transfer 10000 dari Budi ke Siti — tetapi sebelum commit dipanggil, kita urungkan semuanya dengan abortTransaction():
const s2 = db.getMongo().startSession()
s2.startTransaction()
const d2 = s2.getDatabase("latihan_relasi").dompet
d2.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -10000 } })
d2.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 10000 } })
s2.abortTransaction()
s2.endSession()
db.dompet.find({}, { _id: 0, pengguna: 1, saldo: 1 }).sort({ pengguna: 1 })Output yang Dihasilkan:
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
[
{ pengguna: 'Budi', saldo: 75000 },
{ pengguna: 'Siti', saldo: 75000 }
]Sebelum lanjut, satu catatan kecil: blok terakhir di atas memakai projection { _id: 0, pengguna: 1, saldo: 1 } agar hasil verifikasi ringkas dibaca — _id yang panjang tidak relevan untuk pengamatan saldo.
Dua updateOne di dalam transaksi sama-sama melaporkan modifiedCount: 1 — seolah berhasil — namun saldo di akhir tetap 75000/75000. abortTransaction() menggulung seluruh perubahan seakan tidak pernah terjadi; itulah huruf A pada ACID yang bekerja.
Callback API: withTransaction #
Mongosh menyediakan jalan pintas: session.withTransaction(callback) memulai transaksi, menjalankan callback, lalu otomatis commit — dan bila callback melempar galat, otomatis abort. Kita pakai untuk mencatat mutasi TRF-001 sekaligus memindahkan 10000 saldo:
const s3 = db.getMongo().startSession()
s3.withTransaction(() => {
const dbx = s3.getDatabase("latihan_relasi")
dbx.mutasi.insertOne({ kode: "TRF-001", dari: "Budi", ke: "Siti", jumlah: 10000 })
dbx.dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -10000 } })
dbx.dompet.updateOne({ pengguna: "Siti" }, { $inc: { saldo: 10000 } })
})
s3.endSession()
db.dompet.find({}, { _id: 0, pengguna: 1, saldo: 1 }).sort({ pengguna: 1 })Output yang Dihasilkan:
{
acknowledged: true,
insertedId: ObjectId('6ad39e9b9dd0e82a2e17d34e')
}
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
{ acknowledged: true, insertedId: null, matchedCount: 1, modifiedCount: 1, upsertedCount: 0 }
[
{ pengguna: 'Budi', saldo: 65000 },
{ pengguna: 'Siti', saldo: 85000 }
]Satu panggilan withTransaction menjalankan tiga operasi pada dua collection (mutasi dan dompet) dan mengikatnya dalam satu kesatuan. Callback menerima session sebagai argumen bila diperlukan, dan nilai kembaliannya dijadikan hasil commit.
Ketika Operasi Gagal: Error Label #
Ujian terakhir: transaksi yang berisi operasi valid diikuti operasi yang pasti gagal — insertOne mutasi dengan kode: "TRF-001" yang sudah dipakai, tertahan unique index. Kita tangkap galatnya dengan try...catch lalu periksa errorLabels-nya:
try {
const s4 = db.getMongo().startSession()
s4.withTransaction(() => {
const dbx = s4.getDatabase("latihan_relasi")
dbx.dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: -5000 } })
dbx.mutasi.insertOne({ kode: "TRF-001", dari: "Budi", ke: "Siti", jumlah: 5000 })
})
s4.endSession()
} catch (e) {
print("Operasi gagal: " + e.message)
print("errorLabels: " + JSON.stringify(e.errorLabels))
}
db.dompet.find({}, { _id: 0, pengguna: 1, saldo: 1 }).sort({ pengguna: 1 })Output yang Dihasilkan:
Operasi gagal: E11000 duplicate key error collection: latihan_relasi.mutasi index: kode_1 dup key: { kode: "TRF-001" }
errorLabels: []
[
{ pengguna: 'Budi', saldo: 65000 },
{ pengguna: 'Siti', saldo: 85000 }
]Baca urutan kejadiannya: updateOne memotong saldo Budi sukses, insertOne mutasi ditolak E11000, dan withTransaction melempar galat itu keluar untuk ditangkap catch. Yang paling penting ada di baris terakhir: saldo Budi tetap 65000. Perubahan pertama yang “berhasil” ikut tergulung; invariant selamat.
Perhatikan pula errorLabels milik galat ini: kosong. E11000 adalah galat permanen — data yang dikirim memang melanggar unique index, mengulanginya seribu kali pun hasilnya sama. Driver dan withTransaction() hanya mengulang galat yang berlabel tertentu:
TransientTransactionError— kegagalan sementara (misalnya pemilihan primary baru di tengah transaksi): ulangi seluruh transaksi dari awal.UnknownTransactionCommitResult— hasil commit tak diketahui karena gangguan jaringan: ulangi hanyacommitTransaction()-nya, jangan ulangi operasinya.
Kedua label dibaca dari e.errorLabels pada driver, dan itulah yang withTransaction() kerjakan otomatis di balik layar. Galat tanpa label seperti E11000 dibiarkan langsung sampai ke pemanggil — memang begitu seharusnya.
Rangkuman #
- Transaksi menempel pada session; seluruh operasi harus lewat
session.getDatabase(), lalucommitTransaction()membuatnya permanen atauabortTransaction()menggulungnya. withTransaction(callback)memadatkan siklus start/commit/abort dan otomatis menangani coba-ulang berdasarkan error label.- Operasi yang gagal di dalam transaksi membatalkan seluruh transaksi — termasuk perubahan yang lebih dulu “berhasil”.
TransientTransactionError= ulangi transaksi;UnknownTransactionCommitResult= ulangi commit; keduanya tersedia die.errorLabels.
Menjalankan transaksi ternyata mudah; menjalankannya dengan benar adalah seni lain. Chapter Transaction Boundaries merinci aturan praktis menjaga transaksi tetap kecil, cepat, dan aman diulang.