Seluruh peralatan transaksi — commit, savepoint, isolation, locking, hingga penanganan deadlock — bermuara pada satu pertanyaan praktis: bagaimana aplikasi tetap benar ketika banyak pengguna menekan tombol yang sama pada saat bersamaan? Babak akhir section ini merangkai jawabannya menjadi strategi utuh. Kita mulai dari musuh paling senyap — lost update, pembaruan yang lenyap tanpa satu pun error — lalu susun dua gaya penanganan yang saling melengkapi: pessimistic locking yang mengunci lebih dulu dengan SELECT ... FOR UPDATE, dan optimistic locking yang menyerahkan deteksi ke kolom versi. Penutupnya dua pelengkap produksi: idempotency key yang menjadikan percobaan ulang aman, dan pola retry di sisi aplikasi.
Menyiapkan Tabel Latihan #
Skema transfer kita kembali lengkap: rekening menyimpan saldo, mutasi mencatat gerakan uang, dan sepanjang chapter kita tambahkan tabel kerja masing-masing pola:
DROP DATABASE IF EXISTS belajar_trx;
CREATE DATABASE belajar_trx;
USE belajar_trx;
CREATE TABLE rekening (
id INT AUTO_INCREMENT PRIMARY KEY,
pemilik VARCHAR(50) NOT NULL,
saldo DECIMAL(12,2) NOT NULL
);
CREATE TABLE mutasi (
id INT AUTO_INCREMENT PRIMARY KEY,
rekening_id INT NOT NULL,
jumlah DECIMAL(12,2) NOT NULL,
keterangan VARCHAR(100) NOT NULL,
tanggal DATE NOT NULL,
CONSTRAINT fk_mutasi_rekening FOREIGN KEY (rekening_id) REFERENCES rekening(id)
);
INSERT INTO rekening (pemilik, saldo) VALUES ('Budi', 500000.00), ('Siti', 300000.00);
SELECT id, pemilik, saldo FROM rekening ORDER BY id;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changed
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
+----+---------+-----------+
| id | pemilik | saldo |
+----+---------+-----------+
| 1 | Budi | 500000.00 |
| 2 | Siti | 300000.00 |
+----+---------+-----------+
2 rows in set (0.00 sec)Masalah Lost Update: Tulisan yang Lenyap Diam-diam #
Skenario musuhnya begini: dua aplikasi masing-masing menjalankan pola baca → hitung → tulis pada saldo Budi. Keduanya membaca Rp500.000,00, menghitung penarikan versinya sendiri, lalu menulis hasilnya. Tabel waktu berikut memetakan tabrakannya:
| Waktu | Koneksi A (tarik Rp100.000) | Koneksi B (tarik Rp75.000) | Saldo tersimpan |
|---|---|---|---|
| t1 | baca saldo → Rp500.000,00 | Rp500.000,00 | |
| t2 | baca saldo → Rp500.000,00 | Rp500.000,00 | |
| t3 | tulis 500.000 − 100.000 = Rp400.000,00 | Rp400.000,00 | |
| t4 | tulis 500.000 − 75.000 = Rp425.000,00 | Rp425.000,00 | |
| t5 | baca ulang → Rp425.000,00 | penarikan A lenyap |
Kedua penulisan sama-sama sukses tanpa error — namun penulisan B di t4 menimpa hasil A karena perhitungan B memakai saldo basi dari t2. Saldo seharusnya Rp325.000,00 (dua penarikan sama-sama berlaku), tetapi database menyimpan Rp425.000,00. Inilah lost update: pembaruan A hilang tanpa jejak, tanpa pesan error, tanpa yang menyadarinya. Akarnya bukan transaksi yang salah, melainkan pola baca-tulis yang tidak terlindungi — bacaan di t1 dan t2 tidak memasang kunci apa pun, sehingga tidak ada yang mencegah penimpaan di t4.
Pessimistic Locking: Kunci Dulu, Kerja Kemudian #
Gaya pessimistic mengasumsikan tabrakan itu nyata dan mencegahnya sejak awal: sebelum membaca data yang akan diubah, koneksi mengunci barisnya dengan SELECT ... FOR UPDATE. Pola lengkapnya pada transfer kita — ambil, transfer, unggah — tampak seperti ini, dengan tabel transfer ber-idem_key unik sebagai bukti transfer yang bisa diulang:
CREATE TABLE transfer (
id INT AUTO_INCREMENT PRIMARY KEY,
idem_key VARCHAR(32) NOT NULL,
dari_id INT NOT NULL,
ke_id INT NOT NULL,
jumlah DECIMAL(12,2) NOT NULL,
dibuat DATE NOT NULL,
CONSTRAINT uq_idem UNIQUE (idem_key)
);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)START TRANSACTION;
SELECT saldo FROM rekening WHERE id = 1 FOR UPDATE;
UPDATE rekening SET saldo = saldo - 100000 WHERE id = 1;
UPDATE rekening SET saldo = saldo + 100000 WHERE id = 2;
INSERT INTO mutasi (rekening_id, jumlah, keterangan, tanggal) VALUES
(1, -100000.00, 'Transfer ke Siti', '2026-01-15'),
(2, 100000.00, 'Transfer dari Budi', '2026-01-15');
INSERT INTO transfer (idem_key, dari_id, ke_id, jumlah, dibuat) VALUES ('TRF-2026-0001', 1, 2, 100000.00, '2026-01-15');
COMMIT;
SELECT id, pemilik, saldo FROM rekening ORDER BY id;
SELECT id, idem_key, dari_id, ke_id, jumlah FROM transfer ORDER BY id;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
+-----------+
| saldo |
+-----------+
| 500000.00 |
+-----------+
1 row in set (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
Query OK, 1 row affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
+----+---------+-----------+
| id | pemilik | saldo |
+----+---------+-----------+
| 1 | Budi | 400000.00 |
| 2 | Siti | 400000.00 |
+----+---------+-----------+
2 rows in set (0.00 sec)
+----+---------------+---------+-------+-----------+
| id | idem_key | dari_id | ke_id | jumlah |
+----+---------------+---------+-------+-----------+
| 1 | TRF-2026-0001 | 1 | 2 | 100000.00 |
+----+---------------+---------+-------+-----------+
1 row in set (0.00 sec)Mari kita bedah pola ini langkah demi langkah. START TRANSACTION membuka panggung, lalu SELECT ... FOR UPDATE mengunci baris Budi sekaligus membaca saldonya — dari titik ini, koneksi lain yang ikut menjalankan FOR UPDATE pada baris yang sama harus mengantre sampai transaksi kita selesai, sehingga tidak ada bacaan basi yang bisa menyusup. Tiga INSERT berikutnya mencatat kedua mutasi dan bukti transfernya. COMMIT menutup transaksi dan melepas kunci: saldo Budi Rp400.000,00, saldo Siti Rp400.000,00, dan baris transfer TRF-2026-0001 tercatat. Kunci yang dipegang sejak bacaan itulah yang memutus masalah lost update — koneksi B di skenario sebelumnya akan menunggu di SELECT FOR UPDATE-nya, membaca saldo yang sudah diperbarui A, dan menghitung dari angka yang benar.
Optimistic Locking: Deteksi Lewat Kolom Versi #
Gaya optimistic memilih asumsi sebaliknya: tabrakan itu jarang, jadi tidak perlu mengunci — cukup siapkan alarm ketika terjadi. Alarmnya berupa kolom versi yang naik setiap pembaruan; penulis wajib menyertakan versi yang ia baca dalam WHERE, dan pembaruan dari salinan basi otomatis gagal memodifikasi baris mana pun:
CREATE TABLE artikel (
id INT AUTO_INCREMENT PRIMARY KEY,
judul VARCHAR(100) NOT NULL,
isi TEXT,
versi INT NOT NULL DEFAULT 1
);
INSERT INTO artikel (judul, isi, versi) VALUES ('Panduan Kopi Gayo', 'Kopi gayo disangai medium, acidity cerah.', 1);
UPDATE artikel SET isi = 'Kopi gayo disangai medium, acidity cerah, giling halus.', versi = 2 WHERE id = 1 AND versi = 1;
UPDATE artikel SET isi = 'Revisi basi dari salinan lama.', versi = 3 WHERE id = 1 AND versi = 1;
SELECT judul, versi FROM artikel WHERE id = 1;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0 Changed: 0 Warnings: 0
+-------------------+-------+
| judul | versi |
+-------------------+-------+
| Panduan Kopi Gayo | 2 |
+-------------------+-------+
1 row in set (0.00 sec)UPDATE pertama membaca versi 1, mengubah isi, dan menaikkan versi menjadi 2 — laporan Rows matched: 1 Changed: 1 menandakan sukses. UPDATE kedua memakai syarat versi = 1 yang sama, padahal versi baris sudah 2: laporan Rows matched: 0 Changed: 0 adalah alarmnya — statement berakhir tanpa error, tanpa baris tersentuh. Aplikasi yang baik memeriksa jumlah baris terdampak ini: nol berarti salinan kerjanya basi, data harus dibaca ulang dan perubahan diterapkan di atas versi termutakhir. Versi INT ini bisa digantikan timestamp pembaruan — kolom waktu yang sama-sama dibaca dan disertakan di WHERE — dengan logika identik; angka versi dipilih di sini karena deterministik dan mudah diuji. Perbandingan dua gayanya:
| Aspek | Pessimistic (FOR UPDATE) | Optimistic (kolom versi) |
|---|---|---|
| Asumsi | tabrakan sering terjadi | tabrakan jarang terjadi |
| Kunci | ya, sejak bacaan | tidak ada |
| Tabrakan ditangani lewat | antrean tunggu | kegagalan senyap + ulangi dari bacaan |
| Biaya saat sepi | kunci dan transaksi wajib | hanya satu WHERE ekstra |
| Cocok untuk | operasi uang, stok, sumber daya diperebutkan | konten, profil, data jarang bertabrakan |
Idempotency Key: Menjadikan Retry Aman #
Skenario produksi terakhir: aplikasi mengirim transfer, server menjalankan COMMIT, lalu koneksi mati sebelum balasan sukses sampai ke aplikasi. Aplikasi — tidak tahu transfernya berhasil atau gagal — mengirim ulang permintaan yang sama. Tanpa perlindungan, transfer tercatat dua kali dan saldo terpotong dua kali. Kolom idem_key yang barusan kita buat adalah perlindungannya: percobaan ulang membawa identifier yang sama, dan constraint unik menolak duplikasi:
-- @error
INSERT INTO transfer (idem_key, dari_id, ke_id, jumlah, dibuat) VALUES ('TRF-2026-0001', 1, 2, 100000.00, '2026-01-15');Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
ERROR 1062 (23000): Duplicate entry 'TRF-2026-0001' for key 'transfer.uq_idem'
ByeERROR 1062 melaporkan duplikasi TRF-2026-0001 untuk kunci transfer.uq_idem — error yang justru diinginkan. Aplikasi menangkap error ini dan menyimpulkan transfer pertama sudah tercatat, sehingga respons yang tepat bukan mengulang transfer, melainkan melaporkan sukses dengan data yang tersimpan. Prinsip inilah yang disebut idempotent: operasi yang dijalankan sekali atau N kali menghasilkan keadaan yang sama. Identifier unik per operasi bisnis — nomor referensi, token permintaan, UUID dari aplikasi — menjadi kuncinya.
Pola Retry: Menutup Lingkaran #
Idempotency menyelesaikan separuh masalah ulangan; separuh lainnya adalah kapan ulangan dilakukan. Error 1213 kebuntuan dan error 1205 timeout kunci keduanya bersifat sementara — keadaan yang menyebabkannya hilang seiring kunci lawan dilepas — sehingga respons yang benar adalah mencoba ulang, bukan melaporkan kegagalan permanen ke pengguna. Pola retry yang sehat berlapis tiga: tangkap error yang bersifat sementara (1213, 1205, keguguran koneksi), tunggu dengan jeda yang membesar (backoff) agar tabrakan berikutnya tidak langsung berulang, lalu jalankan ulang seluruh transaksi dari START TRANSACTION — aman karena idempotency key menjaga ulangan tidak menciptakan efek ganda. Batas percobaan tetap perlu ada: bila tiga ulangan masih gagal, operasinya dilaporkan gagal dan masuknya diperiksa manual.
Checklist Urutan Kerja yang Aman #
Rangkaian keputusan sepanjang section ini bisa dirangkum menjadi urutan kerja yang bisa langsung dipakai:
- Kumpulkan seluruh masukan dan keputusan bisnis sebelum membuka transaksi — transaksi sesingkat mungkin.
- Bungkus operasi multi-statement dengan
START TRANSACTION, jangan andalkan autocommit. - Kunci baris yang diperebutkan dengan
SELECT ... FOR UPDATE— denganORDER BYyang konsisten bila mengunci banyak baris. - Gunakan savepoint bila proses bertingkat butuh pembatalan parsial.
- Sertakan idempotency key unik pada operasi yang mungkin dikirim ulang.
- Tutup segera dengan
COMMIT, atauROLLBACKpenuh bila error — jangan pernah membiarkan transaksi menggantung. - Di sisi aplikasi: tangkap error 1213/1205, retry dengan backoff atas transaksi yang idempotent.
Membersihkan Database Latihan #
Rangkaian latihan selesai dan database latihan kita jatuhkan kembali:
DROP DATABASE belajar_trx;Output yang Dihasilkan:
Query OK, 4 rows affected (0.00 sec)Referensi #
- InnoDB Locking — MySQL 8.4 Reference Manual
- Deadlocks in InnoDB — MySQL 8.4 Reference Manual
- START TRANSACTION, COMMIT, and ROLLBACK Statements — MySQL 8.4 Reference Manual
Strategi penanganan konkurensi kini utuh dari ujung ke ujung: lost update kita pahami sampai ke tabel waktunya, pessimistic locking mengunci sejak bacaan, optimistic locking mendeteksi lewat kolom versi, idempotency key menjadikan retry aman, dan checklist tujuh langkah merangkai semuanya menjadi kebiasaan kerja. Dengan ini pula perjalanan section Transactions tuntas — kemampuan menjaga kebenaran data di tengah kerumunan pengguna telah lengkap. Perjalanan Course MySQL berikutnya berpindah ke ranah menyimpan logika di dalam database itu sendiri: stored programs, tempat prosedur, fungsi, dan kontrol alur menunggu untuk dipelajari.