Redis lock dan worker yang idempoten adalah dua teknik utama untuk mencegah eksekusi job ganda pada sistem queue. Keduanya tidak saling menggantikan sepenuhnya: lock membantu membatasi konkurensi pada saat yang sama, sedangkan idempotency memastikan efek akhir tetap benar walaupun job diterima lebih dari sekali.

Di sistem produksi, masalah ini jarang sesederhana “job diproses dua kali”. Penyebabnya bisa berupa duplicate delivery dari broker, worker crash setelah mengambil job tetapi sebelum ack, TTL lock yang terlalu pendek, retry storm saat dependency lambat, sampai clock drift yang membuat asumsi timeout menjadi salah. Jika tidak dirancang dengan benar, hasilnya bisa berupa data inkonsisten, pembayaran ganda, email terkirim berulang, stok terpotong dua kali, atau state bisnis yang sulit dipulihkan.

Artikel ini fokus pada desain praktis: kapan memakai Redis lock, kapan cukup mengandalkan idempotency key, bagaimana mengatur TTL lock, menggunakan fencing token, melakukan deduplikasi, merancang retry yang aman, menambahkan observabilitas, dan menyiapkan checklist produksi.

Masalah nyata: mengapa job bisa dieksekusi ganda?

Sebelum memilih solusi, penting memahami bahwa queue pada umumnya memberikan jaminan at-least-once delivery, bukan exactly-once processing. Artinya, sebuah job bisa muncul lagi walaupun secara logika “sudah pernah diproses”.

Sumber duplikasi yang paling umum

  • Duplicate delivery dari broker/queue: job yang sama terkirim dua kali karena retry internal, reconnect, atau kegagalan ack.
  • Worker crash setelah ambil job: worker memproses sebagian lalu mati sebelum menandai job selesai. Queue menganggap job belum selesai dan mengirim ulang.
  • Timeout visibility / lease habis: pekerjaan masih berjalan, tetapi lease habis sehingga worker lain ikut mengambil job yang sama.
  • Producer mengirim event duplikat: upstream system melakukan retry tanpa idempotency key.
  • Retry storm: dependency lambat atau error sementara menyebabkan banyak retry bersamaan dan memperbesar peluang race condition.
  • Scheduler overlap: job periodik dijalankan lagi sebelum eksekusi sebelumnya selesai.

Dampak ke consistency

Dampak utama bukan sekadar beban sistem, tetapi efek samping ganda. Misalnya:

  • saldo atau invoice diposting dua kali,
  • email/notifikasi dikirim berulang,
  • record turunan dibuat ganda,
  • status bisnis meloncat ke state yang salah,
  • operasi eksternal ke payment gateway atau partner API terulang.

Karena itu, target desain yang realistis bukan “job tidak mungkin pernah diterima dua kali”, melainkan job boleh diterima ulang, tetapi hasil akhirnya tetap benar.

Kapan perlu Redis lock, kapan cukup idempoten?

Ini pertanyaan paling penting. Banyak sistem langsung menambahkan lock ke semua worker, padahal tidak selalu perlu.

Saat idempoten saja sering cukup

Gunakan pendekatan idempoten jika:

  • efek samping bisa diidentifikasi dengan idempotency key yang stabil,
  • hasil akhir boleh sama walaupun job diproses lebih dari sekali,
  • operasi utama bisa dilindungi dengan unique constraint, upsert, conditional update, atau tabel deduplication,
  • duplikasi lebih berbahaya pada hasil akhir daripada pada konsumsi resource.

Contoh: membuat invoice dari order_id. Jika sistem menyimpan bahwa invoice untuk order tersebut sudah pernah dibuat, maka eksekusi ulang cukup mengembalikan hasil lama atau tidak melakukan apa-apa.

Saat Redis lock diperlukan

Gunakan Redis lock jika:

  • operasi tidak aman jika berjalan paralel pada resource yang sama,
  • ada bagian proses yang mahal dan ingin dicegah agar tidak dikerjakan bersamaan,
  • ada side effect non-idempoten yang sulit dikendalikan dari hilir,
  • Anda perlu men-serialize akses ke satu entitas, misalnya account:123 atau inventory:sku-9.

Contoh: dua worker menghitung dan menulis ulang stok untuk SKU yang sama. Walaupun hasil akhir bisa dibuat idempoten, proses paralel bisa tetap menimbulkan overwrite atau pembacaan state usang.

Prinsip praktis

Idempotency melindungi correctness. Lock melindungi concurrency. Untuk kasus penting, gunakan keduanya.

Lock tanpa idempotency tetap berisiko bila lock gagal, TTL habis, atau worker crash. Sebaliknya, idempotency tanpa lock kadang cukup benar secara bisnis, tetapi boros resource karena banyak worker mengerjakan hal yang sama.

Desain dasar: lock, idempotency key, dan status pemrosesan

Desain yang aman biasanya terdiri dari tiga lapisan:

  1. Deduplication / idempotency store untuk mencatat bahwa sebuah operasi bisnis pernah diproses.
  2. Distributed lock untuk membatasi satu pemrosesan aktif per resource kritis.
  3. State transisi yang dapat diperiksa agar worker bisa melanjutkan atau mengulang tanpa merusak konsistensi.

Menentukan idempotency key

Idempotency key harus stabil untuk satu niat bisnis yang sama, bukan untuk satu attempt. Contoh yang baik:

  • payment:{merchant_id}:{client_request_id}
  • invoice:{order_id}
  • email:{template}:{user_id}:{business_event_id}

Contoh yang buruk:

  • UUID baru setiap retry,
  • timestamp saat ini,
  • kombinasi field yang berubah antar-attempt.

Jika producer dan worker sama-sama memiliki akses ke key yang sama, maka duplikasi lintas retry lebih mudah dihentikan sejak awal.

Struktur status yang umum

Simpan status pemrosesan minimal seperti:

  • key: idempotency key,
  • status: processing, succeeded, failed,
  • result_ref: referensi hasil atau entity yang dibuat,
  • updated_at: waktu terakhir berubah,
  • attempt_count: jumlah percobaan,
  • fencing_token: bila memakai proteksi lanjutan.

Status ini bisa disimpan di database utama atau storage yang mendukung atomic write. Untuk efek bisnis penting, database transaksional biasanya lebih dapat diandalkan daripada hanya menyimpan state sementara di Redis.

Menggunakan Redis lock dengan benar

Pola lock dasar

Pola yang umum adalah menyimpan key lock dengan opsi “set jika belum ada” dan TTL. Secara konsep:

lock_key = "lock:job:resource:123"
lock_value = random_token()
lock_ttl_ms = 30000

acquired = redis.set(lock_key, lock_value, nx=true, px=lock_ttl_ms)
if !acquired:
    return "already processing"

try:
    do_work()
finally:
    release_lock_if_owner(lock_key, lock_value)

Kenapa perlu lock_value acak? Karena worker yang memegang lock harus memastikan hanya dia yang boleh melepas lock. Jika release dilakukan tanpa verifikasi owner, worker lama bisa menghapus lock milik worker baru.

Release lock yang aman

Release sebaiknya atomik: cek bahwa nilai lock masih milik worker saat ini, lalu hapus. Konsepnya:

function release_lock_if_owner(lock_key, lock_value):
    current = redis.get(lock_key)
    if current == lock_value:
        redis.del(lock_key)

Dalam implementasi nyata, pengecekan dan penghapusan perlu dibuat atomik agar tidak terkena race condition. Intinya, jangan pernah melakukan delete lock secara buta.

Menentukan TTL lock

TTL lock adalah salah satu sumber bug yang paling sering. Jika terlalu pendek, job masih berjalan tetapi lock habis, lalu worker lain masuk. Jika terlalu panjang, lock yatim akibat crash membuat throughput turun.

Panduan umum:

  • TTL harus lebih panjang dari durasi normal pekerjaan, dengan margin untuk lonjakan latensi.
  • Untuk job yang durasinya tidak pasti, gunakan lock renewal/heartbeat.
  • Jangan mengandalkan jam lokal proses untuk keputusan correctness.

Contoh heartbeat:

every 10 seconds:
    if still_owner(lock_key, lock_value):
        redis.pexpire(lock_key, 30000)
    else:
        abort_work_safely()

Jika proses kehilangan lock, worker harus berhenti melakukan write yang bisa merusak state. Ini penting ketika ada kemungkinan lock direbut worker lain setelah TTL habis.

Masalah clock drift

Clock drift menjadi masalah ketika sistem mengandalkan perbandingan waktu dari beberapa mesin untuk memutuskan apakah lock masih valid. Cara paling aman adalah mengandalkan TTL yang dikelola Redis dan bukan hitungan manual antar-node. Walaupun drift kecil sering tidak terasa, pada jaringan yang lambat atau node yang sibuk ia bisa mengubah asumsi tentang siapa pemegang lock yang sah.

Jika desain Anda sangat sensitif terhadap urutan kepemilikan lock, pertimbangkan proteksi tambahan berupa fencing token.

Fencing token untuk mencegah owner lama menulis state baru

Lock dengan TTL saja tidak cukup untuk semua kasus. Skenarionya seperti ini:

  1. Worker A mendapat lock.
  2. Worker A pause lama karena GC, CPU stall, atau network hiccup.
  3. TTL habis.
  4. Worker B mendapat lock baru dan melanjutkan kerja.
  5. Worker A hidup lagi dan masih mencoba menulis hasil lama.

Tanpa proteksi tambahan, owner lama bisa menimpa hasil owner baru.

Cara kerja fencing token

Setiap kali lock berhasil diperoleh, sistem mengeluarkan angka yang selalu naik, misalnya 101, 102, 103. Angka ini ikut dibawa saat menulis ke resource tujuan. Resource tersebut hanya menerima write dengan token yang lebih besar dari token terakhir yang sudah diterima.

token = redis.incr("fencing:resource:123")
acquired = redis.set("lock:resource:123", owner_id, nx=true, px=30000)
if !acquired:
    return

write_to_db(resource_id=123, token=token, data=payload)

-- di sisi database/resource
UPDATE resource_state
SET value = :data, fencing_token = :token
WHERE resource_id = :id
  AND fencing_token < :token;

Jika worker lama datang membawa token lebih kecil, update-nya ditolak. Ini sangat membantu untuk mencegah write usang setelah lock timeout.

Kapan fencing token layak dipakai?

Gunakan bila:

  • ada risiko process pause yang panjang,
  • resource downstream mendukung validasi token/versi,
  • konsekuensi write usang berat, misalnya saldo, stok, atau state mesin.

Kalau resource downstream tidak bisa memvalidasi token, lock tetap berguna, tetapi perlindungannya tidak sekuat itu.

Pola worker idempoten yang praktis

Berikut alur yang umum dan netral framework:

  1. Ambil job dari queue.
  2. Bangun idempotency key dari identitas bisnis yang stabil.
  3. Cek store deduplication/status.
  4. Jika status succeeded, akhiri dengan aman.
  5. Jika perlu serialisasi per resource, ambil Redis lock.
  6. Tandai status processing secara atomik bila belum ada.
  7. Jalankan side effect dengan retry yang terkendali.
  8. Simpan hasil akhir sebagai succeeded beserta referensinya.
  9. Lepas lock jika masih menjadi owner.
  10. Ack job.

Pseudo-code end-to-end

function handle_job(job):
    key = "invoice:" + job.order_id
    resource = "order:" + job.order_id

    existing = store.get(key)
    if existing.status == "succeeded":
        ack(job)
        return

    lockValue = random_token()
    gotLock = redis.set("lock:" + resource, lockValue, nx=true, px=30000)
    if !gotLock:
        retry_later(job)
        return

    token = store.next_fencing_token(resource)

    try:
        state = store.get(key)
        if state.status == "succeeded":
            ack(job)
            return

        created = store.create_processing_if_absent(key, {
            status: "processing",
            attempt_count: increment,
            fencing_token: token
        })

        if !created and state.status == "processing":
            retry_later(job)
            return

        result = create_invoice_if_not_exists(job.order_id, token)

        store.mark_succeeded(key, {
            result_ref: result.invoice_id,
            fencing_token: token
        })

        ack(job)
    catch transient_error:
        schedule_retry_with_backoff(job)
    catch fatal_error:
        store.mark_failed(key)
        move_to_dlq(job)
    finally:
        release_lock_if_owner("lock:" + resource, lockValue)

Ada beberapa hal penting di sini:

  • Cek status dua kali: sebelum dan sesudah lock, untuk mengurangi race.
  • create_if_absent harus atomik agar dua worker tidak sama-sama menandai processing.
  • ack dilakukan setelah state sukses tersimpan, bukan sebelumnya.
  • Retry dibedakan antara error sementara dan error permanen.

Deduplikasi: Redis saja atau database?

Redis untuk dedup jangka pendek

Redis cocok untuk dedup cepat dengan TTL, misalnya menahan event duplikat dalam jendela beberapa menit atau jam. Contoh:

dedup_key = "dedup:event:" + event_id
first = redis.set(dedup_key, "1", nx=true, ex=3600)
if !first:
    skip()

Ini efisien untuk burst duplikasi, tetapi ada trade-off:

  • state hilang jika Redis di-flush atau key expired,
  • tidak ideal sebagai sumber kebenaran untuk operasi bisnis penting jangka panjang,
  • TTL yang terlalu pendek membuka peluang duplikasi terlambat.

Database untuk correctness bisnis

Untuk operasi penting, gunakan database dengan jaminan lebih kuat:

  • unique constraint pada kolom identitas bisnis,
  • tabel idempotency dengan primary key berdasarkan idempotency key,
  • upsert atau conditional insert.

Contoh prinsipnya:

INSERT INTO invoices(order_id, ...)
VALUES(:order_id, ...)
ON CONFLICT(order_id) DO NOTHING;

Jika constraint mencegah duplikasi, worker yang menerima replay cukup membaca kembali record yang sudah ada dan menandai job sukses. Ini sering lebih kuat daripada berharap lock selalu sempurna.

Retry aman dan cara menghindari retry storm

Retry adalah bagian normal dari sistem terdistribusi, tetapi retry yang agresif bisa memperburuk masalah dan memicu job ganda.

Prinsip retry yang aman

  • Gunakan exponential backoff, jangan retry rapat tanpa jeda.
  • Tambahkan jitter agar banyak worker tidak menabrak dependency di waktu yang sama.
  • Bedakan transient dan fatal error.
  • Batasi maksimum percobaan sebelum masuk dead letter queue.
  • Pastikan side effect tetap idempoten antar-attempt.

Contoh kesalahan umum

  • Retry HTTP timeout ke payment API tanpa idempotency key pada request eksternal.
  • Mengulang job setelah gagal menulis status lokal, padahal side effect eksternal sebenarnya sudah sukses.
  • Menjadwalkan semua retry tepat 30 detik kemudian sehingga membentuk gelombang beban baru.

Jika dependency eksternal mendukung idempotency key, gunakan key yang sama untuk semua retry dari operasi bisnis yang sama. Ini penting untuk menutup celah antara “request sukses di server lawan” dan “respons hilang di jaringan”.

Observabilitas: tanpa ini, bug duplikasi sulit dibuktikan

Masalah job ganda sering sulit direproduksi. Karena itu, observabilitas bukan tambahan opsional.

Log yang sebaiknya ada

  • job id, idempotency key, dan resource key,
  • status lock: acquire sukses/gagal, renewal, release,
  • fencing token saat write,
  • attempt number dan alasan retry,
  • hasil dedup: hit atau miss,
  • durasi proses dan dependency call yang lambat.

Metrik yang berguna

  • jumlah lock contention,
  • rasio dedup hit,
  • job replay count,
  • retry count per jenis error,
  • jumlah lock expired saat job masih berjalan,
  • dead letter queue rate.

Trace yang membantu investigasi

Jika memakai distributed tracing, kaitkan satu trace dengan:

  • producer event,
  • pengambilan job oleh worker,
  • akses ke Redis lock,
  • write ke database,
  • panggilan ke service eksternal.

Dengan begitu Anda bisa melihat apakah duplikasi berasal dari producer, broker, atau worker.

Trade-off dan keterbatasan yang perlu dipahami

Redis lock bukan jaminan exactly-once

Lock membantu, tetapi tidak mengubah model dasar queue yang at-least-once. Masih ada kemungkinan:

  • lock timeout saat kerja belum selesai,
  • owner lama menulis setelah kehilangan lock,
  • state sukses belum tersimpan tetapi side effect sudah terjadi.

Karena itu, jangan gunakan lock sebagai satu-satunya lapisan proteksi.

Idempotency juga punya biaya

  • Anda perlu menentukan key bisnis yang benar.
  • Store dedup perlu retention policy.
  • Beberapa operasi sulit dibuat idempoten jika downstream tidak mendukungnya.

Granularitas lock memengaruhi throughput

Lock per seluruh queue terlalu kasar dan akan menurunkan paralelisme. Lebih baik lock per resource bisnis yang benar-benar konflik, misalnya per order, per akun, atau per SKU.

TTL terlalu konservatif bisa menahan pemulihan

Jika TTL sangat panjang, crash worker akan membuat pekerjaan tertunda sampai lock habis. Gunakan heartbeat bila perlu, bukan sekadar menaikkan TTL tanpa batas.

Debugging: gejala umum dan cara memeriksanya

Gejala: record masih bisa dobel walau sudah ada lock

  • Periksa apakah release lock dilakukan tanpa verifikasi owner.
  • Periksa apakah TTL lock habis sebelum job selesai.
  • Periksa apakah ada write ke database yang tidak dilindungi constraint unik.
  • Periksa apakah ada lebih dari satu jalur eksekusi yang melewati lock berbeda untuk resource yang sama.

Gejala: lock contention tinggi, throughput turun

  • Granularitas lock mungkin terlalu kasar.
  • Worker mungkin menahan lock selama I/O yang tidak perlu.
  • TTL mungkin terlalu panjang sehingga recovery lambat.

Gejala: retry melonjak saat dependency lambat

  • Tambahkan backoff dan jitter.
  • Pastikan timeout client realistis.
  • Pertimbangkan circuit breaker atau pembatas concurrency ke dependency yang bermasalah.

Checklist produksi untuk Redis lock dan worker idempoten

  • Tentukan idempotency key yang stabil dari identitas bisnis, bukan dari attempt.
  • Tambahkan unique constraint atau mekanisme dedup yang kuat di storage utama untuk operasi penting.
  • Gunakan Redis lock hanya pada resource yang memang perlu diserialkan.
  • Simpan owner token unik pada lock, dan release secara aman.
  • Atur TTL berdasarkan durasi kerja nyata, lalu tambahkan heartbeat jika durasi variatif.
  • Pertimbangkan fencing token untuk mencegah write usang setelah timeout lock.
  • Bedakan retry untuk error sementara dan permanen; gunakan backoff + jitter.
  • Jangan ack job sebelum status sukses dan efek penting tercatat dengan benar.
  • Tambahkan log, metrik, dan trace untuk lock, dedup, retry, dan replay.
  • Uji skenario crash: worker mati setelah ambil job, setelah side effect, sebelum ack, dan saat lock hampir habis.
  • Uji skenario jaringan lambat, pause proses, dan duplicate delivery dari queue.
  • Siapkan prosedur replay dan rekonsiliasi data jika duplikasi lolos ke produksi.

Penutup

Untuk mencegah job ganda, jangan mengejar ilusi exactly-once di layer queue. Pendekatan yang lebih realistis adalah menggabungkan Redis lock untuk membatasi konkurensi dan idempotency untuk menjaga correctness saat replay tetap terjadi.

Jika efek bisnis sangat penting, prioritaskan idempotency yang ditopang constraint atau state transaksional di database. Tambahkan lock ketika Anda perlu mencegah eksekusi paralel pada resource yang sama. Untuk kasus yang sensitif terhadap lock timeout, gunakan fencing token. Dengan desain seperti ini, duplicate delivery, worker crash, timeout lock, dan retry storm tidak lagi langsung berubah menjadi inkonsistensi data.