Locking baris di Prisma + Next.js diperlukan ketika Anda punya alur read-modify-write yang berjalan bersamaan, misalnya mengurangi stok produk, memotong saldo akun, atau mengambil kuota terbatas. Jika dua request membaca nilai lama yang sama lalu sama-sama menulis hasil update, data bisa menjadi tidak konsisten.
Transaksi biasa belum otomatis menyelesaikan masalah ini. Transaksi memang membuat beberapa query dieksekusi sebagai satu unit, tetapi jika logika Anda adalah baca nilai - hitung di aplikasi - simpan hasil, dua transaksi yang berjalan paralel masih bisa saling menimpa tanpa lock eksplisit atau pola update atomik yang tepat. Untuk kasus seperti ini, pendekatan yang umum adalah memakai SELECT ... FOR UPDATE di dalam transaksi.
Kapan race condition terjadi?
Masalah paling umum muncul pada pola berikut:
Request A membaca stok saat ini = 5
Request B membaca stok saat ini = 5
Request A mengurangi 4, lalu menyimpan stok = 1
Request B juga mengurangi 4, lalu menyimpan stok = 1
Secara bisnis, hasil itu salah. Seharusnya salah satu request gagal karena stok tidak cukup. Ini disebut lost update atau race condition pada alur update paralel.
Masalah serupa juga sering terjadi pada:
Saldo dompet atau rekening
Kuota voucher atau tiket
Batas pemakaian per user
Counter inventaris dan reservasi
Kenapa transaksi biasa belum cukup?
Banyak developer mengira bahwa membungkus kode dengan prisma.$transaction() sudah otomatis aman dari race condition. Itu tidak selalu benar.
Perhatikan pola ini:
await prisma.$transaction(async (tx) => {
const product = await tx.product.findUnique({
where: { id: productId }
})
if (!product || product.stock < qty) {
throw new Error('Stok tidak cukup')
}
await tx.product.update({
where: { id: productId },
data: { stock: product.stock - qty }
})
})Secara sekilas terlihat aman karena semua berada dalam transaksi. Namun dua transaksi bisa saja:
sama-sama membaca nilai stok sebelum salah satunya menulis,
sama-sama lolos validasi
stock >= qty,lalu menulis hasil berdasarkan nilai lama.
Jika database tidak memaksa serialisasi penuh atau Anda tidak mengambil lock pada baris yang dibaca, transaksi tetap bisa saling balapan. Jadi, transaksi adalah fondasi, tetapi untuk skenario tertentu Anda masih perlu row-level locking atau update atomik berbasis kondisi.
Cara kerja row-level locking dengan SELECT ... FOR UPDATE
SELECT ... FOR UPDATE mengambil lock pada baris yang dipilih sampai transaksi selesai. Selama lock aktif:
transaksi lain yang ingin mengunci baris yang sama biasanya akan menunggu,
update yang konflik terhadap baris yang sama akan tertahan sampai transaksi pertama commit atau rollback,
alur baca-update-tulis menjadi terurut untuk baris yang sama.
Ini cocok untuk kasus ketika Anda perlu:
membaca nilai terkini,
memvalidasi aturan bisnis,
menghitung perubahan di aplikasi,
lalu menulis hasilnya tanpa risiko dua request memakai snapshot lama yang sama.
Catatan: lock dilepas saat transaksi selesai, yaitu ketika commit atau rollback. Karena itu, jaga isi transaksi tetap singkat. Jangan lakukan HTTP call, akses API eksternal, atau pekerjaan lambat lain saat lock masih dipegang.
Implementasi locking baris di Prisma + Next.js
Prisma belum menyediakan abstraksi lock baris yang lengkap dan seragam seperti forUpdate() di sebagian ORM lain. Karena itu, pendekatan praktisnya adalah:
pakai
prisma.$transaction(),jalankan
SELECT ... FOR UPDATElewat raw query,lanjutkan update menggunakan client transaksi yang sama.
Contoh API Route Next.js untuk update stok
Contoh berikut menunjukkan alur aman: lock dulu baris produk, cek stok, lalu update di transaksi yang sama.
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const productId = Number(body.productId)
const qty = Number(body.qty)
if (!Number.isInteger(productId) || !Number.isInteger(qty) || qty <= 0) {
return NextResponse.json({ error: 'Input tidak valid' }, { status: 400 })
}
const result = await prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ id: number; stock: number }>>`
SELECT id, stock
FROM "Product"
WHERE id = ${productId}
FOR UPDATE
`
const product = rows[0]
if (!product) {
throw new Error('PRODUCT_NOT_FOUND')
}
if (product.stock < qty) {
throw new Error('INSUFFICIENT_STOCK')
}
const updated = await tx.product.update({
where: { id: productId },
data: { stock: product.stock - qty }
})
return updated
})
return NextResponse.json({ success: true, data: result })
} catch (error) {
if (error instanceof Error) {
if (error.message === 'PRODUCT_NOT_FOUND') {
return NextResponse.json({ error: 'Produk tidak ditemukan' }, { status: 404 })
}
if (error.message === 'INSUFFICIENT_STOCK') {
return NextResponse.json({ error: 'Stok tidak cukup' }, { status: 409 })
}
}
return NextResponse.json({ error: 'Terjadi kesalahan' }, { status: 500 })
}
}Mengapa ini bekerja?
FOR UPDATEmengunci baris produk yang dipilih.Request lain yang mencoba mengunci produk yang sama harus menunggu transaksi selesai.
Setelah request pertama commit, request berikutnya akan membaca stok terbaru, bukan stok lama.
Contoh Server Action dengan pola yang sama
Jika Anda memakai Server Action, pola transaksinya tetap sama. Yang penting, semua query yang terlibat berada dalam callback transaksi yang sama.
'use server'
import { prisma } from '@/lib/prisma'
export async function reserveQuota(resourceId: number, amount: number) {
return prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ id: number; remainingQuota: number }>>`
SELECT id, "remainingQuota"
FROM "ResourceQuota"
WHERE id = ${resourceId}
FOR UPDATE
`
const resource = rows[0]
if (!resource) {
throw new Error('RESOURCE_NOT_FOUND')
}
if (resource.remainingQuota < amount) {
throw new Error('QUOTA_EXCEEDED')
}
return tx.resourceQuota.update({
where: { id: resourceId },
data: {
remainingQuota: resource.remainingQuota - amount
}
})
})
}Alur baca-update-tulis yang aman
Pola aman untuk stok, saldo, atau kuota umumnya seperti ini:
Mulai transaksi
Ambil row lock dengan
SELECT ... FOR UPDATEBaca nilai terkini dari baris yang sudah dikunci
Validasi aturan bisnis, misalnya saldo cukup atau kuota masih tersedia
Lakukan update
Commit transaksi
Penting untuk dipahami: yang dikunci adalah baris yang relevan, bukan seluruh tabel. Itu sebabnya row-level locking lebih presisi dibanding pendekatan lock global di level aplikasi.
Jangan lakukan ini di tengah transaksi
Memanggil API pembayaran eksternal
Mengirim email
Menjalankan proses berat yang lama
Menunggu input lain dari user
Semakin lama transaksi menahan lock, semakin besar antrean request lain, dan throughput sistem akan turun.
Alternatif: update atomik tanpa SELECT FOR UPDATE
Untuk sebagian kasus sederhana, Anda tidak selalu perlu SELECT ... FOR UPDATE. Anda bisa memakai satu query update bersyarat yang atomik di database. Misalnya, kurangi stok hanya jika stok masih cukup.
const result = await prisma.product.updateMany({
where: {
id: productId,
stock: { gte: qty }
},
data: {
stock: { decrement: qty }
}
})
if (result.count === 0) {
throw new Error('Stok tidak cukup atau produk tidak ditemukan')
}Pendekatan ini sering lebih sederhana dan throughput-nya bisa lebih baik karena tidak perlu fase baca terpisah di aplikasi. Namun ada batasannya:
Cocok jika aturan bisnis cukup diekspresikan dalam kondisi query.
Kurang fleksibel jika validasi bergantung pada banyak baris atau logika kompleks.
Jika Anda perlu membaca beberapa entitas lalu memutuskan update secara gabungan, lock eksplisit biasanya lebih jelas.
Praktiknya, pilih pola berikut:
Update atomik bersyarat untuk kasus sederhana dan langsung.
SELECT ... FOR UPDATE untuk alur read-modify-write yang lebih kompleks.
Kompatibilitas PostgreSQL dan MySQL
PostgreSQL
PostgreSQL mendukung SELECT ... FOR UPDATE dengan baik dan umum dipakai untuk kasus seperti ini. Pada Prisma, raw query untuk lock eksplisit biasanya paling praktis jika Anda membutuhkan kontrol row-level locking.
MySQL
MySQL juga mendukung SELECT ... FOR UPDATE, umumnya pada storage engine transaksional seperti InnoDB. Perilaku lock bisa dipengaruhi oleh indeks, pola query, dan level isolasi transaksi. Karena itu, pastikan query WHERE Anda spesifik dan memanfaatkan indeks yang tepat agar lock tidak melebar lebih dari yang dibutuhkan.
Catatan kompatibilitas: sintaks dasar
SELECT ... FOR UPDATEtersedia di PostgreSQL dan MySQL, tetapi detail perilaku konkurensi, waiting, dan locking dapat berbeda. Uji pada database yang benar-benar Anda pakai, jangan hanya mengandalkan asumsi lintas vendor.
Batasan Prisma untuk row-level locking
Ini bagian penting: Prisma belum punya abstraksi lock baris yang lengkap untuk semua kebutuhan konkurensi. Akibatnya:
Anda sering perlu memakai
$queryRawuntukFOR UPDATE.Anda harus lebih hati-hati menjaga query raw tetap aman dan konsisten.
Beberapa fitur locking lanjutan database tidak dibungkus secara tinggi oleh API Prisma.
Konsekuensi praktisnya:
Pastikan raw query dijalankan melalui objek transaksi yang sama, misalnya
tx.$queryRaw, bukan client global di luar transaksi.Gunakan parameter binding yang aman lewat tagged template atau mekanisme parameterized query Prisma.
Dokumentasikan bagian ini di kode karena ia menyentuh area yang lebih rendah levelnya dibanding operasi ORM biasa.
Dampak ke throughput dan trade-off
Locking baris meningkatkan konsistensi, tetapi ada biaya yang harus dipahami:
Latency naik saat banyak request menargetkan baris yang sama.
Throughput turun untuk hotspot yang sama karena request harus antre.
Risiko deadlock bisa muncul jika beberapa transaksi mengunci resource dalam urutan berbeda.
Trade-off utamanya sederhana: Anda menukar sebagian paralelisme dengan konsistensi data.
Untuk mengurangi dampak ini:
Buat transaksi sesingkat mungkin.
Kunci resource dengan urutan yang konsisten jika lebih dari satu baris terlibat.
Gunakan indeks yang tepat agar pencarian dan locking cepat.
Pertimbangkan update atomik bersyarat jika kasusnya memungkinkan.
Kesalahan umum yang sering terjadi
Menganggap transaksi biasa otomatis aman untuk semua race condition.
Menjalankan SELECT FOR UPDATE di luar transaksi, sehingga lock tidak berguna sesuai harapan.
Membaca dengan client transaksi, lalu update dengan client global, sehingga query tidak berada dalam konteks transaksi yang sama.
Transaksi terlalu panjang karena ada operasi jaringan atau proses lambat di dalamnya.
Tidak menyiapkan retry untuk error konkurensi tertentu seperti deadlock atau lock timeout.
Tips pengujian concurrency
Pengujian race condition tidak cukup dilakukan dengan klik manual satu per satu. Anda perlu mensimulasikan beberapa request yang menabrak resource yang sama dalam waktu hampir bersamaan.
Strategi pengujian praktis
Buat data awal, misalnya stok = 10.
Kirim 5 sampai 20 request paralel yang masing-masing mengurangi stok tertentu.
Pastikan hasil akhir tidak pernah negatif atau melanggar aturan bisnis.
Bandingkan perilaku sebelum dan sesudah memakai lock.
Contoh script sederhana dengan fetch paralel
const requests = Array.from({ length: 10 }, () =>
fetch('http://localhost:3000/api/purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: 1, qty: 2 })
}).then(async (res) => ({
status: res.status,
body: await res.json()
}))
)
const results = await Promise.all(requests)
console.log(results)Yang perlu Anda cek:
Apakah jumlah request sukses sesuai stok yang tersedia?
Apakah request sisanya gagal dengan status konflik, bukan diam-diam merusak data?
Apakah stok akhir sesuai ekspektasi?
Debugging saat hasil masih aneh
Aktifkan logging query untuk memastikan
FOR UPDATEbenar-benar dijalankan.Pastikan raw query dan update memakai objek
txyang sama.Cek apakah query pencarian memakai kondisi yang benar dan indeks yang relevan.
Lihat error database terkait deadlock, lock timeout, atau serialization failure jika ada.
Uji pada database yang sama dengan produksi, karena perilaku konkurensi lokal dan produksi bisa berbeda.
Kapan sebaiknya memakai locking baris?
Gunakan locking baris di Prisma + Next.js jika:
Anda punya alur read-modify-write yang harus konsisten.
Aturan bisnis tidak cukup diwakili satu query update atomik.
Resource yang diubah sangat sensitif, seperti uang, stok, dan kuota terbatas.
Jika operasinya sederhana, misalnya hanya mengurangi counter bila masih cukup, coba dulu pola update atomik bersyarat. Tetapi ketika keputusan update membutuhkan pembacaan terkoordinasi atas nilai terbaru, row-level locking dengan SELECT ... FOR UPDATE adalah solusi yang lebih aman.
Ringkasan
Locking baris di Prisma + Next.js adalah cara praktis untuk mencegah race condition saat beberapa request mengubah baris yang sama secara bersamaan. Kuncinya bukan hanya memakai transaksi, tetapi memastikan alur baca-update-tulis dilakukan di dalam transaksi yang sama dengan lock eksplisit pada baris target.
Untuk kasus stok, saldo, dan kuota:
pakai
prisma.$transaction(),ambil lock dengan
SELECT ... FOR UPDATEvia$queryRawbila perlu,jaga transaksi singkat,
uji dengan request paralel,
siapkan penanganan konflik dan timeout.
Dengan pola ini, Anda mengurangi risiko data rusak akibat request bersamaan, meski harus menerima trade-off berupa antrean pada resource yang sama.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!