Portal/Notes πŸ“
Interview prep

Distributed Lock β€” Redis Implementation

Implementasi distributed lock dengan Redis di Go: SET NX, TTL, fencing token, Redlock β€” untuk mencegah double-spending dan double-processing di sistem terdistribusi.

Distributed Lock β€” Redis

Masalah

Di sistem dengan multiple server instances, request yang sama bisa masuk ke instance berbeda:

Instance 1: Process payment TX-123 β†’ charge $100
Instance 2: Process payment TX-123 β†’ charge $100 (DUPLICATE!)

Keduanya gak tahu satu sama lain. Butuh mekanisme lock yang bekerja lintas instance.

Redis SET NX β€” Basic Implementation

package lock

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

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

var ErrLockNotAcquired = errors.New("lock not acquired")

type RedisLock struct {
    client *redis.Client
    key    string
    token  string // unique token mencegah accidental release
    ttl    time.Duration
}

func NewRedisLock(client *redis.Client, key string, ttl time.Duration) *RedisLock {
    return &RedisLock{
        client: client,
        key:    fmt.Sprintf("lock:%s", key),
        token:  generateToken(),
        ttl:    ttl,
    }
}

func (l *RedisLock) Acquire(ctx context.Context) error {
    // SET key token NX EX ttl
    // NX = only set if Not eXists
    // EX = set expiry in seconds
    ok, err := l.client.SetNX(ctx, l.key, l.token, l.ttl).Result()
    if err != nil {
        return fmt.Errorf("acquire lock %s: %w", l.key, err)
    }
    if !ok {
        return ErrLockNotAcquired
    }
    return nil
}

// Retry dengan backoff
func (l *RedisLock) AcquireWithRetry(ctx context.Context, maxRetries int, backoff time.Duration) error {
    for i := 0; i < maxRetries; i++ {
        err := l.Acquire(ctx)
        if err == nil {
            return nil
        }
        if !errors.Is(err, ErrLockNotAcquired) {
            return err
        }

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(backoff):
            backoff *= 2 // exponential backoff
        }
    }
    return ErrLockNotAcquired
}

func (l *RedisLock) Release(ctx context.Context) error {
    // SAFE RELEASE: hanya delete kalau token match
    // Mencegah release lock yang di-acquire instance lain (kasus TTL expired)
    script := `
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
    `
    result, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Result()
    if err != nil {
        return fmt.Errorf("release lock %s: %w", l.key, err)
    }
    if result.(int64) == 0 {
        return errors.New("lock token mismatch β€” lock was taken by another instance")
    }
    return nil
}

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

Usage di Payment Handler

func (h *PaymentHandler) ProcessWithLock(ctx context.Context, paymentID string) error {
    lock := NewRedisLock(h.redis, paymentID, 30*time.Second)

    if err := lock.Acquire(ctx); err != nil {
        return fmt.Errorf("process payment %s: %w", paymentID, err)
    }
    defer lock.Release(ctx)

    // Check if already processed (double-check pattern)
    processed, err := h.isProcessed(ctx, paymentID)
    if err != nil {
        return err
    }
    if processed {
        return nil // idempotent
    }

    // Actually process
    return h.doPayment(ctx, paymentID)
}

Fencing Token β€” Mencegah Race Condition

Problem: Lock expired karena processing lambat, instance lain acquire lock baru.

Timeline:
T0: Instance-A acquires lock (TTL 30s)
T32: Instance-A still processing (slow DB query)
T31: Lock expires
T32: Instance-B acquires new lock
T33: Instance-B starts processing SAME payment
T35: Instance-A finishes, releases lock (BUT IT'S INSTANCE-B'S LOCK NOW!)
T36: Instance-A writes result β†’ CORRUPTION!

Solusi: Fencing Token

type FencingLock struct {
    client      *redis.Client
    key         string
    token       string
    fencingToken int64 // monotonic increasing
}

func (l *FencingLock) Acquire(ctx context.Context) error {
    script := `
        local token = redis.call("INCR", KEYS[1] .. ":fencing")
        local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])
        if ok then
            return token
        else
            return 0
        end
    `
    result, err := l.client.Eval(ctx, script,
        []string{l.key}, l.token, int(l.ttl.Seconds())).Result()
    if err != nil {
        return err
    }

    l.fencingToken = result.(int64)
    if l.fencingToken == 0 {
        return ErrLockNotAcquired
    }
    return nil
}

// Storage service checks fencing token before writing
func (s *StorageService) WriteWithFencing(ctx context.Context, data Data, fencingToken int64) error {
    return s.db.ExecContext(ctx, `
        UPDATE payments
        SET status = $1, updated_at = NOW()
        WHERE id = $2 AND fencing_token < $3
    `, data.Status, data.ID, fencingToken)
    // Hanya write kalau fencing_token kita LEBIH BESAR
    // Mencegah stale write dari lock holder sebelumnya
}

Redlock β€” Multi-Redis untuk High Availability

Single Redis bisa fail. Redlock algorithm (dari Redis creator) pakai multiple Redis instances.

type RedLock struct {
    clients []*redis.Client // minimal 5 instances (odd number)
    quorum  int             // majority: (N/2)+1
}

func (r *RedLock) Acquire(ctx context.Context, key string, ttl time.Duration) (*RedLock, error) {
    token := generateToken()
    acquired := 0
    start := time.Now()

    for _, client := range r.clients {
        ok, err := client.SetNX(ctx, key, token, ttl).Result()
        if err == nil && ok {
            acquired++
        }
    }

    elapsed := time.Since(start)

    // Valid kalau:
    // 1. Acquired di majority (quorum)
    // 2. Elapsed time < TTL (kalau terlalu lama, TTL udah hampir expired)
    if acquired < r.quorum || elapsed > ttl {
        // Release yang udah ke-acquire
        for _, client := range r.clients {
            r.releaseOne(ctx, client, key, token)
        }
        return nil, ErrLockNotAcquired
    }

    return &RedLock{...}, nil
}

Trade-off Redlock:

  • Pros: Tahan terhadap single-node Redis failure
  • Cons: Lebih lambat (N network calls), lebih kompleks
  • Alternatif: etcd, ZooKeeper, Consul (built-in distributed consensus)

Interview Talking Points

"Redis vs etcd untuk distributed lock?"

  • Redis: lebih cepat, lebih simpel, bagus buat short-lived lock (<30 detik)
  • etcd: leader election built-in, lebih strict consistency, bagus buat long-lived lock
  • Pilih Redis untuk rate limiting + idempotency, etcd untuk leader election

"Apa kelemahan Redis lock?"

  • Single point of failure (kalau Redis down β†’ gak bisa acquire lock)
  • Clock drift bisa bikin TTL gak akurat
  • Gak ada fencing token by default

"Kapan gak perlu distributed lock?"

  • Kalau pake database unique constraint: INSERT ... ON CONFLICT DO NOTHING
  • Kalau pake message queue dengan exactly-once delivery
  • Kalau sistem single-instance (monolith) β†’ cukup sync.Mutex
Edit on GitHub

Last updated on

Distributed Lock β€” Redis Implementation | Faisal Affan