Distributed lock Redis dengan fencing token diperlukan ketika beberapa worker dapat memproses resource yang sama, sementara lease lock mungkin kedaluwarsa sebelum pekerjaan selesai. Perintah SET key value NX PX ttl berguna untuk memperoleh lease, tetapi tidak dapat mencegah worker lama menulis setelah lease-nya habis dan worker baru mengambil alih.

Desain yang lebih aman menggabungkan owner token unik, unlock dan renewal atomik, batas waktu renewal, serta fencing token monotonik yang diverifikasi oleh storage tujuan. Lock tetap bukan jaminan exactly-once: idempotensi, transaksi, unique constraint, dan validasi pada database masih diperlukan.

Mengapa SET NX PX Saja Belum Cukup?

Pola dasar berikut hanya memastikan bahwa satu owner memegang key Redis pada suatu saat:

SET lock:{resource-123} owner-uuid NX PX 30000

Nilai owner-uuid mengidentifikasi pemegang lock, sedangkan PX 30000 memberikan lease selama 30 detik. Lease mencegah lock tertinggal selamanya saat proses mati, tetapi juga menciptakan skenario berikut:

  1. Worker A memperoleh lock dan mulai memproses resource.
  2. Worker A berhenti lama karena pause runtime, CPU starvation, gangguan jaringan, atau operasi eksternal yang lambat.
  3. Lease A kedaluwarsa.
  4. Worker B memperoleh lock baru dan memperbarui resource.
  5. Worker A kembali berjalan dan menulis hasil lama setelah Worker B.

Dari sudut pandang Redis, perilaku tersebut benar: A sudah kehilangan lease. Masalahnya adalah A mungkin tidak menyadari bahwa kepemilikannya telah berakhir. Pemeriksaan lock sebelum menulis juga tidak cukup karena terdapat celah waktu antara pemeriksaan dan operasi tulis.

Lease membatasi durasi kepemilikan, tetapi tidak dapat menghentikan proses lama secara fisik. Storage tujuan harus mampu menolak operasi dari owner yang sudah usang.

Kesalahan unlock yang berbahaya

Jangan menghapus lock dengan DEL tanpa memeriksa owner. Jika lease A sudah kedaluwarsa dan lock telah diperoleh B, perintah DEL dari A dapat menghapus lock milik B.

Pola GET lalu DEL dalam dua perintah juga tidak aman. Lease dapat berakhir dan owner baru dapat masuk di antara kedua perintah tersebut. Perbandingan owner dan penghapusan harus menjadi satu operasi atomik.

Komponen Protokol Lock yang Aman

1. Owner token unik

Setiap usaha memperoleh lock harus menggunakan owner token baru yang sulit bertabrakan, misalnya UUID acak. Jangan menggunakan ID worker statis karena proses yang dimulai ulang dapat terlihat seperti owner lama.

ownerToken = randomUUID()
lockKey    = "lock:{resource-123}"
fenceKey   = "fence:{resource-123}"

Owner token hanya membuktikan identitas pemegang lease di Redis. Token ini berbeda dari fencing token yang menentukan urutan kepemilikan.

2. Akuisisi lock dan fencing token secara atomik

Fencing token adalah angka yang selalu meningkat untuk setiap kepemilikan baru. Worker dengan token lebih besar dianggap lebih baru. Akuisisi lock dan penerbitan token dapat dilakukan dengan Lua agar tidak ada worker lain yang menyela rangkaian perintah.

-- KEYS[1] = lock:{resource-id}
-- KEYS[2] = fence:{resource-id}
-- ARGV[1] = owner token unik
-- ARGV[2] = lease dalam milidetik, sudah divalidasi positif

if redis.call('EXISTS', KEYS[1]) == 1 then
    return {0, false}
end

local fence = redis.call('INCR', KEYS[2])
redis.call('PSETEX', KEYS[1], ARGV[2], ARGV[1])
return {1, tostring(fence)}

Lua dieksekusi secara atomik oleh Redis. Pengecekan lock, peningkatan counter, dan pembuatan lease tidak dapat disela command client lain. Counter dinaikkan sebelum lock dibuat sehingga kegagalan INCR, misalnya karena tipe key salah, tidak meninggalkan lock tanpa fencing token. Gap pada nomor token tidak menjadi masalah; sifat yang dibutuhkan adalah urutan monotonik, bukan angka berurutan tanpa celah.

Pada Redis Cluster, seluruh key yang dipakai satu script harus berada pada hash slot yang sama. Hash tag seperti lock:{resource-123} dan fence:{resource-123} menjaga kedua key tetap satu slot.

3. Unlock atomik dengan compare-and-delete

Release hanya boleh menghapus lock jika owner token masih cocok:

-- KEYS[1] = lock key
-- ARGV[1] = owner token

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

Jika lease telah habis atau lock sudah dimiliki worker lain, script mengembalikan 0 dan tidak mengubah lock baru.

4. Renewal atomik dan terbatas

Pekerjaan yang durasinya sedikit bervariasi dapat memperpanjang lease, tetapi renewal tidak boleh berlangsung tanpa batas. Gunakan interval renewal yang memberi ruang untuk mendeteksi kegagalan sebelum TTL habis, lalu tetapkan durasi kepemilikan maksimum atau jumlah renewal maksimum.

-- KEYS[1] = lock key
-- ARGV[1] = owner token
-- ARGV[2] = lease baru dalam milidetik

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0

Renewal tidak menerbitkan fencing token baru karena kepemilikan belum berganti. Jika script mengembalikan 0, worker harus menganggap lease telah hilang. Worker sebaiknya membatalkan pekerjaan jika memungkinkan, tetapi fencing pada storage tetap wajib karena pembatalan proses tidak selalu instan.

Batas renewal menghindari lock yang praktis permanen ketika worker mengalami livelock. Pilih lease berdasarkan durasi operasi normal dan variasinya, bukan sekadar angka besar untuk menyembunyikan pekerjaan yang tidak memiliki batas waktu. Terapkan timeout pada panggilan database dan layanan eksternal agar durasi kerja tetap terkendali.

Fencing Token pada Storage Tujuan

Fencing token hanya efektif jika setiap operasi yang dilindungi mengirimkan token tersebut dan storage tujuan menolaknya ketika lebih kecil atau sama dengan token terakhir yang telah diterima.

Untuk satu pembaruan SQL, pemeriksaan dapat ditempatkan langsung dalam kondisi update:

UPDATE shared_resources
SET value = :new_value,
    last_fence = :fence
WHERE id = :resource_id
  AND last_fence < :fence;

Jika jumlah baris yang berubah adalah nol, worker harus memperlakukan hasil sebagai operasi stale atau resource yang tidak ditemukan. Bedakan kedua kasus jika aplikasi membutuhkan respons yang berbeda.

Untuk beberapa perubahan yang harus atomik, gunakan baris guard dan transaksi:

BEGIN;

SELECT last_fence
FROM resource_guards
WHERE resource_id = :resource_id
FOR UPDATE;

-- Tolak transaksi bila :fence <= last_fence.
-- Jalankan seluruh mutasi resource di transaksi yang sama.

UPDATE resource_guards
SET last_fence = :fence
WHERE resource_id = :resource_id;

COMMIT;

Validasi token, mutasi data, dan pembaruan last_fence harus berada dalam transaksi yang sama. Jika pemeriksaan dilakukan di aplikasi lalu transaksi baru dimulai, worker lain dapat menyela di antara keduanya.

Contoh worker lama yang ditolak

  • A memperoleh fencing token 41, lalu berhenti lama.
  • Lease A habis dan B memperoleh token 42.
  • B menulis ke database; last_fence menjadi 42.
  • A kembali dan mencoba menulis menggunakan token 41.
  • Database menolak A karena 41 < 42.

Inilah perlindungan yang tidak diberikan oleh SET NX PX. Redis mengatur lease, sedangkan database menegakkan urutan owner.

Sumber fencing token harus benar-benar monotonik

INCR monotonik pada satu riwayat primary Redis, tetapi asumsi ini perlu dievaluasi saat menggunakan replikasi asinkron, failover, restore snapshot, atau kehilangan data. Failover yang kehilangan kenaikan counter dapat menerbitkan token yang pernah digunakan atau token yang lebih rendah.

Jika regresi counter tidak dapat diterima, simpan counter pada storage dengan jaminan durabilitas dan konsistensi yang sesuai, sering kali database otoritatif yang sama dengan data tujuan. Acknowledgement replikasi Redis dapat mengurangi risiko kehilangan write, tetapi tidak otomatis mengubah semua skenario failover menjadi sistem linearizable. Dokumentasikan model kegagalan yang benar-benar didukung.

Fencing juga tidak dapat melindungi target yang tidak memiliki cara memvalidasi urutan token. API eksternal tanpa conditional write, pengiriman email, atau perangkat yang langsung menerima command membutuhkan idempotency key, mediator, outbox, atau desain serialisasi lain.

Alur Worker, Retry, dan Penanganan Kegagalan

Pseudocode end-to-end

function process(resourceId, jobId):
    owner = randomUUID()
    deadline = now() + acquisitionTimeout
    attempt = 0

    while now() < deadline:
        result = acquireAtomically(resourceId, owner, leaseMs)
        if result.acquired:
            fence = result.fence
            break

        sleep(random(0, min(maxBackoff, baseBackoff * 2^attempt)))
        attempt += 1

    if not acquired:
        return RETRY_LATER

    start renewal loop with maximumHoldDuration

    try:
        input = loadInput(jobId)
        result = writeWithFence(resourceId, fence, input)

        if result == STALE_FENCE:
            return OWNERSHIP_LOST

        recordIdempotentCompletion(jobId)
        return SUCCESS
    finally:
        stop renewal loop
        compareAndDelete(resourceId, owner)

Full jitter memilih delay acak antara nol dan batas exponential backoff. Cara ini mengurangi banyak worker yang mencoba kembali pada waktu yang sama setelah lock dilepas. Terapkan batas maksimum backoff, batas jumlah percobaan atau deadline total, dan hormati mekanisme pembatalan job.

Jangan menunggu lock tanpa batas. Dalam sistem queue, sering kali lebih baik mengembalikan job ke queue dengan delay daripada menahan thread atau koneksi worker.

Skenario kegagalan yang harus ditangani

  • Worker mati setelah memperoleh lock: lease berakhir secara otomatis. Job harus dapat dijalankan ulang secara idempoten.
  • Worker berhenti lebih lama dari TTL: renewal gagal atau tidak sempat berjalan. Worker baru mengambil alih dan fencing menolak write lama.
  • Respons Redis hilang setelah akuisisi: client tidak tahu apakah script berhasil. Jangan langsung membuat asumsi; gunakan owner token yang sama untuk pemeriksaan terbatas atau tunggu lease berakhir sebelum retry sesuai protokol.
  • Redis tidak tersedia: pilih fail-closed untuk operasi yang membutuhkan eksklusi. Melanjutkan write tanpa lock biasanya merusak asumsi desain.
  • Database menolak fencing token: catat sebagai ownership lost, bukan error database biasa yang di-retry dengan token sama tanpa batas.
  • Proses gagal setelah side effect tetapi sebelum ack job: queue dapat mengirim ulang job. Idempotency key dan constraint database harus mencegah side effect ganda.
  • Redis restart atau failover: lock atau counter mungkin hilang sesuai konfigurasi durabilitas. Uji perilaku nyata deployment, bukan hanya Redis lokal tanpa replikasi.

Lock bukan pengganti idempotensi

Distributed lock mengurangi eksekusi bersamaan, tetapi tidak menjamin sebuah job hanya dijalankan sekali. Duplikasi masih dapat terjadi akibat redelivery queue, timeout respons, crash setelah commit, atau retry aplikasi.

Gunakan perlindungan tambahan sesuai invariannya:

  • Unique constraint untuk mencegah pembuatan entitas bisnis yang sama dua kali.
  • Idempotency key untuk mengenali pengulangan request atau job.
  • Conditional update atau optimistic concurrency untuk melindungi versi data.
  • Transaksi database agar mutasi terkait berhasil atau gagal bersama.
  • Transactional outbox untuk menghubungkan commit database dengan publikasi event.

Fencing membedakan owner lama dan baru. Idempotensi membedakan operasi bisnis yang baru dan operasi yang merupakan pengulangan. Keduanya menyelesaikan masalah yang berbeda.

Observabilitas dan Pengujian Konkurensi

Metrik yang perlu dikumpulkan

  • lock_acquire_total berdasarkan hasil: acquired, contended, timeout, atau Redis error.
  • lock_acquire_duration untuk mengukur waktu tunggu memperoleh lock.
  • lock_contention_total per jenis resource, bukan selalu per ID agar cardinality tetap terkendali.
  • lock_hold_duration dari akuisisi hingga release atau kehilangan lease.
  • lock_renew_total berdasarkan hasil berhasil atau owner mismatch.
  • lock_lease_lost_total ketika renewal gagal, lease habis, atau fencing ditolak.
  • fence_rejected_total pada storage tujuan.
  • Jumlah retry, backoff, dan job yang dikembalikan ke queue akibat contention.

Tambahkan log terstruktur berisi resource type, hash atau ID yang aman, owner token, fencing token, TTL, hasil renewal, dan durasi. Hindari menjadikan ID resource sebagai label metrik jika jumlahnya tidak terbatas.

Alarm yang relevan bukan hanya error Redis. Kenaikan contention, durasi hold yang mendekati TTL, renewal gagal, dan fencing rejection dapat menunjukkan worker lambat, lease terlalu pendek, hotspot resource, atau pause proses.

Rencana pengujian

  1. Uji mutual exclusion normal: jalankan banyak worker pada resource sama dan pastikan hanya satu lease aktif pada satu waktu.
  2. Uji stale worker: hentikan A hingga lease habis, izinkan B menulis, lalu lanjutkan A. Database harus menolak token A.
  3. Uji unlock terlambat: setelah B memperoleh lock, jalankan release milik A. Lock B harus tetap ada.
  4. Uji renewal: pastikan hanya owner yang cocok dapat memperpanjang TTL dan renewal berhenti setelah batas kepemilikan maksimum.
  5. Simulasi crash: hentikan proses secara paksa setelah acquire, selama mutasi, dan setelah commit tetapi sebelum ack queue.
  6. Uji respons ambigu: putuskan koneksi setelah command dikirim untuk menguji kondisi ketika client tidak menerima hasil akuisisi.
  7. Uji failover: jika menggunakan Redis bereplikasi, lakukan failover saat lock aktif dan counter bertambah. Verifikasi asumsi durabilitas fencing token.
  8. Uji beban hotspot: arahkan banyak job ke resource sama dan periksa fairness, retry storm, penggunaan koneksi, serta pertumbuhan latency.

Pengujian unit dapat memvalidasi keputusan retry dan hasil script, tetapi skenario TTL, crash, gangguan jaringan, dan failover membutuhkan integration test dengan Redis serta database yang representatif. Gunakan sinkronisasi eksplisit seperti barrier agar urutan A berhenti, B menulis, dan A melanjutkan dapat direproduksi.

Batas Penggunaan dan Alternatif

Desain ini sesuai ketika Redis tersedia sebagai koordinator, pekerjaan memiliki durasi yang dapat dibatasi, dan storage tujuan mampu memvalidasi fencing token. Kompleksitasnya mungkin tidak sepadan jika seluruh data berada pada satu database yang sudah menyediakan primitive locking yang tepat.

  • Database advisory lock: cocok ketika semua peserta memakai database yang sama dan lifecycle koneksi atau transaksi dapat dikendalikan. Periksa semantik lock, session, dan failover database yang digunakan.
  • Row lock atau conditional update: lebih sederhana jika kritikal section sepenuhnya berada dalam satu transaksi database.
  • Serialisasi melalui queue: gunakan partition key berdasarkan resource agar seluruh operasi resource yang sama diproses berurutan. Tetap siapkan idempotensi karena redelivery masih mungkin terjadi.
  • Optimistic concurrency control: cocok ketika konflik jarang dan operasi dapat diulang berdasarkan version column atau compare-and-swap.
  • Koordinator dengan model konsistensi lebih kuat: pertimbangkan ketika keselamatan lock harus bertahan terhadap kegagalan yang tidak dapat ditoleransi oleh konfigurasi Redis.

Sebelum produksi, pastikan implementasi memiliki owner token baru untuk setiap acquire, compare-and-delete atomik, renewal terbatas, fencing monotonik yang durable, validasi token pada storage, retry dengan jitter, timeout operasi, metrik contention, serta pengujian crash dan failover. Jika salah satu komponen tersebut tidak dapat diterapkan, nyatakan secara eksplisit skenario kegagalan yang belum terlindungi.