Mengatasi Race Condition HTTP Request dengan Atomic Conditional Update
Dalam sistem backend modern, terutama yang berurusan dengan order processing, payment, atau workflow berbasis status, race condition di level HTTP request adalah masalah yang sangat nyata dan sering muncul tanpa diundang. Kondisi ini biasanya dipicu oleh tiga skenario yang sebenarnya cukup umum: client melakukan retry baik secara manual maupun otomatis, UI yang tanpa sengaja mengirim request ganda karena user menekan tombol submit dua kali, atau webhook dari pihak ketiga yang terkirim lebih dari sekali akibat masalah jaringan di sisi mereka. Jika tidak ditangani dengan benar, race condition semacam ini bisa menyebabkan status order berubah berkali-kali secara tidak konsisten, proses berat dieksekusi lebih dari satu kali, dan yang paling berbahaya, side effect seperti charge pembayaran, pengiriman email, atau callback ke sistem lain terjadi secara ganda. Artikel ini membahas salah satu pendekatan paling solid dan praktis untuk mengatasi masalah tersebut: atomic conditional update di level database, dengan memanfaatkan rows affected sebagai indikator idempotency.
Studi Kasus Singkat
Untuk memahami masalah ini secara konkret, bayangkan kita memiliki tabel orders dengan alur status sederhana yang berjalan satu arah:
PENDING → PROCESSING → COMPLETED
Dalam skenario nyata, beberapa HTTP request bisa datang hampir bersamaan untuk memproses order yang sama. Request A dan request B masuk dalam selisih waktu hanya beberapa milidetik, dan keduanya berniat mengubah status order dari PENDING menjadi PROCESSING. Tanpa mekanisme proteksi yang tepat, keduanya bisa sama-sama menganggap dirinya sebagai request pertama yang sah — dan inilah inti dari race condition yang sedang kita bahas.
sequenceDiagram
participant ReqA as Request A
participant ReqB as Request B
participant DB as Database
Note over ReqA,ReqB: Keduanya tiba hampir bersamaan
ReqA->>DB: Cek status order = PENDING?
ReqB->>DB: Cek status order = PENDING?
DB-->>ReqA: Ya, PENDING
DB-->>ReqB: Ya, PENDING (belum tahu A sudah memproses)
ReqA->>DB: Update status -> PROCESSING
ReqB->>DB: Update status -> PROCESSING
Note over DB: Tanpa proteksi, keduanya berhasilTanpa mekanisme atomic, kedua request ini bisa lolos validasi “cek dulu, baru update” secara terpisah, karena keduanya membaca kondisi PENDING sebelum salah satu di antara mereka benar-benar menyelesaikan perubahannya.
Pendekatan yang Digunakan
Solusi yang dibahas di artikel ini menggabungkan tiga elemen sederhana yang, ketika dipadukan, menjadi sangat kuat. Pertama, membungkus proses dalam database transaction agar seluruh urutan operasi bersifat all-or-nothing. Kedua, melakukan conditional UPDATE berdasarkan status saat ini — bukan sekadar update tanpa syarat. Ketiga, mengecek jumlah baris yang ter-update, yang umum disebut rows affected, sebagai sinyal apakah request ini benar-benar berhasil mengubah state atau tidak.
BEGIN;
UPDATE orders
SET status = 'PROCESSING'
WHERE id = :order_id
AND status = 'PENDING';
-- cek rows affected
COMMIT;
Interpretasi hasil dari query ini cukup sederhana untuk dipahami. Jika rows affected lebih besar dari 0, berarti request ini adalah request pertama yang berhasil mengubah status — request yang valid dan harus dilanjutkan ke proses berikutnya. Jika rows affected sama dengan 0, berarti request ini adalah request kedua atau seterusnya, yang pada dasarnya adalah duplikat dari aksi yang sudah dilakukan oleh request lain sebelumnya.
INTERPRETASI ROWS AFFECTED:
✓ rows affected > 0 -- request pertama, valid, lanjutkan proses
✗ rows affected = 0 -- request duplikat, hentikan, jangan ulangi side effect
Kunci dari pendekatan ini adalah klausaWHERE status = 'PENDING'pada query UPDATE. Tanpa kondisi ini, query akan selalu berhasil mengubah status berapa kali pun dijalankan, danrows affectedtidak lagi berguna sebagai sinyal idempotency.
Kenapa Request Kedua dan Seterusnya Mendapat Rows Affected = 0?
Bagian ini adalah inti dari keseluruhan mekanisme, dan paling penting untuk dipahami secara mendalam agar kamu yakin pendekatan ini benar-benar aman, bukan sekadar kebetulan bekerja di kondisi tertentu.
Locking Implisit Saat UPDATE
Ketika sebuah statement UPDATE dijalankan terhadap suatu baris, database secara otomatis mengambil lock pada row yang relevan tanpa kamu perlu meminta secara eksplisit. Lock ini mencegah perubahan paralel pada baris yang sama selama transaksi yang memegangnya belum selesai — baik itu commit maupun rollback.
Pada request pertama, urutan kejadiannya berjalan seperti ini: ia menemukan row dengan status = PENDING, mengambil lock atas row tersebut, mengubah statusnya menjadi PROCESSING, lalu melakukan commit terhadap transaksinya.
sequenceDiagram
participant ReqA as Request A
participant DB as Database
ReqA->>DB: BEGIN
ReqA->>DB: UPDATE ... WHERE status='PENDING'
Note over DB: Lock diambil pada row order_id
DB-->>ReqA: rows affected = 1
ReqA->>DB: COMMIT
Note over DB: Lock dilepas, status sekarang PROCESSINGApa yang Terjadi pada Request Kedua?
Request kedua datang hampir bersamaan dan mencoba menjalankan query yang identik. Tergantung pada timing yang sebenarnya hanya berbeda dalam hitungan milidetik, ada dua kemungkinan skenario yang bisa terjadi.
Skenario pertama, request kedua datang saat lock masih aktif. Dalam situasi ini, request kedua akan menunggu atau ter-block hingga lock dilepas oleh request pertama. Setelah request pertama melakukan commit, lock tersebut dilepas, dan request kedua kemudian melanjutkan eksekusinya yang sebelumnya tertahan. Namun pada saat resume ini terjadi, status row sudah berubah menjadi PROCESSING akibat aksi request pertama, sehingga kondisi WHERE status = 'PENDING' pada query request kedua tidak lagi terpenuhi. Akibatnya, hasil yang didapat adalah rows affected = 0.
sequenceDiagram
participant ReqA as Request A
participant ReqB as Request B
participant DB as Database
ReqA->>DB: BEGIN, UPDATE WHERE status='PENDING'
Note over DB: Lock diambil
ReqB->>DB: BEGIN, UPDATE WHERE status='PENDING'
Note over ReqB: Menunggu (blocked) karena lock aktif
ReqA->>DB: COMMIT
Note over DB: Lock dilepas, status = PROCESSING
DB->>ReqB: Resume eksekusi
Note over ReqB: Kondisi WHERE tidak lagi terpenuhi
DB-->>ReqB: rows affected = 0Skenario kedua, request kedua datang setelah commit selesai dilakukan. Dalam kasus ini, tidak ada lock yang perlu ditunggu sama sekali karena request pertama sudah benar-benar selesai. Tapi status row sudah berubah menjadi PROCESSING, sehingga kondisi WHERE pada query request kedua langsung gagal terpenuhi sejak awal evaluasi. Hasilnya tetap sama: rows affected = 0.
Intinya: Database yang Menjamin Konsistensi
Yang membuat solusi ini benar-benar kuat, terlepas dari skenario timing mana yang terjadi, adalah evaluasi kondisi dan proses update dilakukan secara atomik dalam satu operasi tunggal. Tidak ada celah waktu antara “cek status” dan “update status” yang bisa dimanfaatkan oleh request lain untuk menyelinap di antaranya — keduanya terjadi sebagai satu unit kerja yang tidak bisa dipecah. Seluruh race condition dipatahkan tepat di level database, bukan di level aplikasi yang jauh lebih rentan terhadap kesalahan timing. Aplikasi kamu hanya perlu membaca hasil akhirnya dan bereaksi sesuai nilai rows affected yang dikembalikan.
Mekanisme locking ini bukan sesuatu yang perlu kamu implementasikan secara manual. Hampir semua database relasional modern — PostgreSQL, MySQL, dan lainnya — sudah menyediakan locking implisit semacam ini sebagai bagian dari jaminan ACID standar mereka untuk operasi UPDATE.
Ini Bukan Sekadar Pessimistic Locking
Walaupun sering disebut sebagai pessimistic locking dalam diskusi sehari-hari, pendekatan ini sebenarnya lebih tepat dikategorikan sebagai atomic state transition, atau yang lebih dikenal dengan istilah compare-and-set — sebuah pola yang juga umum dijumpai di luar konteks database, misalnya pada operasi atomic di level memory pada beberapa bahasa programming.
Dibandingkan dengan pendekatan lain yang biasa digunakan untuk menangani concurrency, conditional UPDATE punya posisi yang cukup menarik dalam hal trade-off antara keamanan dan kompleksitas.
| Pendekatan | Aman dari Race Condition | Kompleksitas |
|---|---|---|
| Mutex di level aplikasi | Tidak — gagal pada multi-instance | Tinggi |
| Redis / Distributed Lock | Sebagian — bergantung implementasi | Tinggi |
SELECT ... FOR UPDATE | Ya | Sedang |
| Optimistic Lock (version column) | Ya | Sedang |
| Conditional UPDATE (pendekatan ini) | Ya | Rendah |
Mutex yang diimplementasikan di level aplikasi gagal melindungi dari race condition begitu aplikasi dijalankan dalam lebih dari satu instance, karena lock yang dipegang oleh satu instance tidak terlihat oleh instance lainnya — masalah klasik pada arsitektur yang horizontal scalable. Redis atau distributed lock lain bisa menjadi solusi, tapi menambahkan satu komponen infrastruktur eksternal yang harus dijaga ketersediaannya, dan menambah kompleksitas terhadap skenario kegagalan seperti lock yang tidak terlepas akibat crash.
SELECT ... FOR UPDATE dan optimistic lock berbasis version column keduanya aman secara konsep, tapi membutuhkan lebih banyak kode di sisi aplikasi — mulai dari query tambahan untuk membaca data sebelum update, hingga logic retry ketika konflik version terdeteksi. Conditional UPDATE menyederhanakan semua itu menjadi satu statement tunggal yang langsung memberi jawaban pasti tanpa round-trip tambahan ke database.
Secara keseluruhan, pendekatan conditional UPDATE ini lebih simpel untuk diimplementasikan, lebih efisien karena hanya membutuhkan satu query, dan lebih mudah dirawat karena tidak ada state tambahan yang perlu disinkronkan di luar database.
Kaitan dengan Idempotency
Salah satu manfaat tersembunyi dari pola ini adalah bagaimana ia secara alami menghasilkan idempotency, tanpa kamu perlu membangun mekanisme idempotency key yang terpisah untuk setiap endpoint.
Dengan pola ini, endpoint kamu menjadi idempotent secara alami — request duplikat tidak akan merusak state yang sudah ada, karena percobaan kedua dan seterusnya akan selalu mendapat rows affected = 0 dan tidak melakukan perubahan apapun. Kamu juga tidak perlu menyimpan mutex atau state tambahan di memory aplikasi, karena seluruh logika perlindungan sudah terkandung di dalam query itu sendiri.
flowchart TD
A[Request masuk] --> B[Jalankan conditional UPDATE]
B --> C{rows affected > 0?}
C -- Ya --> D[Request valid, lanjutkan proses]
C -- Tidak --> E[Request duplikat, hentikan tanpa efek samping]
D --> F[Jalankan side effect: charge, email, dll]
E --> G[Response idempotent, tidak ada perubahan]Selama dua kondisi berikut terjaga, sistem akan aman dari duplicate request: status hanya boleh berubah satu arah sesuai state machine yang sudah didefinisikan, dan semua transisi status harus lewat conditional update — tidak ada jalur lain yang mengubah status tanpa melalui mekanisme ini.
Hal yang Perlu Diperhatikan
Pendekatan ini sangat cocok untuk update status, tapi ada beberapa kondisi yang membutuhkan kehati-hatian ekstra sebelum kamu menerapkannya secara membabi buta di seluruh sistem.
Ada Side Effect Eksternal
Jika proses yang mengikuti perubahan status melibatkan side effect eksternal seperti charge payment, kirim email, atau memanggil API eksternal, pastikan side effect tersebut hanya dijalankan jika rows affected > 0. Ini adalah aturan paling kritis dari seluruh pola ini — melanggarnya berarti kembali ke risiko yang ingin dihindari sejak awal.
// ANTI-PATTERN: menjalankan side effect tanpa memeriksa rows affected
rowsAffected := updateOrderStatus(orderID, "PROCESSING")
chargePayment(orderID) // selalu dijalankan, termasuk pada request duplikat
// BENAR: side effect hanya berjalan jika request ini yang berhasil mengubah status
rowsAffected := updateOrderStatus(orderID, "PROCESSING")
if rowsAffected > 0 {
chargePayment(orderID)
}
Workflow Kompleks dengan Banyak Cabang
Untuk workflow yang punya banyak kemungkinan transisi status — misalnya order yang bisa dibatalkan, dikembalikan, atau memiliki beberapa sub-status tergantung kondisi tertentu — pertimbangkan untuk mendefinisikan state machine secara eksplisit. Conditional UPDATE tetap relevan sebagai mekanisme eksekusinya, tapi keputusan transisi mana yang valid sebaiknya divalidasi terhadap state machine yang jelas, bukan hanya mengandalkan satu klausa WHERE yang sederhana.
stateDiagram-v2
[*] --> PENDING
PENDING --> PROCESSING: conditional update
PROCESSING --> COMPLETED: conditional update
PROCESSING --> FAILED: conditional update
PENDING --> CANCELLED: conditional update
COMPLETED --> [*]
FAILED --> [*]
CANCELLED --> [*]Audit dan Observability
Catat dan log request yang menghasilkan rows affected = 0 sebagai bagian dari observability sistem. Jumlah occurrence yang tinggi pada kondisi ini bisa jadi indikasi masalah lain yang lebih besar — misalnya client yang terlalu agresif melakukan retry, atau webhook dari pihak ketiga yang memang secara konsisten mengirim duplikat lebih sering dari yang diharapkan.
Jangan memperlakukan rows affected = 0 sebagai error yang harus dikembalikan dengan HTTP status gagal. Dari perspektif client yang melakukan retry, kondisi ini sebenarnya adalah hasil yang benar — operasi yang mereka minta memang sudah berhasil dilakukan sebelumnya. Response yang tepat biasanya tetap berupa status sukses, mungkin dengan informasi tambahan yang menunjukkan bahwa ini adalah hasil dari request yang sudah pernah diproses.Kesimpulan
Menggunakan conditional UPDATE yang dipadukan dengan pengecekan rows affected di dalam sebuah transaction adalah solusi yang benar secara konseptual, aman secara concurrency, efisien secara performa, dan praktis untuk diterapkan langsung di lingkungan produksi. Pendekatan ini memanfaatkan database sesuai dengan kekuatan alaminya — sebagai penjaga konsistensi data, bukan sekadar tempat penyimpanan pasif yang menunggu instruksi tanpa validasi.
Untuk banyak kasus idempotency dan race condition di level HTTP request — terutama yang melibatkan perubahan status satu arah seperti order processing atau payment — pendekatan ini bukan hanya cukup, melainkan sangat direkomendasikan sebagai pilihan utama sebelum mempertimbangkan solusi yang lebih kompleks seperti distributed lock atau message queue dengan deduplication tambahan.
Ringkasan
- Race condition pada HTTP request sering muncul dari retry client, double submit di UI, atau webhook pihak ketiga yang terkirim berulang.
- Solusi inti: bungkus dalam transaction, jalankan conditional UPDATE berdasarkan status saat ini, lalu periksa
rows affectedsebagai sinyal idempotency.rows affected > 0berarti request ini valid dan berhasil mengubah state;rows affected = 0berarti request duplikat yang tidak boleh memicu side effect lagi.- Locking implisit pada UPDATE memastikan evaluasi kondisi dan perubahan status terjadi secara atomik, tanpa celah waktu yang bisa dieksploitasi request lain.
- Lebih tepat disebut atomic state transition (compare-and-set) dibanding sekadar pessimistic locking, dan secara kompleksitas lebih ringan dibanding mutex, distributed lock,
SELECT FOR UPDATE, atau optimistic locking.- Endpoint menjadi idempotent secara alami selama status hanya berubah satu arah dan semua transisi melalui conditional update.
- Side effect eksternal (charge, email, callback) wajib dijalankan hanya jika
rows affected > 0— ini adalah aturan paling kritis dari seluruh pola ini.- Untuk workflow kompleks, padukan pola ini dengan state machine eksplisit, dan selalu catat occurrence
rows affected = 0untuk keperluan observability.