Implementasi distributed lock berbasis TTL sering memicu race condition tersembunyi. Saat worker queue mengalami latensi tak terduga—seperti Garbage Collection (GC) pause panjang, I/O throttling, atau network drift—durasi proses dapat melampaui masa berlaku lock. Akibatnya, lock kedaluwarsa secara otomatis, worker lain mengklaim lock yang sama, dan sistem masuk ke kondisi split-brain: dua worker memproses dan memodifikasi sumber daya yang sama secara simultan.

Kelemahan Naive Distributed Lock Berbasis Redis SETNX

Pola umum implementasi lock di Redis menggunakan perintah berikut:

SET resource_lock <worker_id> NX PX 30000

Secara teori, lock ini aman karena bersifat mutual exclusive dan memiliki batas waktu sewa (lease) 30 detik. Namun, model ini mengasumsikan waktu eksekusi selalu terprediksi. Pertimbangkan urutan peristiwa berikut:

  1. Worker A mengakuisisi lock untuk order_id: 101 dengan TTL 30 detik.
  2. Worker A mengalami GC pause atau block I/O selama 35 detik tepat sebelum melakukan penulisan ke database.
  3. Pada detik ke-30, lock di Redis kedaluwarsa.
  4. Worker B meminta lock untuk order_id: 101, berhasil mendapatkannya, dan menyelesaikan mutasi ke database.
  5. Worker A terbangun dari pause dan mengeksekusi mutasi ke database tanpa menyadari lock miliknya sudah tidak valid.

Peristiwa ini menyebabkan stale write dan korupsi data. Algoritma Redlock maupun mekanisme renewal berbasis background thread (watchdog) memperkecil jendela kegagalan ini, tetapi tidak bisa menjamin safety secara mutlak pada asynchronous network jika downstream storage tidak memiliki proteksi validasi.

Mekanisme Fencing Token

Martin Kleppmann mengusulkan konsep Fencing Token untuk memecahkan masalah ini. Fencing token adalah angka monotoik naik (strictly monotonically increasing counter) yang diberikan oleh lock manager setiap kali lock berhasil diakuisisi.

Prinsip kerjanya sederhana: setiap write request ke persistent storage (misalnya PostgreSQL atau MySQL) harus menyertakan token ini. Storage downstream kemudian menerapkan Compare-And-Swap (CAS) atau conditional update untuk menolak transaksi apa pun yang membawa token lebih rendah dari token yang sudah pernah diterapkan.

Komponen Utama Fencing Token

  • Lock & Counter Manager: Redis atau distributed coordinator (seperti etcd) yang secara atomik mengunci resource dan menaikkan nilai integer monotonic sequence.
  • Worker Client: Consumer queue yang menerima payload bersama dengan fencing token saat ini.
  • Downstream Storage Guard: Tabel target menyimpan kolom last_fencing_token untuk verifikasi atomik.

Implementasi Akuisisi Lock dan Generasi Token

Akuisisi lock dan inkremen monotonic counter harus dieksekusi secara atomik. Anda dapat memanfaatkan Lua script di Redis untuk menghasilkan token unik per resource.

-- KEYS[1]: lock key (e.g., lock:order:101)
-- KEYS[2]: counter key (e.g., counter:order:101)
-- ARGV[1]: worker identifier
-- ARGV[2]: ttl in milliseconds

if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    local token = redis.call('INCR', KEYS[2])
    return {1, token}
else
    return {0, 0}
end

Berikut contoh implementasi worker dalam Python menggunakan client Redis:

import redis
import psycopg2

redis_client = redis.Redis(host='localhost', port=6379, db=0)

ACQUIRE_LOCK_SCRIPT = """
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    local token = redis.call('INCR', KEYS[2])
    return {1, token}
else
    return {0, 0}
end
"""

acquire_lock = redis_client.register_script(ACQUIRE_LOCK_SCRIPT)

def acquire_fencing_lock(resource_id, worker_id, ttl_ms=30000):
    lock_key = f"lock:{resource_id}"
    counter_key = f"counter:{resource_id}"
    status, token = acquire_lock(keys=[lock_key, counter_key], args=[worker_id, ttl_ms])
    if status == 1:
        return token
    return None

Verifikasi Downstream: Conditional Update (CAS) di Database

Kunci keamanan fencing token terletak pada lapisan database. Tambahkan kolom last_fencing_token pada tabel yang dimutasi.

ALTER TABLE orders ADD COLUMN last_fencing_token BIGINT DEFAULT 0;

Ketika worker melakukan mutasi, update dilakukan secara kondisional menggunakan klausa WHERE:

UPDATE orders 
SET status = 'PROCESSING', 
    last_fencing_token = %(token)s
WHERE id = %(order_id)s 
  AND last_fencing_token < %(token)s;

Jika Worker A (token 33) tertunda dan Worker B (token 34) telah berhasil melakukan update, saat Worker A mencoba mengeksekusi query di atas, kondisi last_fencing_token < 33 akan bernilai FALSE (karena nilai saat ini sudah 34). Query menghasilkan rows_affected = 0, mengonfirmasi penolakan mutasi.

def process_order(db_conn, order_id, token):
    cursor = db_conn.cursor()
    
    # Eksekusi conditional write
    cursor.execute("""
        UPDATE orders
        SET status = 'COMPLETED',
            last_fencing_token = %(token)s
        WHERE id = %(order_id)s
          AND last_fencing_token < %(token)s;
    """, {'token': token, 'order_id': order_id})
    
    db_conn.commit()
    
    if cursor.rowcount == 0:
        raise StaleTokenException(
            f"Mutasi ditolak: Fencing token {token} kedaluwarsa untuk Order {order_id}"
        )
        
    print(f"Order {order_id} berhasil diproses dengan token {token}")

Strategi Operasional: Heartbeat dan Penanganan Stale Token

1. Auto-Renewal Heartbeat (Watchdog)

Meskipun fencing token menjamin konsistensi data saat lock terlewat, Anda tetap harus mencegah rilis lock prematur untuk menghemat resource processing. Gunakan background thread yang secara berkala memperpanjang TTL lock selama proses utama masih berjalan (misalnya setiap sepertiga dari total TTL).

Peringatan: Heartbeat hanya optimasi performa agar worker kedua tidak bekerja sia-sia. Heartbeat bukan pengganti fencing token karena thread heartbeat bisa tetap aktif saat thread eksekusi utama mengalami deadlock atau hang.

2. Penanganan Rejection (Fallback)

Ketika aplikasi menangkap StaleTokenException, jalankan langkah mitigasi berikut:

  • Rollback State Internal: Batalkan perubahan in-memory dan hentikan eksekusi logic downstream.
  • Idempotent Cleanup: Jika ada side-effect non-database (misal upload object storage), hapus artifact yang tertandai token stale.
  • Dead-Letter Queue (DLQ): Masukkan task ke DLQ untuk analisis latensi apabila persentase stale token melebihi ambang batas toleransi sistem.

Batasan dan Trade-Off

Fencing token bukan solusi universal untuk semua arsitektur:

  • Dukungan Storage: Memerlukan target storage yang mendukung linearizable storage atau conditional write (CAS). Sistem penyimpanan yang tidak memiliki operasi kondisional atomik (seperti layanan pihak ketiga via REST API tanpa versioning) tidak bisa dilindungi hanya dengan mekanisme ini.
  • State Counter: Key counter di Redis harus tetap persisten. Jika Redis mengalami crash dan data counter reset ke angka yang lebih kecil, proteksi monotonic sequence dapat terganggu. Pastikan Redis dikonfigurasi dengan persistensi AOF (fsync everysec/always) atau gunakan sistem konsensus kuat seperti etcd/ZooKeeper.