Atomicity per document menjamin satu operasi utuh — tetapi jebakan muncul ketika aplikasi sendiri yang membagi pekerjaan: membaca nilai, menghitung di memori, lalu menulis hasil hitungan itu kembali. Pola read-modify-write seperti ini membuka celah lost update: dua proses membaca nilai yang sama, dan tulisan yang belakangan menimpa hasil perhitungan proses pertama. Domain baru kita untuk tiga chapter ke depan adalah dompet digital: transfer saldo antar pengguna, tempat kesalahan seperti ini terasa langsung dalam rupiah. Kita demokan kekalahannya secara berurutan, lalu menutupnya dengan update operator dan filter penjaga.
Read-Modify-Write Tanpa Penjaga #
Kode berikut adalah simulasi sekuensial dari dua proses yang berjalan nyaris bersamaan — masing-masing membaca saldo, lalu menambah bonus 10000 hasil perhitungannya sendiri lewat replaceOne():
use latihan_relasi
db.dompet.drop()
db.dompet.insertOne({ pengguna: "Budi", saldo: 100000 })
// Proses A membaca saldo
var dibacaA = db.dompet.findOne({ pengguna: "Budi" }).saldo
// Proses B membaca saldo yang SAMA — tulisan A belum terjadi
var dibacaB = db.dompet.findOne({ pengguna: "Budi" }).saldo
// Proses A menulis hasil perhitungannya
db.dompet.replaceOne({ pengguna: "Budi" }, { pengguna: "Budi", saldo: dibacaA + 10000 })
// Proses B menulis hasil perhitungan yang sudah basi
db.dompet.replaceOne({ pengguna: "Budi" }, { pengguna: "Budi", saldo: dibacaB + 10000 })
db.dompet.findOne({ pengguna: "Budi" }, { _id: 0 })Output yang Dihasilkan:
switched to db latihan_relasi
true
{
acknowledged: true,
insertedId: ObjectId('6ad39af2efe22c927cadf56e')
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{ pengguna: 'Budi', saldo: 110000 }Dua bonus @10000 seharusnya menghasilkan saldo 120000, tetapi hasilnya hanya 110000. Urutan peristiwanya:
| Waktu | Proses A | Proses B |
|---|---|---|
| 1 | baca saldo → 100000 | |
| 2 | baca saldo → 100000 | |
| 3 | tulis 100000 + 10000 | |
| 4 | tulis 100000 + 10000 — menimpa tulisan A |
Tulisan proses A tertimpa begitu proses B menulis — itulah lost update. Pada sistem nyata kedua proses berjalan di server dan klien berbeda sehingga interleave di atas terjadi alami; simulasi sekuensial kita hanya memindahkannya ke satu layar. Akar masalahnya bukan replaceOne()-nya, melainkan celah antara baca dan tulis — perhitungan + 10000 dilakukan di memori aplikasi dengan data yang bisa kedaluwarsa kapan saja.
Operator Tunggal Menghitung di Server #
Solusi pertama: pindahkan perhitungan ke server. $inc membaca dan menulis dalam satu operasi atomic, sehingga tidak ada celah yang bisa dimasuki proses lain:
db.dompet.replaceOne({ pengguna: "Budi" }, { pengguna: "Budi", saldo: 100000 })
db.dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: 10000 } })
db.dompet.updateOne({ pengguna: "Budi" }, { $inc: { saldo: 10000 } })
db.dompet.findOne({ pengguna: "Budi" }, { _id: 0 })Output yang Dihasilkan:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{ pengguna: 'Budi', saldo: 120000 }Dua operasi $inc yang sama kini menghasilkan 120000 — masing-masing menambahkan pada nilai terbaru di server, apa pun yang terjadi di antaranya. Keluarga operator yang bekerja dengan cara sama: $mul untuk perkalian, $min/$max untuk batas, $push/$addToSet untuk array (kita bahas semua di Chapter Update Operators). Aturan praktisnya: selama perubahan bisa dinyatakan sebagai operator update, jangan hitung di aplikasi.
Filter Sebagai Penjaga: Compare-and-Set #
Bagaimana bila syarat bisnis lebih rumit dari sekadar menambah — misalnya saldo tidak boleh minus? Jawabannya menempatkan syarat itu ke dalam filter: pencocokan filter dan penerapan update dievaluasi server dalam satu operasi atomic, jadi pengecekan tidak bisa lolos di sela waktu:
// Atur ulang saldo untuk skenario penarikan
db.dompet.updateOne({ pengguna: "Budi" }, { $set: { saldo: 40000 } })
// Saldo cukup — penarikan berjalan
db.dompet.updateOne(
{ pengguna: "Budi", saldo: { $gte: 25000 } },
{ $inc: { saldo: -25000 } }
)
// Saldo tinggal 15000 — penarikan 25000 ditolak
db.dompet.updateOne(
{ pengguna: "Budi", saldo: { $gte: 25000 } },
{ $inc: { saldo: -25000 } }
)
db.dompet.findOne({ pengguna: "Budi" }, { _id: 0 })Output yang Dihasilkan:
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
{
acknowledged: true,
insertedId: null,
matchedCount: 0,
modifiedCount: 0,
upsertedCount: 0
}
{ pengguna: 'Budi', saldo: 15000 }Baca kedua hasil itu dengan cermat: percobaan pertama melaporkan matchedCount: 1 (saldo berkurang menjadi 15000), percobaan kedua matchedCount: 0 — filter saldo: { $gte: 25000 } tidak lagi cocok, sehingga $inc tidak pernah dijalankan dan saldo tetap utuh. Pola filter kondisi + operator ini dikenal sebagai compare-and-set: keputusan dan eksekusi menyatu dalam satu operasi.
Kapan replaceOne() tetap sah? Ketika document praktis tidak pernah ditulis dua proses bersamaan (data konfigurasi dengan satu penulis), atau ketika digunakan bersama penjaga versi — pola yang kita bangun penuh di Chapter Optimistic Concurrency.
Rangkuman #
- Read-modify-write di sisi aplikasi membuka celah lost update: tulisan terakhir menimpa hasil perhitungan yang lebih dulu.
$incdan kawan-kawan menghitung di server secara atomic — sepanjang perubahan bisa dinyatakan sebagai operator update, hindari menghitung di aplikasi.- Filter kondisi (
saldo: { $gte: nominal }) mengubah update menjadi compare-and-set: syarat dan eksekusi tak terpisahkan;matchedCount: 0berarti ditolak. replaceOne()aman untuk document berpenulis tunggal atau dengan penjaga versi.
Penjaga versi itulah langkah berikutnya: bagaimana bila dua proses memang harus read-modify-write pada document yang sama, dan keduanya berhak menang dengan syarat mencoba lagi.