Penukaran kode digital (seperti voucher fisik code-in-a-box atau kartu lisensi) rentan terhadap masalah double-claim bila dua permintaan masuk secara bersamaan. Jika aplikasi membaca status kode sebelum transaksi pertama selesai menulis perubahan, kedua permintaan dapat dianggap valid. Akibatnya, satu kode dapat terpakai dua kali.
Untuk mencegah race condition penukaran kode digital, sistem backend harus menerapkan penguncian baris database yang ketat, validasi token satu arah, dan kunci idempotensi API.
1. Struktur Data dan State Machine Status Voucher
Kode aktivasi digital tidak boleh disimpan dalam bentuk teks polos (plain text). Gunakan fungsi hash kriptografis satu arah (seperti SHA-256) untuk mencari voucher di database, mirip dengan penyimpanan kata sandi.
Siklus hidup kode harus diatur lewat state machine eksplisit dengan status: UNCLAIMED, CLAIMED, dan opsional REVOKED. Transisi status yang diizinkan hanya dari UNCLAIMED menuju CLAIMED.
CREATE TABLE digital_codes (
id BIGSERIAL PRIMARY KEY,
code_hash VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'UNCLAIMED',
claimed_by_user_id BIGINT NULL,
claimed_at TIMESTAMP WITH TIME ZONE NULL,
version INT NOT NULL DEFAULT 0
);
CREATE TABLE claim_idempotency_keys (
idempotency_key VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
response_payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);2. Pola DB Lock: Pesimistik vs Optimistik
Dua pola utama untuk mencegah concurrency anomaly pada penukaran voucher:
- Pessimistic Locking (SELECT ... FOR UPDATE): Mengunci baris database secara eksklusif segera setelah query dijalankan dalam sebuah transaksi. Transaksi paralel lain yang mencoba membaca baris yang sama dengan
FOR UPDATEakan dipaksa menunggu hingga transaksi pertama selesai (COMMITatauROLLBACK). Pilihan tepat untuk sistem dengan kompetisi klaim sangat ketat. - Optimistic Locking (Versioning): Tidak mengunci baris saat dibaca, melainkan memeriksa kolom
versionsaat mutasi. Query eksekusi:UPDATE digital_codes SET status = 'CLAIMED', version = version + 1 WHERE id = :id AND version = :current_version AND status = 'UNCLAIMED'. Jika jumlah baris terpengaruh bernilai 0, mutasi gagal dan aplikasi harus menolak klaim. Pilihan ini lebih hemat beban kunci database jika probabilitas tabrakan rendah.
3. Implementasi Transaksi Atomik dengan Pesimistik Lock
Implementasi berikut menggunakan Node.js dan SQL murni dengan isolasi transaksi untuk menjamin integritas klaim kode.
import crypto from 'node:crypto';
import { pool } from './db.js';
export async function redeemCode(userId, rawCode, idempotencyKey) {
const client = await pool.connect();
const codeHash = crypto.createHash('sha256').update(rawCode.trim()).digest('hex');
try {
await client.query('BEGIN');
// 1. Cek Idempotensi untuk menangani network retry
const existingClaim = await client.query(
'SELECT response_payload FROM claim_idempotency_keys WHERE idempotency_key = $1 FOR UPDATE',
[idempotencyKey]
);
if (existingClaim.rowCount > 0) {
await client.query('COMMIT');
return existingClaim.rows[0].response_payload;
}
// 2. Kunci baris kode digital (Pessimistic Locking)
const codeRes = await client.query(
'SELECT id, status FROM digital_codes WHERE code_hash = $1 FOR UPDATE',
[codeHash]
);
if (codeRes.rowCount === 0) {
throw new Error('CODE_NOT_FOUND');
}
const codeRecord = codeRes.rows[0];
if (codeRecord.status !== 'UNCLAIMED') {
throw new Error('CODE_ALREADY_USED');
}
// 3. Mutasi status secara atomik
await client.query(
`UPDATE digital_codes
SET status = 'CLAIMED', claimed_by_user_id = $1, claimed_at = NOW()
WHERE id = $2`,
[userId, codeRecord.id]
);
const responseData = { success: true, codeId: codeRecord.id, claimedAt: new Date() };
// 4. Simpan kunci idempotensi bersamaan dalam transaksi yang sama
await client.query(
'INSERT INTO claim_idempotency_keys (idempotency_key, user_id, response_payload) VALUES ($1, $2, $3)',
[idempotencyKey, userId, JSON.stringify(responseData)]
);
await client.query('COMMIT');
return responseData;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}4. Mengapa Idempotency Key Diperlukan
Database lock hanya menyelesaikan masalah eksekusi paralel di tingkat database. Jika klien mengirim permintaan, database berhasil melakukan commit, tetapi koneksi internet klien terputus sebelum menerima respons HTTP 200, klien biasanya akan melakukan retry otomatis.
Tanpa idempotensi, percobaan kedua akan membaca status CLAIMED dan melempar galat bahwa kode sudah terpakai. Dengan mencatat idempotency_key di dalam transaksi yang sama, permintaan retry langsung mengembalikan payload sukses yang tersimpan tanpa mencoba memvalidasi ulang kode.
5. Mitigasi Risiko dan Kesalahan Umum
- Deadlock: Jika klaim melibatkan penguncian beberapa tabel (misalnya tabel
usersdandigital_codes), pastikan urutan penguncian tabel selalu konsisten di seluruh endpoint. - Lock Timeout: Tetapkan nilai
statement_timeoutataulock_timeoutpada koneksi database agar antrean permintaan yang memperebutkan satu kode tidak menghabiskan connection pool. - Validasi Input Kriptografis: Selalu bersihkan format kode (hilangkan tanda hubung, ubah ke format seragam) sebelum melakukan hashing agar pencarian hash tepat sasaran.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!