Dalam aplikasi Next.js yang menerima banyak request bersamaan, masalah utamanya bukan sekadar apakah query berhasil, tetapi apakah data tetap konsisten saat dua proses mengubah baris yang sama secara bersamaan. Di sinilah banyak tim bingung: apakah cukup memakai prisma.$transaction(), perlu atomic update, menggunakan optimistic concurrency, atau harus turun ke SQL SELECT ... FOR UPDATE?
Jawaban singkatnya: jangan langsung memakai FOR UPDATE. Untuk banyak kasus, conditional update atau operasi atomik sudah cukup dan lebih murah. FOR UPDATE berguna saat Anda benar-benar perlu membaca lalu memutuskan lalu menulis terhadap data yang sama, dan keputusan itu harus terlindungi dari race condition di level database.
Masalah yang Sering Terjadi di Next.js + Prisma
Next.js route handler atau server action bisa dipanggil secara paralel. Jika dua request datang hampir bersamaan, keduanya bisa membaca nilai yang sama lalu menulis hasil berbeda. Contohnya:
Checkout: stok produk tinggal 1, tetapi dua user lolos checkout.
Klaim voucher: voucher sekali pakai berhasil diklaim dua kali.
Job idempotent: worker memproses job yang sama dua kali karena status dibaca sebelum sempat diubah.
Banyak developer mengira prisma.$transaction() otomatis menyelesaikan semua race condition. Ini tidak selalu benar. Transaksi hanya menjamin serangkaian query dijalankan dalam satu boundary transaksi. Jika pola Anda adalah baca dulu, hitung di aplikasi, lalu update, transaksi biasa tetap bisa mengalami konflik jika tidak disertai mekanisme kontrol konkurensi yang tepat.
Empat Strategi yang Perlu Dibedakan
1. Transaksi biasa dengan Prisma
Prisma menyediakan prisma.$transaction() untuk mengelompokkan beberapa operasi agar commit atau rollback bersama. Ini cocok saat Anda perlu memastikan beberapa write sukses atau gagal sebagai satu unit.
const result = await prisma.$transaction(async (tx) => {
const order = await tx.order.create({
data: { userId, status: 'PENDING' }
})
await tx.payment.create({
data: { orderId: order.id, amount: total }
})
return order
})Pola ini berguna untuk konsistensi antar tabel. Namun, transaksi biasa bukan berarti otomatis mencegah dua transaksi membaca state lama yang sama. Jika logika bisnis bergantung pada hasil pembacaan sebelumnya, Anda perlu strategi tambahan.
2. Atomic update
Atomic update berarti Anda mengubah data langsung di database dalam satu statement yang aman terhadap race condition untuk operasi tertentu. Ini ideal untuk counter, stok, kuota, atau status yang bisa divalidasi langsung di klausa WHERE.
Contoh pengurangan stok secara aman:
const updated = await prisma.product.updateMany({
where: {
id: productId,
stock: { gte: quantity }
},
data: {
stock: { decrement: quantity }
}
})
if (updated.count === 0) {
throw new Error('Stok tidak cukup atau produk tidak ditemukan')
}Mengapa ini aman? Karena validasi stock >= quantity dan pengurangan stok terjadi di database sebagai satu operasi logis. Dua request yang balapan tidak akan sama-sama berhasil jika stok tidak cukup untuk keduanya.
Kapan atomic update cukup?
Aturan bisnis bisa diekspresikan langsung di
WHERE.Tidak perlu membaca banyak data lalu mengambil keputusan kompleks.
Anda ingin mengurangi lock duration dan contention.
Kelebihan: cepat, sederhana, minim lock eksplisit.
Kekurangan: tidak cocok jika keputusan bisnis bergantung pada banyak baris atau perhitungan yang tidak praktis dimasukkan ke query update.
3. Optimistic concurrency control
Optimistic concurrency mengasumsikan konflik jarang terjadi. Anda membaca data beserta version atau updatedAt, lalu saat update Anda cek bahwa versi belum berubah. Jika berubah, update gagal dan caller harus retry atau menampilkan error.
Contoh dengan field version:
const voucher = await prisma.voucher.findUnique({
where: { code: voucherCode },
select: { id: true, claimedByUserId: true, version: true }
})
if (!voucher || voucher.claimedByUserId) {
throw new Error('Voucher tidak tersedia')
}
const updated = await prisma.voucher.updateMany({
where: {
id: voucher.id,
version: voucher.version,
claimedByUserId: null
},
data: {
claimedByUserId: userId,
version: { increment: 1 }
}
})
if (updated.count === 0) {
throw new Error('Voucher sudah diklaim proses lain, silakan retry')
}Ini cocok untuk sistem yang konflik tulisnya tidak terlalu sering. Dibanding lock pesimistis, pendekatan ini biasanya punya throughput lebih baik karena request tidak saling menunggu lock lama.
Kapan optimistic concurrency cocok?
Konflik relatif jarang.
Retry masih dapat diterima.
Anda ingin menghindari lock database yang panjang.
Kekurangan utamanya: logic retry harus jelas. Jika konflik sering terjadi, user experience dan latensi bisa memburuk karena banyak request gagal lalu mengulang.
4. SELECT ... FOR UPDATE
SELECT ... FOR UPDATE adalah pessimistic locking. Database akan mengunci baris yang dibaca sampai transaksi selesai, sehingga transaksi lain yang ingin mengubah baris itu harus menunggu, gagal, atau mengikuti perilaku lock database yang berlaku.
Ini berguna saat Anda perlu pola seperti:
Baca row saat ini
Hitung keputusan bisnis di aplikasi
Tulis perubahan berdasarkan hasil baca itu
Jika langkah 1 dan 3 harus terlindung dari modifikasi transaksi lain, FOR UPDATE bisa menjadi pilihan.
Catatan penting: Prisma tidak menyediakan API tingkat tinggi khusus untuk semua variasi row locking SQL. Dalam praktiknya, Anda sering perlu memakai
prisma.$queryRawdi dalamprisma.$transaction()untukSELECT ... FOR UPDATE.
Prisma Transaction vs FOR UPDATE: Perbedaan Inti
Transaksi bukan lock yang cukup untuk semua kasus
prisma.$transaction() memberi boundary transaksi. Tetapi tanpa query yang memang melakukan lock atau tanpa pola update bersyarat, dua transaksi masih bisa:
membaca nilai awal yang sama,
mengambil keputusan yang sama,
lalu salah satu menimpa hasil yang lain atau menyebabkan oversell.
Jadi pertanyaan yang benar bukan "perlu transaction atau tidak?", melainkan "mekanisme kontrol konkurensi apa yang sesuai untuk bentuk race condition saya?".
FOR UPDATE dipakai saat keputusan bergantung pada state yang dibaca
Jika semua validasi bisa dipindahkan ke klausa WHERE, biasanya atomic update lebih baik. Tetapi jika Anda harus membaca beberapa field, memanggil aturan bisnis, lalu baru memutuskan update, lock pesimistis bisa lebih aman daripada berharap retry akan selalu cukup.
Contoh Kasus Nyata dan Strategi yang Tepat
Kasus 1: Checkout stok produk
Untuk stok sederhana, mulai dari atomic update, bukan FOR UPDATE.
await prisma.$transaction(async (tx) => {
const stockUpdate = await tx.product.updateMany({
where: {
id: productId,
stock: { gte: quantity }
},
data: {
stock: { decrement: quantity }
}
})
if (stockUpdate.count === 0) {
throw new Error('Stok tidak cukup')
}
await tx.order.create({
data: {
userId,
productId,
quantity,
status: 'CONFIRMED'
}
})
})Kenapa ini cukup? Karena constraint inti hanya: stok harus masih cukup saat dikurangi. Database bisa menegakkan itu langsung pada update.
Kapan checkout perlu FOR UPDATE? Saat keputusan tidak sesederhana stok tunggal, misalnya Anda harus:
menggabungkan beberapa sumber kuota,
menentukan alokasi berdasarkan prioritas,
membaca beberapa row yang saling terkait sebelum menentukan mana yang akan dikurangi.
Dalam kasus seperti itu, read-then-decide-then-write lebih aman jika row yang relevan dikunci.
Kasus 2: Klaim voucher sekali pakai
Untuk voucher unik, optimistic concurrency atau update bersyarat sering sudah cukup.
const claimed = await prisma.voucher.updateMany({
where: {
code: voucherCode,
claimedByUserId: null
},
data: {
claimedByUserId: userId,
claimedAt: new Date()
}
})
if (claimed.count === 0) {
throw new Error('Voucher sudah dipakai atau tidak valid')
}Ini lebih sederhana daripada lock. Dua user boleh mencoba klaim bersamaan, tetapi hanya satu yang berhasil karena kondisi claimedByUserId: null.
Jangan tambah FOR UPDATE jika hanya ingin mencegah dua klaim sederhana. Lock justru menambah waiting time tanpa manfaat berarti bila kondisi bisa ditegakkan langsung lewat update atomik.
Kasus 3: Job idempotent di worker atau API callback
Misalnya ada job pembayaran yang bisa terkirim ulang. Anda ingin hanya satu proses yang menandai job sebagai PROCESSING.
Sering kali cukup dengan update bersyarat:
const picked = await prisma.job.updateMany({
where: {
id: jobId,
status: 'PENDING'
},
data: {
status: 'PROCESSING',
pickedAt: new Date()
}
})
if (picked.count === 0) {
return
}
// hanya satu worker yang lolos ke siniIni efektif untuk pola claim then process. Namun jika worker perlu membaca detail job, mengecek resource terkait, lalu memutuskan transisi state kompleks yang harus konsisten dengan baris lain, barulah lock pesimistis bisa relevan.
Contoh Aman Menggunakan SELECT ... FOR UPDATE dengan Prisma
Karena locking tingkat row umumnya dilakukan di SQL, implementasinya biasanya memakai transaksi interaktif Prisma plus $queryRaw. Contoh berikut memperlihatkan pola umum, bukan detail vendor database yang terlalu spesifik.
await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw`
SELECT id, stock
FROM "Product"
WHERE id = ${productId}
FOR UPDATE
`
const product = Array.isArray(rows) ? rows[0] : null
if (!product) {
throw new Error('Produk tidak ditemukan')
}
if (product.stock < quantity) {
throw new Error('Stok tidak cukup')
}
await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } }
})
await tx.order.create({
data: {
userId,
productId,
quantity,
status: 'CONFIRMED'
}
})
})Mengapa pola ini aman? Karena row produk dikunci sejak dibaca, sehingga transaksi lain yang ingin memodifikasi row yang sama tidak bisa lewat sembarangan sampai transaksi selesai.
Hal yang perlu diperhatikan:
Jaga transaksi tetap singkat. Jangan lakukan panggilan API eksternal di dalam transaksi yang memegang lock.
Kunci row dalam urutan yang konsisten jika mengunci lebih dari satu row, untuk mengurangi risiko deadlock.
Pastikan query raw tetap memakai parameter binding seperti contoh tagged template Prisma, jangan menyusun SQL mentah dengan string concatenation.
Trade-off: Deadlock, Latency, dan Contention
Deadlock
Deadlock terjadi saat dua transaksi saling menunggu lock yang dipegang pihak lain. Ini umum jika dua transaksi mengunci row yang sama dalam urutan berbeda.
Contoh sederhana:
Transaksi A mengunci produk 1 lalu ingin produk 2
Transaksi B mengunci produk 2 lalu ingin produk 1
Keduanya bisa buntu. Database biasanya akan membatalkan salah satunya.
Cara mengurangi risiko:
Kunci resource dalam urutan yang deterministik, misalnya berdasarkan ID ascending.
Pendekkan durasi transaksi.
Hindari lock row lebih banyak dari yang dibutuhkan.
Latency
Lock pesimistis membuat request lain menunggu. Jika trafik tinggi, waktu respons bisa naik tajam walau CPU database belum penuh. Ini sering tidak terlihat di local development karena contention rendah.
Atomic update biasanya lebih baik untuk latensi karena database tidak perlu mempertahankan lock lama selama logika aplikasi berjalan.
Contention
Jika banyak request menargetkan row yang sama, misalnya satu voucher populer atau satu inventory record yang sangat hot, maka contention meningkat. Pada kondisi ini:
Optimistic concurrency bisa menghasilkan banyak retry.
FOR UPDATE bisa menghasilkan antrean request yang panjang.
Atomic update sering menjadi pilihan paling efisien jika aturan bisa diekspresikan langsung di query.
Tidak ada solusi universal. Strategi terbaik tergantung bentuk konflik dan toleransi sistem terhadap retry versus waiting.
Kapan Cukup Pakai Update Bersyarat, Kapan Perlu Lock Pesimistis?
Pakai update bersyarat atau atomic update jika:
Kondisi sukses bisa ditulis di klausa
WHERE.Operasi hanya perlu perubahan sederhana seperti decrement stok, claim status, atau transisi state tunggal.
Anda ingin throughput tinggi dan lock minimal.
Pakai optimistic concurrency jika:
Konflik tidak sering.
Retry bisa diterima oleh caller atau worker.
Anda butuh deteksi konflik tanpa lock panjang.
Pakai SELECT ... FOR UPDATE jika:
Anda harus membaca lalu memutuskan lalu menulis terhadap row yang sama.
Keputusan tidak bisa direduksi menjadi satu update bersyarat.
Kegagalan akibat race condition lebih mahal daripada biaya lock dan potensi waiting.
Jangan pakai FOR UPDATE jika:
Tujuan Anda hanya menghindari dua update sederhana yang bisa ditangani
updateManybersyarat.Transaksi Anda melibatkan proses lambat seperti HTTP request, upload file, atau pemanggilan layanan pihak ketiga.
Anda belum memahami pola akses sehingga lock justru menciptakan bottleneck baru.
Kesalahan Umum
Mengira transaksi biasa otomatis mencegah lost update. Tidak selalu.
Membaca data di luar transaksi lalu update di dalam transaksi. Ini membuka celah race condition.
Menaruh proses eksternal di dalam transaksi. Lock menjadi terlalu lama.
Memakai
FOR UPDATEuntuk semua operasi kritis. Ini sering overkill.Tidak menangani retry pada optimistic concurrency atau deadlock victim.
Tips Debugging Saat Terjadi Race Condition
Log ID request, ID resource, dan timestamp pada awal dan akhir transaksi.
Catat jumlah baris yang ter-update pada
updateMany; ini sinyal penting untuk konflik.Uji dengan concurrent requests, bukan hanya klik manual satu per satu.
Jika memakai lock pesimistis, ukur durasi transaksi dan cari bagian yang memperpanjang lock.
Jika menemukan deadlock, audit urutan penguncian resource di semua code path.
Checklist Pemilihan Strategi
Bisakah aturan bisnis ditulis langsung di query update?
Jika ya, pilih atomic update atau update bersyarat.Apakah konflik jarang dan retry masih masuk akal?
Jika ya, pertimbangkan optimistic concurrency.Apakah Anda harus membaca state dulu lalu mengambil keputusan kompleks sebelum menulis?
Jika ya, evaluasiSELECT ... FOR UPDATE.Apakah transaksi akan lama atau melibatkan network call?
Jika ya, hindari lock pesimistis.Apakah resource yang sama sangat sering diakses bersamaan?
Jika ya, waspadai contention; atomic update biasanya lebih tahan daripada lock panjang.Apakah Anda sudah punya strategi retry untuk konflik atau deadlock?
Kalau belum, tambahkan sebelum masuk production.
Penutup
Dalam perbandingan Prisma Transaction vs FOR UPDATE di Next.js, keputusan terbaik hampir selalu bergantung pada bentuk race condition, bukan sekadar tingkat "kekritisan" fitur. Mulailah dari solusi paling sederhana yang benar: update bersyarat atau atomic update. Naik ke optimistic concurrency jika konflik perlu dideteksi tanpa lock. Pakai SELECT ... FOR UPDATE hanya saat logika benar-benar membutuhkan proteksi read-then-write yang kuat di level row.
Jika semua operasi kritis langsung diberi FOR UPDATE, Anda memang mengurangi sebagian race condition, tetapi sering menukar masalah itu dengan latency, contention, dan deadlock. Di sistem nyata, pilihan yang baik bukan yang paling agresif, melainkan yang paling tepat untuk pola akses data Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!