sync.Mutex Anda tidak bertahan dari kill -9. Ia tidak bertahan dari OOM, deployment rollout, atau reboot node. Pada saat proses berhenti, kunci tersebut hilang. Jika kunci itu melindungi sebuah scheduled job, data migration, atau leadership election, Anda kini memiliki dua proses yang yakin mereka adalah satu-satunya yang berjalan.

Itu bukan bug di mutex Anda. Itu adalah category error. Kunci yang bersifat process-local tidak dapat melindungi resource cluster-wide.

Solusinya adalah distributed lease: sebuah kunci yang disimpan di sistem eksternal dengan Time-To-Live, unique owner token, dan mekanisme untuk safe release. Redis, PostgreSQL, etcd, dan ZooKeeper semuanya mengimplementasikan ide yang sama dengan trade-off berbeda. Polanya sederhana. Edge case-nya tidak.

Apa itu distributed lease?

Lease adalah janji dari external store bahwa hanya satu client yang memegang named lock untuk periode waktu tertentu. Berbeda dengan mutex yang hidup di heap memory, lease hidup di Redis atau PostgreSQL. Ia bertahan di seluruh process restart karena store-nya bertahan.

Operasi dasarnya adalah:

  • Acquire: menulis key secara atomic dengan TTL, tetapi hanya jika key tersebut belum ada.
  • Renew: memperpanjang TTL selama Anda masih memegang kunci.
  • Release: menghapus key, tetapi hanya jika key tersebut masih berisi unique token Anda.

Unique token adalah bagian kritis. Tanpanya, client yang lambat dapat melepaskan kunci yang telah di-re-acquire oleh orang lain setelah crash.

Mengapa TTL wajib ada, dan mengapa itu berbahaya

Jika client mengakuisisi kunci lalu mati, kunci tersebut harus akhirnya membebaskan dirinya sendiri. Satu-satunya cara melakukannya tanpa intervensi manusia adalah dengan expiration time. Redis mendukung ini secara native dengan SET key value NX EX 30. PostgreSQL advisory locks terikat pada session dan mati ketika koneksi TCP ditutup, yang merupakan solusi yang elegan tetapi kurang portable.

TTL memperkenalkan failure mode baru: jika pekerjaan Anda memakan waktu lebih lama dari TTL, kunci kedaluwarsa saat Anda masih berjalan. Client lain mengakuisisi kunci dan memulai pekerjaan yang sama. Anda kini memiliki dua proses konkuren yang memutasi data yang sama.

Solusi naifnya adalah TTL yang sangat panjang. Itu berfungsi sampai client mati segera setelah mengakuisisi kunci. Sisa TTL menjadi downtime window wajib di mana tidak ada orang lain yang dapat mengambil alih.

Solusi yang benar adalah heartbeat. Holder memunculkan background goroutine yang me-renew lease setiap beberapa detik. Jika holder crash, heartbeat berhenti, TTL kedaluwarsa, dan owner baru dapat mengakuisisi kunci dalam hitungan detik. Jika holder hanya lambat, heartbeat menjaga lease tetap hidup selama prosesnya hidup.

Implementasi Redis lease yang berfungsi di Go

Berikut adalah implementasi lengkap yang menangani acquisition, renewal, dan safe release. Ia menggunakan Lua script untuk release sehingga delete hanya berhasil jika token cocok.

package lease

import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "fmt"
    "sync"
    "time"

    "github.com/redis/go-redis/v9"
)

type Lease struct {
    client     *redis.Client
    key        string
    token      string
    ttl        time.Duration
    renewEvery time.Duration
    stopRenew  chan struct{}
    once       sync.Once
}

func generateToken() string {
    b := make([]byte, 16)
    rand.Read(b)
    return hex.EncodeToString(b)
}

func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lease, error) {
    token := generateToken()
    ok, err := client.SetNX(ctx, key, token, ttl).Result()
    if err != nil {
        return nil, err
    }
    if !ok {
        return nil, fmt.Errorf("lock already held")
    }

    l := &Lease{
        client:     client,
        key:        key,
        token:      token,
        ttl:        ttl,
        renewEvery: ttl / 3,
        stopRenew:  make(chan struct{}),
    }
    go l.renew()
    return l, nil
}

func (l *Lease) renew() {
    ticker := time.NewTicker(l.renewEvery)
    defer ticker.Stop()
    for {
        select {
        case <-ticker.C:
            ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
            script := `
                if redis.call("get", KEYS[1]) == ARGV[1] then
                    return redis.call("pexpire", KEYS[1], ARGV[2])
                else
                    return 0
                end
            `
            l.client.Eval(ctx, script, []string{l.key}, l.token, l.ttl.Milliseconds()).Result()
            cancel()
        case <-l.stopRenew:
            return
        }
    }
}

func (l *Lease) Release(ctx context.Context) error {
    l.once.Do(func() { close(l.stopRenew) })
    script := `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `
    res, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Int64()
    if err != nil {
        return err
    }
    if res == 0 {
        return fmt.Errorf("lock was lost or stolen")
    }
    return nil
}

Penggunaan:

lease, err := lease.Acquire(ctx, redisClient, "job:invoice-generation", 10*time.Second)
if err != nil {
    // Another instance is running the job.
    return
}
defer lease.Release(ctx)

// Do the work. If this takes 30 seconds, the heartbeat keeps the lease alive.
generateInvoices()

Lua script diperlukan karena Redis tidak mendukung compare-and-delete sebagai single native command. Tanpanya, release dapat berlomba dengan re-acquisition dan menghapus kunci milik orang lain.

Di mana ini gagal: masalah fencing token

Bahkan dengan TTL dan renewal yang sempurna, ada race condition yang halus yang diidentifikasi oleh Martin Kleppmann dalam kritiknya terhadap Redlock. Bayangkan:

  1. Client A mengakuisisi lease.
  2. Client A berhenti selama 45 detik (GC stop-the-world, VM suspension, CPU throttling).
  3. Lease kedaluwarsa.
  4. Client B mengakuisisi lease.
  5. Client A melanjutkan dan menulis ke database.
  6. Client B menulis ke database.

Kedua client yakin mereka memegang kunci. Keduanya memutasi shared state.

TTL melindungi dari deadlock permanen, tetapi tidak dapat melindungi dari proses yang tertunda. Solusinya adalah fencing token: sebuah monotonic number atau UUID yang holder kunci lampirkan pada setiap write. Storage layer menolak write dengan token yang kadaluarsa.

Dalam praktiknya, ini berarti tabel database Anda membutuhkan kolom lock_version, atau blob store Anda membutuhkan conditional writes. Sebagian besar aplikasi melewatkan langkah ini karena memerlukan perubahan pada data layer, bukan hanya locking layer. Itu adalah trade-off yang masuk akal, tetapi tetap saja trade-off. Anda harus tahu bahwa Anda sedang membuatnya.

Alternatif yang patut dipertimbangkan

PostgreSQL advisory locks bersifat session-scoped. Ketika koneksi TCP ditutup, kunci melepaskan diri secara otomatis. Tidak ada TTL management dan tidak ada clock skew. Kekurangannya adalah mereka terikat pada single database connection, sehingga tidak berfungsi dengan baik dengan connection pooling atau setup multi-region.

etcd leases dirancang tepat untuk masalah ini. Mereka mendukung TTL, automatic revocation, dan notifikasi berbasis watch ketika lease mati. Jika Anda sudah menjalankan Kubernetes, Anda memiliki etcd. API-nya lebih verbose daripada Redis, tetapi semantiknya lebih bersih.

ZooKeeper ephemeral sequential nodes adalah solusi klasik. Mereka bersifat CP (consistent dan partition-tolerant) menurut CAP theorem, yang menghilangkan masalah clock skew sepenuhnya. Mereka juga lebih lambat dan lebih berat secara operasional daripada Redis.

Apa yang tidak kami coba

Kami tidak mengimplementasikan custom consensus protocol di atas relational database. Setiap tim pada akhirnya mencoba ini: tabel locks dengan INSERT ... ON CONFLICT dan kolom last_heartbeat yang dibersihkan oleh cron job. Ia berfungsi di happy path. Ia hancur di bawah contention karena database MVCC men-serialize conflicting writes, dan lock acquisition Anda menjadi bottleneck untuk seluruh sistem. Gunakan tool yang tepat untuk pekerjaannya.

Memilih TTL

Terlalu pendek: heartbeat menjadi terlalu sering, dan satu GC pause yang lambat dapat kehilangan lease.

Terlalu panjang: holder yang crash memblokir failover untuk seluruh TTL.

Titik awal yang baik adalah 10 detik dengan heartbeat setiap 3 detik. Sesuaikan dari sana berdasarkan GC pause dan network latency yang Anda amati. Ukur heartbeat latency p99 Anda. Jika nilainya 500 ms, TTL Anda harus setidaknya satu order of magnitude lebih besar.

Cobalah

Jika Anda saat ini menggunakan sync.Mutex untuk melindungi background job, ganti dengan leased lock yang didukung oleh Redis atau PostgreSQL. Mulailah dengan implementasi di atas. Tambahkan metrics untuk lease_acquired, lease_lost, dan heartbeat_latency. Saat pertama kali Anda deploy selama pekerjaan yang berjalan lama dan melihat instance kedua menunggu dengan sopan alih-alih bertabrakan, Anda akan tahu bahwa category error telah teratasi.