INSERT, UPDATE & transaksi
Menulis ke database di MySQL punya satu batasan yang tidak ada di PostgreSQL, dan ia menentukan bentuk hampir semua fungsi tulismu.
Intisari
- MySQL tidak punya
RETURNING.insertIdyang kamu dapat, bukan barisnya. insertIdhanya benar untuk satu baris. Untuk insert massal ia mengembalikan ID pertama saja.db.transaction().execute()otomatis commit saat sukses dan rollback saat melempar. Tidak ada commit manual.- Di dalam transaksi, pakai
trxโ bukandb. Memakaidbberarti kuerinya di luar transaksi. - Jangan pernah memanggil layanan luar (Midtrans) di dalam transaksi yang memegang baris.
INSERT dan kenyataan MySQL
const hasil = await db
.insertInto("artikel")
.values({
judul: "Rupiah menguat",
slug: "rupiah-menguat",
isi: "<p>โฆ</p>",
penulis_id: 7,
status: "draf",
premium: 0,
created_at: Math.floor(Date.now() / 1000),
})
.executeTakeFirstOrThrow();
const id = Number(hasil.insertId); // BigInt โ number
insertId bertipe bigint, bukan number. Membandingkannya
langsung dengan number (hasil.insertId === 5) selalu false di JavaScript.
Konversi sekali di batasnya, lalu lupakan.
// Kalau butuh barisnya, ambil lagi. Dua kueri โ memang begitu di MySQL.
const artikel = await db
.selectFrom("artikel").selectAll()
.where("id", "=", id)
.executeTakeFirstOrThrow();
Insert massal dan jebakan insertId
const hasil = await db
.insertInto("artikel_tag")
.values(tagIds.map((tagId) => ({ artikel_id: id, tag_id: tagId })))
.executeTakeFirstOrThrow();
// hasil.insertId = ID BARIS PERTAMA saja.
// hasil.numInsertedOrUpdatedRows = jumlahnya.
// ID sisanya HANYA berurutan kalau innodb_autoinc_lock_mode = 0 atau 1.
// Jangan pernah mengandalkannya.
Upsert
await db
.insertInto("statistik_harian")
.values({ artikel_id: id, tanggal: hariIni, hits: 1 })
.onDuplicateKeyUpdate((eb) => ({
hits: eb("statistik_harian.hits", "+", 1),
}))
.execute();
onDuplicateKeyUpdate adalah padanan MySQL untuk ON CONFLICT. Ia butuh
UNIQUE pada (artikel_id, tanggal) โ tanpa itu ia diam saja dan membuat baris
duplikat setiap kali.
UPDATE
const hasil = await db
.updateTable("artikel")
.set({
status: "terbit",
terbit_pada: new Date(),
})
.where("id", "=", id)
.where("status", "=", "tinjau") // hanya dari status yang sah
.executeTakeFirstOrThrow();
if (hasil.numUpdatedRows === 0n) {
throw new Error("Artikel tidak ada atau statusnya bukan 'tinjau'");
}
numUpdatedRows bertipe bigint โ perhatikan 0n.
hasil.numUpdatedRows === 0 selalu false. Ini kesalahan yang lolos review karena
terlihat benar.
Dan perhatikan where("status", "=", "tinjau"): itu optimistic concurrency versi
sederhana. Kalau dua editor menekan Terbitkan bersamaan, yang kedua mendapat 0 baris terpengaruh dan
tahu bahwa ia kalah โ alih-alih menimpa diam-diam.
Batasi dengan kepemilikan, bukan dengan if
// BURUK: dua kueri, dan celah di antaranya
const a = await ambilArtikel(id);
if (a.penulis_id !== member.id) throw new Error("Bukan milikmu");
await db.updateTable("artikel").set(data).where("id", "=", id).execute();
// BAIK: satu kueri, kepemilikan jadi bagian dari WHERE
const hasil = await db
.updateTable("artikel")
.set(data)
.where("id", "=", id)
.where("penulis_id", "=", member.id)
.executeTakeFirstOrThrow();
if (hasil.numUpdatedRows === 0n) throw new ActionError({ code: "FORBIDDEN" });
Versi kedua tidak bisa lupa memeriksa. Baris orang lain tidak pernah ditemukan, jadi tidak mungkin tersunting. Terapkan pola ini di setiap operasi tulis yang menyentuh data milik seseorang.
Transaksi
const idLangganan = await db.transaction().execute(async (trx) => {
const langganan = await trx
.insertInto("langganan")
.values({
member_id: memberId,
paket: "bulanan",
status: "menunggu",
dibuat_pada: new Date(),
})
.executeTakeFirstOrThrow();
const id = Number(langganan.insertId);
await trx
.insertInto("tagihan")
.values({ langganan_id: id, jumlah: 49000, status: "menunggu" })
.execute();
return id;
});
| Yang terjadi | Hasilnya |
|---|---|
| Callback selesai normal | COMMIT otomatis |
| Callback melempar | ROLLBACK otomatis, error diteruskan ke pemanggil |
Kamu memanggil db, bukan trx | Kueri itu di luar transaksi โ tidak ikut rollback |
Baris ketiga itu bug yang paling sulit dilihat di seluruh materi ini. Kodenya berjalan, tesnya
lolos, dan baru terlihat saat ada kegagalan sungguhan di produksi โ di mana sebagian data ter-rollback
dan sebagian tidak. Kalau timmu lebih dari satu orang, pasang aturan lint yang melarang db.
di dalam blok transaction.
Aturan yang menyelamatkan alur pembayaranmu
// BURUK: memanggil Midtrans di dalam transaksi
await db.transaction().execute(async (trx) => {
const id = await buatLangganan(trx, memberId);
const snap = await midtrans.buatTransaksi(id, 49000); // โ jaringan!
await simpanToken(trx, id, snap.token);
});
Panggilan ke Midtrans bisa memakan 2โ5 detik, dan bisa menggantung sampai timeout. Selama itu transaksi memegang baris terkunci dan satu koneksi dari pool. Di jam sibuk, sepuluh pembeli bersamaan bisa menghabiskan pool-mu โ dan yang tumbang bukan halaman pembayaran saja, tapi seluruh portal, karena pool-nya sama.
// BAIK: transaksi pendek, jaringan di luarnya
const id = await db.transaction().execute(async (trx) => {
return buatLangganan(trx, memberId);
});
const snap = await midtrans.buatTransaksi(id, 49000);
await db.updateTable("langganan")
.set({ snap_token: snap.token })
.where("id", "=", id)
.execute();
Aturannya: transaksi hanya berisi kueri database, dan sependek mungkin. Tidak ada
panggilan HTTP, tidak ada pembacaan berkas, tidak ada await ke apa pun yang bukan
trx.
Tingkat isolasi
await db
.transaction()
.setIsolationLevel("read committed")
.execute(async (trx) => { โฆ });
Default MySQL adalah REPEATABLE READ, yang lebih ketat dari default PostgreSQL dan bisa
menyebabkan gap lock yang mengejutkan pada INSERT bersamaan di rentang yang sama.
Untuk alur pembayaran dan penghitung, READ COMMITTED biasanya pilihan yang lebih tenang.
Jangan mengubahnya secara global tanpa mengukur โ tapi ketahui bahwa opsinya ada saat kamu melihat deadlock.
Deadlock itu normal, dan harus diulang
export async function denganUlang<T>(fn: () => Promise<T>, maks = 3): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e: any) {
const bisaDiulang = e?.code === "ER_LOCK_DEADLOCK" || e?.errno === 1213;
if (!bisaDiulang || i >= maks) throw e;
await new Promise((r) => setTimeout(r, 50 * 2 ** i));
}
}
}
MySQL mendeteksi deadlock dan membatalkan salah satu transaksi. Itu perilaku yang benar, bukan kerusakan โ dan aplikasi yang benar mengulanginya. Bungkus operasi tulis yang berjalan bersamaan, terutama penghitung hit artikel dan pembaruan status langganan.
Latihan: tulis fungsi buatLanggananMenunggu(memberId, paket) yang menyisipkan ke
dua tabel dalam satu transaksi. Uji rollback-nya: lempar error setelah insert pertama, lalu pastikan
tabel pertama tetap kosong. Lalu sengaja lakukan kesalahan yang dibahas di atas โ pakai db
alih-alih trx di kueri kedua โ dan buktikan bahwa baris kedua tetap tersimpan
meski transaksinya gagal.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.