Pada tahun 1959, Cesare Maestri mengklaim berhasil mencapai puncak Cerro Torre di Patagonia, namun ketiadaan bukti baut tebing (bolts) di rute pendakian memicu kontroversi puluhan tahun: klaim sepihak tanpa verifikasi fisik di lapangan tidak bernilai valid. Dalam sistem terdistribusi, fenomena ini mewujud sebagai phantom claim. Sebuah worker meyakini dirinya memegang hak eksklusif atas suatu resource, mengeksekusi mutasi, padahal masa sewa (lease) lock telah kedaluwarsa di mata sistem koordinator.

Anatomi Masalah: Kegagalan Naif Redis SETNX dan TTL

Pola klasik distributed lock menggunakan Redis umumnya mengandalkan perintah atomik:

SET resource_key worker_id NX PX 30000

Mekanisme ini rapuh terhadap latensi tak terduga pada runtime worker. Tiga skenario yang kerap memicu invalidasi implisit:

  • Stop-the-World (STW) Garbage Collection: Runtimes seperti JVM atau Go runtime dapat menghentikan eksekusi thread aplikasi selama ratusan milidetik hingga detik saat melakukan memory compaction.
  • Network Partition / Socket Stalls: Penundaan I/O pada layer OS atau congestive pause di hypervisor/container runtime.
  • Disk I/O Paging: Virtual memory swapping yang membekukan proses aplikasi secara temporer.

Diagram alur kegagalan tanpa proteksi storage layer:

Worker 1                  Redis Lock Server              Worker 2              Database
   |                              |                         |                     |
   |-- SETNX lock (TTL=10s) ----->|                         |                     |
   |<-- OK -----------------------|                         |                     |
   |                              |                         |                     |
   |== [STW GC Pause: 12 detik] ==|                         |                     |
   |                              |-- TTL Expired ----------|                     |
   |                              |                         |                     |
   |                              |<-- SETNX lock (OK) -----|                     |
   |                              |                         |-- UPDATE data ----->|
   |== [GC Selesai, Lanjut] ======|                         |                     | (Write Worker 2)
   |                              |                         |                     |
   |-- UPDATE data (Corrupted!) ------------------------------------------------->|
   |   (Worker 1 menulis tanpa sadar lock sudah hilang)                           | (Write Worker 1)

Worker 1 mengalami mutasi data tidak sah (phantom write) karena lock manager tidak terintegrasi langsung dengan media penyimpanan akhir.

Solusi 1: Fencing Token (Monotonically Increasing Counter)

Sebagaimana diuraikan Martin Kleppmann, distributed lock server tidak boleh hanya mengembalikan status boolean. Lock server harus menerbitkan fencing token: angka integer yang dijamin naik secara monoton (monotonically increasing) setiap kali lock berhasil diakuisisi.

Storage layer (PostgreSQL, MySQL, atau storage engine lain) bertindak sebagai verifikator akhir dengan menolak setiap penulisan yang membawa token bernilai lebih kecil atau sama dengan token transaksi terakhir yang berhasil ditulis.

Implementasi Go dan PostgreSQL

Tabel resource di database harus melacak token tertinggi yang pernah memodifikasi baris tersebut:

CREATE TABLE account_balances (
    id BIGINT PRIMARY KEY,
    balance NUMERIC(15, 2) NOT NULL,
    last_fencing_token BIGINT NOT NULL DEFAULT 0
);

Implementasi logic update di worker Go:

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"time"

	_ "github.com/lib/pq"
)

var ErrStaleToken = errors.New("fencing token kadaluwarsa, mutasi ditolak")

type AccountRepository struct {
	db *sql.DB
}

// MutateWithFencing memvalidasi fencing token secara atomik di storage layer.
func (r *AccountRepository) MutateWithFencing(ctx context.Context, accountID int64, amount float64, token int64) error {
	tx, err := r.db.BeginTx(ctx, nil)
	if err != nil {
		return fmt.Errorf("gagal memulai transaksi: %w", err)
	}
	defer tx.Rollback()

	// Validasi token langsung pada klausul WHERE
	query := `
		UPDATE account_balances
		SET balance = balance + $1,
		    last_fencing_token = $2
		WHERE id = $3 AND last_fencing_token < $2;
	`
	res, err := tx.ExecContext(ctx, query, amount, token, accountID)
	if err != nil {
		return fmt.Errorf("eksekusi query gagal: %w", err)
	}

	rowsAffected, err := res.RowsAffected()
	if err != nil {
		return err
	}

	if rowsAffected == 0 {
		// Update gagal: ada worker lain dengan token lebih baru yang sudah mengeksekusi state
		return ErrStaleToken
	}

	return tx.Commit()
}

Ketika Worker 1 terbangun dari GC pause panjang dan mencoba mengeksekusi MutateWithFencing menggunakan token=101, query menghasilkan RowsAffected == 0 karena Worker 2 telah memperbarui kolom last_fencing_token menjadi 102. Phantom claim tereliminasi di storage layer.

Solusi 2: Heartbeat Lease Extension

Mencegah lease berakhir prematur selama worker berjalan normal membutuhkan background goroutine/thread terpisah yang bertugas memperpanjang masa berlaku lock (heartbeat renewer).

Panduan desain renewal loop:

  1. Jalankan heartbeat interval pada fraksi TTL, umumnya TTL / 3. Jika TTL = 30 detik, jalankan refresh setiap 10 detik.
  2. Batas toleransi kegagalan: jika heartbeat gagal memperbarui lock sebanyak 2 kali berturut-turut, batalkan context eksekusi lokal (trigger context.CancelFunc) untuk menghentikan mutasi sebelum TTL habis.
  3. Gunakan script Lua atomik pada Redis untuk memastikan renew hanya dilakukan jika value lock masih cocok dengan ID worker saat ini:
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("pexpire", KEYS[1], ARGV[2])
else
    return 0
end

Checklist Audit Integritas Distributed Lock

Sebelum mengoperasikan distributed worker di environment production, pastikan kriteria berikut terpenuhi:

  • Validasi Storage-side: Jangan mengandalkan integritas data hanya pada pengecekan lock.IsHeld() di memori aplikasi sebelum write. Database target wajib memverifikasi monotonic fencing token atau version column.
  • Kepemilikan Rilis Atomik: Pelepasan lock harus memverifikasi bahwa identifier proses masih sesuai. Hindari operasi non-atomik GET kemudian DEL; gunakan Redis Lua script atau primitives compare-and-swap (CAS).
  • Clock Drift Tolerance: Pastikan algoritma konsensus (etcd, Consul, atau Redis Redlock) tidak bergantung pada sinkronisasi wall-clock absolut antarmesin, melainkan monotonic clock atau physical network transit bounds.
  • Graceful Shutdown: Worker wajib menangani signal SIGTERM dengan membatalkan pending renewal loop dan menuntaskan rollback secara deterministik.