Pada sistem pembayaran atau transaksi finansial berkonkurensi tinggi, request duplikat yang datang secara bersamaan dapat memicu race condition dan eksekusi ganda (double processing). Ketika aplikasi di-deploy pada beberapa node worker, mutex lokal di memory (seperti tokio::sync::Mutex) tidak cukup. Anda membutuhkan mekanisme sinkronisasi terdistribusi di level eksternal.
Redis menyediakan primitif cepat untuk distributed mutex. Artikel ini membahas implementasi distributed lock pada Actix Web menggunakan perintah atomik Redis, pola RAII (Resource Acquisition Is Initialization) guard di Rust, script Lua untuk safe release, serta penanganan risiko operasional saat eksekusi worker melebihi batas Time To Live (TTL).
1. Prinsip Atomisitas Akuisisi Lock
Kesalahan umum implementasi distributed lock adalah memisahkan operasi pembuatan key dan penentuan masa aktifnya (misalnya memanggil SETNX lalu EXPIRE secara terpisah). Jika node aplikasi mengalami crash di antara kedua instruksi tersebut, key akan tertinggal tanpa masa kedaluwarsa dan menyebabkan permanent deadlock.
Untuk mencegah masalah ini, gunakan opsi atomik perintah SET standar Redis:
SET <lock_key> <lock_token> NX PX <ttl_milliseconds>- NX: Hanya set key jika key tersebut belum ada di Redis. Jika sudah ada, perintah gagal mengembalikan status nil.
- PX: Menentukan waktu kedaluwarsa dalam satuan milidetik secara atomik bersamaan dengan pembuatan key.
- lock_token: Pengenal acak unik (biasanya UUID v4) untuk memvalidasi kepemilikan lock oleh proses pemegang saat fase pelepasan.
2. Pelepasan Aman Menggunakan Lua Script
Menghapus lock tidak boleh dilakukan hanya dengan perintah DEL lock_key langsung. Skenario masalah berikut sering terjadi:
- Proses A mendapatkan lock dengan TTL 3000 ms.
- Proses A mengalami latensi IO jaringan selama 3200 ms. Lock kedaluwarsa secara otomatis oleh Redis.
- Proses B mengakuisisi lock baru untuk resource yang sama.
- Proses A selesai dan mengeksekusi
DEL lock_key. Tindakan ini menghapus lock milik Proses B, membuka celah untuk Proses C masuk.
Pelepasan lock harus memverifikasi bahwa nilai token di Redis sama persis dengan token yang dipegang oleh worker saat ini. Verifikasi dan penghapusan harus atomik, yang dicapai menggunakan script Lua:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end3. Implementasi RAII Lock Guard di Rust
Di Rust, trait Drop memungkinkan pelepasan resource otomatis saat variabel keluar dari scope. Namun, method drop(&mut self) berjalan sinkron, sedangkan driver Redis modern berbasis async. Solusi idiomatis di Actix Web adalah menyediakan pelepasan otomatis di background runtime via actix_web::rt::spawn jika pengguna tidak memanggil method pelepasan manual.
use redis::AsyncCommands;
use std::sync::Arc;
use uuid::Uuid;
const RELEASE_SCRIPT: &str = r#"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"#;
pub struct RedisLockManager {
client: redis::Client,
}
impl RedisLockManager {
pub fn new(client: redis::Client) -> Self {
Self { client }
}
pub async fn acquire(
&self,
resource_id: &str,
ttl_ms: u64,
) -> Result<Option<RedisLockGuard>, redis::RedisError> {
let mut conn = self.client.get_multiplexed_async_connection().await?;
let lock_key = format!("lock:{}", resource_id);
let lock_token = Uuid::new_v4().to_string();
let acquired: Option<String> = redis::cmd("SET")
.arg(&lock_key)
.arg(&lock_token)
.arg("NX")
.arg("PX")
.arg(ttl_ms)
.query_async(&mut conn)
.await?;
if acquired.is_some() {
Ok(Some(RedisLockGuard {
key: lock_key,
token: lock_token,
client: self.client.clone(),
released: false,
}))
} else {
Ok(None)
}
}
}
pub struct RedisLockGuard {
key: String,
token: String,
client: redis::Client,
released: bool,
}
impl RedisLockGuard {
pub async fn release(&mut self) -> Result<bool, redis::RedisError> {
if self.released {
return Ok(false);
}
self.released = true;
let mut conn = self.client.get_multiplexed_async_connection().await?;
let script = redis::Script::new(RELEASE_SCRIPT);
let result: i32 = script
.key(&self.key)
.arg(&self.token)
.invoke_async(&mut conn)
.await?;
Ok(result == 1)
}
}
impl Drop for RedisLockGuard {
fn drop(&mut self) {
if !self.released {
let client = self.client.clone();
let key = self.key.clone();
let token = self.token.clone();
actix_web::rt::spawn(async move {
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let script = redis::Script::new(RELEASE_SCRIPT);
let _: Result<i32, _> = script
.key(&key)
.arg(&token)
.invoke_async(&mut conn)
.await;
}
});
}
}
}4. Integrasi Handler Actix Web dan Penentuan HTTP Status
Saat request masuk untuk transaksi tertentu (misalnya charge balance atau checkout), endpoint berupaya mengambil lock berdasarkan idempotency key atau order ID.
Pemilihan status code HTTP saat gagal akuisisi lock:
- HTTP 409 Conflict: Tepat digunakan ketika ada operasi aktif lain yang sedang memproses entitas yang sama. Ini menandakan state saat ini sedang bentrok dan klien harus menunggu atau memeriksa status akhir transaksi.
- HTTP 429 Too Many Requests: Digunakan jika batas request per entitas diterapkan secara frekuentif atau rate limiting. Untuk idempotensi transaksi bisnis tunggal, HTTP 409 lebih semantik.
use actix_web::{web, HttpResponse, Responder};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CheckoutPayload {
pub order_id: String,
pub amount: u64,
}
pub async fn process_checkout(
payload: web::Json<CheckoutPayload>,
lock_mgr: web::Data<RedisLockManager>,
) -> impl Responder {
let lock_key = format!("order:{}", payload.order_id);
let ttl_ms = 5000;
let mut lock = match lock_mgr.acquire(&lock_key, ttl_ms).await {
Ok(Some(guard)) => guard,
Ok(None) => {
return HttpResponse::Conflict().json(serde_json::json!({
"error": "Request sedang diproses. Hindari eksekusi ganda."
}));
}
Err(_) => {
return HttpResponse::InternalServerError().json(serde_json::json!({
"error": "Gagal memvalidasi status transaksi"
}));
}
};
// Eksekusi core business logic di sini
let result = execute_transaction(&payload.order_id, payload.amount).await;
// Lepaskan lock eksplisit agar koneksi downstream langsung terbuka
let _ = lock.release().await;
match result {
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"status": "success"})),
Err(_) => HttpResponse::UnprocessableEntity().finish(),
}
}
async fn execute_transaction(_order_id: &str, _amount: u64) -> Result<(), ()> {
// Simulasi pekerjaan I/O (Database, Payment Gateway)
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
Ok(())
}5. Mitigasi Risiko Operasional: Lock Expiration
Mekanisme TTL melindungi sistem dari kebocoran lock saat worker crash. Namun, jika pekerjaan memakan waktu lebih lama dari TTL, properti mutual exclusion rusak. Dua strategi mitigasi utama:
Heartbeat / Lock Renewal Task
Jika durasi proses tidak dapat diprediksi secara ketat, jalankan background loop yang memperpanjang TTL via perintah HEXPIRE atau mengevaluasi token secara berkala selama operasi masih berjalan. Loop dihentikan seketika saat worker selesai memproses tugas.
Fencing Token
Distributed lock di level Redis tidak menjamin serialisasi absolut jika node mengalami stop-the-world GC pause atau network partition panjang. Untuk pertahanan berlapis, gunakan pendekatan Fencing Token: sertakan counter strictly increasing (bisa berupa sequence dari DB atau Redis) pada setiap perolehan lock. Database target menolak transaksi dari token dengan nomor urut yang lebih rendah dari token terakhir yang telah berhasil diproses.
Catatan: Untuk deployment Redis berskala multi-node cluster tanpa shared state mutlak, algoritma Redlock dapat dipertimbangkan, namun sering kali arsitektur multi-instance master-replica standar dengan database transactional boundary sudah mencukupi untuk mayoritas beban kerja API komersial.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!